309 lines
9.4 KiB
TypeScript
309 lines
9.4 KiB
TypeScript
'use client';
|
|
|
|
import { useRef, useState } from 'react';
|
|
|
|
export type JsonValue =
|
|
| string
|
|
| number
|
|
| boolean
|
|
| null
|
|
| JsonValue[]
|
|
| { [k: string]: JsonValue };
|
|
|
|
const IMAGE_KEYS = new Set(['cover', 'image', 'avatar', 'gallery', 'logo', 'icon']);
|
|
const IMAGE_RE = /\.(png|jpe?g|svg|webp|gif|avif)$/i;
|
|
|
|
function looksLikeImage(key: string | undefined, value: string): boolean {
|
|
if (key && IMAGE_KEYS.has(key)) return true;
|
|
return IMAGE_RE.test(value) || value.startsWith('/api/uploads/') || value.startsWith('/portfolio/');
|
|
}
|
|
|
|
/** Produce an "empty" clone of a sample value, for new array entries. */
|
|
function emptyLike(sample: JsonValue | undefined): JsonValue {
|
|
if (sample === undefined || sample === null) return '';
|
|
if (typeof sample === 'string') return '';
|
|
if (typeof sample === 'number') return 0;
|
|
if (typeof sample === 'boolean') return false;
|
|
if (Array.isArray(sample)) return [];
|
|
const out: Record<string, JsonValue> = {};
|
|
for (const [k, v] of Object.entries(sample)) out[k] = emptyLike(v);
|
|
return out;
|
|
}
|
|
|
|
function humanize(key: string): string {
|
|
return key
|
|
.replace(/[_-]/g, ' ')
|
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
.replace(/^\w/, (c) => c.toUpperCase());
|
|
}
|
|
|
|
export function JsonForm({
|
|
value,
|
|
onChange,
|
|
fieldKey,
|
|
depth = 0,
|
|
}: {
|
|
value: JsonValue;
|
|
onChange: (v: JsonValue) => void;
|
|
fieldKey?: string;
|
|
depth?: number;
|
|
}) {
|
|
// ---- Primitive: string ----
|
|
if (typeof value === 'string') {
|
|
if (looksLikeImage(fieldKey, value)) {
|
|
return <ImageInput value={value} onChange={onChange} />;
|
|
}
|
|
const multiline = value.length > 64 || value.includes('\n');
|
|
return multiline ? (
|
|
<textarea
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
rows={Math.min(8, Math.max(2, Math.ceil(value.length / 60)))}
|
|
className="w-full resize-y rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
|
|
/>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ---- Primitive: number ----
|
|
if (typeof value === 'number') {
|
|
return (
|
|
<input
|
|
type="number"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value === '' ? 0 : Number(e.target.value))}
|
|
className="w-40 rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ---- Primitive: boolean ----
|
|
if (typeof value === 'boolean') {
|
|
return (
|
|
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-slate-300">
|
|
<input
|
|
type="checkbox"
|
|
checked={value}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
className="h-4 w-4 accent-electric"
|
|
/>
|
|
{value ? 'true' : 'false'}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
// ---- null ----
|
|
if (value === null) {
|
|
return (
|
|
<input
|
|
type="text"
|
|
value=""
|
|
placeholder="(empty)"
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ---- Array ----
|
|
if (Array.isArray(value)) {
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{value.map((item, i) => (
|
|
<div
|
|
key={i}
|
|
className="rounded-xl border border-white/8 bg-white/[0.015] p-3"
|
|
>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<span className="font-mono text-[0.65rem] uppercase tracking-wider text-slate-500">
|
|
{fieldKey ? humanize(fieldKey).replace(/s$/, '') : 'Item'} #{i + 1}
|
|
</span>
|
|
<div className="flex gap-1">
|
|
{i > 0 && (
|
|
<MiniBtn label="↑" title="Move up" onClick={() => {
|
|
const cp = [...value];
|
|
[cp[i - 1], cp[i]] = [cp[i], cp[i - 1]];
|
|
onChange(cp);
|
|
}} />
|
|
)}
|
|
{i < value.length - 1 && (
|
|
<MiniBtn label="↓" title="Move down" onClick={() => {
|
|
const cp = [...value];
|
|
[cp[i + 1], cp[i]] = [cp[i], cp[i + 1]];
|
|
onChange(cp);
|
|
}} />
|
|
)}
|
|
<MiniBtn
|
|
label="✕"
|
|
title="Remove"
|
|
danger
|
|
onClick={() => onChange(value.filter((_, j) => j !== i))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<JsonForm
|
|
value={item}
|
|
fieldKey={fieldKey}
|
|
depth={depth + 1}
|
|
onChange={(nv) => {
|
|
const cp = [...value];
|
|
cp[i] = nv;
|
|
onChange(cp);
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange([...value, emptyLike(value[0])])}
|
|
className="self-start rounded-lg border border-electric/30 bg-electric/5 px-3 py-1.5 text-xs font-medium text-electric transition-colors hover:bg-electric/10"
|
|
>
|
|
+ Add {fieldKey ? humanize(fieldKey).replace(/s$/, '').toLowerCase() : 'item'}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---- Object ----
|
|
return (
|
|
<div
|
|
className={
|
|
depth === 0
|
|
? 'flex flex-col gap-5'
|
|
: 'flex flex-col gap-4 rounded-xl border-s-2 border-white/10 ps-4'
|
|
}
|
|
>
|
|
{Object.entries(value).map(([k, v]) => {
|
|
const primitive =
|
|
v === null || ['string', 'number', 'boolean'].includes(typeof v);
|
|
return (
|
|
<div
|
|
key={k}
|
|
className={primitive ? 'flex flex-col gap-1.5' : 'flex flex-col gap-2'}
|
|
>
|
|
<label className="font-mono text-[0.68rem] uppercase tracking-wider text-slate-400">
|
|
{humanize(k)}
|
|
</label>
|
|
<JsonForm
|
|
value={v}
|
|
fieldKey={k}
|
|
depth={depth + 1}
|
|
onChange={(nv) => onChange({ ...value, [k]: nv })}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MiniBtn({
|
|
label,
|
|
title,
|
|
onClick,
|
|
danger,
|
|
}: {
|
|
label: string;
|
|
title: string;
|
|
onClick: () => void;
|
|
danger?: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
title={title}
|
|
onClick={onClick}
|
|
className={
|
|
'inline-flex h-6 w-6 items-center justify-center rounded-md border text-xs transition-colors ' +
|
|
(danger
|
|
? 'border-magenta/30 text-magenta hover:bg-magenta/10'
|
|
: 'border-white/10 text-slate-400 hover:bg-white/[0.06] hover:text-white')
|
|
}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ImageInput({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
}) {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
async function upload(file: File) {
|
|
setUploading(true);
|
|
setErr(null);
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
const res = await fetch('/api/admin/upload', { method: 'POST', body: fd });
|
|
if (!res.ok) throw new Error(`upload failed (${res.status})`);
|
|
const json = await res.json();
|
|
if (json?.url) onChange(json.url as string);
|
|
else throw new Error('no url returned');
|
|
} catch (e) {
|
|
setErr(e instanceof Error ? e.message : 'upload error');
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-start gap-3">
|
|
<div className="relative h-16 w-24 shrink-0 overflow-hidden rounded-lg border border-white/10 bg-base-900/60">
|
|
{value ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={value} alt="" className="h-full w-full object-cover" />
|
|
) : (
|
|
<span className="flex h-full w-full items-center justify-center text-[0.6rem] text-slate-600">
|
|
no image
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex grow flex-col gap-2">
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder="/api/uploads/… or /portfolio/…"
|
|
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2 text-sm text-slate-100 outline-none focus:border-electric/60"
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => inputRef.current?.click()}
|
|
disabled={uploading}
|
|
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-slate-200 transition-colors hover:bg-white/[0.08] disabled:opacity-50"
|
|
>
|
|
{uploading ? 'Uploading…' : 'Upload image'}
|
|
</button>
|
|
{err && <span className="text-xs text-magenta">{err}</span>}
|
|
</div>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) upload(f);
|
|
e.target.value = '';
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|