prod setup
This commit is contained in:
223
src/components/admin/fields/FileUploadField.tsx
Normal file
223
src/components/admin/fields/FileUploadField.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user