prod setup
This commit is contained in:
46
src/components/admin/EditPanel.tsx
Normal file
46
src/components/admin/EditPanel.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ContentKey } from '../../content/schemas';
|
||||
import { EditTabs } from './EditTabs';
|
||||
import { RawJsonEditor } from './RawJsonEditor';
|
||||
import { ProfileForm } from './forms/ProfileForm';
|
||||
import { SkillsForm } from './forms/SkillsForm';
|
||||
import { ExperiencesForm } from './forms/ExperiencesForm';
|
||||
import { FormationsForm } from './forms/FormationsForm';
|
||||
import { ProjectsForm } from './forms/ProjectsForm';
|
||||
import { InterestsForm } from './forms/InterestsForm';
|
||||
import { SiteForm } from './forms/SiteForm';
|
||||
|
||||
type Props = {
|
||||
section: ContentKey;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
export function EditPanel({ section, data }: Props) {
|
||||
return (
|
||||
<EditTabs
|
||||
section={section}
|
||||
formNode={renderForm(section, data)}
|
||||
rawNode={<RawJsonEditor section={section} initial={data} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderForm(section: ContentKey, data: unknown) {
|
||||
switch (section) {
|
||||
case 'profile':
|
||||
return <ProfileForm defaultValues={data as never} />;
|
||||
case 'skills':
|
||||
return <SkillsForm defaultValues={data as never} />;
|
||||
case 'experiences':
|
||||
return <ExperiencesForm defaultValues={data as never} />;
|
||||
case 'formations':
|
||||
return <FormationsForm defaultValues={data as never} />;
|
||||
case 'projects':
|
||||
return <ProjectsForm defaultValues={data as never} />;
|
||||
case 'interests':
|
||||
return <InterestsForm defaultValues={data as never} />;
|
||||
case 'site':
|
||||
return <SiteForm defaultValues={data as never} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
83
src/components/admin/EditTabs.tsx
Normal file
83
src/components/admin/EditTabs.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
type Tab = 'form' | 'raw';
|
||||
|
||||
type Props = {
|
||||
section: string;
|
||||
formNode: ReactNode;
|
||||
rawNode: ReactNode;
|
||||
};
|
||||
|
||||
export function EditTabs({ section, formNode, rawNode }: Props) {
|
||||
const storageKey = `admin:tab:${section}`;
|
||||
const [tab, setTab] = useState<Tab>('form');
|
||||
|
||||
useEffect(() => {
|
||||
const saved = sessionStorage.getItem(storageKey) as Tab | null;
|
||||
if (saved === 'form' || saved === 'raw') setTab(saved);
|
||||
}, [storageKey]);
|
||||
|
||||
const switchTo = (t: Tab) => {
|
||||
setTab(t);
|
||||
sessionStorage.setItem(storageKey, t);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav
|
||||
role="tablist"
|
||||
aria-label="Mode d'édition"
|
||||
className="flex gap-1 border-b border-[color:var(--color-border)] mb-6"
|
||||
>
|
||||
<TabButton
|
||||
active={tab === 'form'}
|
||||
onClick={() => switchTo('form')}
|
||||
label="Formulaire"
|
||||
tag="// guidé"
|
||||
/>
|
||||
<TabButton
|
||||
active={tab === 'raw'}
|
||||
onClick={() => switchTo('raw')}
|
||||
label="JSON brut"
|
||||
tag="// avancé"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div role="tabpanel" hidden={tab !== 'form'}>
|
||||
{tab === 'form' && formNode}
|
||||
</div>
|
||||
<div role="tabpanel" hidden={tab !== 'raw'}>
|
||||
{tab === 'raw' && rawNode}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
tag,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
tag: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={onClick}
|
||||
className={`px-4 py-2 font-display uppercase tracking-widest text-sm transition-colors border-b-2 ${
|
||||
active
|
||||
? 'text-[color:var(--color-acid)] border-[color:var(--color-acid)] glow-text-acid'
|
||||
: 'text-[color:var(--color-text-muted)] border-transparent hover:text-[color:var(--color-cyan)]'
|
||||
}`}
|
||||
>
|
||||
<span className="block leading-none">{label}</span>
|
||||
<span className="block font-mono text-[9px] opacity-70 mt-0.5">{tag}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
146
src/components/admin/FormShell.tsx
Normal file
146
src/components/admin/FormShell.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
FormProvider,
|
||||
useForm,
|
||||
type DefaultValues,
|
||||
type FieldValues,
|
||||
type Resolver,
|
||||
} from 'react-hook-form';
|
||||
|
||||
type Props<T extends FieldValues> = {
|
||||
section: string;
|
||||
defaultValues: DefaultValues<T>;
|
||||
resolver: Resolver<T>;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type Status =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'saving' }
|
||||
| { kind: 'saved' }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export function FormShell<T extends FieldValues>({
|
||||
section,
|
||||
defaultValues,
|
||||
resolver,
|
||||
children,
|
||||
}: Props<T>) {
|
||||
const methods = useForm<T>({ defaultValues, resolver, mode: 'onBlur' });
|
||||
const [status, setStatus] = useState<Status>({ kind: 'idle' });
|
||||
|
||||
const onSubmit = methods.handleSubmit(async (values) => {
|
||||
setStatus({ kind: 'saving' });
|
||||
try {
|
||||
const res = await fetch(`/api/content/${section}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setStatus({ kind: 'error', message: data.message || data.error || 'erreur' });
|
||||
return;
|
||||
}
|
||||
setStatus({ kind: 'saved' });
|
||||
methods.reset(values as DefaultValues<T>);
|
||||
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();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onSubmit]);
|
||||
|
||||
const dirty = methods.formState.isDirty;
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="sticky top-16 z-30 -mx-4 px-4 py-3 bg-[rgba(10,10,15,0.85)] backdrop-blur-xl border-b border-[color:var(--color-border)] flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Pill status={status} dirty={dirty} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{dirty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => methods.reset()}
|
||||
className="font-mono text-xs uppercase tracking-widest text-[color:var(--color-text-muted)] hover:text-[color:var(--color-magenta)] px-3 py-1.5 transition-colors"
|
||||
>
|
||||
annuler
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status.kind === 'saving'}
|
||||
className="btn-primary text-xs py-1.5 disabled:opacity-50"
|
||||
>
|
||||
{status.kind === 'saving' ? 'enregistrement…' : 'enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">{children}</div>
|
||||
|
||||
<div className="pt-6 border-t border-[color:var(--color-border)] flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status.kind === 'saving'}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{status.kind === 'saving' ? 'enregistrement…' : 'enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({ status, dirty }: { status: Status; dirty: boolean }) {
|
||||
if (status.kind === 'saving')
|
||||
return (
|
||||
<span className="flex items-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-acid)]">
|
||||
<span className="led led-acid animate-led-red" />
|
||||
enregistrement…
|
||||
</span>
|
||||
);
|
||||
if (status.kind === 'saved')
|
||||
return (
|
||||
<span className="sticker text-[color:var(--color-cyan)] glow-text-cyan animate-pulse-neon">
|
||||
✓ saved
|
||||
</span>
|
||||
);
|
||||
if (status.kind === 'error')
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-magenta)]"
|
||||
title={status.message}
|
||||
>
|
||||
<span className="led led-red animate-led-red" />
|
||||
✗ {status.message.slice(0, 80)}
|
||||
</span>
|
||||
);
|
||||
if (dirty)
|
||||
return (
|
||||
<span className="flex items-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-text-muted)]">
|
||||
<span className="led" style={{ background: 'var(--color-text-muted)', boxShadow: 'none' }} />
|
||||
modifications non sauvées
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="flex items-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-text-dim)]">
|
||||
<span className="led led-cyan" />
|
||||
synced
|
||||
</span>
|
||||
);
|
||||
}
|
||||
122
src/components/admin/RawJsonEditor.tsx
Normal file
122
src/components/admin/RawJsonEditor.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
23
src/components/admin/Section.tsx
Normal file
23
src/components/admin/Section.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Section({ title, subtitle, children }: Props) {
|
||||
return (
|
||||
<section className="card-neon space-y-4">
|
||||
<header className="flex items-baseline gap-3 pb-2 border-b border-[color:var(--color-border)]">
|
||||
<h2 className="font-display uppercase tracking-widest text-lg text-[color:var(--color-cyan)] glow-text-cyan">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<span className="font-mono text-xs text-[color:var(--color-text-dim)]">{subtitle}</span>
|
||||
)}
|
||||
</header>
|
||||
<div className="space-y-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
99
src/components/admin/fields/ArrayEditor.tsx
Normal file
99
src/components/admin/fields/ArrayEditor.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
type Props<T> = {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
newItem: () => T;
|
||||
itemLabel?: (index: number) => string;
|
||||
children: (params: { index: number; namePrefix: string }) => ReactNode;
|
||||
};
|
||||
|
||||
export function ArrayEditor<T>({
|
||||
name,
|
||||
label,
|
||||
hint,
|
||||
newItem,
|
||||
itemLabel,
|
||||
children,
|
||||
}: Props<T>) {
|
||||
const { control } = useFormContext();
|
||||
const { fields, append, remove, move } = useFieldArray({ control, name });
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-display uppercase tracking-widest text-base text-[color:var(--color-acid)] glow-text-acid">
|
||||
{label}
|
||||
</h3>
|
||||
{hint && (
|
||||
<p className="font-mono text-[10px] text-[color:var(--color-text-dim)] mt-0.5">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => append(newItem() as never)}
|
||||
className="px-3 py-1.5 font-mono text-xs uppercase tracking-widest text-[color:var(--color-bg)] bg-[color:var(--color-acid)] rounded hover:opacity-90 transition-opacity"
|
||||
>
|
||||
+ ajouter
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<div className="font-mono text-xs text-[color:var(--color-text-dim)] italic border border-dashed border-[color:var(--color-border)] rounded p-4 text-center">
|
||||
(aucun élément — clique « ajouter » pour commencer)
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ol className="space-y-3">
|
||||
{fields.map((field, idx) => (
|
||||
<li
|
||||
key={field.id}
|
||||
className="border border-[color:var(--color-border)] rounded bg-[color:var(--color-surface)] p-4 relative"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3 pb-2 border-b border-[color:var(--color-border)]">
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-cyan)]">
|
||||
{itemLabel ? itemLabel(idx) : `# ${String(idx + 1).padStart(2, '0')}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => idx > 0 && move(idx, idx - 1)}
|
||||
disabled={idx === 0}
|
||||
className="px-2 py-1 font-mono text-xs text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] disabled:opacity-30"
|
||||
aria-label="monter"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => idx < fields.length - 1 && move(idx, idx + 1)}
|
||||
disabled={idx === fields.length - 1}
|
||||
className="px-2 py-1 font-mono text-xs text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] disabled:opacity-30"
|
||||
aria-label="descendre"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(idx)}
|
||||
className="px-2 py-1 font-mono text-xs text-[color:var(--color-magenta)] hover:text-[color:var(--color-bg)] hover:bg-[color:var(--color-magenta)] rounded transition-colors"
|
||||
aria-label="supprimer"
|
||||
>
|
||||
supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{children({ index: idx, namePrefix: `${name}.${idx}` })}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
95
src/components/admin/fields/ChipsField.tsx
Normal file
95
src/components/admin/fields/ChipsField.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState, type KeyboardEvent } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { FieldError } from './FieldError';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export function ChipsField({ name, label, hint, placeholder = 'Ajouter puis Entrée…' }: Props) {
|
||||
const { control } = useFormContext();
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const items: string[] = Array.isArray(field.value) ? field.value : [];
|
||||
|
||||
const add = () => {
|
||||
const value = draft.trim();
|
||||
if (!value) return;
|
||||
if (items.includes(value)) {
|
||||
setDraft('');
|
||||
return;
|
||||
}
|
||||
field.onChange([...items, value]);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const remove = (idx: number) => {
|
||||
field.onChange(items.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
add();
|
||||
} else if (e.key === 'Backspace' && draft === '' && items.length > 0) {
|
||||
field.onChange(items.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
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-wrap gap-2 mb-2">
|
||||
{items.map((item, idx) => (
|
||||
<span
|
||||
key={`${item}-${idx}`}
|
||||
className="chip group cursor-pointer"
|
||||
onClick={() => remove(idx)}
|
||||
title="Cliquer pour retirer"
|
||||
>
|
||||
{item}
|
||||
<span className="ml-1 opacity-50 group-hover:opacity-100 group-hover:text-[color:var(--color-magenta)]">
|
||||
×
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2 rounded text-sm font-mono text-[color:var(--color-text)] transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
className="px-3 py-1.5 font-mono text-xs uppercase tracking-widest border border-[color:var(--color-cyan)] text-[color:var(--color-cyan)] rounded hover:bg-[color:var(--color-cyan)] hover:text-[color:var(--color-bg)] transition-colors"
|
||||
>
|
||||
+ add
|
||||
</button>
|
||||
</div>
|
||||
<FieldError name={name} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
55
src/components/admin/fields/EmojiField.tsx
Normal file
55
src/components/admin/fields/EmojiField.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { FieldError } from './FieldError';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
const SUGGESTIONS = ['⚡', '🚀', '💼', '🎓', '🎧', '🛠️', '🎵', '💻', '📱', '🤖', '☁️', '🔥', '✨', '🎯', '🎨', '🌐'];
|
||||
|
||||
export function EmojiField({ name, label, hint }: Props) {
|
||||
const { register, setValue, watch } = useFormContext();
|
||||
const value = watch(name);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block">
|
||||
<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 items-center gap-2">
|
||||
<span className="text-2xl w-12 h-12 flex items-center justify-center bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] rounded">
|
||||
{value || '·'}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={4}
|
||||
{...register(name)}
|
||||
className="flex-1 bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2.5 rounded text-sm font-mono text-[color:var(--color-text)] transition-colors"
|
||||
placeholder="emoji ou caractère"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setValue(name, s, { shouldDirty: true, shouldValidate: true })}
|
||||
className="w-8 h-8 flex items-center justify-center text-base hover:bg-[color:var(--color-surface-2)] rounded transition-colors"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FieldError name={name} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src/components/admin/fields/FieldError.tsx
Normal file
17
src/components/admin/fields/FieldError.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useFormContext, get } from 'react-hook-form';
|
||||
|
||||
type Props = { name: string };
|
||||
|
||||
export function FieldError({ name }: Props) {
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext();
|
||||
const error = get(errors, name);
|
||||
if (!error?.message) return null;
|
||||
return (
|
||||
<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" />
|
||||
<span>{String(error.message)}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/components/admin/fields/ImageUploadField.tsx
Normal file
14
src/components/admin/fields/ImageUploadField.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { FileUploadField } from './FileUploadField';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
folder?: string;
|
||||
hint?: string;
|
||||
accept?: string;
|
||||
maxSizeMb?: number;
|
||||
};
|
||||
|
||||
export function ImageUploadField(props: Props) {
|
||||
return <FileUploadField {...props} kind="image" />;
|
||||
}
|
||||
38
src/components/admin/fields/SelectField.tsx
Normal file
38
src/components/admin/fields/SelectField.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { FieldError } from './FieldError';
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
options: Option[];
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export function SelectField({ name, label, options, hint }: Props) {
|
||||
const { register } = useFormContext();
|
||||
return (
|
||||
<label className="block">
|
||||
<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>
|
||||
)}
|
||||
<select
|
||||
{...register(name)}
|
||||
className="w-full bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2.5 rounded text-sm text-[color:var(--color-text)] transition-colors"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FieldError name={name} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
34
src/components/admin/fields/TextAreaField.tsx
Normal file
34
src/components/admin/fields/TextAreaField.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { FieldError } from './FieldError';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
mono?: boolean;
|
||||
};
|
||||
|
||||
export function TextAreaField({ name, label, hint, placeholder, rows = 4, mono = false }: Props) {
|
||||
const { register } = useFormContext();
|
||||
return (
|
||||
<label className="block">
|
||||
<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>
|
||||
)}
|
||||
<textarea
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
{...register(name)}
|
||||
className={`w-full bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2.5 rounded text-sm text-[color:var(--color-text)] transition-colors resize-y ${mono ? 'font-mono' : ''}`}
|
||||
/>
|
||||
<FieldError name={name} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
33
src/components/admin/fields/TextField.tsx
Normal file
33
src/components/admin/fields/TextField.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { FieldError } from './FieldError';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'email' | 'url';
|
||||
};
|
||||
|
||||
export function TextField({ name, label, hint, placeholder, type = 'text' }: Props) {
|
||||
const { register } = useFormContext();
|
||||
return (
|
||||
<label className="block">
|
||||
<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>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
{...register(name)}
|
||||
className="w-full bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2.5 rounded text-sm text-[color:var(--color-text)] transition-colors"
|
||||
/>
|
||||
<FieldError name={name} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
53
src/components/admin/fields/ToggleField.tsx
Normal file
53
src/components/admin/fields/ToggleField.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { FieldError } from './FieldError';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export function ToggleField({ name, label, hint }: Props) {
|
||||
const { control } = useFormContext();
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const on = !!field.value;
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
onClick={() => field.onChange(!on)}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded border bg-[color:var(--color-surface-2)] transition-colors ${
|
||||
on
|
||||
? 'border-[color:var(--color-acid)] text-[color:var(--color-acid)]'
|
||||
: 'border-[color:var(--color-border)] text-[color:var(--color-text-muted)]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`led ${on ? 'led-acid animate-led-green' : ''}`}
|
||||
style={!on ? { background: 'var(--color-text-dim)', boxShadow: 'none' } : undefined}
|
||||
/>
|
||||
<span className="font-mono text-xs uppercase tracking-widest">
|
||||
{label}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] opacity-70">
|
||||
{on ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</button>
|
||||
{hint && (
|
||||
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mt-1">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
<FieldError name={name} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
78
src/components/admin/forms/ExperiencesForm.tsx
Normal file
78
src/components/admin/forms/ExperiencesForm.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ExperiencesSchema, type Experiences } from '../../../content/schemas/experiences';
|
||||
import { FormShell } from '../FormShell';
|
||||
import { Section } from '../Section';
|
||||
import { TextField } from '../fields/TextField';
|
||||
import { TextAreaField } from '../fields/TextAreaField';
|
||||
import { SelectField } from '../fields/SelectField';
|
||||
import { ToggleField } from '../fields/ToggleField';
|
||||
import { ChipsField } from '../fields/ChipsField';
|
||||
import { ImageUploadField } from '../fields/ImageUploadField';
|
||||
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||
|
||||
type Props = { defaultValues: Experiences };
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'cdi', label: 'CDI' },
|
||||
{ value: 'cdd', label: 'CDD' },
|
||||
{ value: 'stage', label: 'Stage' },
|
||||
{ value: 'alternance', label: 'Alternance' },
|
||||
{ value: 'freelance', label: 'Freelance' },
|
||||
];
|
||||
|
||||
export function ExperiencesForm({ defaultValues }: Props) {
|
||||
return (
|
||||
<FormShell<Experiences>
|
||||
section="experiences"
|
||||
defaultValues={defaultValues as never}
|
||||
resolver={zodResolver(ExperiencesSchema) as never}
|
||||
>
|
||||
<Section title="Expériences" subtitle="// setlist">
|
||||
<ArrayEditor
|
||||
name="items"
|
||||
label="Postes"
|
||||
newItem={() =>
|
||||
({
|
||||
id: '',
|
||||
company: '',
|
||||
role: '',
|
||||
period: '',
|
||||
duration: '',
|
||||
description: '',
|
||||
missions: [],
|
||||
stack: [],
|
||||
type: 'cdi' as const,
|
||||
current: false,
|
||||
}) as never
|
||||
}
|
||||
itemLabel={(i) => `track #${String(i + 1).padStart(2, '0')}`}
|
||||
>
|
||||
{({ namePrefix }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug unique" />
|
||||
<TextField name={`${namePrefix}.company`} label="Entreprise" />
|
||||
<TextField name={`${namePrefix}.role`} label="Rôle" />
|
||||
</div>
|
||||
<ImageUploadField name={`${namePrefix}.logo`} label="Logo" folder="company" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 items-end">
|
||||
<TextField name={`${namePrefix}.period`} label="Période" placeholder="Janv 2020 — Now" />
|
||||
<TextField name={`${namePrefix}.duration`} label="Durée" placeholder="4 ans" />
|
||||
<SelectField name={`${namePrefix}.type`} label="Type" options={TYPE_OPTIONS} />
|
||||
</div>
|
||||
<TextAreaField name={`${namePrefix}.description`} label="Description" rows={3} />
|
||||
<ChipsField name={`${namePrefix}.missions`} label="Missions" hint="Une par chip" />
|
||||
<ChipsField name={`${namePrefix}.stack`} label="Stack" hint="Technos utilisées" />
|
||||
<ToggleField name={`${namePrefix}.current`} label="Poste en cours (NOW PLAYING)" />
|
||||
</>
|
||||
)}
|
||||
</ArrayEditor>
|
||||
</Section>
|
||||
|
||||
<Section title="Compétences globales" subtitle="// patch bay">
|
||||
<ChipsField name="skills.technical" label="Techniques" />
|
||||
<ChipsField name="skills.soft" label="Transverses / soft" />
|
||||
</Section>
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
55
src/components/admin/forms/FormationsForm.tsx
Normal file
55
src/components/admin/forms/FormationsForm.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FormationsSchema, type Formations } from '../../../content/schemas/formations';
|
||||
import { FormShell } from '../FormShell';
|
||||
import { Section } from '../Section';
|
||||
import { TextField } from '../fields/TextField';
|
||||
import { TextAreaField } from '../fields/TextAreaField';
|
||||
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||
|
||||
type Props = { defaultValues: Formations };
|
||||
|
||||
export function FormationsForm({ defaultValues }: Props) {
|
||||
return (
|
||||
<FormShell<Formations>
|
||||
section="formations"
|
||||
defaultValues={defaultValues as never}
|
||||
resolver={zodResolver(FormationsSchema) as never}
|
||||
>
|
||||
<Section title="Formations" subtitle="// edu system">
|
||||
<ArrayEditor
|
||||
name="items"
|
||||
label="Cursus"
|
||||
newItem={() =>
|
||||
({
|
||||
id: '',
|
||||
title: '',
|
||||
school: '',
|
||||
period: '',
|
||||
}) as never
|
||||
}
|
||||
itemLabel={(i) => `ticket #${String(i + 1).padStart(3, '0')}`}
|
||||
>
|
||||
{({ namePrefix }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug unique" />
|
||||
<TextField name={`${namePrefix}.period`} label="Période" placeholder="2018 — 2020" />
|
||||
</div>
|
||||
<TextField name={`${namePrefix}.title`} label="Titre / diplôme" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${namePrefix}.school`} label="École" />
|
||||
<TextField
|
||||
name={`${namePrefix}.schoolUrl`}
|
||||
label="Site école"
|
||||
type="url"
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</div>
|
||||
<TextAreaField name={`${namePrefix}.description`} label="Description" rows={3} />
|
||||
</>
|
||||
)}
|
||||
</ArrayEditor>
|
||||
</Section>
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
51
src/components/admin/forms/InterestsForm.tsx
Normal file
51
src/components/admin/forms/InterestsForm.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { InterestsSchema, type Interests } from '../../../content/schemas/interests';
|
||||
import { FormShell } from '../FormShell';
|
||||
import { Section } from '../Section';
|
||||
import { TextField } from '../fields/TextField';
|
||||
import { SelectField } from '../fields/SelectField';
|
||||
import { EmojiField } from '../fields/EmojiField';
|
||||
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||
|
||||
type Props = { defaultValues: Interests };
|
||||
|
||||
const COLOR_OPTIONS = [
|
||||
{ value: 'cyan', label: '◉ cyan' },
|
||||
{ value: 'magenta', label: '◉ magenta' },
|
||||
{ value: 'violet', label: '◉ violet' },
|
||||
{ value: 'ice', label: '◉ ice' },
|
||||
];
|
||||
|
||||
export function InterestsForm({ defaultValues }: Props) {
|
||||
return (
|
||||
<FormShell<Interests>
|
||||
section="interests"
|
||||
defaultValues={defaultValues as never}
|
||||
resolver={zodResolver(InterestsSchema) as never}
|
||||
>
|
||||
<Section title="Centres d'intérêt" subtitle="// off decks">
|
||||
<ArrayEditor
|
||||
name="items"
|
||||
label="Items"
|
||||
newItem={() =>
|
||||
({ id: '', label: '', icon: '🎧', color: 'cyan' as const }) as never
|
||||
}
|
||||
itemLabel={(i) => `pad #${String(i + 1).padStart(2, '0')}`}
|
||||
>
|
||||
{({ namePrefix }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug unique" />
|
||||
<TextField name={`${namePrefix}.label`} label="Label" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<EmojiField name={`${namePrefix}.icon`} label="Icône" />
|
||||
<SelectField name={`${namePrefix}.color`} label="Couleur" options={COLOR_OPTIONS} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ArrayEditor>
|
||||
</Section>
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
107
src/components/admin/forms/ProfileForm.tsx
Normal file
107
src/components/admin/forms/ProfileForm.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ProfileSchema, type Profile } from '../../../content/schemas/profile';
|
||||
import { FormShell } from '../FormShell';
|
||||
import { Section } from '../Section';
|
||||
import { TextField } from '../fields/TextField';
|
||||
import { TextAreaField } from '../fields/TextAreaField';
|
||||
import { SelectField } from '../fields/SelectField';
|
||||
import { ToggleField } from '../fields/ToggleField';
|
||||
import { ChipsField } from '../fields/ChipsField';
|
||||
import { ImageUploadField } from '../fields/ImageUploadField';
|
||||
import { FileUploadField } from '../fields/FileUploadField';
|
||||
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||
|
||||
type Props = { defaultValues: Profile };
|
||||
|
||||
export function ProfileForm({ defaultValues }: Props) {
|
||||
return (
|
||||
<FormShell<Profile>
|
||||
section="profile"
|
||||
defaultValues={defaultValues as never}
|
||||
resolver={zodResolver(ProfileSchema) as never}
|
||||
>
|
||||
<Section title="Identité" subtitle="// owner">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name="name" label="Nom" />
|
||||
<TextField name="title" label="Titre" />
|
||||
</div>
|
||||
<TextField name="tagline" label="Tagline" hint="Phrase courte affichée sous le nom" />
|
||||
<TextAreaField name="bio" label="Bio" rows={5} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-end">
|
||||
<ToggleField name="available" label="Disponible" hint="Affiche le badge ON AIR" />
|
||||
<TextField name="availableLabel" label="Texte du badge" hint="ex : Disponible — Nantes" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Hero" subtitle="// stage">
|
||||
<ImageUploadField name="hero.photo" label="Photo principale" folder="pp" />
|
||||
<ChipsField
|
||||
name="hero.roles"
|
||||
label="Rôles affichés"
|
||||
hint="Mots-clés morphés dans le hero (ex : dev, DJ, dev)"
|
||||
/>
|
||||
<ArrayEditor
|
||||
name="hero.photos"
|
||||
label="Galerie hero"
|
||||
hint="Photos additionnelles (slideshow / mosaïque)"
|
||||
newItem={() => ''}
|
||||
itemLabel={(i) => `photo #${String(i + 1).padStart(2, '0')}`}
|
||||
>
|
||||
{({ namePrefix }) => (
|
||||
<ImageUploadField name={namePrefix} label="URL" folder="pp" />
|
||||
)}
|
||||
</ArrayEditor>
|
||||
<ArrayEditor
|
||||
name="hero.cta"
|
||||
label="Boutons CTA"
|
||||
hint="Actions principales sous le hero"
|
||||
newItem={() => ({ label: '', href: '', variant: 'primary' as const, external: false })}
|
||||
itemLabel={(i) => `cta #${String(i + 1).padStart(2, '0')}`}
|
||||
>
|
||||
{({ namePrefix }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${namePrefix}.label`} label="Label" />
|
||||
<TextField name={`${namePrefix}.href`} label="Lien" placeholder="/projets ou https://…" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-end">
|
||||
<SelectField
|
||||
name={`${namePrefix}.variant`}
|
||||
label="Style"
|
||||
options={[
|
||||
{ value: 'primary', label: 'Primary (acid)' },
|
||||
{ value: 'ghost', label: 'Ghost (cyan)' },
|
||||
]}
|
||||
/>
|
||||
<ToggleField
|
||||
name={`${namePrefix}.external`}
|
||||
label="Externe (target=_blank)"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ArrayEditor>
|
||||
</Section>
|
||||
|
||||
<Section title="Réseaux" subtitle="// patch bay">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name="socials.email" label="Email" type="email" placeholder="hello@example.com" />
|
||||
<TextField name="socials.github" label="GitHub" type="url" placeholder="https://github.com/…" />
|
||||
<TextField name="socials.gitea" label="Gitea" type="url" />
|
||||
<TextField name="socials.linkedin" label="LinkedIn" type="url" />
|
||||
<TextField name="socials.website" label="Site personnel" type="url" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="CV" subtitle="// dossier">
|
||||
<FileUploadField
|
||||
name="cv"
|
||||
label="Fichier PDF du CV"
|
||||
folder="cv"
|
||||
kind="document"
|
||||
hint="Glisser-déposer un PDF (max 8 Mo)"
|
||||
/>
|
||||
</Section>
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
79
src/components/admin/forms/ProjectsForm.tsx
Normal file
79
src/components/admin/forms/ProjectsForm.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ProjectsSchema, type Projects } from '../../../content/schemas/projects';
|
||||
import { FormShell } from '../FormShell';
|
||||
import { Section } from '../Section';
|
||||
import { TextField } from '../fields/TextField';
|
||||
import { TextAreaField } from '../fields/TextAreaField';
|
||||
import { SelectField } from '../fields/SelectField';
|
||||
import { ToggleField } from '../fields/ToggleField';
|
||||
import { ChipsField } from '../fields/ChipsField';
|
||||
import { ImageUploadField } from '../fields/ImageUploadField';
|
||||
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||
|
||||
type Props = { defaultValues: Projects };
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: 'web', label: 'Web' },
|
||||
{ value: 'mobile', label: 'Mobile' },
|
||||
{ value: 'bot', label: 'Bot' },
|
||||
{ value: 'infra', label: 'Infra' },
|
||||
{ value: 'other', label: 'Autre' },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'live', label: 'Live' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
{ value: 'private', label: 'Private' },
|
||||
{ value: 'wip', label: 'WIP' },
|
||||
];
|
||||
|
||||
export function ProjectsForm({ defaultValues }: Props) {
|
||||
return (
|
||||
<FormShell<Projects>
|
||||
section="projects"
|
||||
defaultValues={defaultValues as never}
|
||||
resolver={zodResolver(ProjectsSchema) as never}
|
||||
>
|
||||
<Section title="Projets" subtitle="// crate">
|
||||
<ArrayEditor
|
||||
name="items"
|
||||
label="Projets"
|
||||
newItem={() =>
|
||||
({
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
category: 'web' as const,
|
||||
status: 'live' as const,
|
||||
stack: [],
|
||||
featured: false,
|
||||
}) as never
|
||||
}
|
||||
itemLabel={(i) => `skeud #${String(i + 1).padStart(2, '0')}`}
|
||||
>
|
||||
{({ namePrefix }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug unique" />
|
||||
<TextField name={`${namePrefix}.title`} label="Titre" />
|
||||
</div>
|
||||
<TextField name={`${namePrefix}.subtitle`} label="Sous-titre" />
|
||||
<TextAreaField name={`${namePrefix}.description`} label="Description" rows={3} />
|
||||
<ImageUploadField name={`${namePrefix}.image`} label="Pochette" folder="projet" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<SelectField name={`${namePrefix}.category`} label="Catégorie" options={CATEGORY_OPTIONS} />
|
||||
<SelectField name={`${namePrefix}.status`} label="Statut" options={STATUS_OPTIONS} />
|
||||
<ToggleField name={`${namePrefix}.featured`} label="Mis en avant" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${namePrefix}.url`} label="URL live" type="url" />
|
||||
<TextField name={`${namePrefix}.repoUrl`} label="URL repo" type="url" />
|
||||
</div>
|
||||
<ChipsField name={`${namePrefix}.stack`} label="Stack" />
|
||||
</>
|
||||
)}
|
||||
</ArrayEditor>
|
||||
</Section>
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
39
src/components/admin/forms/SiteForm.tsx
Normal file
39
src/components/admin/forms/SiteForm.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { SiteSchema, type Site } from '../../../content/schemas/site';
|
||||
import { FormShell } from '../FormShell';
|
||||
import { Section } from '../Section';
|
||||
import { TextField } from '../fields/TextField';
|
||||
import { TextAreaField } from '../fields/TextAreaField';
|
||||
import { ToggleField } from '../fields/ToggleField';
|
||||
import { ChipsField } from '../fields/ChipsField';
|
||||
import { ImageUploadField } from '../fields/ImageUploadField';
|
||||
|
||||
type Props = { defaultValues: Site };
|
||||
|
||||
export function SiteForm({ defaultValues }: Props) {
|
||||
return (
|
||||
<FormShell<Site>
|
||||
section="site"
|
||||
defaultValues={defaultValues as never}
|
||||
resolver={zodResolver(SiteSchema) as never}
|
||||
>
|
||||
<Section title="Méta" subtitle="// SEO">
|
||||
<TextField name="title" label="Titre du site" />
|
||||
<TextAreaField name="description" label="Description" rows={3} />
|
||||
<TextField name="url" label="URL canonique" type="url" />
|
||||
<ChipsField name="keywords" label="Keywords" hint="Mots-clés SEO" />
|
||||
<ImageUploadField name="ogImage" label="OG image" folder="" hint="Affichée lors d'un partage social" />
|
||||
<TextField name="twitter" label="Handle Twitter / X" placeholder="@johanleroy" />
|
||||
</Section>
|
||||
|
||||
<Section title="Features" subtitle="// flags">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<ToggleField name="features.hero3D" label="Hero 3D" />
|
||||
<ToggleField name="features.smoothScroll" label="Smooth scroll (Lenis)" />
|
||||
<ToggleField name="features.customCursor" label="Curseur custom" />
|
||||
<ToggleField name="features.partyMode" label="Party mode (Konami)" />
|
||||
</div>
|
||||
</Section>
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
74
src/components/admin/forms/SkillsForm.tsx
Normal file
74
src/components/admin/forms/SkillsForm.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { SkillsSchema, type Skills } from '../../../content/schemas/skills';
|
||||
import { FormShell } from '../FormShell';
|
||||
import { Section } from '../Section';
|
||||
import { TextField } from '../fields/TextField';
|
||||
import { SelectField } from '../fields/SelectField';
|
||||
import { EmojiField } from '../fields/EmojiField';
|
||||
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||
|
||||
type Props = { defaultValues: Skills };
|
||||
|
||||
const LEVEL_OPTIONS = [
|
||||
{ value: 'primary', label: 'Primary' },
|
||||
{ value: 'secondary', label: 'Secondary' },
|
||||
];
|
||||
|
||||
function CategoryArray({ root, label, subtitle }: { root: 'primary' | 'secondary'; label: string; subtitle?: string }) {
|
||||
return (
|
||||
<Section title={label} subtitle={subtitle}>
|
||||
<ArrayEditor
|
||||
name={root}
|
||||
label="Catégories"
|
||||
newItem={() => ({ id: '', label: '', items: [] as { name: string; icon: string; level: 'primary' | 'secondary' }[] })}
|
||||
itemLabel={(i) => `catégorie #${String(i + 1).padStart(2, '0')}`}
|
||||
>
|
||||
{({ namePrefix }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug interne, ex : front, devops" />
|
||||
<TextField name={`${namePrefix}.label`} label="Label affiché" />
|
||||
</div>
|
||||
<ArrayEditor
|
||||
name={`${namePrefix}.items`}
|
||||
label="Skills"
|
||||
newItem={() => ({ name: '', icon: '⚡', level: 'primary' as const })}
|
||||
itemLabel={(i) => `skill #${String(i + 1).padStart(2, '0')}`}
|
||||
>
|
||||
{({ namePrefix: skillName }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField name={`${skillName}.name`} label="Nom" />
|
||||
<SelectField
|
||||
name={`${skillName}.level`}
|
||||
label="Niveau"
|
||||
options={LEVEL_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<EmojiField
|
||||
name={`${skillName}.icon`}
|
||||
label="Icône"
|
||||
hint="Emoji ou caractère court (slug iconify supporté)"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ArrayEditor>
|
||||
</>
|
||||
)}
|
||||
</ArrayEditor>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillsForm({ defaultValues }: Props) {
|
||||
return (
|
||||
<FormShell<Skills>
|
||||
section="skills"
|
||||
defaultValues={defaultValues as never}
|
||||
resolver={zodResolver(SkillsSchema) as never}
|
||||
>
|
||||
<CategoryArray root="primary" label="Stack primaire" subtitle="// rack 1" />
|
||||
<CategoryArray root="secondary" label="Stack secondaire" subtitle="// rack 2" />
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user