Files
portfolio/src/components/admin/fields/FileUploadField.tsx
Johan LEROY 5d05cd4c04 prod setup
2026-05-04 11:00:44 +02:00

224 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}}
/>
);
}