357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|