Major changes: logic add to panel, local variables, panel histor, users management...
This commit is contained in:
+136
-40
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user