feat: add web frontend for FabWorks API

Add static HTML/CSS/JS frontend with export browser, search, and
file download capabilities served via UseStaticFiles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 20:37:16 -05:00
parent cf76ca8bb1
commit 5ec66f9039
7 changed files with 1339 additions and 0 deletions

View File

@@ -0,0 +1,737 @@
:root {
--bg-deep: #0a0e14;
--bg: #0d1117;
--surface: #151b23;
--surface-raised: #1c2128;
--border: #2a313a;
--border-subtle: #21262d;
--text: #e6edf3;
--text-secondary: #7d8590;
--text-dim: #484f58;
--cyan: #00d4ff;
--cyan-dim: rgba(0, 212, 255, 0.15);
--cyan-glow: rgba(0, 212, 255, 0.3);
--amber: #f0883e;
--amber-dim: rgba(240, 136, 62, 0.15);
--green: #3fb950;
--green-dim: rgba(63, 185, 80, 0.12);
--red: #f85149;
--sidebar-w: 64px;
--font-display: 'Outfit', sans-serif;
--font-body: 'IBM Plex Sans', sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--text);
display: flex;
min-height: 100vh;
overflow-x: hidden;
}
/* Blueprint grid background */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px);
background-size: 48px 48px;
pointer-events: none;
z-index: 0;
}
/* ─── Sidebar ─── */
.sidebar {
width: var(--sidebar-w);
background: var(--bg-deep);
border-right: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
align-items: center;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 50;
padding-top: 8px;
}
.sidebar-brand {
width: 40px; height: 40px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
position: relative;
}
.sidebar-brand::after {
content: '';
position: absolute;
bottom: -12px;
left: 8px; right: 8px;
height: 1px;
background: var(--border);
}
.sidebar-brand svg {
width: 26px; height: 26px;
color: var(--cyan);
filter: drop-shadow(0 0 6px rgba(0, 212, 255, 0.4));
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 16px;
width: 100%;
}
.nav-item {
display: flex;
align-items: center;
justify-content: center;
width: 44px; height: 44px;
margin: 0 auto;
color: var(--text-dim);
text-decoration: none;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s;
position: relative;
}
.nav-item:hover {
color: var(--text-secondary);
background: var(--surface);
}
.nav-item.active {
color: var(--cyan);
background: var(--cyan-dim);
}
.nav-item.active::before {
content: '';
position: absolute;
left: -10px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: var(--cyan);
border-radius: 0 2px 2px 0;
box-shadow: 0 0 8px var(--cyan-glow);
}
.nav-item svg { width: 20px; height: 20px; }
.nav-tooltip {
position: absolute;
left: calc(100% + 12px);
top: 50%;
transform: translateY(-50%);
background: var(--surface-raised);
border: 1px solid var(--border);
color: var(--text);
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-family: var(--font-body);
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
z-index: 100;
}
.nav-item:hover .nav-tooltip { opacity: 1; }
/* ─── Main ─── */
.main {
margin-left: var(--sidebar-w);
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
position: relative;
z-index: 1;
}
.topbar {
background: rgba(13, 17, 23, 0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-subtle);
padding: 0 32px;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 40;
}
.topbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.topbar h2 {
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
}
.topbar-tag {
font-family: var(--font-mono);
font-size: 10px;
color: var(--cyan);
background: var(--cyan-dim);
padding: 2px 8px;
border-radius: 3px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.page-content {
padding: 28px 32px;
flex: 1;
}
/* ─── Animations ─── */
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulseGlow {
0%, 100% { box-shadow: 0 0 4px var(--cyan-glow); }
50% { box-shadow: 0 0 12px var(--cyan-glow); }
}
.animate-in {
animation: fadeSlideIn 0.3s ease forwards;
opacity: 0;
}
.animate-in:nth-child(1) { animation-delay: 0.04s; }
.animate-in:nth-child(2) { animation-delay: 0.08s; }
.animate-in:nth-child(3) { animation-delay: 0.12s; }
.animate-in:nth-child(4) { animation-delay: 0.16s; }
/* ─── Cards ─── */
.card {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: 6px;
overflow: hidden;
}
.card-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-display);
font-weight: 600;
font-size: 13px;
letter-spacing: 0.02em;
display: flex;
align-items: center;
justify-content: space-between;
text-transform: uppercase;
color: var(--text-secondary);
}
.card-body { padding: 18px; }
/* ─── Stats ─── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: 6px;
padding: 18px 20px;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 100%; height: 2px;
background: linear-gradient(90deg, var(--cyan), transparent);
opacity: 0.6;
}
.stat-label {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1.5px;
}
.stat-value {
font-family: var(--font-display);
font-size: 32px;
font-weight: 700;
margin-top: 4px;
color: var(--text);
letter-spacing: -0.02em;
}
.stat-value.stat-sm {
font-size: 14px;
font-weight: 500;
font-family: var(--font-mono);
}
/* ─── Tables ─── */
table { width: 100%; border-collapse: collapse; }
th {
text-align: left;
padding: 10px 16px;
background: var(--bg);
border-bottom: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--text-dim);
font-weight: 500;
white-space: nowrap;
}
td {
padding: 11px 16px;
border-bottom: 1px solid var(--border-subtle);
font-size: 13px;
}
tbody tr { transition: background 0.1s; }
tbody tr:hover td { background: rgba(0, 212, 255, 0.03); }
tbody tr:last-child td { border-bottom: none; }
/* ─── Badges ─── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 3px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.badge svg { width: 12px; height: 12px; flex-shrink: 0; }
.badge-cyan { background: var(--cyan-dim); color: var(--cyan); }
.badge-amber { background: var(--amber-dim); color: var(--amber); }
.badge-green { background: var(--green-dim); color: var(--green); }
.badge-count {
background: var(--surface-raised);
color: var(--text-secondary);
border: 1px solid var(--border);
}
/* ─── Buttons ─── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 4px;
font-family: var(--font-body);
font-size: 12px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
border: 1px solid var(--border);
background: var(--surface-raised);
color: var(--text-secondary);
transition: all 0.15s;
white-space: nowrap;
}
.btn:hover {
background: var(--surface);
color: var(--text);
border-color: var(--text-dim);
}
.btn svg { width: 13px; height: 13px; }
.btn-cyan {
background: var(--cyan-dim);
color: var(--cyan);
border-color: rgba(0, 212, 255, 0.2);
}
.btn-cyan:hover {
background: rgba(0, 212, 255, 0.2);
border-color: rgba(0, 212, 255, 0.4);
color: var(--cyan);
}
.btn-sm { padding: 3px 8px; font-size: 11px; }
/* ─── Search ─── */
.search-box {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 12px;
height: 34px;
width: 300px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-box:focus-within {
border-color: var(--cyan);
box-shadow: 0 0 0 1px var(--cyan-dim);
}
.search-box svg {
width: 14px; height: 14px;
color: var(--text-dim);
flex-shrink: 0;
}
.search-box input {
border: none;
outline: none;
font-family: var(--font-body);
font-size: 13px;
width: 100%;
background: transparent;
color: var(--text);
}
.search-box input::placeholder { color: var(--text-dim); }
/* ─── Clickable ─── */
.clickable { cursor: pointer; }
/* ─── Detail sections ─── */
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
}
.detail-field label {
display: block;
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--text-dim);
margin-bottom: 4px;
}
.detail-field .value {
font-size: 14px;
font-weight: 500;
word-break: break-all;
}
.detail-field .value.mono {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
}
/* ─── Back link ─── */
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-dim);
text-decoration: none;
font-size: 12px;
cursor: pointer;
margin-bottom: 20px;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.5px;
transition: color 0.15s;
}
.back-link:hover { color: var(--cyan); }
.back-link svg { width: 14px; height: 14px; }
/* ─── BOM Expansion ─── */
.bom-expand-row td {
padding: 0 !important;
background: var(--bg) !important;
}
.bom-expand-content {
padding: 16px 16px 16px 48px;
border-left: 2px solid var(--cyan-dim);
margin-left: 16px;
}
.bom-expand-content .info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 6px 24px;
}
.bom-expand-content .info-item {
font-size: 12px;
padding: 2px 0;
}
.bom-expand-content .info-item .lbl {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-right: 6px;
}
.bom-expand-content .info-item .val {
font-family: var(--font-mono);
color: var(--text);
}
.bom-section-title {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--cyan);
margin: 14px 0 8px;
display: flex;
align-items: center;
gap: 8px;
}
.bom-section-title svg { width: 14px; height: 14px; flex-shrink: 0; }
.bom-section-title:first-child { margin-top: 0; }
.bom-section-title::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-subtle);
}
/* ─── File Browser ─── */
.breadcrumb {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
padding: 8px 14px;
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: 4px;
}
.breadcrumb a {
color: var(--cyan);
text-decoration: none;
cursor: pointer;
transition: opacity 0.15s;
}
.breadcrumb a:hover { opacity: 0.7; }
.breadcrumb .sep { color: var(--text-dim); font-size: 10px; }
.breadcrumb .current { color: var(--text); font-weight: 500; }
.file-name-cell {
display: flex;
align-items: center;
gap: 10px;
}
.file-name-cell svg { width: 18px; height: 18px; flex-shrink: 0; }
.file-name-cell a {
color: var(--text);
text-decoration: none;
cursor: pointer;
transition: color 0.15s;
}
.file-name-cell a:hover { color: var(--cyan); }
/* ─── Loading / Empty ─── */
.loading, .empty {
text-align: center;
padding: 60px 24px;
color: var(--text-dim);
font-size: 13px;
font-family: var(--font-mono);
}
.loading::before {
content: '';
display: block;
width: 24px;
height: 24px;
border: 2px solid var(--border);
border-top-color: var(--cyan);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ─── Chevron toggle ─── */
.chevron-toggle {
display: inline-flex;
width: 18px; height: 18px;
align-items: center;
justify-content: center;
transition: transform 0.2s;
color: var(--text-dim);
}
.chevron-toggle.open {
transform: rotate(90deg);
color: var(--cyan);
}
/* ─── Drawing cards grid ─── */
.drawings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.drawing-card {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: 6px;
padding: 18px 20px;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.drawing-card:hover {
border-color: var(--cyan);
background: rgba(0, 212, 255, 0.03);
box-shadow: 0 0 0 1px var(--cyan-dim);
}
.drawing-card-title {
font-family: var(--font-display);
font-size: 15px;
font-weight: 600;
margin-bottom: 2px;
}
.drawing-card-sub {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
}
/* ─── Equipment Groups ─── */
.equip-group {
margin-bottom: 16px;
}
.equip-group:last-child { margin-bottom: 0; }
.equip-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: 6px 6px 0 0;
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.equip-header:hover { background: rgba(0, 212, 255, 0.03); }
.equip-header .chevron-toggle { flex-shrink: 0; }
.equip-header-title {
font-family: var(--font-display);
font-size: 15px;
font-weight: 600;
}
.equip-header-number {
font-family: var(--font-mono);
font-size: 13px;
color: var(--cyan);
font-weight: 600;
}
.equip-header-meta {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.equip-header-stat {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
}
.equip-header-stat strong {
color: var(--text-secondary);
}
.equip-body {
border: 1px solid var(--border-subtle);
border-top: none;
border-radius: 0 0 6px 6px;
overflow: hidden;
}
.equip-body table { margin: 0; }
.equip-group.collapsed .equip-body { display: none; }
.equip-group.collapsed .equip-header { border-radius: 6px; }
/* ─── Responsive ─── */
@media (max-width: 768px) {
.sidebar { display: none; }
.main { margin-left: 0; }
.search-box { width: 100%; }
.topbar { padding: 0 16px; }
.page-content { padding: 16px; }
}

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FabWorks</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<aside class="sidebar">
<div class="sidebar-brand">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 20V8l4-4h6l2 2h6a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2z"/>
<path d="M8 10v6" opacity="0.5"/>
<path d="M12 8v8" opacity="0.5"/>
<path d="M16 11v3" opacity="0.5"/>
</svg>
</div>
<nav class="sidebar-nav">
<a class="nav-item active" data-page="exports" onclick="router.go('exports')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<span class="nav-tooltip">Exports</span>
</a>
<a class="nav-item" data-page="drawings" onclick="router.go('drawings')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
<span class="nav-tooltip">Drawings</span>
</a>
<a class="nav-item" data-page="files" onclick="router.go('files')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span class="nav-tooltip">Files</span>
</a>
</nav>
</aside>
<div class="main">
<div class="topbar">
<div class="topbar-left">
<h2 id="page-title">Exports</h2>
<span class="topbar-tag" id="page-tag"></span>
</div>
<div id="topbar-actions"></div>
</div>
<div class="page-content" id="page-content"></div>
</div>
<script src="js/icons.js"></script>
<script src="js/helpers.js"></script>
<script src="js/components.js"></script>
<script src="js/pages.js"></script>
<script src="js/router.js"></script>
<script>router.init();</script>
</body>
</html>

View File

@@ -0,0 +1,60 @@
/* ─── BOM Detail Expansion ─── */
function renderBomDetails(b) {
let html = '<div class="bom-expand-content">';
if (b.cutTemplate) {
const ct = b.cutTemplate;
const displayName = ct.dxfFilePath?.split(/[/\\]/).pop() || '';
html += `
<div class="bom-section-title">${icons.laser} Cut Template</div>
<div class="info-grid">
<div class="info-item"><span class="lbl">File</span><span class="val">${esc(displayName)}</span></div>
<div class="info-item"><span class="lbl">Thickness</span><span class="val">${fmtThickness(ct.thickness)}</span></div>
<div class="info-item"><span class="lbl">K-Factor</span><span class="val">${ct.kFactor != null ? ct.kFactor : '\u2014'}</span></div>
<div class="info-item"><span class="lbl">Bend Radius</span><span class="val">${ct.defaultBendRadius != null ? ct.defaultBendRadius.toFixed(4) + '"' : '\u2014'}</span></div>
</div>`;
if (ct.contentHash) {
html += `<div style="margin-top:10px">
<a class="btn btn-cyan btn-sm" href="/api/files/blob/${encodeURIComponent(ct.contentHash)}?ext=dxf&download=true&name=${encodeURIComponent(displayName)}" onclick="event.stopPropagation()">${icons.download} Download DXF</a>
<span style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-left:8px">${esc(displayName)}</span>
</div>`;
}
}
if (b.formProgram) {
const fp = b.formProgram;
html += `
<div class="bom-section-title">${icons.bend} Form Program</div>
<div class="info-grid">
<div class="info-item"><span class="lbl">Program</span><span class="val">${esc(fp.programName)}</span></div>
<div class="info-item"><span class="lbl">Thickness</span><span class="val">${fmtThickness(fp.thickness)}</span></div>
<div class="info-item"><span class="lbl">Material</span><span class="val">${esc(fp.materialType)}</span></div>
<div class="info-item"><span class="lbl">K-Factor</span><span class="val">${fp.kFactor != null ? fp.kFactor : '\u2014'}</span></div>
<div class="info-item"><span class="lbl">Bends</span><span class="val">${fp.bendCount}</span></div>
<div class="info-item"><span class="lbl">Upper Tools</span><span class="val">${esc(fp.upperToolNames) || '\u2014'}</span></div>
<div class="info-item"><span class="lbl">Lower Tools</span><span class="val">${esc(fp.lowerToolNames) || '\u2014'}</span></div>
</div>
${fp.setupNotes ? `<div style="margin-top:8px;padding:8px 12px;background:var(--amber-dim);border-radius:4px;font-size:12px;font-family:var(--font-mono);color:var(--amber)"><span class="lbl">Setup Notes</span>${esc(fp.setupNotes)}</div>` : ''}`;
}
html += '</div>';
return html;
}
function toggleEquipGroup(id) {
const group = document.getElementById(id);
const icon = document.getElementById(id + '-icon');
if (!group) return;
group.classList.toggle('collapsed');
if (icon) icon.classList.toggle('open', !group.classList.contains('collapsed'));
}
function toggleBomRow(id) {
const row = document.getElementById(id);
const icon = document.getElementById(id + '-icon');
if (!row) return;
const visible = row.style.display !== 'none';
row.style.display = visible ? 'none' : '';
if (icon) icon.classList.toggle('open', !visible);
}

View File

@@ -0,0 +1,36 @@
function fmtSize(b) {
if (!b) return '0 B';
const k = 1024, s = ['B','KB','MB','GB'];
const i = Math.floor(Math.log(b) / Math.log(k));
return parseFloat((b / Math.pow(k, i)).toFixed(1)) + ' ' + s[i];
}
function fmtDate(d) {
if (!d) return '';
const dt = new Date(d);
return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +
' ' + dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function fmtThickness(t) {
if (t == null) return '\u2014';
return `<span style="font-family:var(--font-mono)">${t.toFixed(4)}"</span>`;
}
function esc(s) {
return s ? s.replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;') : '';
}
function setPage(title, tag = '') {
document.getElementById('page-title').textContent = title;
document.getElementById('page-tag').textContent = tag;
document.getElementById('page-tag').style.display = tag ? '' : 'none';
}
const api = {
async get(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json();
}
};

View File

@@ -0,0 +1,19 @@
const icons = {
search: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
folder: `<svg viewBox="0 0 24 24" fill="rgba(240,136,62,0.2)" stroke="#f0883e" stroke-width="1.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
fileDxf: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--cyan)" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
filePdf: `<svg viewBox="0 0 24 24" fill="none" stroke="#f85149" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
fileGeneric: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
download: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
back: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>`,
chevron: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>`,
laser: `<svg viewBox="0 0 16 16" fill="none" stroke="var(--cyan)" stroke-width="1.2"><circle cx="8" cy="8" r="2"/><path d="M8 2v3M8 11v3M2 8h3M11 8h3" opacity="0.5"/></svg>`,
bend: `<svg viewBox="0 0 16 16" fill="none" stroke="var(--amber)" stroke-width="1.2"><path d="M3 13V7a4 4 0 0 1 4-4h6"/><polyline points="10 6 13 3 10 0" transform="translate(0,2)"/></svg>`,
};
function fileIcon(name) {
const ext = name.split('.').pop().toLowerCase();
if (ext === 'dxf') return icons.fileDxf;
if (ext === 'pdf') return icons.filePdf;
return icons.fileGeneric;
}

View File

@@ -0,0 +1,396 @@
const pages = {
async exports(params) {
const actions = document.getElementById('topbar-actions');
const content = document.getElementById('page-content');
setPage('Exports');
const searchVal = params.q || '';
actions.innerHTML = `
<div class="search-box">
${icons.search}
<input type="text" id="export-search" placeholder="Search drawing, part, user..." value="${esc(searchVal)}">
</div>`;
content.innerHTML = `<div class="loading">Loading exports</div>`;
const searchInput = document.getElementById('export-search');
let debounce;
searchInput.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(() => router.go('exports', { q: searchInput.value }), 400);
});
try {
const searchQ = searchVal ? `&search=${encodeURIComponent(searchVal)}` : '';
const data = await api.get(`/api/exports?take=500${searchQ}`);
if (data.items.length === 0) {
content.innerHTML = `<div class="empty">No exports found.</div>`;
return;
}
// Deduplicate: keep only the latest export per drawing number
const seen = new Set();
const unique = data.items.filter(e => {
const dn = e.drawingNumber || '';
if (seen.has(dn)) return false;
seen.add(dn);
return true;
});
// Group by equipment number (first token of drawing number)
const groups = new Map();
unique.forEach(e => {
const dn = e.drawingNumber || '';
const spaceIdx = dn.indexOf(' ');
const equip = spaceIdx > 0 ? dn.substring(0, spaceIdx) : (dn || 'Other');
if (!groups.has(equip)) groups.set(equip, []);
groups.get(equip).push(e);
});
// Sort equipment groups by number descending (most recent equipment first)
const sortedGroups = [...groups.entries()].sort((a, b) => {
const numA = parseInt(a[0]) || 0;
const numB = parseInt(b[0]) || 0;
return numB - numA;
});
const uniqueEquip = sortedGroups.length;
const uniqueDrawings = unique.length;
setPage('Exports', `${uniqueDrawings} drawings / ${uniqueEquip} equipment`);
const groupsHtml = sortedGroups.map(([equip, items], gi) => {
const totalBom = items.reduce((s, e) => s + e.bomItemCount, 0);
const rows = items.map((e, i) => {
const dn = e.drawingNumber || '';
const spaceIdx = dn.indexOf(' ');
const drawingPart = spaceIdx > 0 ? dn.substring(spaceIdx + 1) : dn;
return `
<tr class="clickable" onclick="router.go('export-detail', {id: ${e.id}})" style="animation: fadeSlideIn 0.2s ease ${0.02 * i}s forwards; opacity: 0">
<td style="font-family:var(--font-mono);color:var(--text-dim);font-size:11px">${e.id}</td>
<td><strong>${esc(drawingPart) || '<span style="color:var(--text-dim)">\u2014</span>'}</strong></td>
<td style="color:var(--text-secondary);font-size:12px">${esc(e.title) || ''}</td>
<td><span class="badge badge-count">${e.bomItemCount}</span></td>
<td style="color:var(--text-secondary)">${esc(e.exportedBy)}</td>
<td style="font-family:var(--font-mono);font-size:12px;color:var(--text-secondary)">${fmtDate(e.exportedAt)}</td>
</tr>`;
}).join('');
return `
<div class="equip-group animate-in" id="equip-${esc(equip)}" style="animation-delay:${0.04 * gi}s">
<div class="equip-header" onclick="toggleEquipGroup('equip-${esc(equip)}')">
<span class="chevron-toggle open" id="equip-${esc(equip)}-icon">${icons.chevron}</span>
<span class="equip-header-number">${esc(equip)}</span>
<div class="equip-header-meta">
<span class="equip-header-stat"><strong>${items.length}</strong> exports</span>
<span class="equip-header-stat"><strong>${totalBom}</strong> items</span>
</div>
</div>
<div class="equip-body">
<table>
<thead><tr>
<th style="width:50px">#</th>
<th>Drawing</th>
<th>Title</th>
<th style="width:80px">Items</th>
<th>Exported By</th>
<th style="width:180px">Date</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>`;
}).join('');
content.innerHTML = `
<div class="stats-grid">
<div class="stat-card animate-in"><div class="stat-label">Drawings</div><div class="stat-value">${uniqueDrawings}</div></div>
<div class="stat-card animate-in"><div class="stat-label">Equipment</div><div class="stat-value">${uniqueEquip}</div></div>
</div>
${groupsHtml}`;
} catch (err) {
content.innerHTML = `<div class="empty">Error: ${esc(err.message)}</div>`;
}
},
async exportDetail(id) {
const actions = document.getElementById('topbar-actions');
const content = document.getElementById('page-content');
setPage('Loading...');
actions.innerHTML = '';
content.innerHTML = `<div class="loading">Loading export</div>`;
try {
const exp = await api.get(`/api/exports/${id}`);
setPage(exp.drawingNumber || `Export #${exp.id}`, 'export detail');
const dxfCount = (exp.bomItems || []).filter(b => b.cutTemplate?.contentHash).length;
const bomRows = (exp.bomItems || []).map((b, i) => {
const hasDetails = b.cutTemplate || b.formProgram;
const toggleId = `bom-${b.id}`;
return `
<tr class="${hasDetails ? 'clickable' : ''}" ${hasDetails ? `onclick="toggleBomRow('${toggleId}')"` : ''} style="animation: fadeSlideIn 0.25s ease ${0.03 * i}s forwards; opacity: 0">
<td style="width:32px">${hasDetails ? `<span class="chevron-toggle" id="${toggleId}-icon">${icons.chevron}</span>` : ''}</td>
<td style="font-family:var(--font-mono);font-weight:600;color:var(--cyan)">${esc(b.itemNo)}</td>
<td><strong>${esc(b.partName)}</strong></td>
<td style="color:var(--text-secondary)">${esc(b.description)}</td>
<td><span style="font-family:var(--font-mono);font-size:12px">${esc(b.material)}</span></td>
<td style="font-family:var(--font-mono);text-align:center">${b.qty ?? ''}</td>
<td style="font-family:var(--font-mono);text-align:center">${b.totalQty ?? ''}</td>
<td>
${b.cutTemplate ? `<span class="badge badge-cyan">${icons.laser} DXF</span>` : ''}
${b.formProgram ? `<span class="badge badge-amber">${icons.bend} Form</span>` : ''}
</td>
</tr>
${hasDetails ? `<tr class="bom-expand-row" id="${toggleId}" style="display:none"><td colspan="8">${renderBomDetails(b)}</td></tr>` : ''}`;
}).join('');
content.innerHTML = `
<a class="back-link" onclick="router.go('exports')">${icons.back} Back to exports</a>
<div class="card animate-in" style="margin-bottom:20px">
<div class="card-header">Export Information</div>
<div class="card-body">
<div class="detail-grid">
<div class="detail-field"><label>Drawing Number</label><div class="value">${esc(exp.drawingNumber) || '\u2014'}</div></div>
${exp.title ? `<div class="detail-field"><label>Title</label><div class="value">${esc(exp.title)}</div></div>` : ''}
<div class="detail-field"><label>Exported By</label><div class="value">${esc(exp.exportedBy)}</div></div>
<div class="detail-field"><label>Date</label><div class="value mono">${fmtDate(exp.exportedAt)}</div></div>
<div class="detail-field"><label>Source File</label><div class="value mono">${esc(exp.sourceFilePath)}</div></div>
</div>
</div>
</div>
<div class="card animate-in">
<div class="card-header">
BOM Items
<span class="badge badge-count">${exp.bomItems?.length || 0} items</span>
${dxfCount > 0 ? `<a class="btn btn-cyan btn-sm" style="margin-left:auto" href="/api/exports/${exp.id}/download-dxfs">${icons.download} Download All DXFs</a>` : ''}
</div>
${exp.bomItems?.length ? `
<table>
<thead><tr>
<th style="width:32px"></th>
<th style="width:60px">Item</th>
<th>Part Name</th>
<th>Description</th>
<th>Material</th>
<th style="width:50px;text-align:center">Qty</th>
<th style="width:55px;text-align:center">Total</th>
<th style="width:120px">Data</th>
</tr></thead>
<tbody>${bomRows}</tbody>
</table>` : '<div class="empty">No BOM items for this export.</div>'}
</div>`;
} catch (err) {
content.innerHTML = `<div class="empty">Error: ${esc(err.message)}</div>`;
}
},
async drawings() {
const actions = document.getElementById('topbar-actions');
const content = document.getElementById('page-content');
setPage('Drawings');
actions.innerHTML = '';
content.innerHTML = `<div class="loading">Loading drawings</div>`;
try {
const numbers = await api.get('/api/exports/drawing-numbers');
if (numbers.length === 0) {
content.innerHTML = `<div class="empty">No drawings found.</div>`;
return;
}
numbers.sort();
setPage('Drawings', `${numbers.length} drawings`);
const cards = numbers.map((d, i) => `
<div class="drawing-card" onclick="router.go('drawing-detail', {id: '${encodeURIComponent(d)}'})" style="animation: fadeSlideIn 0.3s ease ${0.025 * Math.min(i, 20)}s forwards; opacity: 0">
<div class="drawing-card-title">${esc(d)}</div>
<div class="drawing-card-sub">Drawing</div>
</div>`).join('');
content.innerHTML = `
<div class="stats-grid">
<div class="stat-card animate-in"><div class="stat-label">Total Drawings</div><div class="stat-value">${numbers.length}</div></div>
</div>
<div class="drawings-grid">${cards}</div>`;
} catch (err) {
content.innerHTML = `<div class="empty">Error: ${esc(err.message)}</div>`;
}
},
async drawingDetail(drawingEncoded) {
const drawingNumber = decodeURIComponent(drawingEncoded);
const actions = document.getElementById('topbar-actions');
const content = document.getElementById('page-content');
setPage(drawingNumber, 'drawing');
actions.innerHTML = '';
content.innerHTML = `<div class="loading">Loading drawing</div>`;
try {
const exports = await api.get(`/api/exports/by-drawing?drawingNumber=${encodeURIComponent(drawingNumber)}`);
if (exports.length === 0) {
content.innerHTML = `
<a class="back-link" onclick="router.go('drawings')">${icons.back} Back to drawings</a>
<div class="empty">No exports found for this drawing.</div>`;
return;
}
const allBom = [];
exports.forEach(exp => {
(exp.bomItems || []).forEach(b => {
allBom.push({ ...b, exportId: exp.id, exportedAt: exp.exportedAt });
});
});
const bomRows = allBom.map((b, i) => {
const hasDetails = b.cutTemplate || b.formProgram;
const toggleId = `dbom-${b.id}`;
return `
<tr class="${hasDetails ? 'clickable' : ''}" ${hasDetails ? `onclick="toggleBomRow('${toggleId}')"` : ''} style="animation: fadeSlideIn 0.25s ease ${0.03 * i}s forwards; opacity: 0">
<td style="width:32px">${hasDetails ? `<span class="chevron-toggle" id="${toggleId}-icon">${icons.chevron}</span>` : ''}</td>
<td style="font-family:var(--font-mono);font-weight:600;color:var(--cyan)">${esc(b.itemNo)}</td>
<td><strong>${esc(b.partName)}</strong></td>
<td style="color:var(--text-secondary)">${esc(b.description)}</td>
<td><span style="font-family:var(--font-mono);font-size:12px">${esc(b.material)}</span></td>
<td style="font-family:var(--font-mono);text-align:center">${b.qty ?? ''}</td>
<td style="font-family:var(--font-mono);text-align:center">${b.totalQty ?? ''}</td>
<td>
${b.cutTemplate ? `<span class="badge badge-cyan">${icons.laser} DXF</span>` : ''}
${b.formProgram ? `<span class="badge badge-amber">${icons.bend} Form</span>` : ''}
</td>
</tr>
${hasDetails ? `<tr class="bom-expand-row" id="${toggleId}" style="display:none"><td colspan="8">${renderBomDetails(b)}</td></tr>` : ''}`;
}).join('');
content.innerHTML = `
<a class="back-link" onclick="router.go('drawings')">${icons.back} Back to drawings</a>
<div class="stats-grid">
<div class="stat-card animate-in"><div class="stat-label">Exports</div><div class="stat-value">${exports.length}</div></div>
<div class="stat-card animate-in"><div class="stat-label">BOM Items</div><div class="stat-value">${allBom.length}</div></div>
<div class="stat-card animate-in"><div class="stat-label">Latest Export</div><div class="stat-value stat-sm">${fmtDate(exports[0].exportedAt)}</div></div>
</div>
<div class="card animate-in">
<div class="card-header">
All BOM Items
<span class="badge badge-count">${allBom.length} items</span>
</div>
${allBom.length ? `
<table>
<thead><tr>
<th style="width:32px"></th>
<th style="width:60px">Item</th>
<th>Part Name</th>
<th>Description</th>
<th>Material</th>
<th style="width:50px;text-align:center">Qty</th>
<th style="width:55px;text-align:center">Total</th>
<th style="width:120px">Data</th>
</tr></thead>
<tbody>${bomRows}</tbody>
</table>` : '<div class="empty">No BOM items.</div>'}
</div>`;
} catch (err) {
content.innerHTML = `<div class="empty">Error: ${esc(err.message)}</div>`;
}
},
async files(params) {
const actions = document.getElementById('topbar-actions');
const content = document.getElementById('page-content');
setPage('Files');
const searchVal = params.q || '';
actions.innerHTML = `
<div style="display:flex;gap:8px;align-items:center">
<div class="search-box">
${icons.search}
<input type="text" id="file-search" placeholder="Search drawing number, filename..." value="${esc(searchVal)}">
</div>
<select id="file-type-filter" style="background:var(--surface);border:1px solid var(--border);border-radius:4px;padding:6px 10px;color:var(--text);font-family:var(--font-body);font-size:13px;height:34px">
<option value="">All types</option>
<option value="dxf">DXF only</option>
<option value="pdf">PDF only</option>
</select>
</div>`;
content.innerHTML = `<div class="loading">Loading files</div>`;
const searchInput = document.getElementById('file-search');
const typeFilter = document.getElementById('file-type-filter');
let debounce;
const refresh = () => {
clearTimeout(debounce);
debounce = setTimeout(() => router.go('files', { q: searchInput.value + (typeFilter.value ? '&type=' + typeFilter.value : '') }), 400);
};
searchInput.addEventListener('input', refresh);
typeFilter.addEventListener('change', refresh);
// Parse search and type from combined param
let searchQ = searchVal;
let typeQ = '';
if (searchVal.includes('&type=')) {
const parts = searchVal.split('&type=');
searchQ = parts[0];
typeQ = parts[1] || '';
searchInput.value = searchQ;
typeFilter.value = typeQ;
}
try {
let url = '/api/filebrowser/files?';
if (searchQ) url += `search=${encodeURIComponent(searchQ)}&`;
if (typeQ) url += `type=${encodeURIComponent(typeQ)}&`;
const data = await api.get(url);
setPage('Files', `${data.total} files`);
if (data.files.length === 0) {
content.innerHTML = `<div class="empty">No files found.</div>`;
return;
}
const rows = data.files.map((f, i) => {
const ext = f.fileType || f.fileName.split('.').pop().toLowerCase();
const hashShort = f.contentHash ? f.contentHash.substring(0, 12) : '';
return `
<tr style="animation: fadeSlideIn 0.25s ease ${0.02 * i}s forwards; opacity: 0">
<td><div class="file-name-cell">${ext === 'pdf' ? icons.filePdf : icons.fileDxf}<a href="/api/filebrowser/download?hash=${encodeURIComponent(f.contentHash)}&ext=${ext}&name=${encodeURIComponent(f.fileName)}">${esc(f.fileName)}</a></div></td>
<td><span class="badge ${ext === 'dxf' ? 'badge-cyan' : 'badge-amber'}">${ext.toUpperCase()}</span></td>
<td style="color:var(--text-secondary)">${esc(f.drawingNumber)}</td>
<td style="font-family:var(--font-mono);font-size:12px;color:var(--text-secondary)">${f.thickness != null ? f.thickness.toFixed(4) + '"' : '\u2014'}</td>
<td style="font-family:var(--font-mono);font-size:12px;color:var(--text-secondary)">${fmtDate(f.createdAt)}</td>
<td style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">${esc(hashShort)}</td>
<td style="white-space:nowrap">
<a class="btn btn-cyan btn-sm" href="/api/filebrowser/download?hash=${encodeURIComponent(f.contentHash)}&ext=${ext}&name=${encodeURIComponent(f.fileName)}">${icons.download}</a>
</td>
</tr>`;
}).join('');
content.innerHTML = `
<div class="card animate-in">
<table>
<thead><tr>
<th>Name</th>
<th style="width:60px">Type</th>
<th>Drawing</th>
<th style="width:90px">Thickness</th>
<th style="width:170px">Date</th>
<th style="width:100px">Hash</th>
<th style="width:90px">Actions</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
} catch (err) {
content.innerHTML = `<div class="empty">Error: ${esc(err.message)}</div>`;
}
}
};

View File

@@ -0,0 +1,35 @@
const router = {
go(page, params = {}) {
const hash = page + (params.id ? '/' + params.id : '') + (params.q ? '?q=' + encodeURIComponent(params.q) : '');
location.hash = hash;
},
parse() {
const h = location.hash.slice(1) || 'exports';
const [path, qs] = h.split('?');
const parts = path.split('/');
const params = {};
if (qs) qs.split('&').forEach(p => { const [k,v] = p.split('='); params[k] = decodeURIComponent(v); });
return { page: parts[0], id: parts[1], params };
},
init() {
window.addEventListener('hashchange', () => this.dispatch());
this.dispatch();
},
dispatch() {
const { page, id, params } = this.parse();
document.querySelectorAll('.nav-item').forEach(el => {
el.classList.toggle('active',
el.dataset.page === page ||
(page === 'export-detail' && el.dataset.page === 'exports') ||
(page === 'drawing-detail' && el.dataset.page === 'drawings'));
});
switch(page) {
case 'exports': pages.exports(params); break;
case 'export-detail': pages.exportDetail(id); break;
case 'drawings': pages.drawings(); break;
case 'drawing-detail': pages.drawingDetail(id, params); break;
case 'files': pages.files(params); break;
default: pages.exports(params);
}
}
};