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