feat: add file upload and management features; enhance pagination and permissions handling
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m43s
Workflow / ⚫️ Test (push) Has been skipped

This commit is contained in:
T. Zehetbauer
2026-04-01 20:13:15 +02:00
parent db4e19c3af
commit bbb33aa63d
39 changed files with 2858 additions and 99 deletions

View File

@@ -0,0 +1,70 @@
'use client';
import { useState } from 'react';
import { Trash2 } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
interface DeleteConfirmButtonProps {
title: string;
description: string;
isPending?: boolean;
onConfirm: () => void;
}
export function DeleteConfirmButton({
title,
description,
isPending,
onConfirm,
}: DeleteConfirmButtonProps) {
const [open, setOpen] = useState(false);
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="sm"
data-test="file-delete-btn"
disabled={isPending}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Trash2 className="text-destructive h-4 w-4" />
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
onConfirm();
setOpen(false);
}}
>
{isPending ? 'Wird gelöscht...' : 'Löschen'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Upload } from 'lucide-react';
import { uploadFile } from '@kit/module-builder/actions/file-actions';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
interface FileUploadDialogProps {
accountId: string;
}
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const ACCEPTED_TYPES = [
'image/*',
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv',
'text/plain',
'application/zip',
].join(',');
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
return `${(bytes / 1024).toFixed(1)} KB`;
}
export function FileUploadDialog({ accountId }: FileUploadDialogProps) {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<{
name: string;
type: string;
size: number;
base64: string;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const { execute, isPending } = useActionWithToast(uploadFile, {
successMessage: 'Datei hochgeladen',
onSuccess: () => {
setOpen(false);
setSelectedFile(null);
setError(null);
router.refresh();
},
});
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setError(null);
const file = e.target.files?.[0];
if (!file) {
setSelectedFile(null);
return;
}
if (file.size > MAX_FILE_SIZE) {
setError('Die Datei darf maximal 10 MB groß sein.');
setSelectedFile(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// Remove the data:...;base64, prefix
const base64 = result.split(',')[1] ?? '';
setSelectedFile({
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
base64,
});
};
reader.onerror = () => {
setError('Fehler beim Lesen der Datei.');
setSelectedFile(null);
};
reader.readAsDataURL(file);
},
[],
);
const handleUpload = useCallback(() => {
if (!selectedFile) return;
execute({
accountId,
fileName: selectedFile.name,
fileType: selectedFile.type,
fileSize: selectedFile.size,
base64: selectedFile.base64,
});
}, [accountId, execute, selectedFile]);
const handleOpenChange = useCallback(
(isOpen: boolean) => {
if (!isPending) {
setOpen(isOpen);
if (!isOpen) {
setSelectedFile(null);
setError(null);
}
}
},
[isPending],
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger
render={
<Button size="sm" data-test="file-upload-btn">
<Upload className="mr-2 h-4 w-4" />
Datei hochladen
</Button>
}
/>
<DialogContent showCloseButton={!isPending}>
<DialogHeader>
<DialogTitle>Datei hochladen</DialogTitle>
<DialogDescription>
Wählen Sie eine Datei aus (max. 10 MB).
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_TYPES}
onChange={handleFileSelect}
className="file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 block w-full text-sm file:mr-4 file:rounded-md file:border-0 file:px-4 file:py-2 file:text-sm file:font-medium"
data-test="file-upload-input"
/>
{error && <p className="text-destructive text-sm">{error}</p>}
{selectedFile && (
<div className="bg-muted rounded-md p-3">
<p className="text-sm font-medium">{selectedFile.name}</p>
<p className="text-muted-foreground text-xs">
{selectedFile.type} &middot; {formatFileSize(selectedFile.size)}
</p>
</div>
)}
<Button
onClick={handleUpload}
disabled={!selectedFile || isPending}
data-test="file-upload-submit"
>
{isPending ? 'Wird hochgeladen...' : 'Hochladen'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,196 @@
'use client';
import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Download, FileIcon } from 'lucide-react';
import { deleteFile } from '@kit/module-builder/actions/file-actions';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { DeleteConfirmButton } from './delete-confirm-button';
interface FileRecord {
id: string;
file_name: string;
original_name: string;
mime_type: string;
file_size: number;
created_at: string;
storage_path: string;
publicUrl: string;
}
interface FilesTableProps {
files: FileRecord[];
pagination: { total: number; page: number; pageSize: number };
}
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
return `${(bytes / 1024).toFixed(1)} KB`;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-AT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function getMimeLabel(mimeType: string): string {
const map: Record<string, string> = {
'application/pdf': 'PDF',
'image/jpeg': 'JPEG',
'image/png': 'PNG',
'image/gif': 'GIF',
'image/webp': 'WebP',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
'DOCX',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
'text/csv': 'CSV',
'text/plain': 'TXT',
'application/zip': 'ZIP',
};
return map[mimeType] ?? mimeType.split('/').pop()?.toUpperCase() ?? 'Datei';
}
export function FilesTable({ files, pagination }: FilesTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { total, page, pageSize } = pagination;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const { execute: executeDelete, isPending: isDeleting } = useActionWithToast(
deleteFile,
{
successMessage: 'Datei gelöscht',
onSuccess: () => router.refresh(),
},
);
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set('page', String(newPage));
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
return (
<Card>
<CardHeader>
<CardTitle>Dateien ({total})</CardTitle>
</CardHeader>
<CardContent>
{files.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<FileIcon className="text-muted-foreground mb-4 h-12 w-12" />
<h3 className="text-lg font-semibold">Keine Dateien vorhanden</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Laden Sie Ihre erste Datei hoch.
</p>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Dateiname</th>
<th className="p-3 text-left font-medium">Typ</th>
<th className="p-3 text-right font-medium">Größe</th>
<th className="p-3 text-left font-medium">Hochgeladen</th>
<th className="p-3 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr key={file.id} className="hover:bg-muted/30 border-b">
<td className="max-w-[300px] truncate p-3 font-medium">
{file.original_name}
</td>
<td className="p-3">
<Badge variant="secondary">
{getMimeLabel(file.mime_type)}
</Badge>
</td>
<td className="text-muted-foreground p-3 text-right">
{formatFileSize(file.file_size)}
</td>
<td className="text-muted-foreground p-3">
{formatDate(file.created_at)}
</td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-1">
<a
href={file.publicUrl}
target="_blank"
rel="noopener noreferrer"
download={file.original_name}
>
<Button
variant="ghost"
size="sm"
data-test="file-download-btn"
>
<Download className="h-4 w-4" />
</Button>
</a>
<DeleteConfirmButton
title="Datei löschen"
description="Möchten Sie diese Datei wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."
isPending={isDeleting}
onConfirm={() => executeDelete({ fileId: file.id })}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-muted-foreground text-sm">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
data-test="files-prev-page"
>
Zurück
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
data-test="files-next-page"
>
Weiter
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,70 @@
import { createModuleBuilderApi } from '@kit/module-builder/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { ListToolbar } from '@kit/ui/list-toolbar';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { FileUploadDialog } from './file-upload-dialog';
import { FilesTable } from './files-table';
interface Props {
params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function FilesPage({ params, searchParams }: Props) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createModuleBuilderApi(client);
const page = Number(search.page) || 1;
const pageSize = 25;
const result = await api.files.listFiles(acct.id, {
search: search.q as string,
page,
pageSize,
});
// Resolve public URLs for each file
const filesWithUrls = result.data.map((file) => ({
id: String(file.id),
file_name: String(file.file_name),
original_name: String(file.original_name),
mime_type: String(file.mime_type),
file_size: Number(file.file_size),
created_at: String(file.created_at),
storage_path: String(file.storage_path),
publicUrl: api.files.getPublicUrl(String(file.storage_path)),
}));
return (
<CmsPageShell
account={account}
title="Dateien"
description="Dateien hochladen und verwalten"
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<ListToolbar searchPlaceholder="Datei suchen..." />
<FileUploadDialog accountId={acct.id} />
</div>
<FilesTable
files={filesWithUrls}
pagination={{ total: result.total, page, pageSize }}
/>
</div>
</CmsPageShell>
);
}