improve editor and add type detection
This commit is contained in:
@@ -137,6 +137,26 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kv-tree-branch .kv-tree-filename {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||
font-size: 0.85em;
|
||||
color: #1e3a8a;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kv-tree-hash-pill {
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
background: rgba(100, 108, 255, 0.12);
|
||||
color: #4a50c8;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||
font-size: 0.7em;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kv-tree-leaf .kv-tree-label {
|
||||
color: #4a5170;
|
||||
}
|
||||
@@ -251,6 +271,307 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── CID leaf ─────────────────────────────────────────────────────────── */
|
||||
.kv-leaf-cid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.kv-leaf-cid-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.kv-leaf-cid-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.kv-leaf-cid-meta {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.kv-leaf-cid-preview {
|
||||
max-width: 100%;
|
||||
max-height: 320px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e2e6ee;
|
||||
background: #f5f6fa;
|
||||
object-fit: contain;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.kv-leaf-cid-preview-media {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
/* ── File-detail view ─────────────────────────────────────────────────── */
|
||||
.kv-file {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.kv-file-header {
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e6ee;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
"name actions"
|
||||
"meta actions";
|
||||
gap: 6px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.kv-file-name {
|
||||
grid-area: name;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #1e3a8a;
|
||||
word-break: break-all;
|
||||
}
|
||||
.kv-file-name-fallback {
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
.kv-file-meta {
|
||||
grid-area: meta;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
min-width: 0;
|
||||
}
|
||||
.kv-file-hash {
|
||||
background: rgba(100, 122, 250, 0.1);
|
||||
color: #4a50c8;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.kv-file-path {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.kv-file-actions {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kv-file-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.kv-file-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 220px) 1fr auto;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e6ee;
|
||||
border-left: 3px solid #d6deed;
|
||||
border-radius: 6px;
|
||||
align-items: start;
|
||||
}
|
||||
.kv-file-row.pending-delete {
|
||||
border-left-color: #dc2626;
|
||||
background: #fef2f2;
|
||||
}
|
||||
.kv-file-row.pending-delete .kv-file-row-label {
|
||||
text-decoration: line-through;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.kv-file-row-deleted-note {
|
||||
color: #b91c1c;
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.kv-file-row-delete {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: #dc2626;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.kv-file-row:hover .kv-file-row-delete,
|
||||
.kv-file-row.pending-delete .kv-file-row-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
.kv-file-row-delete:hover {
|
||||
background: rgba(220, 38, 38, 0.1);
|
||||
border-color: rgba(220, 38, 38, 0.3);
|
||||
}
|
||||
|
||||
.kv-file-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: rgba(100, 122, 250, 0.05);
|
||||
border: 1px dashed rgba(100, 122, 250, 0.3);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.kv-file-add-prefix {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.kv-file-add > input {
|
||||
flex: 1;
|
||||
}
|
||||
.kv-file-add > input:first-of-type {
|
||||
flex: 0 0 200px;
|
||||
}
|
||||
.kv-file-row.dirty {
|
||||
border-left-color: #fbbf24;
|
||||
background: #fffbeb;
|
||||
}
|
||||
.kv-file-row.pulsed {
|
||||
animation: kv-row-pulse 1.4s ease-out;
|
||||
}
|
||||
@keyframes kv-row-pulse {
|
||||
0% { background: rgba(100, 122, 250, 0.35); border-left-color: #4a50c8; }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
.kv-file-row-label,
|
||||
.kv-file-row-label-static {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: #2a3050;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 6px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
.kv-file-row-label-static {
|
||||
cursor: default;
|
||||
}
|
||||
.kv-file-row-label:hover {
|
||||
color: #4a50c8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.kv-file-row-value {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
.kv-file-row-value > * {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kv-file-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #e2e6ee;
|
||||
border-radius: 8px;
|
||||
background: rgba(100, 122, 250, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
.kv-file-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
background: rgba(100, 122, 250, 0.1);
|
||||
cursor: pointer;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: #2a3050;
|
||||
}
|
||||
.kv-file-group-header:hover {
|
||||
background: rgba(100, 122, 250, 0.2);
|
||||
}
|
||||
.kv-file-group-chevron {
|
||||
font-size: 10px;
|
||||
color: #6b7280;
|
||||
}
|
||||
.kv-file-group-name {
|
||||
font-weight: 600;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
.kv-file-group-count {
|
||||
margin-left: auto;
|
||||
padding: 1px 8px;
|
||||
background: rgba(100, 122, 250, 0.2);
|
||||
color: #4a50c8;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.kv-file-group-body {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.kv-file-row-lang .kv-file-lang-entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.kv-file-lang-entry {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.kv-file-lang-entry > .kv-leaf-input {
|
||||
flex: 1;
|
||||
}
|
||||
.kv-file-lang-remove {
|
||||
color: #dc2626;
|
||||
}
|
||||
.kv-file-lang-add {
|
||||
align-self: flex-start;
|
||||
background: rgba(100, 122, 250, 0.1);
|
||||
}
|
||||
|
||||
/* ── DateTime leaf ────────────────────────────────────────────────────── */
|
||||
.kv-leaf-datetime {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.kv-leaf-datetime-shape {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
background: rgba(100, 122, 250, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Branch ───────────────────────────────────────────────────────────── */
|
||||
.kv-branch-section {
|
||||
border: 1px solid #e2e6ee;
|
||||
|
||||
+41
-4
@@ -4,6 +4,9 @@ import { ancestorBranches, KVTree } from './KVTree';
|
||||
import { FilteredKVTree } from './FilteredKVTree';
|
||||
import { KVLeafEditor } from './KVLeafEditor';
|
||||
import { KVBranchEditor } from './KVBranchEditor';
|
||||
import { KVFileEditor } from './KVFileEditor';
|
||||
|
||||
const FILE_BRANCH_RE = /^file:[^/]+\/$/;
|
||||
import { SearchFieldChips } from './SearchFieldChips';
|
||||
import { ToastStack, makeToast } from './Toast';
|
||||
import { ToastEntry } from './types';
|
||||
@@ -44,6 +47,28 @@ export default function KVApp() {
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const [toasts, setToasts] = useState<ToastEntry[]>([]);
|
||||
const searchTimerRef = useRef<number | null>(null);
|
||||
// When the right pane is a file-detail view and the user clicks a leaf
|
||||
// under the same file branch in the tree, we route the click as a
|
||||
// scroll-anchor inside the file detail instead of a leaf-editor switch.
|
||||
// The token bumps each click so identical anchors still trigger a scroll.
|
||||
const [anchor, setAnchor] = useState<{ field: string; token: number } | null>(null);
|
||||
const isFileBranch = (p: string | null) => !!p && /^file:[^/]+\/$/.test(p);
|
||||
|
||||
const handleLeafClick = (key: string) => {
|
||||
if (isFileBranch(branchPath) && key.startsWith(branchPath!)) {
|
||||
setAnchor({ field: key.slice(branchPath!.length), token: (anchor?.token ?? 0) + 1 });
|
||||
return;
|
||||
}
|
||||
setSelectedKey(key);
|
||||
setBranchPath(null);
|
||||
setAnchor(null);
|
||||
};
|
||||
|
||||
const handleBranchClick = (path: string) => {
|
||||
setBranchPath(path);
|
||||
setSelectedKey(null);
|
||||
setAnchor(null);
|
||||
};
|
||||
|
||||
const pushToast = (t: ToastEntry) => setToasts((cur) => [...cur, t]);
|
||||
const dismissToast = (id: number) => setToasts((cur) => cur.filter((t) => t.id !== id));
|
||||
@@ -184,8 +209,8 @@ export default function KVApp() {
|
||||
searching={searching}
|
||||
selectedKey={selectedKey}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelectLeaf={(k) => { setSelectedKey(k); setBranchPath(null); }}
|
||||
onSelectBranch={(p) => { setBranchPath(p); setSelectedKey(null); }}
|
||||
onSelectLeaf={handleLeafClick}
|
||||
onSelectBranch={handleBranchClick}
|
||||
onToggleBranch={(p, willOpen) => {
|
||||
setExpandedPaths((cur) => {
|
||||
const next = new Set(cur);
|
||||
@@ -199,8 +224,8 @@ export default function KVApp() {
|
||||
selectedKey={selectedKey}
|
||||
expandedPaths={expandedPaths}
|
||||
reloadToken={reloadToken}
|
||||
onSelectLeaf={(k) => { setSelectedKey(k); setBranchPath(null); }}
|
||||
onSelectBranch={(p) => { setBranchPath(p); setSelectedKey(null); }}
|
||||
onSelectLeaf={handleLeafClick}
|
||||
onSelectBranch={handleBranchClick}
|
||||
onToggleBranch={(p, willOpen) => {
|
||||
setExpandedPaths((cur) => {
|
||||
const next = new Set(cur);
|
||||
@@ -227,6 +252,18 @@ export default function KVApp() {
|
||||
}}
|
||||
onError={(msg) => pushToast(makeToast('error', msg))}
|
||||
/>
|
||||
) : branchPath && FILE_BRANCH_RE.test(branchPath) ? (
|
||||
<KVFileEditor
|
||||
key={branchPath}
|
||||
branchPath={branchPath}
|
||||
reloadToken={reloadToken}
|
||||
scrollToField={anchor}
|
||||
onChanged={(info, errors) => {
|
||||
pushToast(makeToast(errors && errors.length > 0 ? 'error' : 'success', info, errors));
|
||||
setReloadToken((n) => n + 1);
|
||||
}}
|
||||
onSelectLeaf={(k) => { setSelectedKey(k); setBranchPath(null); setAnchor(null); }}
|
||||
/>
|
||||
) : branchPath ? (
|
||||
<KVBranchEditor
|
||||
branchPath={branchPath}
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { KVAPI } from '../api/kvApi';
|
||||
import { SchemaFieldInfo, useSchema } from '../api/schemaApi';
|
||||
import { LanguageCombobox, formatLanguageOption } from '../components/LanguageCombobox';
|
||||
import { LeafValueEditor } from './leafRenderers';
|
||||
import { schemaToFieldType } from './schemaLookup';
|
||||
import { detectType } from './typeHeuristics';
|
||||
import { FieldType } from './types';
|
||||
|
||||
interface Props {
|
||||
/** Branch path of a file, e.g. "file:<hash>/". */
|
||||
branchPath: string;
|
||||
reloadToken: number;
|
||||
onChanged: (info: string, errors?: string[]) => void;
|
||||
/** Navigate to a single leaf for the manual deep editor. */
|
||||
onSelectLeaf: (key: string) => void;
|
||||
/**
|
||||
* When set, scroll to and pulse the row matching this logical field path
|
||||
* (e.g. "releasedate", "plot", "stream/0"). The `token` lets the same field
|
||||
* fire a fresh scroll across consecutive clicks.
|
||||
*/
|
||||
scrollToField?: { field: string; token: number } | null;
|
||||
}
|
||||
|
||||
const LANG_CODE_RE = /^[a-z]{2,3}$/;
|
||||
|
||||
interface LeafRow {
|
||||
kind: 'leaf';
|
||||
fieldKey: string;
|
||||
value: string;
|
||||
fieldType: FieldType;
|
||||
info?: SchemaFieldInfo;
|
||||
}
|
||||
|
||||
interface LanguageRow {
|
||||
kind: 'language-group';
|
||||
fieldKey: string;
|
||||
/** Current language → text map. */
|
||||
languageMap: Record<string, string>;
|
||||
info?: SchemaFieldInfo;
|
||||
}
|
||||
|
||||
type Row = LeafRow | LanguageRow;
|
||||
|
||||
interface RowGroup {
|
||||
prefix: string;
|
||||
isCluster: boolean;
|
||||
rows: Row[];
|
||||
}
|
||||
|
||||
export function KVFileEditor({
|
||||
branchPath,
|
||||
reloadToken,
|
||||
onChanged,
|
||||
onSelectLeaf,
|
||||
scrollToField,
|
||||
}: Props) {
|
||||
const [metadata, setMetadata] = useState<Record<string, string> | null>(null);
|
||||
const [edits, setEdits] = useState<Map<string, string | null>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
const [pulse, setPulse] = useState<string | null>(null);
|
||||
const [newFieldKey, setNewFieldKey] = useState('');
|
||||
const [newFieldValue, setNewFieldValue] = useState('');
|
||||
const [addingField, setAddingField] = useState(false);
|
||||
// Track whether we've seeded collapsedGroups for the current branch; resets
|
||||
// when the branch changes so each file gets its defaults reapplied.
|
||||
const defaultsAppliedRef = useRef<string | null>(null);
|
||||
|
||||
const { schema } = useSchema();
|
||||
|
||||
// When the parent passes a target field, expand its prefix group (if any)
|
||||
// and scroll the row into view. We also briefly pulse the row to draw the
|
||||
// eye — the pulse is reset after the animation duration.
|
||||
useEffect(() => {
|
||||
if (!scrollToField) return;
|
||||
const { field } = scrollToField;
|
||||
const prefix = field.split('/', 1)[0];
|
||||
setCollapsedGroups((cur) => {
|
||||
if (!cur.has(prefix)) return cur;
|
||||
const next = new Set(cur);
|
||||
next.delete(prefix);
|
||||
return next;
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector(
|
||||
`[data-row-key="${cssEscape(field)}"]`,
|
||||
) as HTMLElement | null;
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setPulse(field);
|
||||
window.setTimeout(() => setPulse((p) => (p === field ? null : p)), 1400);
|
||||
}
|
||||
});
|
||||
}, [scrollToField?.field, scrollToField?.token]);
|
||||
|
||||
const hashId = branchPath.slice('file:'.length, -1);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
KVAPI.treeAll(branchPath, '/', 5000)
|
||||
.then(async (resp) => {
|
||||
// resp.leaves = full keys under branchPath. Recursive descent: also
|
||||
// walk branch children (they're paths to sub-objects with their own
|
||||
// leaves, e.g. file:<h>/plot/ → plot/eng).
|
||||
const all: string[] = [...resp.leaves];
|
||||
const queue = [...resp.branches];
|
||||
while (queue.length > 0) {
|
||||
const next = queue.shift()!;
|
||||
try {
|
||||
const sub = await KVAPI.treeAll(next, '/', 5000);
|
||||
all.push(...sub.leaves);
|
||||
queue.push(...sub.branches);
|
||||
} catch {
|
||||
// Ignore failures on a sub-branch; show what we can.
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
// MGET-equivalent: parallel fetches with a small cap to avoid burst.
|
||||
const values = await Promise.all(
|
||||
all.map((k) => KVAPI.get(k).then((r) => [k, r.exists ? r.value : null] as const).catch(() => [k, null] as const)),
|
||||
);
|
||||
if (cancelled) return;
|
||||
const flat: Record<string, string> = {};
|
||||
for (const [k, v] of values) {
|
||||
if (v == null) continue;
|
||||
// Strip the branch prefix so keys are the logical field path
|
||||
// ("releasedate", "plot/eng", "stream/0").
|
||||
const fieldKey = k.slice(branchPath.length);
|
||||
if (fieldKey) flat[fieldKey] = v;
|
||||
}
|
||||
setMetadata(flat);
|
||||
setEdits(new Map());
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setMetadata({});
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [branchPath, reloadToken]);
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupRows(buildRows(metadata ?? {}, schema)),
|
||||
[metadata, schema],
|
||||
);
|
||||
|
||||
// Default state: every cluster group (e.g. stream/*, cid_*) starts collapsed.
|
||||
// Reapplied when the user switches to a different file.
|
||||
useEffect(() => {
|
||||
if (groups.length === 0) return;
|
||||
if (defaultsAppliedRef.current === branchPath) return;
|
||||
defaultsAppliedRef.current = branchPath;
|
||||
setCollapsedGroups(new Set(groups.filter((g) => g.isCluster).map((g) => g.prefix)));
|
||||
}, [groups, branchPath]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="kv-pane-status">loading file…</div>;
|
||||
}
|
||||
|
||||
if (!metadata) {
|
||||
return <div className="kv-pane-status">no data for {branchPath}</div>;
|
||||
}
|
||||
|
||||
const fileName = metadata['fileName'];
|
||||
const filePath = metadata['filePath'];
|
||||
|
||||
const onLeafChange = (fieldKey: string, value: string) => {
|
||||
setEdits((prev) => {
|
||||
const next = new Map(prev);
|
||||
const original = metadata[fieldKey] ?? null;
|
||||
if (value === original) next.delete(fieldKey);
|
||||
else next.set(fieldKey, value);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle a field's pending-delete state. Re-clicking restores; if the field
|
||||
* is brand-new (no original value) we just drop it from the edits map.
|
||||
*/
|
||||
const onLeafDeleteToggle = (fieldKey: string) => {
|
||||
setEdits((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.get(fieldKey) === null) {
|
||||
// Currently pending delete → undo.
|
||||
next.delete(fieldKey);
|
||||
} else {
|
||||
next.set(fieldKey, null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddField = async () => {
|
||||
const key = newFieldKey.trim();
|
||||
if (!key) return;
|
||||
const fullKey = branchPath + key;
|
||||
setAddingField(true);
|
||||
try {
|
||||
await KVAPI.put(fullKey, newFieldValue);
|
||||
setNewFieldKey('');
|
||||
setNewFieldValue('');
|
||||
onChanged(`Added ${fullKey}`);
|
||||
// Refetch this file so the new field appears in the row list.
|
||||
const r = await KVAPI.get(fullKey);
|
||||
if (r.exists) {
|
||||
setMetadata((cur) => ({ ...(cur ?? {}), [key]: r.value }));
|
||||
}
|
||||
} catch (e: any) {
|
||||
onChanged(`Failed to add ${fullKey}`, [e.message]);
|
||||
} finally {
|
||||
setAddingField(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onLanguageGroupChange = (
|
||||
groupKey: string,
|
||||
nextMap: Record<string, string>,
|
||||
originalMap: Record<string, string>,
|
||||
) => {
|
||||
setEdits((prev) => {
|
||||
const next = new Map(prev);
|
||||
const allLangs = new Set([...Object.keys(originalMap), ...Object.keys(nextMap)]);
|
||||
for (const lang of allLangs) {
|
||||
const fieldKey = `${groupKey}/${lang}`;
|
||||
const orig = originalMap[lang] ?? null;
|
||||
const cur = nextMap[lang] ?? null;
|
||||
if (cur === orig) next.delete(fieldKey);
|
||||
else if (cur == null) next.set(fieldKey, null);
|
||||
else next.set(fieldKey, cur);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (edits.size === 0) return;
|
||||
setSaving(true);
|
||||
const errors: string[] = [];
|
||||
for (const [fieldKey, newValue] of edits) {
|
||||
const fullKey = branchPath + fieldKey;
|
||||
try {
|
||||
if (newValue === null) await KVAPI.del(fullKey);
|
||||
else await KVAPI.put(fullKey, newValue);
|
||||
} catch (e: any) {
|
||||
errors.push(`${fieldKey}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
setSaving(false);
|
||||
if (errors.length === 0) {
|
||||
onChanged(`Saved ${edits.size} field${edits.size === 1 ? '' : 's'} on ${fileName ?? hashId}`);
|
||||
} else {
|
||||
onChanged(
|
||||
`Saved with ${errors.length} failure(s) on ${fileName ?? hashId}`,
|
||||
errors,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => setEdits(new Map());
|
||||
|
||||
const toggleGroup = (prefix: string) => {
|
||||
setCollapsedGroups((cur) => {
|
||||
const next = new Set(cur);
|
||||
if (next.has(prefix)) next.delete(prefix);
|
||||
else next.add(prefix);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="kv-file">
|
||||
<div className="kv-file-header">
|
||||
<div className="kv-file-name">
|
||||
{fileName ?? <span className="kv-file-name-fallback">(no fileName)</span>}
|
||||
</div>
|
||||
<div className="kv-file-meta">
|
||||
<code className="kv-file-hash" title={hashId}>
|
||||
{hashId.slice(0, 8)}…{hashId.slice(-6)}
|
||||
</code>
|
||||
{filePath && <span className="kv-file-path" title={filePath}>{filePath}</span>}
|
||||
</div>
|
||||
<div className="kv-file-actions">
|
||||
<button
|
||||
className="kv-btn kv-btn-primary"
|
||||
disabled={edits.size === 0 || saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saving ? 'saving…' : `Save (${edits.size})`}
|
||||
</button>
|
||||
<button
|
||||
className="kv-btn"
|
||||
disabled={edits.size === 0 || saving}
|
||||
onClick={handleDiscard}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kv-file-rows">
|
||||
{groups.length === 0 && (
|
||||
<div className="kv-pane-status">No data yet for this file.</div>
|
||||
)}
|
||||
{groups.map((g) => (
|
||||
<FieldGroupView
|
||||
key={g.prefix}
|
||||
group={g}
|
||||
edits={edits}
|
||||
collapsed={collapsedGroups.has(g.prefix)}
|
||||
pulse={pulse}
|
||||
onToggle={() => toggleGroup(g.prefix)}
|
||||
onLeafChange={onLeafChange}
|
||||
onLeafDeleteToggle={onLeafDeleteToggle}
|
||||
onLanguageChange={onLanguageGroupChange}
|
||||
onJumpToLeaf={(fieldKey) => onSelectLeaf(branchPath + fieldKey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="kv-file-add">
|
||||
<span className="kv-file-add-prefix">{branchPath}</span>
|
||||
<input
|
||||
type="text"
|
||||
className="kv-leaf-input kv-leaf-mono"
|
||||
placeholder="field path (may contain /)"
|
||||
value={newFieldKey}
|
||||
onChange={(e) => setNewFieldKey(e.target.value)}
|
||||
disabled={addingField}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="kv-leaf-input"
|
||||
placeholder="value"
|
||||
value={newFieldValue}
|
||||
onChange={(e) => setNewFieldValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newFieldKey.trim()) handleAddField();
|
||||
}}
|
||||
disabled={addingField}
|
||||
/>
|
||||
<button
|
||||
className="kv-btn kv-btn-primary"
|
||||
disabled={!newFieldKey.trim() || addingField}
|
||||
onClick={handleAddField}
|
||||
>
|
||||
{addingField ? 'adding…' : '+ Add field'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroupView({
|
||||
group,
|
||||
edits,
|
||||
collapsed,
|
||||
pulse,
|
||||
onToggle,
|
||||
onLeafChange,
|
||||
onLeafDeleteToggle,
|
||||
onLanguageChange,
|
||||
onJumpToLeaf,
|
||||
}: {
|
||||
group: RowGroup;
|
||||
edits: Map<string, string | null>;
|
||||
collapsed: boolean;
|
||||
pulse: string | null;
|
||||
onToggle: () => void;
|
||||
onLeafChange: (fieldKey: string, value: string) => void;
|
||||
onLeafDeleteToggle: (fieldKey: string) => void;
|
||||
onLanguageChange: (
|
||||
groupKey: string,
|
||||
nextMap: Record<string, string>,
|
||||
originalMap: Record<string, string>,
|
||||
) => void;
|
||||
onJumpToLeaf: (fieldKey: string) => void;
|
||||
}) {
|
||||
if (!group.isCluster) {
|
||||
return (
|
||||
<>
|
||||
{group.rows.map((row) => (
|
||||
<RowView
|
||||
key={row.fieldKey}
|
||||
row={row}
|
||||
edits={edits}
|
||||
pulse={pulse}
|
||||
onLeafChange={onLeafChange}
|
||||
onLeafDeleteToggle={onLeafDeleteToggle}
|
||||
onLanguageChange={onLanguageChange}
|
||||
onJumpToLeaf={onJumpToLeaf}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`kv-file-group ${collapsed ? 'collapsed' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="kv-file-group-header"
|
||||
onClick={onToggle}
|
||||
title={collapsed ? 'expand' : 'collapse'}
|
||||
>
|
||||
<span className="kv-file-group-chevron">{collapsed ? '▸' : '▾'}</span>
|
||||
<span className="kv-file-group-name">{group.prefix}</span>
|
||||
<span className="kv-file-group-count">{group.rows.length}</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="kv-file-group-body">
|
||||
{group.rows.map((row) => (
|
||||
<RowView
|
||||
key={row.fieldKey}
|
||||
row={row}
|
||||
edits={edits}
|
||||
pulse={pulse}
|
||||
onLeafChange={onLeafChange}
|
||||
onLeafDeleteToggle={onLeafDeleteToggle}
|
||||
onLanguageChange={onLanguageChange}
|
||||
onJumpToLeaf={onJumpToLeaf}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowView({
|
||||
row,
|
||||
edits,
|
||||
pulse,
|
||||
onLeafChange,
|
||||
onLeafDeleteToggle,
|
||||
onLanguageChange,
|
||||
onJumpToLeaf,
|
||||
}: {
|
||||
row: Row;
|
||||
edits: Map<string, string | null>;
|
||||
pulse: string | null;
|
||||
onLeafChange: (fieldKey: string, value: string) => void;
|
||||
onLeafDeleteToggle: (fieldKey: string) => void;
|
||||
onLanguageChange: (
|
||||
groupKey: string,
|
||||
nextMap: Record<string, string>,
|
||||
originalMap: Record<string, string>,
|
||||
) => void;
|
||||
onJumpToLeaf: (fieldKey: string) => void;
|
||||
}) {
|
||||
const pulsed = pulse === row.fieldKey;
|
||||
if (row.kind === 'leaf') {
|
||||
const pendingDelete = edits.get(row.fieldKey) === null;
|
||||
const draft = edits.has(row.fieldKey) ? (edits.get(row.fieldKey) ?? '') : row.value;
|
||||
const dirty = edits.has(row.fieldKey);
|
||||
return (
|
||||
<div
|
||||
className={`kv-file-row ${dirty ? 'dirty' : ''} ${pendingDelete ? 'pending-delete' : ''} ${pulsed ? 'pulsed' : ''}`}
|
||||
data-row-key={row.fieldKey}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="kv-file-row-label"
|
||||
onClick={() => onJumpToLeaf(row.fieldKey)}
|
||||
title="open in single-leaf editor"
|
||||
>
|
||||
{row.fieldKey}
|
||||
</button>
|
||||
<div className="kv-file-row-value">
|
||||
{pendingDelete ? (
|
||||
<div className="kv-file-row-deleted-note">marked for deletion</div>
|
||||
) : (
|
||||
<LeafValueEditor
|
||||
type={row.fieldType}
|
||||
value={draft}
|
||||
onChange={(v) => onLeafChange(row.fieldKey, v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="kv-file-row-delete"
|
||||
onClick={() => onLeafDeleteToggle(row.fieldKey)}
|
||||
title={pendingDelete ? 'undo deletion' : 'delete this field'}
|
||||
aria-label={pendingDelete ? 'undo deletion' : 'delete this field'}
|
||||
>
|
||||
{pendingDelete ? '↺' : '×'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LanguageGroupRow
|
||||
row={row}
|
||||
edits={edits}
|
||||
pulsed={pulsed}
|
||||
onLanguageChange={onLanguageChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LanguageGroupRow({
|
||||
row,
|
||||
edits,
|
||||
pulsed,
|
||||
onLanguageChange,
|
||||
}: {
|
||||
row: LanguageRow;
|
||||
edits: Map<string, string | null>;
|
||||
pulsed: boolean;
|
||||
onLanguageChange: (
|
||||
groupKey: string,
|
||||
nextMap: Record<string, string>,
|
||||
originalMap: Record<string, string>,
|
||||
) => void;
|
||||
}) {
|
||||
// Apply pending edits on top of the original language map.
|
||||
const effectiveMap: Record<string, string> = { ...row.languageMap };
|
||||
for (const [k, v] of edits) {
|
||||
if (!k.startsWith(`${row.fieldKey}/`)) continue;
|
||||
const lang = k.slice(row.fieldKey.length + 1);
|
||||
if (v === null) delete effectiveMap[lang];
|
||||
else effectiveMap[lang] = v;
|
||||
}
|
||||
|
||||
const entries = Object.entries(effectiveMap);
|
||||
const dirty = [...edits.keys()].some((k) => k.startsWith(`${row.fieldKey}/`));
|
||||
|
||||
const updateLang = (lang: string, text: string) => {
|
||||
onLanguageChange(row.fieldKey, { ...effectiveMap, [lang]: text }, row.languageMap);
|
||||
};
|
||||
|
||||
const renameLang = (oldLang: string, newLang: string) => {
|
||||
if (oldLang === newLang) return;
|
||||
const next = { ...effectiveMap };
|
||||
const text = next[oldLang];
|
||||
delete next[oldLang];
|
||||
next[newLang] = text;
|
||||
onLanguageChange(row.fieldKey, next, row.languageMap);
|
||||
};
|
||||
|
||||
const removeLang = (lang: string) => {
|
||||
const next = { ...effectiveMap };
|
||||
delete next[lang];
|
||||
onLanguageChange(row.fieldKey, next, row.languageMap);
|
||||
};
|
||||
|
||||
const addLang = () => {
|
||||
const used = new Set(entries.map(([k]) => k));
|
||||
const defaults = ['eng', 'jpn', 'zho', 'kor', 'fra', 'deu', 'spa', 'fre'];
|
||||
const defaultLang = defaults.find((l) => !used.has(l)) || 'eng';
|
||||
onLanguageChange(row.fieldKey, { ...effectiveMap, [defaultLang]: '' }, row.languageMap);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`kv-file-row kv-file-row-lang ${dirty ? 'dirty' : ''} ${pulsed ? 'pulsed' : ''}`}
|
||||
data-row-key={row.fieldKey}
|
||||
>
|
||||
<div className="kv-file-row-label-static">{row.fieldKey}</div>
|
||||
<div className="kv-file-row-value">
|
||||
<div className="kv-file-lang-entries">
|
||||
{entries.map(([lang, text]) => (
|
||||
<div key={lang} className="kv-file-lang-entry">
|
||||
<LanguageCombobox
|
||||
value={lang}
|
||||
onChange={(newLang) => renameLang(lang, newLang)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="kv-leaf-input"
|
||||
value={text}
|
||||
title={formatLanguageOption(lang)}
|
||||
onChange={(e) => updateLang(lang, e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="kv-btn-icon kv-file-lang-remove"
|
||||
onClick={() => removeLang(lang)}
|
||||
title="remove this language"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="kv-btn kv-file-lang-add"
|
||||
onClick={addLang}
|
||||
>
|
||||
+ Add language
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Row model — kept local because it's only used here and is small.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildRows(
|
||||
flat: Record<string, string>,
|
||||
schema: ReturnType<typeof useSchema>['schema'],
|
||||
): Row[] {
|
||||
const languageGroupKeys = new Set<string>();
|
||||
if (schema) {
|
||||
for (const [k, info] of Object.entries(schema.fields)) {
|
||||
if (info.key_hint === 'language-code') languageGroupKeys.add(k);
|
||||
}
|
||||
}
|
||||
|
||||
const langMaps: Record<string, Record<string, string>> = {};
|
||||
const rows: Row[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(flat)) {
|
||||
const slash = key.lastIndexOf('/');
|
||||
if (slash > 0) {
|
||||
const prefix = key.slice(0, slash);
|
||||
const tail = key.slice(slash + 1);
|
||||
if (languageGroupKeys.has(prefix) && LANG_CODE_RE.test(tail)) {
|
||||
if (!langMaps[prefix]) langMaps[prefix] = {};
|
||||
langMaps[prefix][tail] = value;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const info = schema?.fields[key];
|
||||
rows.push({
|
||||
kind: 'leaf',
|
||||
fieldKey: key,
|
||||
value,
|
||||
// Schema decides when it can; otherwise heuristic on (path, value).
|
||||
fieldType: schemaToFieldType(info) ?? detectType(key, value),
|
||||
info,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [groupKey, langMap] of Object.entries(langMaps)) {
|
||||
rows.push({
|
||||
kind: 'language-group',
|
||||
fieldKey: groupKey,
|
||||
languageMap: langMap,
|
||||
info: schema?.fields[groupKey],
|
||||
});
|
||||
}
|
||||
|
||||
rows.sort((a, b) => a.fieldKey.localeCompare(b.fieldKey));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Escape characters that would break a CSS attribute selector value. */
|
||||
function cssEscape(s: string): string {
|
||||
return s.replace(/["\\]/g, (c) => `\\${c}`);
|
||||
}
|
||||
|
||||
function groupRows(rows: Row[]): RowGroup[] {
|
||||
const out: RowGroup[] = [];
|
||||
for (const row of rows) {
|
||||
const prefix = row.fieldKey.split('/', 1)[0];
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.prefix === prefix) {
|
||||
last.rows.push(row);
|
||||
} else {
|
||||
out.push({ prefix, isCluster: false, rows: [row] });
|
||||
}
|
||||
}
|
||||
for (const g of out) g.isCluster = g.rows.length > 1;
|
||||
return out;
|
||||
}
|
||||
+11
-108
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { KVAPI } from '../api/kvApi';
|
||||
import { LanguageCombobox } from '../components/LanguageCombobox';
|
||||
import { useSchema } from '../api/schemaApi';
|
||||
import { ALL_TYPES, describeType, detectType } from './typeHeuristics';
|
||||
import { lookupSchemaForKey } from './schemaLookup';
|
||||
import { LeafValueEditor } from './leafRenderers';
|
||||
import { FieldType } from './types';
|
||||
|
||||
interface Props {
|
||||
@@ -22,9 +24,12 @@ export function KVLeafEditor({ selectedKey, reloadToken, onSaved, onDeleted, onE
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Manual override for the inferred type. "auto" defers to detectType.
|
||||
// Manual override for the inferred type. "auto" defers to schema → heuristic.
|
||||
const [typeOverride, setTypeOverride] = useState<FieldType>('auto');
|
||||
const detected = detectType(selectedKey, original);
|
||||
const { schema } = useSchema();
|
||||
const schemaLookup = lookupSchemaForKey(schema, selectedKey);
|
||||
// Schema is authoritative when it has an opinion; heuristics fill the gap.
|
||||
const detected = schemaLookup.fieldType ?? detectType(selectedKey, original);
|
||||
const effectiveType = typeOverride === 'auto' ? detected : typeOverride;
|
||||
|
||||
// Reset override when the selected key changes — the previous override
|
||||
@@ -113,7 +118,7 @@ export function KVLeafEditor({ selectedKey, reloadToken, onSaved, onDeleted, onE
|
||||
{!exists && <span className="kv-leaf-hint">new key — save to create</span>}
|
||||
</div>
|
||||
|
||||
<ValueEditor type={effectiveType} value={draft} onChange={setDraft} />
|
||||
<LeafValueEditor type={effectiveType} value={draft} onChange={setDraft} />
|
||||
|
||||
<div className="kv-leaf-actions">
|
||||
<button
|
||||
@@ -151,107 +156,5 @@ function KeyHeader({ keyPath }: { keyPath: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueProps {
|
||||
type: FieldType;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function ValueEditor({ type, value, onChange }: ValueProps) {
|
||||
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 (
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="kv-leaf-input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
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 (
|
||||
<div className="kv-leaf-url">
|
||||
<input
|
||||
type="text"
|
||||
className="kv-leaf-input kv-leaf-mono"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
className="kv-btn-icon"
|
||||
onClick={() => navigator.clipboard.writeText(value)}
|
||||
title="copy"
|
||||
>⧉</button>
|
||||
</div>
|
||||
);
|
||||
case 'string':
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
className="kv-leaf-input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
// Leaf value rendering moved to leafRenderers.tsx (LeafValueEditor)
|
||||
// so the file-detail view can reuse it per row.
|
||||
|
||||
+90
-18
@@ -1,6 +1,57 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { KVAPI } from '../api/kvApi';
|
||||
|
||||
const FILE_BRANCH_RE = /^file:[^/]+\/$/;
|
||||
const fileNameCache = new Map<string, string | null>();
|
||||
const fileNamePromises = new Map<string, Promise<void>>();
|
||||
const fileNameSubscribers = new Set<() => void>();
|
||||
|
||||
function notifyFileNameSubscribers() {
|
||||
for (const cb of fileNameSubscribers) cb();
|
||||
}
|
||||
|
||||
function ensureFileName(branchPath: string): void {
|
||||
if (fileNameCache.has(branchPath) || fileNamePromises.has(branchPath)) return;
|
||||
const p = KVAPI.get(branchPath + 'fileName')
|
||||
.then((r) => {
|
||||
fileNameCache.set(branchPath, r.exists && r.value ? r.value : null);
|
||||
})
|
||||
.catch(() => {
|
||||
fileNameCache.set(branchPath, null);
|
||||
})
|
||||
.finally(() => {
|
||||
fileNamePromises.delete(branchPath);
|
||||
notifyFileNameSubscribers();
|
||||
});
|
||||
fileNamePromises.set(branchPath, p);
|
||||
}
|
||||
|
||||
function useFileName(branchPath: string | null): string | null {
|
||||
const [, force] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!branchPath || !FILE_BRANCH_RE.test(branchPath)) return;
|
||||
ensureFileName(branchPath);
|
||||
const cb = () => force((n) => n + 1);
|
||||
fileNameSubscribers.add(cb);
|
||||
return () => {
|
||||
fileNameSubscribers.delete(cb);
|
||||
};
|
||||
}, [branchPath]);
|
||||
if (!branchPath || !FILE_BRANCH_RE.test(branchPath)) return null;
|
||||
return fileNameCache.get(branchPath) ?? null;
|
||||
}
|
||||
|
||||
function isFileBranch(path: string): boolean {
|
||||
return FILE_BRANCH_RE.test(path);
|
||||
}
|
||||
|
||||
function shortHashFromFileBranch(path: string): string {
|
||||
// path = "file:<hash>/" → last 6 chars of hash
|
||||
const hash = path.slice('file:'.length, -1);
|
||||
if (hash.length <= 12) return hash;
|
||||
return `…${hash.slice(-6)}`;
|
||||
}
|
||||
|
||||
// A node in the lazy tree. branch-typed nodes have an associated path that
|
||||
// ends with the delimiter and lazy-load their children on first expand.
|
||||
type NodeKind = 'branch' | 'leaf';
|
||||
@@ -174,17 +225,15 @@ function Branch({
|
||||
const childState = branches[node.path];
|
||||
return (
|
||||
<li key={node.path}>
|
||||
<div
|
||||
className={`kv-tree-row kv-tree-branch${open ? ' open' : ''}`}
|
||||
style={{ paddingLeft: depth * 14 + 4 }}
|
||||
onClick={() => {
|
||||
<BranchRow
|
||||
node={node}
|
||||
open={open}
|
||||
depth={depth}
|
||||
onToggle={() => {
|
||||
onToggleBranch(node.path, !open);
|
||||
onSelectBranch(node.path);
|
||||
}}
|
||||
>
|
||||
<span className="kv-tree-toggle">{open ? '▾' : '▸'}</span>
|
||||
<span className="kv-tree-label">{node.label}</span>
|
||||
</div>
|
||||
/>
|
||||
{open && (
|
||||
<Branch
|
||||
path={node.path}
|
||||
@@ -219,6 +268,38 @@ function Branch({
|
||||
);
|
||||
}
|
||||
|
||||
interface BranchRowProps {
|
||||
node: TreeNode;
|
||||
open: boolean;
|
||||
depth: number;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function BranchRow({ node, open, depth, onToggle }: BranchRowProps) {
|
||||
// Surface the actual filename next to file:<hash>/ branches so the tree
|
||||
// reads like a file list at that level instead of a wall of hashes.
|
||||
const fileName = useFileName(isFileBranch(node.path) ? node.path : null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`kv-tree-row kv-tree-branch${open ? ' open' : ''}`}
|
||||
style={{ paddingLeft: depth * 14 + 4 }}
|
||||
onClick={onToggle}
|
||||
title={isFileBranch(node.path) ? node.path : undefined}
|
||||
>
|
||||
<span className="kv-tree-toggle">{open ? '▾' : '▸'}</span>
|
||||
{fileName ? (
|
||||
<>
|
||||
<span className="kv-tree-label kv-tree-filename">{fileName}</span>
|
||||
<span className="kv-tree-hash-pill">{shortHashFromFileBranch(node.path)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="kv-tree-label">{node.label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: given a key path with both ":" and "/" separators, return the list
|
||||
// of ancestor branch paths that need to be expanded to reveal it.
|
||||
export function ancestorBranches(key: string): string[] {
|
||||
@@ -242,14 +323,5 @@ function nextSep(s: string, from: number): number {
|
||||
return best;
|
||||
}
|
||||
|
||||
// Memoize the ancestor calculation across renders (used by the search-hit
|
||||
// auto-expand).
|
||||
export function useAncestorBranches(key: string | null): Set<string> {
|
||||
return useMemo(() => {
|
||||
if (!key) return new Set();
|
||||
return new Set(ancestorBranches(key));
|
||||
}, [key]);
|
||||
}
|
||||
|
||||
// Re-export for convenience in the shell
|
||||
export type { TreeNode };
|
||||
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { SchemaFieldInfo, SchemaResponse } from '../api/schemaApi';
|
||||
import { FieldType } from './types';
|
||||
|
||||
export interface SchemaLookup {
|
||||
/** The matched schema entry, if any. */
|
||||
info?: SchemaFieldInfo;
|
||||
/** Suggested FieldType for the leaf editor, or null when schema can't decide. */
|
||||
fieldType: FieldType | null;
|
||||
/**
|
||||
* True when the leaf is one variant under a language-keyed group
|
||||
* (e.g. plot/eng). The schema entry returned is the group's, not the leaf's.
|
||||
*/
|
||||
isLanguageVariant: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the logical schema entry for a full Redis key.
|
||||
*
|
||||
* - "file:<hash>/releasedate" → schema.fields["releasedate"]
|
||||
* - "file:<hash>/plot/eng" → schema.fields["plot"] (language-keyed group)
|
||||
* - non-file keys → undefined (schema is per-file today)
|
||||
*/
|
||||
export function lookupSchemaForKey(
|
||||
schema: SchemaResponse | null,
|
||||
key: string,
|
||||
): SchemaLookup {
|
||||
if (!schema) {
|
||||
return { fieldType: null, isLanguageVariant: false };
|
||||
}
|
||||
|
||||
const propertyPath = stripFilePrefix(key);
|
||||
if (!propertyPath) {
|
||||
return { fieldType: null, isLanguageVariant: false };
|
||||
}
|
||||
|
||||
const direct = schema.fields[propertyPath];
|
||||
if (direct) {
|
||||
return {
|
||||
info: direct,
|
||||
fieldType: schemaToFieldType(direct),
|
||||
isLanguageVariant: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Language-keyed collapse: tail is a 2/3-letter lang code under a known group.
|
||||
const lastSlash = propertyPath.lastIndexOf('/');
|
||||
if (lastSlash > 0) {
|
||||
const tail = propertyPath.slice(lastSlash + 1);
|
||||
if (/^[a-z]{2,3}$/.test(tail)) {
|
||||
const group = schema.fields[propertyPath.slice(0, lastSlash)];
|
||||
if (group?.key_hint === 'language-code') {
|
||||
return {
|
||||
info: group,
|
||||
fieldType: schemaToFieldType(group),
|
||||
isLanguageVariant: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { fieldType: null, isLanguageVariant: false };
|
||||
}
|
||||
|
||||
export function schemaToFieldType(info: SchemaFieldInfo | undefined): FieldType | null {
|
||||
if (!info) return null;
|
||||
if (info.hint === 'cid') return 'hash';
|
||||
if (info.hint === 'timestamp') return 'datetime';
|
||||
switch (info.type) {
|
||||
case 'bool':
|
||||
return 'bool';
|
||||
case 'int':
|
||||
case 'float':
|
||||
return 'number';
|
||||
case 'json-object':
|
||||
case 'json-array':
|
||||
return 'json';
|
||||
case 'string':
|
||||
case 'mixed':
|
||||
case 'undefined':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripFilePrefix(key: string): string {
|
||||
if (!key.startsWith('file:')) return '';
|
||||
const slash = key.indexOf('/');
|
||||
if (slash < 0) return '';
|
||||
return key.slice(slash + 1);
|
||||
}
|
||||
@@ -12,14 +12,14 @@ const PATH_PATTERNS: Array<[RegExp, FieldType]> = [
|
||||
[/(^|\/)(description|overview|notes?|comment|summary|nfo)$/i, 'text'],
|
||||
];
|
||||
|
||||
export function detectFromPath(path: string): FieldType | null {
|
||||
function detectFromPath(path: string): FieldType | null {
|
||||
for (const [re, t] of PATH_PATTERNS) {
|
||||
if (re.test(path)) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectFromValue(v: string): FieldType {
|
||||
function detectFromValue(v: string): FieldType {
|
||||
if (v === '') return 'string';
|
||||
if (v === 'true' || v === 'false') return 'bool';
|
||||
if (/^-?\d+$/.test(v)) return 'number';
|
||||
|
||||
Reference in New Issue
Block a user