Major changes: logic add to panel, local variables, panel histor, users management...

This commit is contained in:
Martino Ferrari
2026-06-18 17:37:04 +02:00
parent 71430bc3b0
commit aba394b84d
54 changed files with 6104 additions and 1166 deletions
+136 -40
View File
@@ -1,32 +1,49 @@
import { h } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import type { Interface } from './lib/types';
interface ServerInterface {
id: string;
name: string;
version: number;
}
import { useAuth, canWrite } from './lib/auth';
import type { Interface, InterfaceListItem, Folder } from './lib/types';
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;
}
export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, width }: Props) {
const [collapsed, setCollapsed] = useState(false);
const [interfaces, setInterfaces] = useState<ServerInterface[]>([]);
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 fileInputRef = useRef<HTMLInputElement>(null);
const me = useAuth();
const writable = canWrite(me.level);
function refresh() {
setLoading(true);
fetch('/api/v1/interfaces')
.then(r => r.ok ? r.json() : [])
.then(data => setInterfaces(data))
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),
owner: item.owner ? String(item.owner) : '',
folder: item.folder ? String(item.folder) : '',
perm: (item.perm as InterfaceListItem['perm']) || 'write',
})).filter((item: InterfaceListItem) => item.id);
setInterfaces(normalized);
setFolders(Array.isArray(folderData) ? folderData : []);
})
.catch(() => {})
.finally(() => setLoading(false));
}
@@ -71,13 +88,89 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, widt
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) {
return (
<li key={item.id} class="iface-item">
<span class="iface-item-name" onClick={() => onSelect?.(item.id)} title={item.owner ? `Owner: ${item.owner}` : undefined}>
{item.name || item.id}
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
</span>
<div class="iface-item-actions">
{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).
function renderFolder(folder: Folder) {
const children = folders.filter(f => f.parent === folder.id);
const panels = interfaces.filter(i => i.folder === folder.id);
const open = expanded[folder.id] !== false; // default expanded
return (
<li key={folder.id} class="iface-folder">
<div class="iface-folder-header">
<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 = interfaces.filter(i => !i.folder);
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={() => setCollapsed(c => !c)}
onClick={onToggleCollapse}
title={collapsed ? 'Expand panel' : 'Collapse panel'}
>
{collapsed ? '▶' : '◀'}
@@ -85,44 +178,47 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, widt
</div>
{!collapsed && (
<div>
<div class="panel-actions">
<button class="panel-btn" onClick={() => onEdit?.()}>+ New</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>
<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>
)}
<div class="panel-list">
{loading ? (
<p class="hint">Loading</p>
) : interfaces.length === 0 ? (
) : interfaces.length === 0 && folders.length === 0 ? (
<p class="hint">No interfaces yet. Create one or import XML.</p>
) : (
<ul class="iface-list">
{interfaces.map(iface => (
<li key={iface.id} class="iface-item">
<span class="iface-item-name" onClick={() => onSelect?.(iface.id)}>
{iface.name || iface.id}
</span>
<div class="iface-item-actions">
<button class="icon-btn" title="Edit" onClick={() => onEditId?.(iface.id)}></button>
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(iface.id)}></button>
<button class="icon-btn" title="Clone" onClick={() => handleClone(iface.id)}></button>
<button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(iface.id, iface.name)}></button>
</div>
</li>
))}
{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>
);
}