improve editor and add type detection

This commit is contained in:
Pierre
2026-05-14 23:31:07 +02:00
parent 3d65e7e951
commit aa45f4f32e
37 changed files with 2309 additions and 4220 deletions
+295
View File
@@ -0,0 +1,295 @@
import { useEffect, useState } from 'react';
import { LanguageCombobox } from '../components/LanguageCombobox';
import { FieldType } from './types';
interface ValueRendererProps {
value: string;
onChange: (v: string) => void;
}
interface LeafValueEditorProps extends ValueRendererProps {
type: FieldType;
}
/**
* Type-dispatched leaf value editor. Used by both the single-leaf editor
* (KVLeafEditor) and the per-row editor in the file-detail view.
*/
export function LeafValueEditor({ type, value, onChange }: LeafValueEditorProps) {
switch (type) {
case 'bool':
return (
<select
className="kv-leaf-input"
value={value === 'true' ? 'true' : value === 'false' ? 'false' : ''}
onChange={(e) => onChange(e.target.value)}
>
<option value="">(unset)</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
);
case 'number':
return (
<input
type="number"
className="kv-leaf-input"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
case 'datetime':
return <DateTimeLeaf value={value} onChange={onChange} />;
case 'lang':
return <LanguageCombobox value={value} onChange={onChange} />;
case 'url':
return (
<div className="kv-leaf-url">
<input
type="url"
className="kv-leaf-input"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{value && (
<a className="kv-leaf-url-open" href={value} target="_blank" rel="noreferrer">
open ↗
</a>
)}
</div>
);
case 'json':
return (
<textarea
className="kv-leaf-textarea kv-leaf-mono"
rows={12}
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
/>
);
case 'text':
return (
<textarea
className="kv-leaf-textarea"
rows={8}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
case 'hash':
return <CidLeaf value={value} onChange={onChange} />;
case 'auto':
case 'string':
default:
return (
<input
type="text"
className="kv-leaf-input"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
}
interface CidFileInfo {
exists: boolean;
contentType?: string;
size?: number;
filePath?: string;
}
/**
* CID leaf editor. Shows the value as an editable monospace input plus a
* content-typed preview when meta-core can resolve the CID to a file on disk
* (`/api/file/{cid}/info` → image/video/audio rendering, else download-only).
*/
function CidLeaf({ value, onChange }: ValueRendererProps) {
const [info, setInfo] = useState<CidFileInfo | null>(null);
const cid = value.trim();
const fileUrl = cid ? `/file/${cid}` : '';
const downloadName = info?.filePath?.split('/').pop() || cid;
useEffect(() => {
if (!cid) {
setInfo(null);
return;
}
let alive = true;
fetch(`/api/file/${cid}/info`)
.then((r) => (r.ok ? r.json() : { exists: false }))
.then((data: CidFileInfo) => {
if (alive) setInfo(data);
})
.catch(() => {
if (alive) setInfo({ exists: false });
});
return () => {
alive = false;
};
}, [cid]);
const renderPreview = () => {
if (!info?.exists || !info.contentType || !cid) return null;
if (info.contentType.startsWith('image/')) {
return <img src={fileUrl} alt="" className="kv-leaf-cid-preview" loading="lazy" />;
}
if (info.contentType.startsWith('video/')) {
return (
<video
src={fileUrl}
className="kv-leaf-cid-preview kv-leaf-cid-preview-media"
controls
preload="metadata"
/>
);
}
if (info.contentType.startsWith('audio/')) {
return (
<audio
src={fileUrl}
className="kv-leaf-cid-preview-media"
controls
preload="metadata"
/>
);
}
return null;
};
return (
<div className="kv-leaf-cid">
{renderPreview()}
<div className="kv-leaf-cid-controls">
<input
type="text"
className="kv-leaf-input kv-leaf-mono"
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
/>
<button
type="button"
className="kv-btn-icon"
onClick={() => navigator.clipboard.writeText(value)}
title="copy CID"
>
⧉
</button>
</div>
{info?.exists && cid && (
<div className="kv-leaf-cid-actions">
<a
href={fileUrl}
download={downloadName}
className="kv-btn kv-btn-primary"
title={`Download ${downloadName}${info.size ? ` (${formatBytes(info.size)})` : ''}`}
>
⬇ Download
</a>
<a href={fileUrl} className="kv-btn" target="_blank" rel="noreferrer">
open ↗
</a>
<span className="kv-leaf-cid-meta">
{info.contentType}
{info.size != null ? ` · ${formatBytes(info.size)}` : ''}
</span>
</div>
)}
</div>
);
}
/** Timestamp shapes we round-trip through the picker. */
type TimestampShape = 'iso-date' | 'iso-datetime' | 'unix-s' | 'unix-ms';
function detectShape(raw: string): TimestampShape | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return 'iso-date';
if (/^\d+$/.test(trimmed)) {
const n = Number.parseInt(trimmed, 10);
if (n >= 1_000_000_000 && n <= 4_102_444_800) return 'unix-s';
if (n >= 1_000_000_000_000 && n <= 4_102_444_800_000) return 'unix-ms';
return null;
}
if (!Number.isNaN(Date.parse(trimmed))) return 'iso-datetime';
return null;
}
function rawToDate(raw: string, shape: TimestampShape): Date | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (shape === 'unix-s') return new Date(Number.parseInt(trimmed, 10) * 1000);
if (shape === 'unix-ms') return new Date(Number.parseInt(trimmed, 10));
const d = new Date(trimmed);
return Number.isNaN(d.getTime()) ? null : d;
}
function dateToRaw(d: Date, shape: TimestampShape): string {
switch (shape) {
case 'unix-s':
return String(Math.floor(d.getTime() / 1000));
case 'unix-ms':
return String(d.getTime());
case 'iso-date':
return d.toISOString().slice(0, 10);
case 'iso-datetime':
return d.toISOString();
}
}
/**
* Datetime leaf editor. Preserves the original storage shape — ISO date stays
* ISO date, unix seconds stay unix seconds, etc. Unknown shapes fall back to
* iso-datetime.
*/
function DateTimeLeaf({ value, onChange }: ValueRendererProps) {
const shape: TimestampShape = detectShape(value) ?? 'iso-datetime';
const date = rawToDate(value, shape);
const inputType = shape === 'iso-date' ? 'date' : 'datetime-local';
const inputValue = (() => {
if (!date || Number.isNaN(date.getTime())) return '';
if (inputType === 'date') return date.toISOString().slice(0, 10);
// datetime-local wants YYYY-MM-DDTHH:mm in the user's local timezone.
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 16);
})();
return (
<div className="kv-leaf-datetime">
<input
type={inputType}
className="kv-leaf-input"
value={inputValue}
onChange={(e) => {
const v = e.target.value;
if (!v) {
onChange('');
return;
}
const parsed = new Date(v);
if (Number.isNaN(parsed.getTime())) return;
onChange(dateToRaw(parsed, shape));
}}
/>
<span className="kv-leaf-datetime-shape" title="Storage format preserved on save">
{shape}
</span>
</div>
);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = n / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`;
}