Implemented admin pane + user permission
This commit is contained in:
+89
-1
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user