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 @@
+