Compare commits

...

2 commits

Author SHA1 Message Date
dlawler489
ecdcafc967 Merge printable-now badges, section and filter
All checks were successful
build-and-push / docker (push) Successful in 16s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:54:09 +10:00
dlawler489
5f708e24f5 Show what's printable with the filament you have
Three placements: a pill beside the sellable badge on each card, a "Printable
now" section between Source and License on the model page, and a toolbar filter
for "printable now" / "missing filament".

Only the 101 models whose slicer project names its filaments can answer this,
so the rest say so plainly rather than being reported as unprintable. Colour is
not matched — a different colour still prints.

Also fixes a bug from the filament page itself: 'filament' was missing from
SPECIAL_SCOPES, so any data refresh while that page was open (after an AI run,
say) decided the collection had vanished and dropped you back to All Models.

Caveat: the client's printability() mirrors lib/filaments.js by hand. The server
side has tests; this copy cannot easily get them, because app.js touches DOM
globals at load — so the two can drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:46:54 +10:00
3 changed files with 77 additions and 5 deletions

View file

@ -5,14 +5,17 @@ let projects = [];
let config = { importers: {} }; let config = { importers: {} };
let stats = { trash: 0, duplicateGroups: 0, fingerprintsPending: 0 }; let stats = { trash: 0, duplicateGroups: 0, fingerprintsPending: 0 };
let selectedScope = 'all'; // 'all' | 'duplicates' | 'trash' | a collection id 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 tagFilters = {}; // tag -> 'include' | 'exclude'
let duplicateData = null; // last /api/duplicates response let duplicateData = null; // last /api/duplicates response
let duplicatePoll = null; let duplicatePoll = null;
// View preferences, remembered per browser. // 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 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 // DOM
const fileGrid = document.getElementById('fileGrid'); const fileGrid = document.getElementById('fileGrid');
@ -144,16 +147,18 @@ async function handleLogout() {
async function loadData() { async function loadData() {
try { try {
const [modelsRes, projectsRes, configRes, statsRes] = await Promise.all([ const [modelsRes, projectsRes, configRes, statsRes, filamentRes] = await Promise.all([
fetch('/api/models'), fetch('/api/models'),
fetch('/api/projects'), fetch('/api/projects'),
fetch('/api/config'), fetch('/api/config'),
fetch('/api/library/stats'), fetch('/api/library/stats'),
fetch('/api/filament'),
]); ]);
models = await modelsRes.json(); models = await modelsRes.json();
projects = await projectsRes.json(); projects = await projectsRes.json();
config = await configRes.json(); config = await configRes.json();
stats = statsRes.ok ? await statsRes.json() : stats; stats = statsRes.ok ? await statsRes.json() : stats;
spools = filamentRes.ok ? (await filamentRes.json()).spools : [];
if (selectedScope === 'trash') await loadTrash(); if (selectedScope === 'trash') await loadTrash();
if (!SPECIAL_SCOPES.includes(selectedScope) && !projects.some((p) => String(p.id) === String(selectedScope))) { if (!SPECIAL_SCOPES.includes(selectedScope) && !projects.some((p) => String(p.id) === String(selectedScope))) {
selectedScope = 'all'; // the open collection was deleted selectedScope = 'all'; // the open collection was deleted
@ -253,6 +258,30 @@ const SOURCE_LABELS = {
const sourceKey = (m) => m.source_site || 'upload'; const sourceKey = (m) => m.source_site || 'upload';
const sourceLabel = (key) => SOURCE_LABELS[key] || key; const sourceLabel = (key) => SOURCE_LABELS[key] || key;
const licenseKey = (m) => (m.commercial_use === true ? 'sellable' : m.commercial_use === false ? 'noncommercial' : 'unknown'); 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
? '<span class="print-badge ok" title="Every filament it needs is on a spool">✔ printable</span>'
: `<span class="print-badge no" title="Not on the shelf: ${escapeHtml(p.missing.join(', '))}">needs ${escapeHtml(p.missing.join(', '))}</span>`;
}
const totalSize = (m) => (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0); 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'}`; const fileCountLabel = (m) => `${m.files.length} file${m.files.length === 1 ? '' : 's'}`;
@ -416,6 +445,7 @@ function cardHtml(m) {
<div class="file-name">${escapeHtml(m.name)}</div> <div class="file-name">${escapeHtml(m.name)}</div>
${m.source_site ? `<span class="source-badge">${escapeHtml(m.source_site)}</span>` : ''} ${m.source_site ? `<span class="source-badge">${escapeHtml(m.source_site)}</span>` : ''}
${m.commercial_use === true ? '<span class="com-badge ok" title="Commercial use allowed">$ sellable</span>' : m.commercial_use === false ? '<span class="com-badge no" title="Non-commercial only">non-commercial</span>' : ''} ${m.commercial_use === true ? '<span class="com-badge ok" title="Commercial use allowed">$ sellable</span>' : m.commercial_use === false ? '<span class="com-badge no" title="Non-commercial only">non-commercial</span>' : ''}
${printBadgeHtml(m)}
${m.description ? `<div class="file-description">${escapeHtml(m.description)}</div>` : ''} ${m.description ? `<div class="file-description">${escapeHtml(m.description)}</div>` : ''}
${(m.tags && m.tags.length) ? `<div class="file-tags">${m.tags.map((t) => `<span class="file-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''} ${(m.tags && m.tags.length) ? `<div class="file-tags">${m.tags.map((t) => `<span class="file-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
<div class="file-meta">${fileCountLabel(m)} • ${formatFileSize(totalSize(m))} • ${when}</div> <div class="file-meta">${fileCountLabel(m)} • ${formatFileSize(totalSize(m))} • ${when}</div>
@ -456,6 +486,13 @@ function getFilteredModels() {
if (prefs.format) result = result.filter((m) => (m.files || []).some((f) => f.format === prefs.format)); 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.source) result = result.filter((m) => sourceKey(m) === prefs.source);
if (prefs.license) result = result.filter((m) => licenseKey(m) === prefs.license); 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); const terms = searchInput.value.toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length) { if (terms.length) {
@ -699,10 +736,11 @@ function setupEventListeners() {
}); });
searchInput.addEventListener('input', renderModels); 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]) => { .forEach(([id, key]) => {
const el = document.getElementById(id); 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(); }); 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` }; 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 = '<div class="lic-note">This model doesn’t name its filaments — only Bambu Studio and OrcaSlicer projects do.</div>';
return;
}
el.innerHTML = p.ok
? `<div><span class="print-badge ok">✔ printable now</span></div>
<div class="lic-note">Needs ${escapeHtml(p.needed.join(', '))} — all on the shelf.</div>`
: `<div><span class="print-badge no">not right now</span></div>
<div class="lic-note">Needs ${escapeHtml(p.needed.join(', '))}; you have no ${escapeHtml(p.missing.join(', '))}.
Colour isn’t matched — a different colour still prints.</div>`;
}
function renderDetailLicense(model) { function renderDetailLicense(model) {
renderDetailPrintable(model);
const com = model.commercial_use; // true / false / null const com = model.commercial_use; // true / false / null
const badge = com === true const badge = com === true
? '<span class="lic-badge ok">Commercial use allowed</span>' ? '<span class="lic-badge ok">Commercial use allowed</span>'
@ -2242,6 +2301,7 @@ async function refreshSpools() {
list.innerHTML = `<p class="form-hint">Couldn’t load your spools: ${escapeHtml(err.message)}</p>`; list.innerHTML = `<p class="form-hint">Couldn’t load your spools: ${escapeHtml(err.message)}</p>`;
return; return;
} }
spools = data.spools; // cards and the filter judge against this
const spoolCount = document.getElementById('spoolCount'); const spoolCount = document.getElementById('spoolCount');
if (spoolCount) spoolCount.textContent = data.spools.length; if (spoolCount) spoolCount.textContent = data.spools.length;
list.innerHTML = data.spools.length list.innerHTML = data.spools.length

View file

@ -162,6 +162,11 @@
<option value="noncommercial">Non-commercial</option> <option value="noncommercial">Non-commercial</option>
<option value="unknown">License unclear</option> <option value="unknown">License unclear</option>
</select> </select>
<select id="filterPrintable" aria-label="Filter by what you can print">
<option value="">Any filament</option>
<option value="yes">Printable now</option>
<option value="no">Missing filament</option>
</select>
<select id="sortBy" aria-label="Sort"> <select id="sortBy" aria-label="Sort">
<option value="newest">Newest first</option> <option value="newest">Newest first</option>
<option value="oldest">Oldest first</option> <option value="oldest">Oldest first</option>
@ -505,6 +510,10 @@
<h3>Source</h3> <h3>Source</h3>
<div id="detailSource"></div> <div id="detailSource"></div>
</div> </div>
<div class="info-section" id="detailPrintableSection">
<h3>Printable now</h3>
<div id="detailPrintable"></div>
</div>
<div class="info-section"> <div class="info-section">
<h3>License</h3> <h3>License</h3>
<div id="detailLicense"></div> <div id="detailLicense"></div>

View file

@ -464,6 +464,9 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.watch-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 14px; } .watch-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 14px; }
.watch-actions { margin-left: auto; display: flex; flex-wrap: wrap; gap: 8px; } .watch-actions { margin-left: auto; display: flex; flex-wrap: wrap; gap: 8px; }
.watch-actions .btn-sm { align-self: auto; } .watch-actions .btn-sm { align-self: auto; }
.print-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; white-space: nowrap; }
.print-badge.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #4ade80; }
.print-badge.no { background: color-mix(in srgb, #f59e0b 16%, transparent); color: #fbbf24; }
.spool-dot { width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--border); display: inline-block; } .spool-dot { width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--border); display: inline-block; }
.spool .watch-actions label { display: inline-flex; align-items: center; gap: 6px; } .spool .watch-actions label { display: inline-flex; align-items: center; gap: 6px; }
.spool .spool-remaining { width: 90px; padding: 5px 8px; font-size: 13px; } .spool .spool-remaining { width: 90px; padding: 5px 8px; font-size: 13px; }