123 lines
3.7 KiB
TypeScript
123 lines
3.7 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
|
|
type Props = {
|
|
section: string;
|
|
initial: unknown;
|
|
};
|
|
|
|
type Status =
|
|
| { kind: 'idle' }
|
|
| { kind: 'saving' }
|
|
| { kind: 'saved' }
|
|
| { kind: 'error'; message: string }
|
|
| { kind: 'invalid' };
|
|
|
|
export function RawJsonEditor({ section, initial }: Props) {
|
|
const [value, setValue] = useState(() => JSON.stringify(initial, null, 2));
|
|
const [status, setStatus] = useState<Status>({ kind: 'idle' });
|
|
const taRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const format = () => {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
setValue(JSON.stringify(parsed, null, 2));
|
|
setStatus({ kind: 'idle' });
|
|
} catch {
|
|
setStatus({ kind: 'invalid' });
|
|
}
|
|
};
|
|
|
|
const save = async () => {
|
|
let body: unknown;
|
|
try {
|
|
body = JSON.parse(value);
|
|
} catch {
|
|
setStatus({ kind: 'invalid' });
|
|
return;
|
|
}
|
|
setStatus({ kind: 'saving' });
|
|
try {
|
|
const res = await fetch(`/api/content/${section}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setStatus({ kind: 'error', message: data.message || data.error || 'erreur' });
|
|
return;
|
|
}
|
|
setStatus({ kind: 'saved' });
|
|
setTimeout(() => setStatus({ kind: 'idle' }), 2500);
|
|
} catch (e) {
|
|
setStatus({ kind: 'error', message: (e as Error).message });
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
e.preventDefault();
|
|
save();
|
|
}
|
|
};
|
|
const ta = taRef.current;
|
|
ta?.addEventListener('keydown', onKey as EventListener);
|
|
return () => ta?.removeEventListener('keydown', onKey as EventListener);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [value]);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<p className="font-mono text-xs text-[color:var(--color-text-dim)]">
|
|
Mode avancé — JSON brut, validé Zod côté serveur.
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<StatusPill status={status} />
|
|
<button type="button" onClick={format} className="btn-ghost text-xs py-1.5">
|
|
Formater
|
|
</button>
|
|
<button type="button" onClick={save} className="btn-primary text-xs py-1.5">
|
|
Enregistrer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<textarea
|
|
ref={taRef}
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
spellCheck={false}
|
|
className="w-full min-h-[400px] h-[calc(100vh-300px)] max-h-[80vh] bg-[color:var(--color-surface)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-4 rounded text-sm font-mono text-[color:var(--color-text)] resize-none"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusPill({ status }: { status: Status }) {
|
|
if (status.kind === 'idle') return null;
|
|
if (status.kind === 'saving')
|
|
return (
|
|
<span className="font-mono text-xs text-[color:var(--color-text-muted)]">
|
|
enregistrement…
|
|
</span>
|
|
);
|
|
if (status.kind === 'saved')
|
|
return (
|
|
<span className="font-mono text-xs text-[color:var(--color-cyan)] glow-text-cyan">
|
|
✓ enregistré
|
|
</span>
|
|
);
|
|
if (status.kind === 'invalid')
|
|
return (
|
|
<span className="font-mono text-xs text-[color:var(--color-magenta)]">
|
|
JSON invalide
|
|
</span>
|
|
);
|
|
return (
|
|
<span className="font-mono text-xs text-[color:var(--color-magenta)]" title={status.message}>
|
|
✗ {status.message.slice(0, 60)}
|
|
</span>
|
|
);
|
|
}
|