diff --git a/public/app.js b/public/app.js index d8523cc..298efad 100644 --- a/public/app.js +++ b/public/app.js @@ -5,14 +5,17 @@ let projects = []; let config = { importers: {} }; let stats = { trash: 0, duplicateGroups: 0, fingerprintsPending: 0 }; let selectedScope = 'all'; // 'all' | 'duplicates' | 'trash' | a collection id +let spools = []; // filament on hand, so cards can say what's printable let tagFilters = {}; // tag -> 'include' | 'exclude' let duplicateData = null; // last /api/duplicates response let duplicatePoll = null; // View preferences, remembered per browser. -const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license: '', sort: 'newest', group: '', slicer: '', slicerTemplate: '', searchMode: 'words' }; +const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license: '', printable: '', sort: 'newest', group: '', slicer: '', slicerTemplate: '', searchMode: 'words' }; const PREFS_KEY = 'printhive.prefs'; -const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai', 'watch']; +// Scopes that aren't a collection id. Leaving one out means loadData() decides +// the collection vanished and drops you back to All Models on the next refresh. +const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai', 'watch', 'filament']; // DOM const fileGrid = document.getElementById('fileGrid'); @@ -144,16 +147,18 @@ async function handleLogout() { async function loadData() { try { - const [modelsRes, projectsRes, configRes, statsRes] = await Promise.all([ + const [modelsRes, projectsRes, configRes, statsRes, filamentRes] = await Promise.all([ fetch('/api/models'), fetch('/api/projects'), fetch('/api/config'), fetch('/api/library/stats'), + fetch('/api/filament'), ]); models = await modelsRes.json(); projects = await projectsRes.json(); config = await configRes.json(); stats = statsRes.ok ? await statsRes.json() : stats; + spools = filamentRes.ok ? (await filamentRes.json()).spools : []; if (selectedScope === 'trash') await loadTrash(); if (!SPECIAL_SCOPES.includes(selectedScope) && !projects.some((p) => String(p.id) === String(selectedScope))) { selectedScope = 'all'; // the open collection was deleted @@ -253,6 +258,30 @@ const SOURCE_LABELS = { const sourceKey = (m) => m.source_site || 'upload'; const sourceLabel = (key) => SOURCE_LABELS[key] || key; const licenseKey = (m) => (m.commercial_use === true ? 'sellable' : m.commercial_use === false ? 'noncommercial' : 'unknown'); + +// Can you print it with what's on the shelf? Mirrors lib/filaments.js on the +// server: material only (a different colour still prints), an empty spool +// doesn't count, and a model whose filaments were never read is "unknown" +// rather than "no" — only slicer projects name their filaments. +const filamentType = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' '); + +function printability(m) { + const needed = [...new Set(((m && m.filaments) || []).map((f) => filamentType(f.type)).filter(Boolean))]; + if (!needed.length) return { known: false, needed: [], missing: [], ok: false }; + const onHand = new Set(spools + .filter((s) => s.remaining_g == null || Number(s.remaining_g) > 0) + .map((s) => filamentType(s.material))); + const missing = needed.filter((t) => !onHand.has(t)); + return { known: true, needed, missing, ok: missing.length === 0 }; +} + +function printBadgeHtml(m) { + const p = printability(m); + if (!p.known) return ''; + return p.ok + ? '✔ printable' + : `needs ${escapeHtml(p.missing.join(', '))}`; +} const totalSize = (m) => (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0); const fileCountLabel = (m) => `${m.files.length} file${m.files.length === 1 ? '' : 's'}`; @@ -416,6 +445,7 @@ function cardHtml(m) {
${escapeHtml(m.name)}
${m.source_site ? `${escapeHtml(m.source_site)}` : ''} ${m.commercial_use === true ? '$ sellable' : m.commercial_use === false ? 'non-commercial' : ''} + ${printBadgeHtml(m)} ${m.description ? `
${escapeHtml(m.description)}
` : ''} ${(m.tags && m.tags.length) ? `
${m.tags.map((t) => `${escapeHtml(t)}`).join('')}
` : ''}
${fileCountLabel(m)} • ${formatFileSize(totalSize(m))} • ${when}
@@ -456,6 +486,13 @@ function getFilteredModels() { if (prefs.format) result = result.filter((m) => (m.files || []).some((f) => f.format === prefs.format)); if (prefs.source) result = result.filter((m) => sourceKey(m) === prefs.source); if (prefs.license) result = result.filter((m) => licenseKey(m) === prefs.license); + // Only models we've actually read filaments for can answer this either way. + if (prefs.printable) { + result = result.filter((m) => { + const p = printability(m); + return p.known && (prefs.printable === 'yes' ? p.ok : !p.ok); + }); + } const terms = searchInput.value.toLowerCase().split(/\s+/).filter(Boolean); if (terms.length) { @@ -699,10 +736,11 @@ function setupEventListeners() { }); searchInput.addEventListener('input', renderModels); - [['filterFormat', 'format'], ['filterSource', 'source'], ['filterLicense', 'license'], ['sortBy', 'sort'], ['groupBy', 'group']] + [['filterFormat', 'format'], ['filterSource', 'source'], ['filterLicense', 'license'], + ['filterPrintable', 'printable'], ['sortBy', 'sort'], ['groupBy', 'group']] .forEach(([id, key]) => { const el = document.getElementById(id); - el.value = prefs[key]; + el.value = prefs[key] || ''; // a preference added later starts undefined el.addEventListener('change', () => { prefs[key] = el.value; savePrefs(); renderModels(); }); }); @@ -2011,7 +2049,28 @@ function modelDragTarget(model) { return { url: `/api/models/${model.id}/download`, name: `${(model.name || 'model').replace(/[^\w.-]+/g, '_')}.zip` }; } +// What the shelf says about this model, shown between Source and License. The +// Filaments section further down lists what the project uses; this one answers +// whether you can print it today. +function renderDetailPrintable(model) { + const section = document.getElementById('detailPrintableSection'); + const el = document.getElementById('detailPrintable'); + if (!section || !el) return; + const p = printability(model); + if (!p.known) { + el.innerHTML = '
This model doesn’t name its filaments — only Bambu Studio and OrcaSlicer projects do.
'; + return; + } + el.innerHTML = p.ok + ? `
✔ printable now
+
Needs ${escapeHtml(p.needed.join(', '))} — all on the shelf.
` + : `
not right now
+
Needs ${escapeHtml(p.needed.join(', '))}; you have no ${escapeHtml(p.missing.join(', '))}. + Colour isn’t matched — a different colour still prints.
`; +} + function renderDetailLicense(model) { + renderDetailPrintable(model); const com = model.commercial_use; // true / false / null const badge = com === true ? 'Commercial use allowed' @@ -2242,6 +2301,7 @@ async function refreshSpools() { list.innerHTML = `

Couldn’t load your spools: ${escapeHtml(err.message)}

`; return; } + spools = data.spools; // cards and the filter judge against this const spoolCount = document.getElementById('spoolCount'); if (spoolCount) spoolCount.textContent = data.spools.length; list.innerHTML = data.spools.length diff --git a/public/index.html b/public/index.html index 775206b..d3b8705 100644 --- a/public/index.html +++ b/public/index.html @@ -162,6 +162,11 @@ +