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 ( {plot ? ( ) : ( )} ); } export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onEditId, width, collapsed, onToggleCollapse }: Props) { const [interfaces, setInterfaces] = useState([]); const [folders, setFolders] = useState([]); const [loading, setLoading] = useState(true); const [share, setShare] = useState<{ id: string; name: string } | null>(null); const [expanded, setExpanded] = useState>({}); const [dropTarget, setDropTarget] = useState(null); const fileInputRef = useRef(null); const dragId = useRef(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('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 (
  • 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)} > {item.name || item.id} {item.perm === 'read' && ro}
    e.stopPropagation()}> {item.perm === 'write' && } {writable && } {canShare(item) && } {item.perm === 'write' && }
  • ); } // 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 (
  • { 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} > toggle(folder.id)}> {open ? '▾' : '▸'} {folder.name} {folder.perm === 'write' && (
    )}
    {open && (
      {children.map(renderFolder)} {panels.map(renderPanel)}
    )}
  • ); } const rootFolders = folders.filter(f => !f.parent); const rootPanels = visiblePanelsIn(''); return ( ); }