complete first iteration
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||
|
||||
interface Props {
|
||||
mode: 'view' | 'edit';
|
||||
onOpenManual: (section?: string) => void;
|
||||
}
|
||||
|
||||
const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
|
||||
view: [
|
||||
{ text: 'Click any interface in the left panel to load it on the canvas.', section: 'view' },
|
||||
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
|
||||
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
|
||||
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
|
||||
{ text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' },
|
||||
],
|
||||
edit: [
|
||||
{ text: 'Drag a signal from the left tree onto the canvas to create a widget.', section: 'edit' },
|
||||
{ text: 'Click a widget to select it, then adjust its properties on the right.', section: 'edit' },
|
||||
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
|
||||
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
|
||||
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
|
||||
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
|
||||
],
|
||||
};
|
||||
|
||||
export default function ContextualHelp({ mode, onOpenManual }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tipIndex, setTipIndex] = useState(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const tips = TIPS[mode];
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handler(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
// Cycle to a new random tip each time the popover opens
|
||||
function handleOpen() {
|
||||
setTipIndex(t => (t + 1) % tips.length);
|
||||
setOpen(o => !o);
|
||||
}
|
||||
|
||||
const tip = tips[tipIndex];
|
||||
|
||||
return (
|
||||
<div class="ctx-help" ref={ref}>
|
||||
<button
|
||||
class="ctx-help-btn"
|
||||
onClick={handleOpen}
|
||||
title="Contextual help"
|
||||
aria-label="Open contextual help"
|
||||
aria-expanded={open}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div class="ctx-help-popover" role="tooltip">
|
||||
<div class="ctx-help-tip">
|
||||
<span class="ctx-help-icon">💡</span>
|
||||
<span>{tip.text}</span>
|
||||
</div>
|
||||
|
||||
<div class="ctx-help-footer">
|
||||
<button
|
||||
class="ctx-help-nav"
|
||||
onClick={() => setTipIndex(t => (t + 1) % tips.length)}
|
||||
title="Next tip"
|
||||
>
|
||||
Next tip →
|
||||
</button>
|
||||
<button
|
||||
class="ctx-help-link"
|
||||
onClick={() => { setOpen(false); onOpenManual(tip.section); }}
|
||||
>
|
||||
Open manual ↗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
interface Props {
|
||||
initialSection?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ── SVG Diagrams ─────────────────────────────────────────────────────────────
|
||||
|
||||
function DiagramViewMode() {
|
||||
return (
|
||||
<svg viewBox="0 0 580 230" class="help-diagram" role="img" aria-label="View mode layout diagram">
|
||||
{/* outer bg */}
|
||||
<rect width="580" height="230" rx="8" fill="#0f1117" />
|
||||
|
||||
{/* toolbar */}
|
||||
<rect width="580" height="38" rx="0" fill="#1a1f2e" />
|
||||
<rect y="37" width="580" height="1" fill="#2d3748" />
|
||||
<text x="12" y="24" fill="#4a9eff" font-size="12" font-weight="bold">uopi</text>
|
||||
<text x="54" y="24" fill="#e2e8f0" font-size="11">Control Room</text>
|
||||
{/* live chip */}
|
||||
<rect x="488" y="10" width="80" height="18" rx="9" fill="#1e3a1e" />
|
||||
<circle cx="500" cy="19" r="4" fill="#22c55e" />
|
||||
<text x="509" y="23" fill="#22c55e" font-size="9">live</text>
|
||||
<rect x="454" y="10" width="30" height="18" rx="4" fill="#2d3748" />
|
||||
<text x="469" y="23" fill="#e2e8f0" font-size="9" text-anchor="middle">Edit</text>
|
||||
|
||||
{/* interface list panel */}
|
||||
<rect x="0" y="38" width="148" height="192" fill="#151b2e" />
|
||||
<rect x="0" y="38" width="148" height="192" fill="none" stroke="#2d3748" />
|
||||
<text x="10" y="57" fill="#64748b" font-size="8">INTERFACES</text>
|
||||
<rect x="6" y="63" width="136" height="22" rx="4" fill="#2d3748" />
|
||||
<text x="14" y="79" fill="#4a9eff" font-size="10">Control Room</text>
|
||||
<rect x="6" y="89" width="136" height="22" rx="4" fill="none" />
|
||||
<text x="14" y="105" fill="#94a3b8" font-size="10">Vacuum System</text>
|
||||
<rect x="6" y="115" width="136" height="22" rx="4" fill="none" />
|
||||
<text x="14" y="131" fill="#94a3b8" font-size="10">RF Status</text>
|
||||
|
||||
{/* canvas area */}
|
||||
<rect x="148" y="38" width="432" height="192" fill="#0f1117" />
|
||||
|
||||
{/* gauge widget */}
|
||||
<rect x="162" y="52" width="108" height="88" rx="4" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<text x="216" y="69" fill="#64748b" font-size="8" text-anchor="middle">Temperature</text>
|
||||
<circle cx="216" cy="108" r="26" fill="none" stroke="#2d3748" stroke-width="5" />
|
||||
<circle cx="216" cy="108" r="26" fill="none" stroke="#4a9eff" stroke-width="5"
|
||||
stroke-dasharray="55 108" stroke-dashoffset="27" stroke-linecap="round" />
|
||||
<text x="216" y="113" fill="#e2e8f0" font-size="10" text-anchor="middle" font-weight="bold">67°C</text>
|
||||
|
||||
{/* LED */}
|
||||
<rect x="282" y="52" width="90" height="38" rx="4" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<circle cx="300" cy="71" r="8" fill="#22c55e" />
|
||||
<text x="314" y="75" fill="#94a3b8" font-size="9">Beam ON</text>
|
||||
|
||||
{/* text view */}
|
||||
<rect x="282" y="98" width="90" height="30" rx="4" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<text x="327" y="118" fill="#e2e8f0" font-size="12" text-anchor="middle" font-weight="bold">1.23 mA</text>
|
||||
|
||||
{/* bar */}
|
||||
<rect x="162" y="150" width="108" height="68" rx="4" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<text x="216" y="167" fill="#64748b" font-size="8" text-anchor="middle">Pressure</text>
|
||||
<rect x="172" y="174" width="88" height="14" rx="3" fill="#2d3748" />
|
||||
<rect x="172" y="174" width="57" height="14" rx="3" fill="#4a9eff" />
|
||||
<text x="216" y="204" fill="#94a3b8" font-size="8" text-anchor="middle">65 %</text>
|
||||
|
||||
{/* plot */}
|
||||
<rect x="386" y="52" width="182" height="166" rx="4" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<text x="477" y="69" fill="#64748b" font-size="8" text-anchor="middle">Beam Current</text>
|
||||
<polyline points="398,195 418,180 438,170 458,175 478,160 498,155 518,158 548,145"
|
||||
fill="none" stroke="#4a9eff" stroke-width="1.5" />
|
||||
<line x1="398" y1="75" x2="398" y2="200" stroke="#2d3748" stroke-width="1" />
|
||||
<line x1="398" y1="200" x2="560" y2="200" stroke="#2d3748" stroke-width="1" />
|
||||
|
||||
{/* Annotations */}
|
||||
{/* arrow to interface list */}
|
||||
<line x1="74" y1="218" x2="74" y2="170" stroke="#fbbf24" stroke-width="1.5" marker-end="url(#arr)" />
|
||||
<text x="74" y="228" fill="#fbbf24" font-size="9" text-anchor="middle">① Interface list</text>
|
||||
|
||||
{/* arrow to canvas */}
|
||||
<line x1="360" y1="218" x2="360" y2="180" stroke="#fbbf24" stroke-width="1.5" marker-end="url(#arr)" />
|
||||
<text x="360" y="228" fill="#fbbf24" font-size="9" text-anchor="middle">② Live canvas</text>
|
||||
|
||||
<defs>
|
||||
<marker id="arr" markerWidth="6" markerHeight="6" refX="3" refY="3" orient="auto">
|
||||
<path d="M0,0 L6,3 L0,6 Z" fill="#fbbf24" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagramEditMode() {
|
||||
return (
|
||||
<svg viewBox="0 0 580 230" class="help-diagram" role="img" aria-label="Edit mode layout diagram">
|
||||
<rect width="580" height="230" rx="8" fill="#0f1117" />
|
||||
{/* toolbar */}
|
||||
<rect width="580" height="38" fill="#1a1f2e" />
|
||||
<rect y="37" width="580" height="1" fill="#2d3748" />
|
||||
<text x="12" y="24" fill="#4a9eff" font-size="12" font-weight="bold">uopi</text>
|
||||
<rect x="50" y="12" width="26" height="14" rx="3" fill="#1e40af" />
|
||||
<text x="63" y="23" fill="#93c5fd" font-size="8" text-anchor="middle">Edit</text>
|
||||
<text x="86" y="24" fill="#e2e8f0" font-size="11">My Panel</text>
|
||||
{/* toolbar buttons */}
|
||||
<rect x="350" y="11" width="28" height="16" rx="3" fill="#2d3748" />
|
||||
<text x="364" y="23" fill="#e2e8f0" font-size="9" text-anchor="middle">↩</text>
|
||||
<rect x="382" y="11" width="28" height="16" rx="3" fill="#2d3748" />
|
||||
<text x="396" y="23" fill="#e2e8f0" font-size="9" text-anchor="middle">↪</text>
|
||||
<rect x="480" y="11" width="36" height="16" rx="3" fill="#2563eb" />
|
||||
<text x="498" y="23" fill="#fff" font-size="9" text-anchor="middle">Save</text>
|
||||
<rect x="520" y="11" width="40" height="16" rx="3" fill="#2d3748" />
|
||||
<text x="540" y="23" fill="#e2e8f0" font-size="9" text-anchor="middle">✕ Close</text>
|
||||
|
||||
{/* signal tree */}
|
||||
<rect x="0" y="38" width="140" height="192" fill="#151b2e" />
|
||||
<rect x="0" y="38" width="140" height="192" fill="none" stroke="#2d3748" />
|
||||
<text x="10" y="57" fill="#64748b" font-size="8">SIGNALS</text>
|
||||
<rect x="4" y="63" width="132" height="20" rx="3" fill="#1a2236" stroke="#2d3748" />
|
||||
<text x="10" y="78" fill="#64748b" font-size="9">Filter signals…</text>
|
||||
<text x="10" y="100" fill="#94a3b8" font-size="9">▾ epics</text>
|
||||
<text x="20" y="115" fill="#cbd5e1" font-size="9"> CURRENT</text>
|
||||
<text x="20" y="129" fill="#cbd5e1" font-size="9"> PRESSURE</text>
|
||||
<text x="10" y="148" fill="#94a3b8" font-size="9">▾ stub</text>
|
||||
<text x="20" y="163" fill="#cbd5e1" font-size="9"> sine_1hz</text>
|
||||
<text x="20" y="177" fill="#cbd5e1" font-size="9"> ramp_10s</text>
|
||||
|
||||
{/* canvas */}
|
||||
<rect x="140" y="38" width="288" height="192" fill="#0f1117" />
|
||||
{/* grid dots */}
|
||||
{[0,1,2,3,4,5,6,7,8].map(col =>
|
||||
[0,1,2,3,4,5,6].map(row => (
|
||||
<circle key={`${col}-${row}`} cx={140 + 10 + col * 30} cy={38 + 10 + row * 26}
|
||||
r="0.8" fill="#2d3748" />
|
||||
))
|
||||
)}
|
||||
{/* selected widget with handles */}
|
||||
<rect x="185" y="75" width="120" height="60" rx="4" fill="#1a1f2e" stroke="#4a9eff" stroke-width="1.5" />
|
||||
<text x="245" y="110" fill="#e2e8f0" font-size="11" text-anchor="middle" font-weight="bold">42.3°C</text>
|
||||
<text x="245" y="90" fill="#64748b" font-size="8" text-anchor="middle">Temperature</text>
|
||||
{/* resize handles */}
|
||||
<rect x="182" y="72" width="8" height="8" rx="1" fill="#4a9eff" />
|
||||
<rect x="301" y="72" width="8" height="8" rx="1" fill="#4a9eff" />
|
||||
<rect x="182" y="131" width="8" height="8" rx="1" fill="#4a9eff" />
|
||||
<rect x="301" y="131" width="8" height="8" rx="1" fill="#4a9eff" />
|
||||
{/* another widget */}
|
||||
<rect x="220" y="155" width="80" height="30" rx="4" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<rect x="230" y="163" width="60" height="10" rx="2" fill="#2d3748" />
|
||||
<rect x="230" y="163" width="38" height="10" rx="2" fill="#22c55e" />
|
||||
|
||||
{/* properties pane */}
|
||||
<rect x="428" y="38" width="152" height="192" fill="#1a1f2e" />
|
||||
<rect x="428" y="38" width="152" height="192" fill="none" stroke="#2d3748" />
|
||||
<text x="438" y="56" fill="#64748b" font-size="8">PROPERTIES</text>
|
||||
<line x1="428" y1="61" x2="580" y2="61" stroke="#2d3748" />
|
||||
<text x="438" y="78" fill="#94a3b8" font-size="8">Signal</text>
|
||||
<rect x="438" y="82" width="132" height="16" rx="3" fill="#1a2236" stroke="#2d3748" />
|
||||
<text x="444" y="94" fill="#e2e8f0" font-size="9">TEMPERATURE</text>
|
||||
<text x="438" y="112" fill="#94a3b8" font-size="8">X</text>
|
||||
<rect x="438" y="116" width="58" height="14" rx="3" fill="#1a2236" stroke="#2d3748" />
|
||||
<text x="444" y="127" fill="#e2e8f0" font-size="9">185</text>
|
||||
<text x="504" y="112" fill="#94a3b8" font-size="8">Y</text>
|
||||
<rect x="504" y="116" width="58" height="14" rx="3" fill="#1a2236" stroke="#2d3748" />
|
||||
<text x="510" y="127" fill="#e2e8f0" font-size="9">75</text>
|
||||
<text x="438" y="145" fill="#94a3b8" font-size="8">Width</text>
|
||||
<rect x="438" y="149" width="58" height="14" rx="3" fill="#1a2236" stroke="#2d3748" />
|
||||
<text x="444" y="160" fill="#e2e8f0" font-size="9">120</text>
|
||||
<text x="504" y="145" fill="#94a3b8" font-size="8">Height</text>
|
||||
<rect x="504" y="149" width="58" height="14" rx="3" fill="#1a2236" stroke="#2d3748" />
|
||||
<text x="510" y="160" fill="#e2e8f0" font-size="9">60</text>
|
||||
|
||||
{/* Annotations */}
|
||||
<text x="70" y="218" fill="#fbbf24" font-size="9" text-anchor="middle">① Signal tree</text>
|
||||
<text x="284" y="218" fill="#fbbf24" font-size="9" text-anchor="middle">② Canvas</text>
|
||||
<text x="504" y="218" fill="#fbbf24" font-size="9" text-anchor="middle">③ Properties</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagramDragDrop() {
|
||||
return (
|
||||
<svg viewBox="0 0 580 200" class="help-diagram" role="img" aria-label="Drag and drop workflow">
|
||||
<rect width="580" height="200" rx="8" fill="#0f1117" />
|
||||
|
||||
{/* Step 1 */}
|
||||
<rect x="10" y="10" width="160" height="180" rx="6" fill="#151b2e" stroke="#2d3748" />
|
||||
<text x="90" y="30" fill="#64748b" font-size="9" text-anchor="middle">① SIGNAL TREE</text>
|
||||
<text x="20" y="55" fill="#94a3b8" font-size="9">▾ epics</text>
|
||||
<rect x="16" y="62" width="148" height="22" rx="4" fill="#1e3a5f" stroke="#4a9eff" />
|
||||
<text x="26" y="78" fill="#4a9eff" font-size="10">TEMPERATURE</text>
|
||||
<text x="26" y="78" fill="none" stroke="#4a9eff" stroke-width="0" />
|
||||
<text x="20" y="100" fill="#94a3b8" font-size="9"> PRESSURE</text>
|
||||
<text x="20" y="115" fill="#94a3b8" font-size="9"> CURRENT</text>
|
||||
{/* drag cursor hint */}
|
||||
<text x="90" y="175" fill="#64748b" font-size="8" text-anchor="middle">drag signal →</text>
|
||||
|
||||
{/* Arrow 1→2 */}
|
||||
<line x1="178" y1="100" x2="218" y2="100" stroke="#fbbf24" stroke-width="2"
|
||||
marker-end="url(#arr2)" stroke-dasharray="4 2" />
|
||||
|
||||
{/* Step 2 */}
|
||||
<rect x="220" y="10" width="160" height="180" rx="6" fill="#0f1117" stroke="#4a9eff" stroke-dasharray="4 3" />
|
||||
<text x="300" y="30" fill="#64748b" font-size="9" text-anchor="middle">② DROP ON CANVAS</text>
|
||||
{/* ghost drag indicator */}
|
||||
<rect x="235" y="75" width="130" height="50" rx="4" fill="#1e3a5f" stroke="#4a9eff" stroke-width="1.5" stroke-dasharray="4 2" />
|
||||
<text x="300" y="105" fill="#4a9eff" font-size="10" text-anchor="middle">TEMPERATURE</text>
|
||||
<text x="300" y="175" fill="#64748b" font-size="8" text-anchor="middle">drop here</text>
|
||||
|
||||
{/* Arrow 2→3 */}
|
||||
<line x1="388" y1="100" x2="408" y2="100" stroke="#fbbf24" stroke-width="2"
|
||||
marker-end="url(#arr2)" stroke-dasharray="4 2" />
|
||||
|
||||
{/* Step 3: widget picker */}
|
||||
<rect x="410" y="10" width="160" height="180" rx="6" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<text x="490" y="30" fill="#64748b" font-size="9" text-anchor="middle">③ PICK WIDGET TYPE</text>
|
||||
<rect x="420" y="40" width="140" height="24" rx="4" fill="#2d3748" />
|
||||
<text x="490" y="57" fill="#e2e8f0" font-size="10" text-anchor="middle">Gauge</text>
|
||||
<rect x="420" y="68" width="140" height="24" rx="4" fill="#2d3748" />
|
||||
<text x="490" y="85" fill="#e2e8f0" font-size="10" text-anchor="middle">Bar (horizontal)</text>
|
||||
<rect x="420" y="96" width="140" height="24" rx="4" fill="#2d3748" />
|
||||
<text x="490" y="113" fill="#e2e8f0" font-size="10" text-anchor="middle">Text View</text>
|
||||
<rect x="420" y="124" width="140" height="24" rx="4" fill="#1e40af" />
|
||||
<text x="490" y="141" fill="#93c5fd" font-size="10" text-anchor="middle">Plot</text>
|
||||
<rect x="420" y="152" width="140" height="24" rx="4" fill="#2d3748" />
|
||||
<text x="490" y="169" fill="#e2e8f0" font-size="10" text-anchor="middle">LED</text>
|
||||
|
||||
<defs>
|
||||
<marker id="arr2" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto">
|
||||
<path d="M0,0 L6,3 L0,6 Z" fill="#fbbf24" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagramWidgets() {
|
||||
return (
|
||||
<svg viewBox="0 0 580 220" class="help-diagram" role="img" aria-label="Widget types overview">
|
||||
<rect width="580" height="220" rx="8" fill="#0f1117" />
|
||||
{/* Text View */}
|
||||
<g>
|
||||
<rect x="20" y="20" width="110" height="56" rx="4" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<text x="75" y="50" fill="#e2e8f0" font-size="14" text-anchor="middle" font-weight="bold">42.7 °C</text>
|
||||
<text x="75" y="66" fill="#64748b" font-size="8" text-anchor="middle">Text View</text>
|
||||
</g>
|
||||
{/* Gauge */}
|
||||
<g>
|
||||
<rect x="150" y="20" width="110" height="90" rx="4" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<circle cx="205" cy="82" r="26" fill="none" stroke="#2d3748" stroke-width="5" />
|
||||
<circle cx="205" cy="82" r="26" fill="none" stroke="#4a9eff" stroke-width="5"
|
||||
stroke-dasharray="52 108" stroke-dashoffset="27" stroke-linecap="round" />
|
||||
<text x="205" y="87" fill="#e2e8f0" font-size="10" text-anchor="middle">67%</text>
|
||||
<text x="205" y="34" fill="#64748b" font-size="8" text-anchor="middle">Gauge</text>
|
||||
</g>
|
||||
{/* Bar H */}
|
||||
<g>
|
||||
<rect x="280" y="20" width="130" height="56" rx="4" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<text x="345" y="36" fill="#64748b" font-size="8" text-anchor="middle">Bar (horiz.)</text>
|
||||
<rect x="290" y="44" width="110" height="18" rx="3" fill="#2d3748" />
|
||||
<rect x="290" y="44" width="71" height="18" rx="3" fill="#4a9eff" />
|
||||
<text x="345" y="66" fill="#94a3b8" font-size="8" text-anchor="middle">65 %</text>
|
||||
</g>
|
||||
{/* LED */}
|
||||
<g>
|
||||
<rect x="430" y="20" width="130" height="56" rx="4" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<circle cx="460" cy="48" r="12" fill="#22c55e" />
|
||||
<text x="477" y="46" fill="#94a3b8" font-size="9">Beam ON</text>
|
||||
<text x="477" y="59" fill="#64748b" font-size="8">LED</text>
|
||||
</g>
|
||||
{/* Plot */}
|
||||
<g>
|
||||
<rect x="20" y="130" width="260" height="80" rx="4" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<text x="150" y="148" fill="#64748b" font-size="8" text-anchor="middle">Time-series Plot</text>
|
||||
<line x1="30" y1="152" x2="30" y2="200" stroke="#2d3748" />
|
||||
<line x1="30" y1="200" x2="270" y2="200" stroke="#2d3748" />
|
||||
<polyline points="32,195 55,182 78,175 101,180 124,165 147,160 170,163 193,150 216,155 239,142 262,138"
|
||||
fill="none" stroke="#4a9eff" stroke-width="1.5" />
|
||||
<polyline points="32,190 55,188 78,192 101,185 124,179 147,183 170,175 193,170 216,175 239,168 262,162"
|
||||
fill="none" stroke="#22c55e" stroke-width="1.5" />
|
||||
</g>
|
||||
{/* Set Value */}
|
||||
<g>
|
||||
<rect x="300" y="130" width="130" height="80" rx="4" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<text x="365" y="150" fill="#64748b" font-size="8" text-anchor="middle">Set-point Control</text>
|
||||
<rect x="310" y="157" width="110" height="22" rx="4" fill="#1a2236" stroke="#2d3748" />
|
||||
<text x="365" y="173" fill="#e2e8f0" font-size="11" text-anchor="middle">100.0</text>
|
||||
<rect x="320" y="185" width="90" height="18" rx="4" fill="#2563eb" />
|
||||
<text x="365" y="198" fill="#fff" font-size="9" text-anchor="middle">Set</text>
|
||||
</g>
|
||||
{/* Button */}
|
||||
<g>
|
||||
<rect x="450" y="130" width="110" height="80" rx="4" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<text x="505" y="150" fill="#64748b" font-size="8" text-anchor="middle">Button</text>
|
||||
<rect x="460" y="158" width="90" height="30" rx="6" fill="#2563eb" />
|
||||
<text x="505" y="178" fill="#fff" font-size="11" text-anchor="middle">RESET</text>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagramHistory() {
|
||||
return (
|
||||
<svg viewBox="0 0 580 130" class="help-diagram" role="img" aria-label="Historical data toolbar">
|
||||
<rect width="580" height="130" rx="8" fill="#0f1117" />
|
||||
|
||||
{/* toolbar */}
|
||||
<rect width="580" height="44" fill="#1a1f2e" />
|
||||
<rect y="43" width="580" height="1" fill="#2d3748" />
|
||||
|
||||
{/* Live button (inactive) */}
|
||||
<rect x="14" y="12" width="50" height="20" rx="10" fill="#2d3748" />
|
||||
<circle cx="26" cy="22" r="4" fill="#64748b" />
|
||||
<text x="42" y="27" fill="#94a3b8" font-size="9" text-anchor="middle">Live</text>
|
||||
|
||||
{/* start input */}
|
||||
<rect x="72" y="12" width="148" height="20" rx="4" fill="#1a2236" stroke="#2d3748" />
|
||||
<text x="80" y="26" fill="#e2e8f0" font-size="9">2026-01-15 09:00</text>
|
||||
|
||||
<text x="228" y="26" fill="#475569" font-size="11" text-anchor="middle">→</text>
|
||||
|
||||
{/* end input */}
|
||||
<rect x="242" y="12" width="148" height="20" rx="4" fill="#1a2236" stroke="#63b3ed" />
|
||||
<text x="250" y="26" fill="#e2e8f0" font-size="9">2026-01-15 10:00</text>
|
||||
|
||||
{/* Load button */}
|
||||
<rect x="398" y="12" width="44" height="20" rx="4" fill="#2d3748" />
|
||||
<text x="420" y="26" fill="#e2e8f0" font-size="9" text-anchor="middle">Load</text>
|
||||
|
||||
{/* Plot showing historical data */}
|
||||
<rect x="10" y="54" width="560" height="66" rx="4" fill="#1a1f2e" stroke="#2d3748" />
|
||||
<text x="290" y="70" fill="#64748b" font-size="8" text-anchor="middle">Loaded 3 600 historical points · 09:00 → 10:00</text>
|
||||
<line x1="22" y1="75" x2="22" y2="112" stroke="#2d3748" />
|
||||
<line x1="22" y1="112" x2="560" y2="112" stroke="#2d3748" />
|
||||
<polyline
|
||||
points="24,108 70,100 116,92 162,96 208,85 254,80 300,84 346,75 392,78 438,70 484,73 530,65 558,62"
|
||||
fill="none" stroke="#4a9eff" stroke-width="1.5" />
|
||||
|
||||
{/* annotations */}
|
||||
<line x1="39" y1="54" x2="39" y2="44" stroke="#fbbf24" stroke-width="1.5" />
|
||||
<text x="39" y="122" fill="#fbbf24" font-size="8" text-anchor="middle">① Live</text>
|
||||
<line x1="146" y1="54" x2="146" y2="44" stroke="#fbbf24" stroke-width="1.5" />
|
||||
<text x="146" y="122" fill="#fbbf24" font-size="8" text-anchor="middle">② Start time</text>
|
||||
<line x1="316" y1="54" x2="316" y2="44" stroke="#fbbf24" stroke-width="1.5" />
|
||||
<text x="316" y="122" fill="#fbbf24" font-size="8" text-anchor="middle">③ End time</text>
|
||||
<line x1="420" y1="54" x2="420" y2="44" stroke="#fbbf24" stroke-width="1.5" />
|
||||
<text x="420" y="122" fill="#fbbf24" font-size="8" text-anchor="middle">④ Load</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagramSignalTree() {
|
||||
return (
|
||||
<svg viewBox="0 0 280 280" class="help-diagram help-diagram-narrow" role="img" aria-label="Signal tree">
|
||||
<rect width="280" height="280" rx="8" fill="#151b2e" />
|
||||
|
||||
{/* header */}
|
||||
<text x="14" y="22" fill="#e2e8f0" font-size="11" font-weight="bold">Signals</text>
|
||||
<rect x="240" y="8" width="28" height="20" rx="4" fill="#2d3748" />
|
||||
<text x="254" y="23" fill="#e2e8f0" font-size="10" text-anchor="middle">◀</text>
|
||||
<line x1="0" y1="32" x2="280" y2="32" stroke="#2d3748" />
|
||||
|
||||
{/* search */}
|
||||
<rect x="8" y="38" width="194" height="20" rx="4" fill="#1a2236" stroke="#2d3748" />
|
||||
<text x="16" y="53" fill="#64748b" font-size="9">Filter signals…</text>
|
||||
<rect x="208" y="38" width="64" height="20" rx="4" fill="#2d3748" />
|
||||
<text x="240" y="53" fill="#e2e8f0" font-size="8" text-anchor="middle">🔍 CF</text>
|
||||
<line x1="0" y1="63" x2="280" y2="63" stroke="#2d3748" />
|
||||
|
||||
{/* toolbar */}
|
||||
<rect x="8" y="68" width="80" height="18" rx="4" fill="#2d3748" />
|
||||
<text x="48" y="81" fill="#e2e8f0" font-size="8" text-anchor="middle">+ Synthetic</text>
|
||||
<rect x="94" y="68" width="38" height="18" rx="4" fill="#2d3748" />
|
||||
<text x="113" y="81" fill="#e2e8f0" font-size="8" text-anchor="middle">CSV</text>
|
||||
<rect x="138" y="68" width="28" height="18" rx="4" fill="#2d3748" />
|
||||
<text x="152" y="81" fill="#e2e8f0" font-size="9" text-anchor="middle">↺</text>
|
||||
<line x1="0" y1="90" x2="280" y2="90" stroke="#2d3748" />
|
||||
|
||||
{/* epics group */}
|
||||
<text x="10" y="110" fill="#94a3b8" font-size="10">▾ epics</text>
|
||||
<rect x="250" y="100" width="22" height="16" rx="3" fill="none" />
|
||||
<text x="261" y="112" fill="#64748b" font-size="9" text-anchor="middle">+</text>
|
||||
<rect x="8" y="118" width="264" height="20" rx="3" fill="none" />
|
||||
<text x="22" y="133" fill="#cbd5e1" font-size="10" style="cursor:grab">TEMPERATURE</text>
|
||||
<text x="22" y="152" fill="#cbd5e1" font-size="10">PRESSURE</text>
|
||||
<text x="22" y="170" fill="#cbd5e1" font-size="10">BEAM_CURRENT</text>
|
||||
|
||||
{/* stub group */}
|
||||
<line x1="0" y1="180" x2="280" y2="180" stroke="#2d3748" stroke-dasharray="2 2" />
|
||||
<text x="10" y="198" fill="#94a3b8" font-size="10">▾ stub</text>
|
||||
<text x="22" y="217" fill="#cbd5e1" font-size="10">sine_1hz</text>
|
||||
<text x="22" y="235" fill="#cbd5e1" font-size="10">ramp_10s</text>
|
||||
<text x="22" y="253" fill="#94a3b8" font-size="9" font-style="italic">my_custom_pv</text>
|
||||
<rect x="250" y="243" width="22" height="14" rx="3" fill="none" />
|
||||
<text x="261" y="254" fill="#ef4444" font-size="9" text-anchor="middle">✕</text>
|
||||
|
||||
{/* label custom */}
|
||||
<rect x="100" y="246" width="52" height="12" rx="3" fill="#2d3748" />
|
||||
<text x="126" y="256" fill="#fbbf24" font-size="7" text-anchor="middle">custom</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section content ───────────────────────────────────────────────────────────
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'start', label: 'Getting Started' },
|
||||
{ id: 'view', label: 'View Mode' },
|
||||
{ id: 'edit', label: 'Edit Mode' },
|
||||
{ id: 'widgets', label: 'Widgets' },
|
||||
{ id: 'signals', label: 'Signals' },
|
||||
{ id: 'history', label: 'Historical Data' },
|
||||
{ id: 'shortcuts', label: 'Keyboard Shortcuts' },
|
||||
];
|
||||
|
||||
function SectionStart() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
<strong>uopi</strong> is a web-based HMI for monitoring and controlling EPICS-based scientific and industrial systems.
|
||||
It runs as a single binary — open a browser, no installation required.
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">How it works</h3>
|
||||
<ol class="help-list">
|
||||
<li>The server connects to your control system (EPICS, synthetic signals).</li>
|
||||
<li>Open <code>http://<host>:8080</code> in any browser.</li>
|
||||
<li>Pick an HMI panel from the left sidebar to view live data.</li>
|
||||
<li>Switch to <strong>Edit mode</strong> to build or modify panels.</li>
|
||||
</ol>
|
||||
|
||||
<h3 class="help-h3">Two modes</h3>
|
||||
<div class="help-cards">
|
||||
<div class="help-card">
|
||||
<div class="help-card-title">👁 View Mode</div>
|
||||
<p>Monitor live signals in read-only HMI panels. Right-click any widget for signal info or to export data as CSV.</p>
|
||||
</div>
|
||||
<div class="help-card">
|
||||
<div class="help-card-title">✏ Edit Mode</div>
|
||||
<p>Drag signals from the tree onto the canvas, resize and configure widgets, then save your panel to the server.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="help-h3">Quick navigation</h3>
|
||||
<ul class="help-list">
|
||||
<li>Click an interface in the left list to open it in view mode.</li>
|
||||
<li>Click <strong>Edit</strong> (top-right) to switch to the editor.</li>
|
||||
<li>Click <strong>✕ Close</strong> in Edit mode to return to View mode.</li>
|
||||
<li>Press <kbd>?</kbd> anywhere to open this help.</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionView() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
View mode displays a live HMI panel. Widget values update in real time via WebSocket.
|
||||
</p>
|
||||
|
||||
<DiagramViewMode />
|
||||
<p class="help-caption">View mode layout: ① interface list · ② live canvas · WebSocket status top-right.</p>
|
||||
|
||||
<h3 class="help-h3">Interface list</h3>
|
||||
<ul class="help-list">
|
||||
<li>Click any panel name to load it on the canvas.</li>
|
||||
<li>Use the action buttons (edit ✎, clone ⧉, delete ✕) that appear on hover.</li>
|
||||
<li>Collapse the list by clicking the <strong>◀</strong> button at the top.</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Context menu</h3>
|
||||
<p>Right-click any widget to:</p>
|
||||
<ul class="help-list">
|
||||
<li>View the signal name and current value.</li>
|
||||
<li>Copy the signal name to the clipboard.</li>
|
||||
<li>Export the last 1 000 samples as a CSV file.</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Connection status</h3>
|
||||
<p>
|
||||
The coloured chip in the top-right shows the WebSocket connection state.
|
||||
A red <em>"disconnected"</em> banner appears at the top while reconnecting — live values will resume automatically.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionEdit() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
Edit mode gives you a drag-and-drop canvas, a signal tree on the left, and a properties pane on the right.
|
||||
</p>
|
||||
|
||||
<DiagramEditMode />
|
||||
<p class="help-caption">Edit mode layout: ① signal tree · ② canvas with snap grid · ③ properties pane.</p>
|
||||
|
||||
<h3 class="help-h3">Adding widgets</h3>
|
||||
<DiagramDragDrop />
|
||||
<p class="help-caption">Drag a signal from the tree → drop on canvas → choose a widget type.</p>
|
||||
|
||||
<h3 class="help-h3">Selecting and moving</h3>
|
||||
<ul class="help-list">
|
||||
<li>Click a widget to select it — resize handles appear at the corners and edges.</li>
|
||||
<li>Drag the widget body to move it; drag a handle to resize.</li>
|
||||
<li><kbd>Ctrl</kbd>+click to add/remove widgets from the selection.</li>
|
||||
<li>Drag on empty canvas to rubber-band select multiple widgets.</li>
|
||||
<li>Arrow keys nudge the selection by 1 px (or grid size with <kbd>Shift</kbd>).</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Align & distribute</h3>
|
||||
<p>When 2+ widgets are selected the alignment toolbar appears. Buttons align edges or centres; with 3+ selected you can distribute spacing evenly.</p>
|
||||
|
||||
<h3 class="help-h3">Saving</h3>
|
||||
<ul class="help-list">
|
||||
<li><strong>Save</strong> — stores the panel on the server (XML). A <span style="color:#f59e0b">●</span> indicates unsaved changes.</li>
|
||||
<li><strong>Export</strong> — downloads the XML file locally.</li>
|
||||
<li><strong>Import</strong> — loads an XML file from disk (replaces the current canvas).</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionWidgets() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
Each widget is configured in the Properties pane on the right.
|
||||
The available options depend on the widget type.
|
||||
</p>
|
||||
|
||||
<DiagramWidgets />
|
||||
<p class="help-caption">Widget type overview — drag a signal from the tree to place one.</p>
|
||||
|
||||
<div class="help-widget-table">
|
||||
{[
|
||||
{ type: 'Text View', desc: 'Shows the current numeric or string value. Supports alarm colouring.' },
|
||||
{ type: 'Gauge', desc: 'Circular arc gauge with configurable range and colour zones.' },
|
||||
{ type: 'Bar (H / V)', desc: 'Horizontal or vertical filled bar, great for percentages or levels.' },
|
||||
{ type: 'LED', desc: 'Coloured indicator. Turns green/red based on a threshold or enum value.' },
|
||||
{ type: 'Multi-LED', desc: 'Bitset indicator — shows individual bits of an integer signal as LEDs.' },
|
||||
{ type: 'Set Value', desc: 'Text input that writes a value back to a writable signal on Enter or click.' },
|
||||
{ type: 'Button', desc: 'Sends a fixed value to a signal when clicked.' },
|
||||
{ type: 'Plot', desc: 'Multi-signal chart. Supports time-series (uPlot), FFT, waterfall, histogram, bar chart, logic analyser.' },
|
||||
{ type: 'Text Label', desc: 'Static annotation text — no signal required.' },
|
||||
{ type: 'Image', desc: 'Static image loaded from a URL.' },
|
||||
{ type: 'Link', desc: 'Navigates to another interface when clicked.' },
|
||||
].map(w => (
|
||||
<div key={w.type} class="help-widget-row">
|
||||
<span class="help-widget-name">{w.type}</span>
|
||||
<span class="help-widget-desc">{w.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 class="help-h3">Plot widget details</h3>
|
||||
<ul class="help-list">
|
||||
<li>Add multiple signals via the <em>Signals</em> section in the Properties pane.</li>
|
||||
<li>Choose plot type: <em>timeseries</em>, <em>FFT</em>, <em>waterfall</em>, <em>histogram</em>, <em>bar</em>, or <em>logic</em>.</li>
|
||||
<li>Set <em>Time window</em> to control how many seconds of live data are shown.</li>
|
||||
<li>In Historical mode the entire requested range is loaded at once.</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionSignals() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
The signal tree (left pane in Edit mode) lets you browse, search, and manage the signals available from each data source.
|
||||
</p>
|
||||
|
||||
<div class="help-two-col">
|
||||
<div>
|
||||
<h3 class="help-h3">Finding signals</h3>
|
||||
<ul class="help-list">
|
||||
<li>Type in the <em>Filter signals…</em> box to search by name or description.</li>
|
||||
<li>Click a group header to collapse or expand that data source.</li>
|
||||
<li>Drag any signal onto the canvas to create a widget.</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Adding custom signals</h3>
|
||||
<ul class="help-list">
|
||||
<li>Click <strong>+</strong> next to a group name to type a PV name manually.</li>
|
||||
<li>Custom signals are saved in your browser (localStorage) and persist across reloads.</li>
|
||||
<li>Click <strong>✕</strong> on a custom signal to remove it.</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">CSV import</h3>
|
||||
<p>Click <strong>CSV</strong> to import a list of signals from a file. Each line should be either:</p>
|
||||
<ul class="help-list">
|
||||
<li><code>PV_NAME</code> — added to the <em>epics</em> data source.</li>
|
||||
<li><code>ds,PV_NAME</code> — added to the specified data source.</li>
|
||||
</ul>
|
||||
<p>Lines starting with <code>#</code> are treated as comments.</p>
|
||||
|
||||
<h3 class="help-h3">Channel Finder</h3>
|
||||
<p>
|
||||
If your server is configured with an EPICS Channel Finder URL,
|
||||
a <strong>🔍</strong> button appears when you type in the filter box.
|
||||
Click it to search Channel Finder and import matching PV names.
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">Synthetic signals</h3>
|
||||
<p>Click <strong>+ Synthetic</strong> to open the wizard. Choose an input signal, a processing node (gain, moving average, lowpass filter, …), and optional metadata. The new signal appears in the tree immediately.</p>
|
||||
</div>
|
||||
<DiagramSignalTree />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHistory() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
Historical data is retrieved from EPICS Archive Appliance when the server is configured with an <code>archive_url</code>.
|
||||
Only the <strong>Plot</strong> widget in <em>time-series</em> mode supports historical ranges.
|
||||
</p>
|
||||
|
||||
<DiagramHistory />
|
||||
<p class="help-caption">Time navigation toolbar: ① Live button · ② start · ③ end datetime · ④ Load.</p>
|
||||
|
||||
<h3 class="help-h3">How to load historical data</h3>
|
||||
<ol class="help-list">
|
||||
<li>Set the <strong>start</strong> and <strong>end</strong> date/time fields in the toolbar.</li>
|
||||
<li>Click <strong>Load</strong> — all plot widgets on the current panel will fetch the requested range.</li>
|
||||
<li>While loading, a <em>"Loading history…"</em> overlay appears on each plot.</li>
|
||||
<li>If no data is available, the overlay shows <em>"No archive data for this range"</em>.</li>
|
||||
<li>Click <strong>● Live</strong> (highlighted when active) to return to streaming mode.</li>
|
||||
</ol>
|
||||
|
||||
<h3 class="help-h3">Notes</h3>
|
||||
<ul class="help-list">
|
||||
<li>Historical data is requested over the WebSocket connection (not REST).</li>
|
||||
<li>Requests time out after 15 seconds if the archive does not respond.</li>
|
||||
<li>Non-timeseries plot types (FFT, waterfall, etc.) remain in live mode regardless of the time range.</li>
|
||||
<li>Point-value widgets (gauge, bar, LED, …) are unaffected by the time range selection.</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionShortcuts() {
|
||||
const shortcuts: Array<[string, string]> = [
|
||||
['Ctrl+Z', 'Undo last change (Edit mode)'],
|
||||
['Ctrl+Y / Ctrl+Shift+Z', 'Redo (Edit mode)'],
|
||||
['Ctrl+A', 'Select all widgets (Edit mode)'],
|
||||
['Delete / Backspace', 'Delete selected widgets (Edit mode)'],
|
||||
['Arrow keys', 'Nudge selection by 1 px (Edit mode)'],
|
||||
['Shift+Arrow', 'Nudge by grid size (Edit mode)'],
|
||||
['Ctrl+click', 'Add/remove widget from multi-selection'],
|
||||
['Escape', 'Close dialogs / cancel input'],
|
||||
['?', 'Open this help'],
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">All shortcuts that work while the canvas or modal has focus.</p>
|
||||
|
||||
<table class="help-shortcut-table">
|
||||
<thead>
|
||||
<tr><th>Shortcut</th><th>Action</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shortcuts.map(([key, action]) => (
|
||||
<tr key={key}>
|
||||
<td><kbd class="help-kbd">{key}</kbd></td>
|
||||
<td>{action}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 class="help-h3">API & Metrics</h3>
|
||||
<ul class="help-list">
|
||||
<li>REST API: <code>/api/v1/datasources</code>, <code>/api/v1/signals</code>, <code>/api/v1/interfaces</code>, …</li>
|
||||
<li>Prometheus metrics: <code>/metrics</code></li>
|
||||
<li>Health check: <code>/healthz</code></li>
|
||||
<li>WebSocket: <code>ws://<host>/ws</code></li>
|
||||
</ul>
|
||||
|
||||
<h3 class="help-h3">Configuration file</h3>
|
||||
<pre class="help-pre">[server]{'\n'}listen = ":8080"{'\n'}storage_dir = "./data"{'\n\n'}[datasource.epics]{'\n'}enabled = true{'\n'}archive_url = "http://archiver:17665"{'\n\n'}[datasource.synthetic]{'\n'}enabled = true</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
|
||||
start: SectionStart,
|
||||
view: SectionView,
|
||||
edit: SectionEdit,
|
||||
widgets: SectionWidgets,
|
||||
signals: SectionSignals,
|
||||
history: SectionHistory,
|
||||
shortcuts: SectionShortcuts,
|
||||
};
|
||||
|
||||
// ── Main modal ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function HelpModal({ initialSection = 'start', onClose }: Props) {
|
||||
const [active, setActive] = useState(initialSection);
|
||||
|
||||
const Content = SECTION_COMPONENTS[active] ?? SectionStart;
|
||||
|
||||
// Close on backdrop click
|
||||
function handleBackdrop(e: MouseEvent) {
|
||||
if ((e.target as Element).classList.contains('help-backdrop')) onClose();
|
||||
}
|
||||
|
||||
// Close on Escape
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="help-backdrop" onClick={handleBackdrop} onKeyDown={handleKey} tabIndex={-1}>
|
||||
<div class="help-modal" role="dialog" aria-modal="true" aria-label="uopi Help Manual">
|
||||
{/* Header */}
|
||||
<div class="help-header">
|
||||
<span class="help-title">uopi — User Manual</span>
|
||||
<button class="icon-btn help-close" onClick={onClose} title="Close help">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="help-body">
|
||||
{/* Sidebar nav */}
|
||||
<nav class="help-sidebar">
|
||||
{SECTIONS.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
class={`help-nav-item${active === s.id ? ' active' : ''}`}
|
||||
onClick={() => setActive(s.id)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Content area */}
|
||||
<div class="help-content">
|
||||
<h2 class="help-h2">{SECTIONS.find(s => s.id === active)?.label}</h2>
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user