Implemented confi snapshots
This commit is contained in:
+41
-111
@@ -11,20 +11,13 @@ import PropertiesPane from './PropertiesPane';
|
||||
import HelpModal from './HelpModal';
|
||||
import ZoomControl from './ZoomControl';
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
import { VersionTree, DiffViewer } from './VersionHistory';
|
||||
|
||||
interface Props {
|
||||
initial: Interface | null;
|
||||
onDone: (iface: Interface | null) => void;
|
||||
}
|
||||
|
||||
interface VersionMeta {
|
||||
version: number;
|
||||
name: string;
|
||||
tag?: string;
|
||||
current: boolean;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function blankInterface(): Interface {
|
||||
return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] };
|
||||
}
|
||||
@@ -83,6 +76,7 @@ const STATIC_WIDGET_TYPES = [
|
||||
{ type: 'button', label: 'Action Button' },
|
||||
{ type: 'image', label: 'Image' },
|
||||
{ type: 'link', label: 'Link' },
|
||||
{ type: 'configselect', label: 'Config Selector' },
|
||||
];
|
||||
|
||||
export default function EditMode({ initial, onDone }: Props) {
|
||||
@@ -105,8 +99,9 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
|
||||
// History panel
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [versions, setVersions] = useState<VersionMeta[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
|
||||
const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
|
||||
const [verReload, setVerReload] = useState(0);
|
||||
const [tag, setTag] = useState('');
|
||||
// Bumped whenever the editor content is replaced wholesale (version load /
|
||||
// promote) to force a full canvas remount, so no stale widget state lingers.
|
||||
@@ -328,6 +323,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
options:
|
||||
type === 'textlabel' ? { label: 'Label' } :
|
||||
type === 'button' ? { label: 'Button', mode: 'oneshot' } :
|
||||
type === 'configselect' ? { label: 'Config', set: '', output: '', instances: '' } :
|
||||
{},
|
||||
};
|
||||
handleWidgetsChange([...(iface.widgets || []), newWidget]);
|
||||
@@ -359,7 +355,8 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
setDirty(false);
|
||||
setTag('');
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
if (showHistory) loadVersions();
|
||||
setViewingVersion(null);
|
||||
setVerReload(k => k + 1);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -370,26 +367,12 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
|
||||
// ── Version history ────────────────────────────────────────────────────────
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
if (!iface.id) { setVersions([]); return; }
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions`);
|
||||
if (!res.ok) throw new Error(`Load versions failed (${res.status})`);
|
||||
setVersions(await res.json());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, [iface.id]);
|
||||
|
||||
const toggleHistory = useCallback(() => {
|
||||
setShowHistory(s => {
|
||||
if (!s) loadVersions();
|
||||
if (!s) setVerReload(k => k + 1);
|
||||
return !s;
|
||||
});
|
||||
}, [loadVersions]);
|
||||
}, []);
|
||||
|
||||
// Load a past revision into the editor non-destructively: the current id is
|
||||
// preserved, so saving the restored content creates a new revision on top.
|
||||
@@ -403,45 +386,26 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
restored.id = iface.id; // keep editing the same interface
|
||||
setIface(restored);
|
||||
setSelectedIds([]);
|
||||
setDirty(true);
|
||||
// Viewing a past revision is not itself a change — only subsequent edits
|
||||
// dirty the editor (saving then promotes it to a new revision).
|
||||
setDirty(false);
|
||||
setCanvasNonce(n => n + 1);
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(0);
|
||||
setViewingVersion(version);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, dirty]);
|
||||
|
||||
// Edit (or clear) the label of an existing revision in place.
|
||||
const editVersionTag = useCallback(async (version: number, currentTag: string) => {
|
||||
// Reload the now-current interface content into the editor (after a promote,
|
||||
// which the VersionTree performs server-side).
|
||||
const reloadCurrentIface = useCallback(async () => {
|
||||
if (!iface.id) return;
|
||||
const next = prompt('Label for this version (leave empty to clear):', currentTag ?? '');
|
||||
if (next === null) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/tag?tag=${encodeURIComponent(next)}`,
|
||||
{ method: 'PUT' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Tag update failed (${res.status})`);
|
||||
loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, loadVersions]);
|
||||
|
||||
// Make a past revision the current one (saved as a new revision on top).
|
||||
const promoteVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
if (dirty && !confirm('You have unsaved changes that will be discarded. Set this version as current?')) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/promote`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Promote failed (${res.status})`);
|
||||
// Reload the now-current interface content into the editor.
|
||||
const cur = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
|
||||
if (!cur.ok) throw new Error(`Reload failed (${cur.status})`);
|
||||
const reloaded = parseInterface(await cur.text());
|
||||
setIface(reloaded);
|
||||
setSelectedIds([]);
|
||||
@@ -450,25 +414,9 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(0);
|
||||
setViewingVersion(null);
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, dirty, loadVersions]);
|
||||
|
||||
// Fork a revision into a brand-new interface.
|
||||
const forkVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/fork`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Fork failed (${res.status})`);
|
||||
const json = await res.json();
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
alert(`Forked into new interface "${json.id}". Open it from the interface list.`);
|
||||
setVerReload(k => k + 1);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -733,49 +681,31 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
Save snapshot
|
||||
</button>
|
||||
</div>
|
||||
<div class="history-list">
|
||||
{historyLoading && <div class="history-empty">Loading…</div>}
|
||||
{!historyLoading && versions.length === 0 && (
|
||||
<div class="history-empty">No saved versions yet.</div>
|
||||
)}
|
||||
{!historyLoading && versions.map(v => (
|
||||
<div
|
||||
key={v.version}
|
||||
class={`history-item${v.current ? ' history-item-current' : ''}`}
|
||||
>
|
||||
<div class="history-item-main">
|
||||
<span class="history-version">v{v.version}</span>
|
||||
{v.tag && <span class="history-tag">{v.tag}</span>}
|
||||
{v.current && <span class="history-current-badge">current</span>}
|
||||
</div>
|
||||
<div class="history-item-meta">
|
||||
{new Date(v.savedAt).toLocaleString()}
|
||||
</div>
|
||||
<div class="history-item-actions">
|
||||
{!v.current && (
|
||||
<button class="history-action-btn" onClick={() => loadVersion(v.version)} title="Load this version into the editor (does not change history)">
|
||||
Load
|
||||
</button>
|
||||
)}
|
||||
{!v.current && (
|
||||
<button class="history-action-btn" onClick={() => promoteVersion(v.version)} title="Make this version the current one">
|
||||
Set current
|
||||
</button>
|
||||
)}
|
||||
<button class="history-action-btn" onClick={() => forkVersion(v.version)} title="Create a new interface from this version">
|
||||
Fork
|
||||
</button>
|
||||
<button class="history-action-btn" onClick={() => editVersionTag(v.version, v.tag ?? '')} title="Edit this version's label">
|
||||
Label
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!iface.id ? (
|
||||
<div class="history-empty">Save the interface first to enable history.</div>
|
||||
) : (
|
||||
<VersionTree
|
||||
base={`/api/v1/interfaces/${encodeURIComponent(iface.id)}`}
|
||||
viewing={viewingVersion}
|
||||
dirty={dirty}
|
||||
canWrite={writable}
|
||||
reloadKey={verReload}
|
||||
onView={loadVersion}
|
||||
onForked={() => window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'))}
|
||||
onPromoted={reloadCurrentIface}
|
||||
onCompare={(av, bv) => setDiff({ av, bv })}
|
||||
onError={setError} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{diff && iface.id && (
|
||||
<DiffViewer base={`/api/v1/interfaces/${encodeURIComponent(iface.id)}`}
|
||||
av={diff.av} bv={diff.bv} title={`${iface.name} — v${diff.av} → v${diff.bv}`}
|
||||
onClose={() => setDiff(null)} />
|
||||
)}
|
||||
|
||||
{showHelp && (
|
||||
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user