prod setup

This commit is contained in:
Johan LEROY
2026-05-04 11:00:44 +02:00
parent 7c7ff160eb
commit 5d05cd4c04
46 changed files with 2016 additions and 190 deletions

View 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>
);
}}
/>
);
}