Kaart om bestanden aan te leveren: icoon, titel en toelichting bovenin, daaronder het neerzetvak met de gekozen bestanden (file-dropzone), en een voet met telling, een tweede actie (slot) en de hoofdknop die pas actief wordt zodra er iets gekozen is. onSubmit krijgt de files; uploaden is een projectzaak. Componeert file-dropzone en button.
Documenten aanleveren
Zet je bestanden neer of kies ze; PDF, DOCX of afbeeldingen tot 10 MB.
Sleep bestanden hierheen of klik om te kiezen.pdf, .docx, image/* · Max. 10.0 MB
De hoofdknop wordt actief zodra er een bestand ligt.
blocks/upload-card/upload-card.tsx
/**
* UploadCard — een kaart om bestanden aan te leveren: icoon, titel en
* toelichting bovenin, daaronder het neerzetvak met de gekozen bestanden
* (file-dropzone), en een voet met een tweede actie (slot) en de hoofdknop
* die pas actief wordt zodra er iets gekozen is. onSubmit krijgt de files;
* uploaden is een projectzaak.
*
* Geoogst uit 21st.dev FileUpload (arunjdass): de eigen dropzone met
* useState/useRef en lucide Upload/File/X → file-dropzone (FC-0042) en
* button (FC-0003); text-gray-500, border-gray-300, bg-gray-50 → tokens;
* 'Upload your files' → props.
*/
"use client";
import { useState } from "react";
import type * as React from "react";
import { Button } from "../../primitives/ui/button";
import { FileDropzone, type FileDropzoneLabels } from "../../primitives/ui/file-dropzone";
export interface UploadCardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSubmit" | "title"> {
title: React.ReactNode;
description?: React.ReactNode;
icon?: React.ReactNode;
accept?: string;
multiple?: boolean;
maxSize?: number;
onFilesChange?: (files: File[]) => void;
onSubmit?: (files: File[]) => void | Promise<void>;
submitLabel?: string;
secondaryAction?: React.ReactNode;
labels?: Partial<FileDropzoneLabels>;
/** Telling in de voet; standaard NL. */
countLabel?: (n: number) => React.ReactNode;
}
export function UploadCard({ title, description, icon, accept, multiple = true, maxSize, onFilesChange, onSubmit, submitLabel = "Uploaden", secondaryAction, labels, countLabel = (n) => `${n} ${n === 1 ? "bestand" : "bestanden"}`, className, ...rest }: UploadCardProps) {
const [files, setFiles] = useState<File[]>([]);
const [bezig, setBezig] = useState(false);
const verstuur = async () => {
if (files.length === 0 || !onSubmit) return;
setBezig(true);
try { await onSubmit(files); } finally { setBezig(false); }
};
return (
<div {...rest} className={["fp-upload", className].filter(Boolean).join(" ")}>
<div className="fp-upload__kop">
{icon && <span className="fp-upload__icoon" aria-hidden="true">{icon}</span>}
<div>
<div className="fp-upload__titel">{title}</div>
{description && <p className="fp-upload__sub">{description}</p>}
</div>
</div>
<FileDropzone accept={accept} multiple={multiple} maxSize={maxSize} labels={labels} onFilesChange={(f) => { setFiles(f); onFilesChange?.(f); }} />
{(onSubmit || secondaryAction) && (
<div className="fp-upload__voet">
<span className="fp-upload__telling">{files.length > 0 ? countLabel(files.length) : ""}</span>
<div className="fp-upload__acties">
{secondaryAction}
{onSubmit && <Button variant="primary" disabled={files.length === 0 || bezig} onPress={verstuur}>{submitLabel}</Button>}
</div>
</div>
)}
</div>
);
}
blocks/upload-card/upload-card.css
/** UploadCard — importeren in de app-root (registry-site: app/globals.css). */
.fp-upload { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 30rem; padding: 1.25rem; border-radius: calc(var(--radius) * 1.5); border: 1px solid var(--border); background: var(--card); color: var(--card-foreground, var(--foreground)); }
.fp-upload__kop { display: flex; align-items: flex-start; gap: 0.75rem; }
.fp-upload__icoon { display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; flex: 0 0 auto; border-radius: var(--radius); background: color-mix(in srgb, var(--primary) 12%, transparent); color: var(--primary); }
.fp-upload__titel { font: inherit; font-size: 1rem; font-weight: 600; line-height: 1.3; }
.fp-upload__sub { margin: 0.125rem 0 0; font: inherit; font-size: 0.8125rem; line-height: 1.5; color: var(--muted-foreground); }
.fp-upload__voet { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); }
.fp-upload__telling { font: inherit; font-size: 0.75rem; color: var(--muted-foreground); }
.fp-upload__acties { display: flex; align-items: center; gap: 0.5rem; }