Merge printable-now badges, section and filter
All checks were successful
build-and-push / docker (push) Successful in 16s
All checks were successful
build-and-push / docker (push) Successful in 16s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
ecdcafc967
3 changed files with 77 additions and 5 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue