Implemented admin pane + user permission

This commit is contained in:
Martino Ferrari
2026-06-22 17:49:14 +02:00
parent 73fcbe7b28
commit ac24011487
67 changed files with 5925 additions and 411 deletions
+89 -1
View File
@@ -11,6 +11,7 @@ import PropertiesPane from './PropertiesPane';
import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
import { useAuth, canWrite } from './lib/auth';
import { withContainedWidgets } from './lib/containers';
import { VersionTree, DiffViewer } from './VersionHistory';
interface Props {
@@ -22,6 +23,31 @@ function blankInterface(): Interface {
return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] };
}
// Rename a widget id everywhere it is referenced so logic actions and plot
// layouts keep pointing at the same widget after the user customizes its id.
function renameInLayout(l: PlotLayout, oldId: string, newId: string): PlotLayout {
if (l.type === 'leaf') return l.widget === oldId ? { ...l, widget: newId } : l;
return { ...l, a: renameInLayout(l.a, oldId, newId), b: renameInLayout(l.b, oldId, newId) };
}
function renameWidgetId(f: Interface, oldId: string, newId: string): Interface {
const next: Interface = {
...f,
widgets: (f.widgets || []).map(w => (w.id === oldId ? { ...w, id: newId } : w)),
};
if (f.layout) next.layout = renameInLayout(f.layout, oldId, newId);
if (f.logic) {
next.logic = {
...f.logic,
nodes: f.logic.nodes.map(n =>
n.kind === 'action.widget' && n.params.widget === oldId
? { ...n, params: { ...n.params, widget: newId } }
: n),
};
}
return next;
}
// ── Align / distribute helpers ──────────────────────────────────────────────
function alignWidgets(widgets: Widget[], ids: string[], mode: string): Widget[] {
@@ -77,6 +103,8 @@ const STATIC_WIDGET_TYPES = [
{ type: 'image', label: 'Image' },
{ type: 'link', label: 'Link' },
{ type: 'configselect', label: 'Config Selector' },
{ type: 'container', label: 'Container Pane' },
{ type: 'table', label: 'Table' },
];
export default function EditMode({ initial, onDone }: Props) {
@@ -90,6 +118,7 @@ export default function EditMode({ initial, onDone }: Props) {
const [snapGrid, setSnapGrid] = useState(10);
const [showSnapGrid, setShowSnapGrid] = useState(true);
const [showInsertMenu, setShowInsertMenu] = useState(false);
const [showGroupMenu, setShowGroupMenu] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [helpSection, setHelpSection] = useState('edit');
const [leftW, setLeftW] = useState(220);
@@ -195,6 +224,13 @@ export default function EditMode({ initial, onDone }: Props) {
setDirty(true);
}, [pushUndo]);
const handleWidgetRename = useCallback((oldId: string, newId: string) => {
pushUndo();
setIface(f => renameWidgetId(f, oldId, newId));
setSelectedIds(ids => ids.map(id => id === oldId ? newId : id));
setDirty(true);
}, [pushUndo]);
const handleIfaceChange = useCallback((updated: Interface) => {
setIface(updated);
setDirty(true);
@@ -284,8 +320,10 @@ export default function EditMode({ initial, onDone }: Props) {
const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1);
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
// Nudging a container also nudges the widgets sitting on it.
const movers = withContainedWidgets(selectedIds, curWidgets);
handleWidgetsChange(curWidgets.map(w =>
selectedIds.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
movers.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
));
}
}, [selectedIds, snapGrid, undo, redo, handleWidgetsChange, openHelp, pushUndo, iface.kind, centerTab]);
@@ -307,6 +345,43 @@ export default function EditMode({ initial, onDone }: Props) {
handleWidgetsChange(distributed);
}, [iface.widgets, selectedIds, handleWidgetsChange]);
// ── Group selection into a container ────────────────────────────────────────
// Wrap the selected widgets in a new container sized to their bounding box.
// The container is appended so it paints behind the grouped widgets (the
// canvas always renders containers first); membership is geometric, so simply
// enclosing the widgets places them inside. Extra top inset leaves room for
// the title / tab bar above the grouped content.
const groupIntoContainer = useCallback((variant: 'pane' | 'tabs') => {
setShowGroupMenu(false);
const widgets = iface.widgets || [];
const sel = widgets.filter(w => selectedIds.includes(w.id));
if (sel.length < 2) return;
const minX = Math.min(...sel.map(w => w.x));
const minY = Math.min(...sel.map(w => w.y));
const maxX = Math.max(...sel.map(w => w.x + w.w));
const maxY = Math.max(...sel.map(w => w.y + w.h));
const PAD = 12;
const TOP = 36; // room for the title / tab bar above the grouped widgets
const container: Widget = {
id: genWidgetId(),
type: 'container',
x: minX - PAD,
y: minY - TOP,
w: (maxX - minX) + PAD * 2,
h: (maxY - minY) + TOP + PAD,
signals: [],
options: variant === 'tabs'
? { variant: 'tabs', tabs: 'Tab 1,Tab 2', bg: 'true' }
: { variant: 'pane', title: 'Group', collapsible: 'false', bg: 'true' },
};
// In tab mode, place the grouped widgets on the first tab so they show by default.
const rest = variant === 'tabs'
? widgets.map(w => selectedIds.includes(w.id) ? { ...w, options: { ...w.options, tab: '0' } } : w)
: widgets;
handleWidgetsChange([...rest, container]);
setSelectedIds([container.id]);
}, [iface.widgets, selectedIds, handleWidgetsChange]);
// ── Insert static widget ───────────────────────────────────────────────────
const insertWidget = useCallback((type: string) => {
@@ -324,6 +399,8 @@ export default function EditMode({ initial, onDone }: Props) {
type === 'textlabel' ? { label: 'Label' } :
type === 'button' ? { label: 'Button', mode: 'oneshot' } :
type === 'configselect' ? { label: 'Config', set: '', output: '', instances: '' } :
type === 'container' ? { title: 'Pane', collapsible: 'false', bg: 'true' } :
type === 'table' ? { columns: 'name,value,unit', header: 'true' } :
{},
};
handleWidgetsChange([...(iface.widgets || []), newWidget]);
@@ -565,6 +642,16 @@ export default function EditMode({ initial, onDone }: Props) {
<button class="toolbar-btn icon-only" onClick={() => doDistribute('v')} title="Distribute V"></button>
</div>
)}
<span class="toolbar-sep" />
<div class="toolbar-dropdown">
<button class="toolbar-btn" onClick={() => setShowGroupMenu(m => !m)} title="Group selection into a container"> Group </button>
{showGroupMenu && (
<div class="toolbar-dropdown-menu" onClick={() => setShowGroupMenu(false)}>
<button class="ctx-item" onClick={() => groupIntoContainer('pane')}>Into Pane</button>
<button class="ctx-item" onClick={() => groupIntoContainer('tabs')}>Into Tabs</button>
</div>
)}
</div>
</div>
)}
@@ -659,6 +746,7 @@ export default function EditMode({ initial, onDone }: Props) {
multiCount={multiSelected ? selectedIds.length : 0}
iface={iface}
onChange={handleWidgetChange}
onRenameId={handleWidgetRename}
onIfaceChange={handleIfaceChange}
width={rightW}
/>