Phase 6
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useCallback } from 'preact/hooks';
|
||||
import type { Interface, Widget } from './lib/types';
|
||||
import { serializeInterface, parseInterface } from './lib/xml';
|
||||
import SignalTree from './SignalTree';
|
||||
import EditCanvas from './EditCanvas';
|
||||
import PropertiesPane from './PropertiesPane';
|
||||
|
||||
interface Props {
|
||||
initial: Interface | null;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
let _newId = Date.now();
|
||||
function newIfaceId() { return `iface-${_newId++}`; }
|
||||
|
||||
function blankInterface(): Interface {
|
||||
return {
|
||||
id: newIfaceId(),
|
||||
name: 'New Interface',
|
||||
version: 1,
|
||||
w: 1200,
|
||||
h: 800,
|
||||
widgets: [],
|
||||
};
|
||||
}
|
||||
|
||||
export default function EditMode({ initial, onDone }: Props) {
|
||||
const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedWidget = iface.widgets.find(w => w.id === selectedId) ?? null;
|
||||
|
||||
const handleWidgetsChange = useCallback((widgets: Widget[]) => {
|
||||
setIface(f => ({ ...f, widgets }));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleWidgetChange = useCallback((updated: Widget) => {
|
||||
setIface(f => ({ ...f, widgets: f.widgets.map(w => w.id === updated.id ? updated : w) }));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleIfaceChange = useCallback((updated: Interface) => {
|
||||
setIface(updated);
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const xml = serializeInterface(iface);
|
||||
const url = iface.id
|
||||
? `/api/v1/interfaces/${encodeURIComponent(iface.id)}`
|
||||
: '/api/v1/interfaces';
|
||||
const method = iface.id ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
body: xml,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Save failed (${res.status}): ${text}`);
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const json = await res.json();
|
||||
setIface(f => ({ ...f, id: json.id }));
|
||||
}
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const xml = serializeInterface(iface);
|
||||
const blob = new Blob([xml], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${iface.name.replace(/[^a-z0-9]/gi, '_')}.xml`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.xml,application/xml,text/xml';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const xml = await file.text();
|
||||
const parsed = parseInterface(xml);
|
||||
setIface(parsed);
|
||||
setSelectedId(null);
|
||||
setDirty(true);
|
||||
} catch (err) {
|
||||
setError(`Import failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
function handleLeave() {
|
||||
if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return;
|
||||
onDone();
|
||||
}
|
||||
|
||||
function handleDeleteSelected() {
|
||||
if (!selectedId) return;
|
||||
setIface(f => ({ ...f, widgets: f.widgets.filter(w => w.id !== selectedId) }));
|
||||
setSelectedId(null);
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId &&
|
||||
!(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) {
|
||||
handleDeleteSelected();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="edit-mode-layout" onKeyDown={handleKeyDown} tabIndex={-1}>
|
||||
{/* Toolbar */}
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="app-name">uopi</span>
|
||||
<span class="edit-badge">Edit</span>
|
||||
<input
|
||||
class="iface-name-input"
|
||||
value={iface.name}
|
||||
onInput={(e) => handleIfaceChange({ ...iface, name: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
{dirty && <span class="dirty-dot" title="Unsaved changes">●</span>}
|
||||
</div>
|
||||
<div class="toolbar-center">
|
||||
{error && <span class="parse-error" title={error}>Save error — check console</span>}
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button class="toolbar-btn" onClick={handleImport} title="Import XML file">Import</button>
|
||||
<button class="toolbar-btn" onClick={handleExport} title="Export XML file">Export</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button class="toolbar-btn" onClick={handleLeave} title="Back to view mode">✕ Close</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Three-panel body */}
|
||||
<div class="edit-body">
|
||||
<SignalTree />
|
||||
<EditCanvas
|
||||
iface={iface}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onChange={handleWidgetsChange}
|
||||
/>
|
||||
<PropertiesPane
|
||||
selected={selectedWidget}
|
||||
iface={iface}
|
||||
onChange={handleWidgetChange}
|
||||
onIfaceChange={handleIfaceChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user