224 lines
7.7 KiB
TypeScript
224 lines
7.7 KiB
TypeScript
import { useRef, useState, type DragEvent } from 'react';
|
||
import { Controller, useFormContext } from 'react-hook-form';
|
||
import { FieldError } from './FieldError';
|
||
|
||
type Kind = 'image' | 'document';
|
||
|
||
type Props = {
|
||
name: string;
|
||
label: string;
|
||
folder?: string;
|
||
hint?: string;
|
||
accept?: string;
|
||
maxSizeMb?: number;
|
||
kind?: Kind;
|
||
};
|
||
|
||
const ACCEPT: Record<Kind, string> = {
|
||
image: 'image/png,image/jpeg,image/webp,image/svg+xml,image/gif',
|
||
document: 'application/pdf,.pdf',
|
||
};
|
||
|
||
const HELP_TEXT: Record<Kind, string> = {
|
||
image: 'png, jpg, webp, svg, gif',
|
||
document: 'pdf uniquement',
|
||
};
|
||
|
||
function fileNameFromUrl(url: string): string {
|
||
try {
|
||
const clean = url.split('?')[0];
|
||
const segments = clean.split('/');
|
||
return segments[segments.length - 1] || url;
|
||
} catch {
|
||
return url;
|
||
}
|
||
}
|
||
|
||
export function FileUploadField({
|
||
name,
|
||
label,
|
||
folder = '',
|
||
hint,
|
||
accept,
|
||
maxSizeMb,
|
||
kind = 'image',
|
||
}: Props) {
|
||
const { control } = useFormContext();
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const [hover, setHover] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const [localError, setLocalError] = useState<string | null>(null);
|
||
|
||
const effectiveAccept = accept ?? ACCEPT[kind];
|
||
const effectiveMax = maxSizeMb ?? (kind === 'document' ? 8 : 5);
|
||
|
||
return (
|
||
<Controller
|
||
name={name}
|
||
control={control}
|
||
render={({ field }) => {
|
||
const url: string = field.value || '';
|
||
|
||
const upload = async (file: File) => {
|
||
setLocalError(null);
|
||
if (file.size > effectiveMax * 1024 * 1024) {
|
||
setLocalError(`Fichier trop lourd (max ${effectiveMax} Mo).`);
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
fd.append('kind', kind);
|
||
if (folder) fd.append('folder', folder);
|
||
const res = await fetch('/api/upload', { method: 'POST', body: fd });
|
||
const data = await res.json();
|
||
if (!res.ok || !data.url) {
|
||
setLocalError(data?.error || 'upload échoué');
|
||
return;
|
||
}
|
||
field.onChange(data.url);
|
||
} catch (e) {
|
||
setLocalError((e as Error).message || 'upload échoué');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
setHover(false);
|
||
const file = e.dataTransfer.files?.[0];
|
||
if (file) upload(file);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<span className="block font-mono text-xs uppercase tracking-widest text-[color:var(--color-cyan)] mb-1.5">
|
||
{label}
|
||
</span>
|
||
{hint && (
|
||
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mb-1.5">
|
||
{hint}
|
||
</span>
|
||
)}
|
||
<div className="flex flex-col sm:flex-row sm:items-start gap-3">
|
||
{url && kind === 'image' ? (
|
||
<div className="relative shrink-0">
|
||
<img
|
||
src={url}
|
||
alt=""
|
||
className="w-28 h-28 object-cover border border-[color:var(--color-cyan)] rounded"
|
||
/>
|
||
<span className="sticker absolute -top-2 -left-2 text-[color:var(--color-cyan)]">
|
||
ok
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
|
||
{url && kind === 'document' ? (
|
||
<div className="relative shrink-0 w-full sm:w-44 p-3 border border-[color:var(--color-cyan)] rounded bg-[color:var(--color-surface-2)] flex flex-col gap-1">
|
||
<span className="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-cyan)]">
|
||
pdf actuel
|
||
</span>
|
||
<span className="font-mono text-[11px] text-[color:var(--color-text)] truncate">
|
||
{fileNameFromUrl(url)}
|
||
</span>
|
||
<a
|
||
href={url}
|
||
target="_blank"
|
||
rel="noopener"
|
||
className="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-acid)] hover:underline"
|
||
>
|
||
voir le fichier ↗
|
||
</a>
|
||
</div>
|
||
) : null}
|
||
|
||
<div
|
||
onDragEnter={(e) => {
|
||
e.preventDefault();
|
||
setHover(true);
|
||
}}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
setHover(true);
|
||
}}
|
||
onDragLeave={() => setHover(false)}
|
||
onDrop={onDrop}
|
||
onClick={() => inputRef.current?.click()}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
inputRef.current?.click();
|
||
}
|
||
}}
|
||
className={`flex-1 cursor-pointer p-4 rounded text-center transition-all ${
|
||
hover
|
||
? 'neon-border-cyan glow-cyan'
|
||
: 'border border-dashed border-[color:var(--color-border)] hover:border-[color:var(--color-cyan)]'
|
||
}`}
|
||
>
|
||
{busy ? (
|
||
<div className="flex items-center justify-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-acid)]">
|
||
<span className="led led-acid animate-led-red" />
|
||
upload en cours…
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center gap-1 font-mono text-xs uppercase tracking-widest text-[color:var(--color-text-muted)]">
|
||
<span className="text-[color:var(--color-cyan)]">
|
||
{url ? '↻ remplacer' : '↑ glisser-déposer'}
|
||
</span>
|
||
<span className="text-[10px] text-[color:var(--color-text-dim)] normal-case tracking-normal">
|
||
ou cliquer · {HELP_TEXT[kind]} (max {effectiveMax} Mo)
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{url && (
|
||
<button
|
||
type="button"
|
||
onClick={() => field.onChange('')}
|
||
className="self-start sm:self-auto shrink-0 px-2 py-1 font-mono text-xs uppercase tracking-widest text-[color:var(--color-magenta)] border border-[color:var(--color-magenta)] rounded hover:bg-[color:var(--color-magenta)] hover:text-[color:var(--color-bg)] transition-colors"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
accept={effectiveAccept}
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) upload(file);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
|
||
{url && (
|
||
<p className="mt-1 font-mono text-[10px] text-[color:var(--color-text-dim)] truncate">
|
||
{url}
|
||
</p>
|
||
)}
|
||
|
||
{localError && (
|
||
<p className="mt-1 flex items-center gap-2 font-mono text-xs text-[color:var(--color-magenta)]">
|
||
<span className="led led-red animate-led-red" />
|
||
{localError}
|
||
</p>
|
||
)}
|
||
|
||
<FieldError name={name} />
|
||
</div>
|
||
);
|
||
}}
|
||
/>
|
||
);
|
||
}
|