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
+161 -3
View File
@@ -1,12 +1,14 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import type { Widget, Interface } from './lib/types';
import { containingTabPane, tabLabels } from './lib/containers';
interface Props {
selected: Widget | null;
multiCount: number;
iface: Interface;
onChange: (updated: Widget) => void;
onRenameId: (oldId: string, newId: string) => void;
onIfaceChange: (updated: Interface) => void;
width?: number;
}
@@ -44,6 +46,37 @@ function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) =
);
}
// Like TextInput, but for the widget id: rejects invalid (empty/duplicate)
// commits and reverts the field to the current id when `onRename` returns false.
function IdInput({ value, onRename }: { value: string; onRename: (v: string) => boolean }) {
const [local, setLocal] = useState(value || '');
useEffect(() => {
setLocal(value || '');
}, [value]);
function commit() {
const v = local.trim();
if (v === value) { setLocal(value); return; }
if (!onRename(v)) setLocal(value); // rejected — revert to the current id
}
return (
<input
class="prop-input"
value={local}
onInput={(e) => setLocal((e.target as HTMLInputElement).value)}
onChange={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commit();
(e.target as HTMLInputElement).blur();
}
}}
/>
);
}
// Widgets that show units from signal metadata (can be overridden)
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
// Widgets where the user can set a numeric format string
@@ -51,9 +84,9 @@ const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']);
// Widgets with a signal-based label (can be overridden)
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled', 'toggle']);
// Widgets with multiple signals
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled', 'table']);
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) {
export default function PropertiesPane({ selected, multiCount, iface, onChange, onRenameId, onIfaceChange, width }: Props) {
const [collapsed, setCollapsed] = useState(false);
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
@@ -77,6 +110,17 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
onChange({ ...selected, [key]: value } as Widget);
}
// Apply a custom widget id. Rejects empty or duplicate ids (returns false so
// the field reverts); on success the rename propagates to logic/plot refs.
function renameId(newId: string): boolean {
if (!selected) return false;
if (!newId) return false;
if (newId === selected.id) return false;
if ((iface.widgets || []).some(x => x.id === newId)) return false;
onRenameId(selected.id, newId);
return true;
}
function removeSignal(idx: number) {
if (!selected) return;
const signals = selected.signals.filter((_, i) => i !== idx);
@@ -135,6 +179,11 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
<div class="props-section" key={w.id}>
<div class="props-section-title">{w.type}</div>
{/* Widget id — editable so logic/plot refs can use a friendly name */}
<Field label="Widget ID">
<IdInput value={w.id} onRename={renameId} />
</Field>
{/* Position / size */}
<Field label="X">
<input class="prop-input prop-input-num" type="number" value={w.x}
@@ -180,7 +229,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
{/* Label: button and textlabel use it as display text;
signal widgets use it as header label */}
{w.type !== 'image' && w.type !== 'link' && (
{w.type !== 'image' && w.type !== 'link' && w.type !== 'container' && w.type !== 'table' && (
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
{LABEL_WIDGETS.has(w.type) ? (
<div class="prop-field-col">
@@ -428,6 +477,115 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
</Field>
</div>
)}
{w.type === 'container' && (
<div>
<Field label="Variant">
<select class="prop-select" value={w.options['variant'] ?? 'pane'}
onChange={(e) => setOpt('variant', (e.target as HTMLSelectElement).value)}>
<option value="pane">Pane</option>
<option value="tabs">Tabs</option>
</select>
</Field>
<Field label="Accent">
<TextInput value={w.options['accent'] ?? '#3d4f6e'} onCommit={(v) => setOpt('accent', v)} />
</Field>
<Field label="Background">
<select class="prop-select" value={w.options['bg'] ?? 'true'}
onChange={(e) => setOpt('bg', (e.target as HTMLSelectElement).value)}>
<option value="true">Filled</option>
<option value="false">None</option>
</select>
</Field>
{(w.options['variant'] ?? 'pane') === 'tabs' ? (
<Field label="Tabs">
<div class="prop-field-col">
<TextInput value={w.options['tabs'] ?? 'Tab 1,Tab 2'} onCommit={(v) => setOpt('tabs', v)} />
<span class="prop-hint">Comma-separated tab names. Assign each widget to a tab via its "Tab" field.</span>
</div>
</Field>
) : (
<div>
<Field label="Title">
<TextInput value={w.options['title'] ?? ''} onCommit={(v) => setOpt('title', v)} />
</Field>
<Field label="Collapsible">
<select class="prop-select" value={w.options['collapsible'] ?? 'false'}
onChange={(e) => setOpt('collapsible', (e.target as HTMLSelectElement).value)}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</Field>
</div>
)}
</div>
)}
{w.type === 'table' && (() => {
const allCols = ['name', 'value', 'unit', 'quality', 'time'];
const colLabels: Record<string, string> = {
name: 'Name', value: 'Value', unit: 'Unit', quality: 'Status', time: 'Time',
};
const cur = (w.options['columns'] ?? 'name,value,unit')
.split(',').map(s => s.trim()).filter(Boolean);
const toggleCol = (c: string) => {
const next = cur.includes(c)
? cur.filter(x => x !== c)
: allCols.filter(x => cur.includes(x) || x === c);
setOpt('columns', next.join(','));
};
return (
<div>
<Field label="Title">
<TextInput value={w.options['title'] ?? ''} onCommit={(v) => setOpt('title', v)} />
</Field>
<Field label="Columns">
<div class="prop-field-col">
{allCols.map(c => (
<label key={c} class="prop-check">
<input type="checkbox" checked={cur.includes(c)} onChange={() => toggleCol(c)} />
{colLabels[c]}
</label>
))}
</div>
</Field>
<Field label="Header row">
<select class="prop-select" value={w.options['header'] ?? 'true'}
onChange={(e) => setOpt('header', (e.target as HTMLSelectElement).value)}>
<option value="true">Show</option>
<option value="false">Hide</option>
</select>
</Field>
<Field label="Value format">
<div class="prop-field-col">
<TextInput value={w.options['format'] ?? ''} onCommit={(v) => setOpt('format', v)} />
<span class="prop-hint">3f · 2e · 4g · empty=auto</span>
</div>
</Field>
<Field label="Row labels">
<div class="prop-field-col">
<TextInput value={w.options['labels'] ?? ''} onCommit={(v) => setOpt('labels', v)} />
<span class="prop-hint">Comma-separated, one per signal. Empty = signal name.</span>
</div>
</Field>
</div>
);
})()}
{/* Tab assignment for a widget sitting inside a tabbed container. */}
{w.type !== 'container' && (() => {
const host = containingTabPane(w, iface.widgets);
if (!host) return null;
const tabs = tabLabels(host);
return (
<Field label="Tab">
<select class="prop-select" value={w.options['tab'] ?? '0'}
onChange={(e) => setOpt('tab', (e.target as HTMLSelectElement).value)}>
{tabs.map((t, i) => <option key={i} value={String(i)}>{t}</option>)}
</select>
</Field>
);
})()}
</div>
)}