Compare commits

..

No commits in common. "ecdcafc9677f7ab3702dec7d1b1985640541e714" and "5b559326e38b5624a3981439bcb40d0f2c2cdcbc" have entirely different histories.

3 changed files with 5 additions and 77 deletions

View file

@ -5,17 +5,14 @@ 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: '', printable: '', sort: 'newest', group: '', slicer: '', slicerTemplate: '', searchMode: 'words' }; const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license: '', sort: 'newest', group: '', slicer: '', slicerTemplate: '', searchMode: 'words' };
const PREFS_KEY = 'printhive.prefs'; const PREFS_KEY = 'printhive.prefs';
// Scopes that aren't a collection id. Leaving one out means loadData() decides const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai', 'watch'];
// 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');
@ -147,18 +144,16 @@ async function handleLogout() {
async function loadData() { async function loadData() {
try { try {
const [modelsRes, projectsRes, configRes, statsRes, filamentRes] = await Promise.all([ const [modelsRes, projectsRes, configRes, statsRes] = 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
@ -258,30 +253,6 @@ 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'}`;
@ -445,7 +416,6 @@ 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>
@ -486,13 +456,6 @@ 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) {
@ -736,11 +699,10 @@ function setupEventListeners() {
}); });
searchInput.addEventListener('input', renderModels); searchInput.addEventListener('input', renderModels);
[['filterFormat', 'format'], ['filterSource', 'source'], ['filterLicense', 'license'], [['filterFormat', 'format'], ['filterSource', 'source'], ['filterLicense', 'license'], ['sortBy', 'sort'], ['groupBy', 'group']]
['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] || ''; // a preference added later starts undefined el.value = prefs[key];
el.addEventListener('change', () => { prefs[key] = el.value; savePrefs(); renderModels(); }); el.addEventListener('change', () => { prefs[key] = el.value; savePrefs(); renderModels(); });
}); });
@ -2049,28 +2011,7 @@ 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>'
@ -2301,7 +2242,6 @@ 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,11 +162,6 @@
<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>
@ -510,10 +505,6 @@
<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,9 +464,6 @@ 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; }