Files
uopi/web/src/InterfaceList.tsx
T
2026-06-23 17:59:40 +02:00

357 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { h } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import { useAuth, canWrite } from './lib/auth';
import type { Interface, InterfaceListItem, Folder } from './lib/types';
import { ScopeFilter, bucketOf, type Bucket } from './lib/scope';
import ShareDialog from './ShareDialog';
interface Props {
onLoad: (xml: string) => void;
onSelect?: (id: string) => void;
onEdit?: (iface?: Interface) => void;
onNewPlot?: () => void;
onEditId?: (id: string) => void;
width?: number;
collapsed: boolean;
onToggleCollapse: () => void;
}
// Monochrome (currentColor) panel-kind glyph. Inline SVG avoids font-dependent
// emoji that may render in colour or as a missing-glyph box.
function KindIcon({ kind }: { kind?: 'panel' | 'plot' }) {
const plot = kind === 'plot';
return (
<span class="iface-item-icon" title={plot ? 'Plot panel' : 'HMI panel'}>
{plot ? (
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2.5 2.5 v11 h11" />
<path d="M4.5 11 L7 7.5 L9 9.5 L13 4.5" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="2.5" width="12" height="11" rx="1.5" />
<path d="M4.5 6 h7" />
<circle cx="6" cy="6" r="1.2" fill="currentColor" stroke="none" />
<path d="M4.5 10 h7" />
<circle cx="10" cy="10" r="1.2" fill="currentColor" stroke="none" />
</svg>
)}
</span>
);
}
export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onEditId, width, collapsed, onToggleCollapse }: Props) {
const [interfaces, setInterfaces] = useState<InterfaceListItem[]>([]);
const [folders, setFolders] = useState<Folder[]>([]);
const [loading, setLoading] = useState(true);
const [share, setShare] = useState<{ id: string; name: string } | null>(null);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [dropTarget, setDropTarget] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragId = useRef<string | null>(null);
const me = useAuth();
const writable = canWrite(me.level);
// Mine/Group/Global visibility filter. Default to Global so the primary
// navigation list isn't empty for users who own no panels; legacy/public
// panels live there.
const [bucket, setBucket] = useState<Bucket>('global');
const [scopeGroup, setScopeGroup] = useState('');
// Panels in a folder ('' = root), sorted by their stored order then name.
function panelsIn(folder: string): InterfaceListItem[] {
return interfaces
.filter(i => (i.folder || '') === folder)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || (a.name || a.id).localeCompare(b.name || b.id));
}
// Whether a panel falls in the active visibility bucket. In group mode a
// chosen group narrows to panels shared with that specific group.
function inScope(item: InterfaceListItem): boolean {
if (bucketOf(item, me) !== bucket) return false;
if (bucket === 'group' && scopeGroup) return (item.groups ?? []).includes(scopeGroup);
return true;
}
// Panels in a folder that pass the active visibility filter.
function visiblePanelsIn(folder: string): InterfaceListItem[] {
return panelsIn(folder).filter(inScope);
}
// A folder is shown when it (recursively) contains any in-scope panel, so
// empty branches collapse out under the active filter.
function folderHasVisible(id: string): boolean {
if (visiblePanelsIn(id).length > 0) return true;
return folders.some(f => f.parent === id && folderHasVisible(f.id));
}
// Persist a new ordering for a folder's panels (also moves panels between folders).
async function reorder(folder: string, ids: string[]) {
const res = await fetch('/api/v1/interfaces/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder, ids }),
});
if (res.ok) refresh();
}
function endDrag() {
dragId.current = null;
setDropTarget(null);
}
// Drop dragged panel just before `target` within target's folder.
function dropOnPanel(target: InterfaceListItem, e: DragEvent) {
e.preventDefault();
e.stopPropagation();
const id = dragId.current;
if (!id || id === target.id) { endDrag(); return; }
const dest = target.folder || '';
const ids = panelsIn(dest).map(p => p.id).filter(pid => pid !== id);
const idx = ids.indexOf(target.id);
ids.splice(idx < 0 ? ids.length : idx, 0, id);
reorder(dest, ids);
endDrag();
}
// Drop dragged panel at the end of `folder` ('' = root).
function dropInFolder(folder: string, e: DragEvent) {
e.preventDefault();
e.stopPropagation();
const id = dragId.current;
if (!id) { endDrag(); return; }
const ids = panelsIn(folder).map(p => p.id).filter(pid => pid !== id);
ids.push(id);
reorder(folder, ids);
endDrag();
}
function refresh() {
setLoading(true);
Promise.all([
fetch('/api/v1/interfaces').then(r => r.ok ? r.json() : []),
fetch('/api/v1/folders').then(r => r.ok ? r.json() : []),
])
.then(([ifaceData, folderData]) => {
const list = Array.isArray(ifaceData) ? ifaceData : [];
const normalized: InterfaceListItem[] = list.map((item: any) => ({
id: String(item.id || item.ID || ''),
name: String(item.name || item.Name || ''),
version: Number(item.version || item.Version || 0),
kind: item.kind === 'plot' ? ('plot' as const) : undefined,
owner: item.owner ? String(item.owner) : '',
folder: item.folder ? String(item.folder) : '',
order: Number(item.order || 0),
perm: (item.perm as InterfaceListItem['perm']) || 'write',
scope: item.scope ? String(item.scope) : '',
groups: Array.isArray(item.groups) ? item.groups.map(String) : [],
})).filter((item: InterfaceListItem) => item.id);
setInterfaces(normalized);
setFolders(Array.isArray(folderData) ? folderData : []);
})
.catch(() => {})
.finally(() => setLoading(false));
}
useEffect(() => {
refresh();
const handleRefresh = () => refresh();
window.addEventListener('uopi:refresh-interfaces', handleRefresh);
return () => window.removeEventListener('uopi:refresh-interfaces', handleRefresh);
}, []);
function triggerImport() {
fileInputRef.current?.click();
}
async function handleFileChange(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const xml = await file.text();
onLoad(xml);
} catch (err) {
console.error('Failed to read interface file:', err);
} finally {
input.value = '';
}
}
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete interface "${name}"?`)) return;
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) refresh();
}
async function handleClone(id: string) {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}/clone`, { method: 'POST' });
if (res.ok) refresh();
}
function handleFullscreen(id: string) {
window.open(`?fs=${encodeURIComponent(id)}`, '_blank');
}
async function handleNewFolder(parent: string) {
const name = prompt('New folder name:');
if (!name || !name.trim()) return;
const res = await fetch('/api/v1/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), parent }),
});
if (res.ok) refresh();
}
async function handleDeleteFolder(id: string, name: string) {
if (!confirm(`Delete folder "${name}"? Its panels and subfolders move up one level.`)) return;
const res = await fetch(`/api/v1/folders/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) refresh();
}
// The caller may manage sharing when they can write and either own the panel
// or it is unmanaged (no owner recorded yet).
function canShare(item: InterfaceListItem): boolean {
return writable && (!item.owner || item.owner === me.user);
}
function toggle(id: string) {
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
}
function renderPanel(item: InterfaceListItem) {
const drag = writable && item.perm === 'write';
return (
<li
key={item.id}
class={`iface-item iface-item-clickable${dropTarget === `p:${item.id}` ? ' drop-target' : ''}`}
draggable={drag}
onClick={() => onSelect?.(item.id)}
onDragStart={drag ? (e) => { dragId.current = item.id; e.dataTransfer!.effectAllowed = 'move'; } : undefined}
onDragEnd={endDrag}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`p:${item.id}`); } }}
onDragLeave={() => setDropTarget(t => t === `p:${item.id}` ? null : t)}
onDrop={(e) => dropOnPanel(item, e)}
>
<span class="iface-item-name" title={item.owner ? `Owner: ${item.owner}` : undefined}>
<KindIcon kind={item.kind} />
{item.name || item.id}
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
</span>
<div class="iface-item-actions" onClick={(e) => e.stopPropagation()}>
{item.perm === 'write' && <button class="icon-btn" title="Edit" onClick={() => onEditId?.(item.id)}></button>}
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(item.id)}></button>
{writable && <button class="icon-btn" title="Clone" onClick={() => handleClone(item.id)}></button>}
{canShare(item) && <button class="icon-btn" title="Share" onClick={() => setShare({ id: item.id, name: item.name })}></button>}
{item.perm === 'write' && <button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(item.id, item.name)}></button>}
</div>
</li>
);
}
// Render a folder and everything beneath it (subfolders first, then panels).
// Folders with no in-scope descendant panels are hidden under the filter.
function renderFolder(folder: Folder) {
if (!folderHasVisible(folder.id)) return null;
const children = folders.filter(f => f.parent === folder.id);
const panels = visiblePanelsIn(folder.id);
const open = expanded[folder.id] !== false; // default expanded
const canDrop = folder.perm === 'write';
return (
<li key={folder.id} class="iface-folder">
<div
class={`iface-folder-header${dropTarget === `f:${folder.id}` ? ' drop-target' : ''}`}
onDragOver={canDrop ? (e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`f:${folder.id}`); } } : undefined}
onDragLeave={() => setDropTarget(t => t === `f:${folder.id}` ? null : t)}
onDrop={canDrop ? (e) => dropInFolder(folder.id, e) : undefined}
>
<span class="iface-folder-name" onClick={() => toggle(folder.id)}>
{open ? '▾' : '▸'} {folder.name}
</span>
{folder.perm === 'write' && (
<div class="iface-item-actions">
<button class="icon-btn" title="New subfolder" onClick={() => handleNewFolder(folder.id)}></button>
<button class="icon-btn iface-delete" title="Delete folder" onClick={() => handleDeleteFolder(folder.id, folder.name)}></button>
</div>
)}
</div>
{open && (
<ul class="iface-list iface-sublist">
{children.map(renderFolder)}
{panels.map(renderPanel)}
</ul>
)}
</li>
);
}
const rootFolders = folders.filter(f => !f.parent);
const rootPanels = visiblePanelsIn('');
return (
<aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
<div class="panel-header">
{!collapsed && <span class="panel-title">Interfaces</span>}
<button
class="icon-btn"
onClick={onToggleCollapse}
title={collapsed ? 'Expand panel' : 'Collapse panel'}
>
{collapsed ? '▶' : '◀'}
</button>
</div>
{!collapsed && (
<div class="panel-body">
{writable && (
<div class="panel-actions">
<button class="panel-btn" onClick={() => onEdit?.()}>+ New</button>
<button class="panel-btn" onClick={() => onNewPlot?.()}>+ Plot</button>
<button class="panel-btn" onClick={() => handleNewFolder('')}>+ Folder</button>
<button class="panel-btn" onClick={triggerImport}>Import</button>
<input
type="file"
accept=".xml,application/xml,text/xml"
style="display:none"
ref={fileInputRef}
onChange={handleFileChange}
/>
</div>
)}
<ScopeFilter me={me} value={bucket} onChange={setBucket}
group={scopeGroup} onGroupChange={setScopeGroup} />
<div class="panel-list">
{loading ? (
<p class="hint">Loading</p>
) : interfaces.length === 0 && folders.length === 0 ? (
<p class="hint">No interfaces yet. Create one or import XML.</p>
) : (
<ul
class={`iface-list${dropTarget === 'f:' ? ' drop-target' : ''}`}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); setDropTarget('f:'); } }}
onDragLeave={() => setDropTarget(t => t === 'f:' ? null : t)}
onDrop={(e) => dropInFolder('', e)}
>
{rootFolders.map(renderFolder)}
{rootPanels.map(renderPanel)}
</ul>
)}
</div>
</div>
)}
{share && (
<ShareDialog
ifaceId={share.id}
ifaceName={share.name}
folders={folders}
onClose={() => setShare(null)}
onSaved={refresh}
/>
)}
</aside>
);
}