Opening a model adds /model/<id> to the address (#collection/12/model/34), so a refresh or bookmark reopens it and Back closes it. Moving from one model to the next doesn't add Back steps, and closing takes back the step opening added. A model that's gone is dropped from the address. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3715 lines
166 KiB
JavaScript
3715 lines
166 KiB
JavaScript
// State
|
||
let models = []; // the library (not in the trash)
|
||
let trashModels = []; // loaded while the Trash view is open
|
||
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 printerList = []; // your printers, so spools can say which one they're in
|
||
let palette = []; // the maker's filament list: names, codes and colours
|
||
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: '', printable: '', sort: 'newest', group: '', slicer: '', slicerTemplate: '', searchMode: 'words' };
|
||
const PREFS_KEY = 'printhive.prefs';
|
||
// 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', 'printers'];
|
||
|
||
// DOM
|
||
const fileGrid = document.getElementById('fileGrid');
|
||
const projectsList = document.getElementById('projectsList');
|
||
const tagsList = document.getElementById('tagsList');
|
||
const searchInput = document.getElementById('searchInput');
|
||
const contentTitle = document.getElementById('contentTitle');
|
||
const resultCount = document.getElementById('resultCount');
|
||
const emptyTrashBtn = document.getElementById('emptyTrashBtn');
|
||
const uploadModal = document.getElementById('uploadModal');
|
||
const projectModal = document.getElementById('projectModal');
|
||
const importModal = document.getElementById('importModal');
|
||
const settingsModal = document.getElementById('settingsModal');
|
||
const setupModal = document.getElementById('setupModal');
|
||
const editModal = document.getElementById('editModal');
|
||
const fileDetailModal = document.getElementById('fileDetailModal');
|
||
const slicerModal = document.getElementById('slicerModal');
|
||
const tokenModal = document.getElementById('tokenModal');
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
loadPrefs();
|
||
setupAuthListeners();
|
||
bootstrap();
|
||
});
|
||
|
||
function loadPrefs() {
|
||
try { Object.assign(prefs, JSON.parse(localStorage.getItem(PREFS_KEY)) || {}); } catch { /* storage unavailable */ }
|
||
}
|
||
|
||
function savePrefs() {
|
||
try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } catch { /* storage unavailable */ }
|
||
}
|
||
|
||
// Decide what to show: the app (if signed in), the reset form (if ?reset=...),
|
||
// or the sign-in / create-account screen.
|
||
async function bootstrap() {
|
||
const resetToken = new URLSearchParams(location.search).get('reset');
|
||
if (resetToken) { showAuth('reset'); return; }
|
||
let status;
|
||
try {
|
||
status = await (await fetch('/api/auth/status')).json();
|
||
} catch {
|
||
status = { authenticated: false, needsSetup: false };
|
||
}
|
||
if (status.authenticated) startApp();
|
||
else showAuth(status.needsSetup ? 'register' : 'login');
|
||
}
|
||
|
||
let appStarted = false;
|
||
function startApp() {
|
||
document.getElementById('authScreen').style.display = 'none';
|
||
document.querySelector('.container').style.display = '';
|
||
if (!appStarted) { setupEventListeners(); appStarted = true; }
|
||
selectedScope = scopeFromHash();
|
||
const model = modelFromHash(); // read now: drawing the page rewrites the address
|
||
loadData().then(() => {
|
||
if (!model) return;
|
||
if (findModel(model)) openModelDetail(model, { push: false });
|
||
else syncAddress(); // gone, or in the trash: drop it from the address
|
||
});
|
||
}
|
||
|
||
function showAuth(mode) {
|
||
document.querySelector('.container').style.display = 'none';
|
||
document.getElementById('authScreen').style.display = 'flex';
|
||
['login', 'register', 'forgot', 'reset'].forEach((m) => {
|
||
document.getElementById('auth-' + m).style.display = m === mode ? '' : 'none';
|
||
});
|
||
document.getElementById('authError').textContent = '';
|
||
}
|
||
|
||
function setupAuthListeners() {
|
||
document.getElementById('auth-login').addEventListener('submit', handleLogin);
|
||
document.getElementById('auth-register').addEventListener('submit', handleRegister);
|
||
document.getElementById('auth-forgot').addEventListener('submit', handleForgot);
|
||
document.getElementById('auth-reset').addEventListener('submit', handleReset);
|
||
document.querySelectorAll('.auth-nav').forEach((b) => {
|
||
b.addEventListener('click', () => showAuth(b.dataset.auth));
|
||
});
|
||
}
|
||
|
||
async function authPost(url, body) {
|
||
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || 'Something went wrong.');
|
||
return data;
|
||
}
|
||
function authError(msg) { document.getElementById('authError').textContent = msg; }
|
||
|
||
async function handleLogin(e) {
|
||
e.preventDefault();
|
||
try {
|
||
await authPost('/api/auth/login', {
|
||
email: document.getElementById('loginEmail').value,
|
||
password: document.getElementById('loginPassword').value,
|
||
});
|
||
location.reload();
|
||
} catch (err) { authError(err.message); }
|
||
}
|
||
|
||
async function handleRegister(e) {
|
||
e.preventDefault();
|
||
try {
|
||
await authPost('/api/auth/register', {
|
||
email: document.getElementById('regEmail').value,
|
||
password: document.getElementById('regPassword').value,
|
||
});
|
||
location.reload();
|
||
} catch (err) { authError(err.message); }
|
||
}
|
||
|
||
async function handleForgot(e) {
|
||
e.preventDefault();
|
||
try {
|
||
await authPost('/api/auth/forgot', { email: document.getElementById('forgotEmail').value });
|
||
authError('If that email has an account and email is configured, a reset link is on its way.');
|
||
} catch (err) { authError(err.message); }
|
||
}
|
||
|
||
async function handleReset(e) {
|
||
e.preventDefault();
|
||
const token = new URLSearchParams(location.search).get('reset');
|
||
try {
|
||
await authPost('/api/auth/reset', { token, password: document.getElementById('resetPassword').value });
|
||
window.history.replaceState({}, '', location.pathname); // drop ?reset=
|
||
showAuth('login');
|
||
authError('Password updated — you can sign in now.');
|
||
} catch (err) { authError(err.message); }
|
||
}
|
||
|
||
async function handleLogout() {
|
||
try { await fetch('/api/auth/logout', { method: 'POST' }); } catch { /* ignore */ }
|
||
location.reload();
|
||
}
|
||
|
||
async function loadData() {
|
||
try {
|
||
const [modelsRes, projectsRes, configRes, statsRes, filamentRes, printersRes] = await Promise.all([
|
||
fetch('/api/models'),
|
||
fetch('/api/projects'),
|
||
fetch('/api/config'),
|
||
fetch('/api/library/stats'),
|
||
fetch('/api/filament'),
|
||
fetch('/api/printers'),
|
||
]);
|
||
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 : [];
|
||
printerList = printersRes.ok ? await printersRes.json() : [];
|
||
setCount('printerCount', printerList.length);
|
||
// Static and ~40KB, so fetched once rather than on every refresh.
|
||
if (!palette.length) {
|
||
palette = await fetch('/api/filament/palette')
|
||
.then((r) => (r.ok ? r.json() : { filaments: [] })).then((p) => p.filaments || []).catch(() => []);
|
||
}
|
||
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
|
||
}
|
||
|
||
renderProjects();
|
||
renderTags();
|
||
renderFilterOptions();
|
||
renderModels();
|
||
updateCounts();
|
||
generateMissingThumbnails(); // fire-and-forget; updates cards as they finish
|
||
watchBackgroundWork();
|
||
|
||
// First-run onboarding: prompt for import sources once.
|
||
if (config.setupComplete === false && !setupModal.classList.contains('active')) {
|
||
setupModal.classList.add('active');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading data:', error);
|
||
}
|
||
}
|
||
|
||
// While the server is still fingerprinting or making thumbnails, check back
|
||
// every few seconds and reload once its progress changes, so new thumbnails
|
||
// and duplicates show up without a manual refresh.
|
||
let backgroundPoll = null;
|
||
function watchBackgroundWork() {
|
||
clearTimeout(backgroundPoll);
|
||
const pendingOf = (s) => (s.fingerprintsPending || 0) + (s.thumbnailsPending || 0) + (s.tagsPending || 0);
|
||
const pending = pendingOf(stats);
|
||
if (!pending) return;
|
||
backgroundPoll = setTimeout(async () => {
|
||
try {
|
||
const next = await (await fetch('/api/library/stats')).json();
|
||
if (pendingOf(next) !== pending || next.duplicateGroups !== stats.duplicateGroups) {
|
||
await loadData(); // re-arms the watch
|
||
return;
|
||
}
|
||
stats = next;
|
||
watchBackgroundWork();
|
||
} catch { /* offline: try again on the next reload */ }
|
||
}, 5000);
|
||
}
|
||
|
||
async function loadTrash() {
|
||
const res = await fetch('/api/models?view=trash');
|
||
trashModels = res.ok ? await res.json() : [];
|
||
}
|
||
|
||
// The server makes STL/OBJ/3MF thumbnails in the background; the browser
|
||
// renders the formats only it can read, then saves them. One at a time, to
|
||
// avoid thrashing the GPU / WebGL contexts.
|
||
const BROWSER_THUMBNAIL_FORMATS = ['fbx', '3dm', 'step', 'stp'];
|
||
const thumbAttempted = new Set();
|
||
async function generateMissingThumbnails() {
|
||
if (!window.ModelViewer) return;
|
||
for (const m of models) {
|
||
if (m.thumbnailUrl || thumbAttempted.has(m.id)) continue;
|
||
const file = (m.files || []).find((f) => BROWSER_THUMBNAIL_FORMATS.includes(f.format));
|
||
if (!file) continue;
|
||
thumbAttempted.add(m.id);
|
||
try {
|
||
const src = viewerSourceFor({ kind: 'file', id: file.id, format: file.format });
|
||
const image = await window.ModelViewer.snapshot(src.url, src.format, 512, { resolve: resolverFor(m) });
|
||
if (!image) continue;
|
||
const res = await fetch(`/api/models/${m.id}/thumbnail`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ image }),
|
||
});
|
||
if (!res.ok) continue;
|
||
const updated = await res.json();
|
||
updateCardThumbnail(m.id, updated.thumbnailUrl);
|
||
} catch (err) {
|
||
console.warn('Thumbnail generation failed for model', m.id, err);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- lookups
|
||
function findModel(id) {
|
||
return models.find((m) => String(m.id) === String(id))
|
||
|| trashModels.find((m) => String(m.id) === String(id));
|
||
}
|
||
|
||
function replaceModel(updated) {
|
||
for (const list of [models, trashModels]) {
|
||
const i = list.findIndex((m) => m.id === updated.id);
|
||
if (i !== -1) list[i] = updated;
|
||
}
|
||
}
|
||
|
||
const SOURCE_LABELS = {
|
||
upload: 'Manual upload', thingiverse: 'Thingiverse', printables: 'Printables',
|
||
makerworld: 'MakerWorld', cubee3d: 'Cubee3D', web: 'Web',
|
||
};
|
||
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, ' ');
|
||
|
||
// Mirrors lib/filaments.js. Bambu writes #RRGGBB and #RRGGBBAA; alpha isn't colour.
|
||
function rgbOf(hex) {
|
||
const m = /^#?([0-9a-f]{6})(?:[0-9a-f]{2})?$/i.exec(String(hex || '').trim());
|
||
if (!m) return null;
|
||
const n = parseInt(m[1], 16);
|
||
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
||
}
|
||
|
||
// Redmean distance: nearer to how an eye judges colours than plain RGB.
|
||
function colourDistance(a, b) {
|
||
const x = rgbOf(a);
|
||
const y = rgbOf(b);
|
||
if (!x || !y) return null;
|
||
const rm = (x.r + y.r) / 2;
|
||
const dr = x.r - y.r;
|
||
const dg = x.g - y.g;
|
||
const db = x.b - y.b;
|
||
return Math.sqrt((2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db);
|
||
}
|
||
|
||
const COLOUR_TOLERANCE = 50; // keeps #000000 and #161616 (~66 apart) distinct
|
||
|
||
// The maker's name for a colour, when the palette has one that close. The
|
||
// threshold is deliberately tight: this is identification, not substitution.
|
||
function paletteName(hex, material) {
|
||
// Seventeen PLA filaments are #000000. Whichever matched first would win, so
|
||
// prefer the everyday product — Basic, then Matte — over specialist ones.
|
||
const rank = (type) => (type === 'Basic' ? 0 : type === 'Matte' ? 1 : 2);
|
||
let best = null;
|
||
for (const f of palette) {
|
||
if (material && filamentType(f.material) !== filamentType(material)) continue;
|
||
for (const c of f.colours || []) {
|
||
const d = colourDistance(hex, c);
|
||
if (d === null || d > 12) continue;
|
||
const better = !best || rank(f.type) < rank(best.type) || (rank(f.type) === rank(best.type) && d < best.d);
|
||
if (better) best = { d, name: f.name, type: f.type };
|
||
}
|
||
}
|
||
return best ? `${best.type} ${best.name}` : null;
|
||
}
|
||
|
||
// Which spool fills which slot — best colour matches first, one spool per slot.
|
||
function printability(m) {
|
||
const slots = ((m && m.filaments) || []).map((f, i) => ({
|
||
index: i, material: filamentType(f.type), colour: f.colour || null,
|
||
}));
|
||
if (!slots.length) return { known: false, slots: [], unfilled: [], ok: false };
|
||
|
||
const shelf = spools
|
||
.filter((s) => s.remaining_g == null || Number(s.remaining_g) > 0)
|
||
.map((s, i) => ({ key: s.id == null ? `i${i}` : `s${s.id}`, material: filamentType(s.material),
|
||
colour: s.colour_hex || null, name: s.colour || s.material }));
|
||
|
||
const pairs = [];
|
||
for (const slot of slots) {
|
||
for (const spool of shelf) {
|
||
if (spool.material !== slot.material) continue;
|
||
// Filled last, from what's left: scoring it 0 would let an "any colour"
|
||
// slot take the spool an exact-match slot needs.
|
||
if (slot.colour === null) { pairs.push({ slot: slot.index, spool: spool.key, d: Number.MAX_SAFE_INTEGER }); continue; }
|
||
const d = colourDistance(slot.colour, spool.colour);
|
||
if (d !== null && d <= COLOUR_TOLERANCE) pairs.push({ slot: slot.index, spool: spool.key, d });
|
||
}
|
||
}
|
||
pairs.sort((a, b) => a.d - b.d);
|
||
|
||
const filledBy = new Map();
|
||
const used = new Set();
|
||
for (const p of pairs) {
|
||
if (filledBy.has(p.slot) || used.has(p.spool)) continue;
|
||
filledBy.set(p.slot, p.spool);
|
||
used.add(p.spool);
|
||
}
|
||
|
||
const resolved = slots.map((slot) => {
|
||
const key = filledBy.get(slot.index);
|
||
const by = key ? shelf.find((s) => s.key === key) : null;
|
||
let nearest = null;
|
||
if (!by && slot.colour) {
|
||
for (const spool of shelf) {
|
||
if (spool.material !== slot.material) continue;
|
||
const d = colourDistance(slot.colour, spool.colour);
|
||
if (d !== null && (!nearest || d < nearest.distance)) nearest = { name: spool.name, distance: Math.round(d) };
|
||
}
|
||
}
|
||
return { ...slot, filled: Boolean(by), by: by ? by.name : null, nearest };
|
||
});
|
||
const unfilled = resolved.filter((s) => !s.filled);
|
||
return { known: true, slots: resolved, unfilled, ok: unfilled.length === 0 };
|
||
}
|
||
|
||
function printBadgeHtml(m) {
|
||
const p = printability(m);
|
||
if (!p.known) return '';
|
||
if (p.ok) return `<span class="print-badge ok" title="A spool for every slot">✔ printable</span>`;
|
||
const n = p.unfilled.length;
|
||
return `<span class="print-badge no" title="${n} of ${p.slots.length} slots have no matching spool">needs ${n} colour${n === 1 ? '' : 's'}</span>`;
|
||
}
|
||
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'}`;
|
||
|
||
// Everything a search term can match, lower-cased. Cached per model object
|
||
// (objects are replaced whenever data reloads, which invalidates the cache).
|
||
const searchCache = new WeakMap();
|
||
function searchText(m) {
|
||
if (!searchCache.has(m)) {
|
||
searchCache.set(m, [
|
||
m.name, m.description, m.notes, m.license, sourceLabel(sourceKey(m)),
|
||
m.designer && m.designer.name,
|
||
...(m.tags || []),
|
||
...(m.projects || []).map((p) => p.name),
|
||
...(m.files || []).map((f) => f.originalName),
|
||
].filter(Boolean).join('\n').toLowerCase());
|
||
}
|
||
return searchCache.get(m);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- rendering
|
||
function renderProjects() {
|
||
projectsList.innerHTML = projects.length
|
||
? projects.map((p) => `
|
||
<div class="project-item" data-scope="${p.id}">
|
||
<span class="project-name">${escapeHtml(p.name)}</span>
|
||
<span class="project-meta">
|
||
<button class="collection-action collection-rename" title="Rename" aria-label="Rename collection">✎</button>
|
||
<button class="collection-action collection-delete" title="Delete" aria-label="Delete collection">×</button>
|
||
<span class="count">${p.model_count}</span>
|
||
</span>
|
||
</div>`).join('')
|
||
: '<p class="form-hint">No collections yet.</p>';
|
||
}
|
||
|
||
function renderTags() {
|
||
const all = knownTags();
|
||
for (const t of Object.keys(tagFilters)) if (!all.includes(t)) delete tagFilters[t];
|
||
const titles = {
|
||
include: 'Showing models with this tag — click to exclude it',
|
||
exclude: 'Hiding models with this tag — click to clear',
|
||
};
|
||
tagsList.innerHTML = all.length
|
||
? all.map((tag) => {
|
||
const state = tagFilters[tag];
|
||
return `<span class="tag${state ? ` ${state}` : ''}" data-tag="${escapeHtml(tag)}"
|
||
title="${titles[state] || 'Click to show only models with this tag'}">${escapeHtml(tag)}</span>`;
|
||
}).join('')
|
||
: '<p class="form-hint">No tags yet.</p>';
|
||
document.getElementById('clearTags').hidden = Object.keys(tagFilters).length === 0;
|
||
document.querySelectorAll('#tagMatch button').forEach((b) => {
|
||
b.classList.toggle('active', b.dataset.match === prefs.tagMatch);
|
||
});
|
||
}
|
||
|
||
// Format/source dropdowns list only what's in the library. A remembered choice
|
||
// that no longer matches anything is reset rather than silently hiding everything.
|
||
function renderFilterOptions() {
|
||
const formats = [...new Set(models.flatMap((m) => (m.files || []).map((f) => f.format)))].sort();
|
||
const sources = [...new Set(models.map(sourceKey))].sort();
|
||
fillSelect('filterFormat', 'Any format', formats.map((f) => [f, `.${f}`]), 'format');
|
||
fillSelect('filterSource', 'Any source', sources.map((s) => [s, sourceLabel(s)]), 'source');
|
||
}
|
||
|
||
function fillSelect(id, anyLabel, options, prefKey) {
|
||
if (prefs[prefKey] && !options.some(([value]) => value === prefs[prefKey])) {
|
||
prefs[prefKey] = '';
|
||
savePrefs();
|
||
}
|
||
const el = document.getElementById(id);
|
||
el.innerHTML = `<option value="">${anyLabel}</option>`
|
||
+ options.map(([value, label]) => `<option value="${escapeHtml(value)}">${escapeHtml(label)}</option>`).join('');
|
||
el.value = prefs[prefKey];
|
||
}
|
||
|
||
function scopeTitle() {
|
||
if (selectedScope === 'all') return 'All Models';
|
||
if (selectedScope === 'duplicates') return 'Duplicates';
|
||
if (selectedScope === 'trash') return 'Trash';
|
||
if (selectedScope === 'ai') return 'AI & automatic tags';
|
||
if (selectedScope === 'watch') return 'Watched collections';
|
||
if (selectedScope === 'filament') return 'Filament';
|
||
if (selectedScope === 'printers') return 'Printers';
|
||
const p = projects.find((x) => String(x.id) === String(selectedScope));
|
||
return p ? p.name : 'Collection';
|
||
}
|
||
|
||
function updateScopeChrome() {
|
||
syncAddress(); // e.g. a deleted collection falling back to All Models
|
||
contentTitle.textContent = scopeTitle();
|
||
const panel = ['duplicates', 'ai', 'watch', 'filament', 'printers'].includes(selectedScope); // pages without the model grid
|
||
document.getElementById('filterBar').hidden = panel;
|
||
document.querySelector('.view-options').hidden = panel;
|
||
emptyTrashBtn.hidden = !(selectedScope === 'trash' && trashModels.length);
|
||
document.querySelectorAll('.project-item[data-scope]').forEach((el) => {
|
||
el.classList.toggle('active', el.dataset.scope === String(selectedScope));
|
||
});
|
||
document.querySelectorAll('.view-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === prefs.view));
|
||
}
|
||
|
||
function renderModels() {
|
||
updateScopeChrome();
|
||
if (selectedScope === 'duplicates') { clearTimeout(aiPoll); renderDuplicates(); return; }
|
||
clearTimeout(duplicatePoll);
|
||
if (selectedScope === 'ai') { clearTimeout(watchPoll); renderAiPanel(); return; }
|
||
clearTimeout(aiPoll);
|
||
if (selectedScope === 'watch') { renderWatchPanel(); return; }
|
||
if (selectedScope === 'filament') { renderFilamentPanel(); return; }
|
||
if (selectedScope === 'printers') { renderPrintersPanel(); return; }
|
||
clearTimeout(watchPoll);
|
||
|
||
const list = getFilteredModels();
|
||
resultCount.textContent = `${list.length} model${list.length === 1 ? '' : 's'}${searchNote()}`;
|
||
fileGrid.className = prefs.view === 'list' ? 'file-grid list-view' : 'file-grid';
|
||
|
||
if (list.length === 0) {
|
||
fileGrid.innerHTML = emptyStateHtml();
|
||
return;
|
||
}
|
||
if (!prefs.group) {
|
||
fileGrid.innerHTML = list.map(cardHtml).join('');
|
||
return;
|
||
}
|
||
const groups = new Map();
|
||
for (const m of list) {
|
||
const key = groupLabel(m);
|
||
if (!groups.has(key)) groups.set(key, []);
|
||
groups.get(key).push(m);
|
||
}
|
||
fileGrid.innerHTML = [...groups.entries()]
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.map(([label, ms]) => `<div class="group-header">${escapeHtml(label)} <span>${ms.length}</span></div>`
|
||
+ ms.map(cardHtml).join(''))
|
||
.join('');
|
||
}
|
||
|
||
function groupLabel(m) {
|
||
if (prefs.group === 'source') return sourceLabel(sourceKey(m));
|
||
const formats = [...new Set((m.files || []).map((f) => `.${f.format}`))].sort();
|
||
return formats.length ? formats.join(' + ') : 'No files';
|
||
}
|
||
|
||
function emptyStateHtml() {
|
||
const state = (title, text) => `<div class="empty-state"><h3>${title}</h3><p>${text}</p></div>`;
|
||
if (selectedScope === 'trash' && trashModels.length === 0) {
|
||
return state('Trash is empty', 'Models you delete wait here until you restore them or empty the trash.');
|
||
}
|
||
if (selectedScope === 'all' && models.length === 0) {
|
||
return state('No models yet', 'Upload files or import from a URL to get started');
|
||
}
|
||
return state('No models match', 'Try clearing the search, filters or tag selection.');
|
||
}
|
||
|
||
function thumbHtml(m) {
|
||
return m.thumbnailUrl ? `<img src="${m.thumbnailUrl}" alt="" loading="lazy" draggable="false">` : '<span>📦</span>';
|
||
}
|
||
|
||
function cardHtml(m) {
|
||
const trashed = Boolean(m.deleted_at);
|
||
const when = trashed ? `trashed ${formatDate(m.deleted_at)}` : formatDate(m.date_added);
|
||
return `
|
||
<div class="file-card" data-model-id="${m.id}"${CAN_DRAG_OUT && !trashed && m.files.length ? ' draggable="true" title="Drag out to save the file"' : ''}>
|
||
<div class="file-thumbnail">${thumbHtml(m)}</div>
|
||
<div class="file-info">
|
||
<div class="file-name">${escapeHtml(m.name)}</div>
|
||
${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>' : ''}
|
||
${printBadgeHtml(m)}
|
||
${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>` : ''}
|
||
<div class="file-meta">${fileCountLabel(m)} • ${formatFileSize(totalSize(m))} • ${when}</div>
|
||
${trashed ? `
|
||
<div class="card-actions">
|
||
<button class="btn btn-secondary btn-sm" data-action="restore">Restore</button>
|
||
<button class="btn btn-danger btn-sm" data-action="purge">Delete forever</button>
|
||
</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
const SORTS = {
|
||
newest: (a, b) => new Date(b.date_added) - new Date(a.date_added),
|
||
oldest: (a, b) => new Date(a.date_added) - new Date(b.date_added),
|
||
name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
|
||
'name-desc': (a, b) => b.name.localeCompare(a.name, undefined, { numeric: true, sensitivity: 'base' }),
|
||
size: (a, b) => totalSize(b) - totalSize(a),
|
||
modified: (a, b) => new Date(b.date_modified || b.date_added) - new Date(a.date_modified || a.date_added),
|
||
};
|
||
|
||
function getFilteredModels() {
|
||
let result = selectedScope === 'trash' ? trashModels : models;
|
||
if (!SPECIAL_SCOPES.includes(selectedScope)) {
|
||
result = result.filter((m) => (m.projects || []).some((p) => String(p.id) === String(selectedScope)));
|
||
}
|
||
|
||
const include = Object.keys(tagFilters).filter((t) => tagFilters[t] === 'include');
|
||
const exclude = Object.keys(tagFilters).filter((t) => tagFilters[t] === 'exclude');
|
||
if (include.length) {
|
||
result = result.filter((m) => {
|
||
const tags = m.tags || [];
|
||
return prefs.tagMatch === 'all' ? include.every((t) => tags.includes(t)) : include.some((t) => tags.includes(t));
|
||
});
|
||
}
|
||
if (exclude.length) result = result.filter((m) => !exclude.some((t) => (m.tags || []).includes(t)));
|
||
|
||
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) {
|
||
const wordMatch = (m) => terms.every((t) => searchText(m).includes(t));
|
||
const meaning = meaningResults();
|
||
if (meaning) {
|
||
// Models close in meaning, plus anything matching the words; ranked by
|
||
// closeness, with word matches first.
|
||
const best = Math.max(0, ...meaning.values());
|
||
const cutoff = Math.max(MEANING_MIN_SCORE, best - MEANING_SPREAD);
|
||
const rank = (m) => (meaning.get(m.id) || 0) + (wordMatch(m) ? 1 : 0);
|
||
return result.filter((m) => wordMatch(m) || (meaning.get(m.id) || 0) >= cutoff).sort((a, b) => rank(b) - rank(a));
|
||
}
|
||
result = result.filter(wordMatch);
|
||
}
|
||
|
||
return [...result].sort(SORTS[prefs.sort] || SORTS.newest);
|
||
}
|
||
|
||
function updateCounts() {
|
||
document.getElementById('allFilesCount').textContent = models.length;
|
||
document.getElementById('aiBusy').hidden = !stats.aiRunning;
|
||
document.getElementById('watchCount').textContent = stats.watches ?? 0;
|
||
document.getElementById('trashCount').textContent = stats.trash ?? 0;
|
||
document.getElementById('duplicatesCount').textContent = stats.duplicateGroups ?? 0;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- duplicates
|
||
async function renderDuplicates() {
|
||
fileGrid.className = 'dup-list';
|
||
resultCount.textContent = '';
|
||
if (!duplicateData) fileGrid.innerHTML = '<div class="empty-state"><p>Looking for duplicates…</p></div>';
|
||
try {
|
||
const res = await fetch('/api/duplicates');
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
duplicateData = await res.json();
|
||
} catch (err) {
|
||
fileGrid.innerHTML = `<div class="empty-state"><h3>Couldn't check for duplicates</h3><p>${escapeHtml(err.message)}</p></div>`;
|
||
return;
|
||
}
|
||
if (selectedScope !== 'duplicates') return; // navigated away while loading
|
||
|
||
const { groups, pending } = duplicateData;
|
||
stats.duplicateGroups = groups.length;
|
||
updateCounts();
|
||
resultCount.textContent = `${groups.length} group${groups.length === 1 ? '' : 's'}`;
|
||
const pendingNote = pending
|
||
? `<div class="dup-pending">Fingerprinting ${pending} file${pending === 1 ? '' : 's'}… this list updates automatically.</div>`
|
||
: '';
|
||
fileGrid.innerHTML = pendingNote + (groups.length
|
||
? groups.map(duplicateGroupHtml).join('')
|
||
: '<div class="empty-state"><h3>No duplicates found</h3><p>Files are compared by content and by shape, so renamed or re-exported copies show up here too.</p></div>');
|
||
|
||
clearTimeout(duplicatePoll);
|
||
if (pending) duplicatePoll = setTimeout(() => { if (selectedScope === 'duplicates') renderDuplicates(); }, 4000);
|
||
}
|
||
|
||
function duplicateGroupHtml(g) {
|
||
const title = g.exact ? 'Exact copies' : 'Same shape';
|
||
const hint = g.exact
|
||
? 'These models share byte-for-byte identical files.'
|
||
: 'Geometry matches but the files differ (re-exported, converted, or possibly mirrored) — compare before trashing.';
|
||
return `
|
||
<section class="dup-group" data-signature="${escapeHtml(g.signature)}">
|
||
<header class="dup-head">
|
||
<div>
|
||
<h3>${title} · ${g.models.length} models</h3>
|
||
<p class="form-hint">${hint}</p>
|
||
</div>
|
||
<button class="btn btn-secondary btn-sm" data-action="dismiss">Not duplicates</button>
|
||
</header>
|
||
<div class="dup-cards">${g.models.map((m) => duplicateCardHtml(g, m)).join('')}</div>
|
||
</section>`;
|
||
}
|
||
|
||
function duplicateCardHtml(g, m) {
|
||
const recommended = m.id === g.recommendedId;
|
||
const unique = m.matchableFiles - m.matchedFiles;
|
||
return `
|
||
<div class="dup-card${recommended ? ' recommended' : ''}" data-model-id="${m.id}">
|
||
<div class="file-thumbnail">${thumbHtml(m)}</div>
|
||
<div class="file-info">
|
||
${recommended ? '<span class="rec-badge">Recommended keep</span>' : ''}
|
||
<div class="file-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</div>
|
||
<div class="file-meta">${escapeHtml(sourceLabel(sourceKey(m)))} • ${formatDate(m.date_added)}</div>
|
||
<div class="file-meta">${m.matchedFiles} of ${m.matchableFiles} files match${unique ? ` • <span class="warn">${unique} unique</span>` : ''}</div>
|
||
<button class="btn btn-primary btn-sm" data-action="keep">Keep this one</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
async function handleKeepDuplicate(signature, keepId) {
|
||
const group = duplicateData && duplicateData.groups.find((g) => g.signature === signature);
|
||
if (!group) return;
|
||
const keep = group.models.find((m) => m.id === keepId);
|
||
const others = group.models.filter((m) => m.id !== keepId);
|
||
let message = `Keep "${keep.name}" and move ${others.length} other model${others.length === 1 ? '' : 's'} to the trash?\n\n`
|
||
+ 'Their tags, collections and notes are merged into the one you keep.';
|
||
const withUnique = others.filter((m) => m.matchableFiles > m.matchedFiles);
|
||
if (withUnique.length) {
|
||
message += `\n\nHeads up: ${withUnique.map((m) => `"${m.name}"`).join(', ')} also contain files that aren't duplicated — they go to the trash too.`;
|
||
}
|
||
if (!confirm(message)) return;
|
||
try {
|
||
const res = await fetch('/api/duplicates/resolve', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ keepId, trashIds: others.map((m) => m.id) }),
|
||
});
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
await loadData();
|
||
} catch (err) {
|
||
alert('Could not resolve duplicates: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function handleDismissDuplicates(signature) {
|
||
const group = duplicateData && duplicateData.groups.find((g) => g.signature === signature);
|
||
if (!group) return;
|
||
try {
|
||
const res = await fetch('/api/duplicates/dismiss', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ modelIds: group.models.map((m) => m.id) }),
|
||
});
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
await loadData();
|
||
} catch (err) {
|
||
alert('Could not dismiss: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- trash
|
||
async function restoreModel(id) {
|
||
try {
|
||
const res = await fetch(`/api/models/${id}/restore`, { method: 'POST' });
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
await loadData();
|
||
return true;
|
||
} catch (err) {
|
||
alert('Restore failed: ' + err.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function purgeModel(id) {
|
||
const model = findModel(id);
|
||
if (!confirm(`Permanently delete "${model ? model.name : 'this model'}" and its files?\n\nThis can't be undone.`)) return false;
|
||
try {
|
||
const res = await fetch(`/api/models/${id}?permanent=true`, { method: 'DELETE' });
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
await loadData();
|
||
return true;
|
||
} catch (err) {
|
||
alert('Delete failed: ' + err.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function handleEmptyTrash() {
|
||
const n = trashModels.length;
|
||
if (!n || !confirm(`Permanently delete ${n} model${n === 1 ? '' : 's'} in the trash and all their files?\n\nThis can't be undone.`)) return;
|
||
try {
|
||
const res = await fetch('/api/trash', { method: 'DELETE' });
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
await loadData();
|
||
} catch (err) {
|
||
alert('Emptying the trash failed: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- events
|
||
function setupEventListeners() {
|
||
document.getElementById('uploadBtn').addEventListener('click', openUploadModal);
|
||
document.getElementById('newProjectBtn').addEventListener('click', openProjectModal);
|
||
document.getElementById('importBtn').addEventListener('click', openImportModal);
|
||
document.getElementById('settingsBtn').addEventListener('click', openSettingsModal);
|
||
document.getElementById('logoutBtn').addEventListener('click', handleLogout);
|
||
document.getElementById('settingsForm').addEventListener('submit', handleSaveSettings);
|
||
document.getElementById('cancelSettings').addEventListener('click', () => closeModal(settingsModal));
|
||
document.querySelectorAll('.clear-setting').forEach((btn) => {
|
||
btn.addEventListener('click', () => handleClearSetting(btn.dataset.setting));
|
||
});
|
||
|
||
document.querySelectorAll('.close-btn').forEach((btn) => {
|
||
btn.addEventListener('click', (e) => closeModal(e.target.closest('.modal')));
|
||
});
|
||
document.querySelectorAll('.modal').forEach((modal) => {
|
||
if (modal.id === 'setupModal') return; // must be finished or skipped, not backdrop-dismissed
|
||
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(modal); });
|
||
});
|
||
|
||
document.getElementById('setupForm').addEventListener('submit', handleFinishSetup);
|
||
document.getElementById('skipSetup').addEventListener('click', handleSkipSetup);
|
||
|
||
document.getElementById('uploadForm').addEventListener('submit', handleUpload);
|
||
document.getElementById('cancelUpload').addEventListener('click', () => closeModal(uploadModal));
|
||
document.getElementById('projectForm').addEventListener('submit', handleCreateProject);
|
||
document.getElementById('cancelProject').addEventListener('click', () => closeModal(projectModal));
|
||
document.getElementById('importForm').addEventListener('submit', handleImport);
|
||
document.getElementById('cancelImport').addEventListener('click', () => closeModal(importModal));
|
||
|
||
document.getElementById('fileInput').addEventListener('change', (e) => {
|
||
const f = e.target.files[0];
|
||
const nameField = document.getElementById('fileName');
|
||
if (f && !nameField.value) nameField.value = f.name.replace(/\.[^.]+$/, '');
|
||
});
|
||
|
||
// Library views (All / Duplicates / Trash) and collections share one scope.
|
||
document.querySelector('.sidebar').addEventListener('click', (e) => {
|
||
const item = e.target.closest('.project-item[data-scope]');
|
||
if (!item) return;
|
||
const scope = item.dataset.scope;
|
||
if (e.target.closest('.collection-rename')) { handleRenameCollection(scope, item.querySelector('.project-name').textContent); return; }
|
||
if (e.target.closest('.collection-delete')) { handleDeleteCollection(scope, item.querySelector('.project-name').textContent); return; }
|
||
selectScope(scope);
|
||
});
|
||
|
||
tagsList.addEventListener('click', (e) => {
|
||
const el = e.target.closest('.tag');
|
||
if (!el) return;
|
||
const tag = el.dataset.tag;
|
||
if (!tagFilters[tag]) tagFilters[tag] = 'include';
|
||
else if (tagFilters[tag] === 'include') tagFilters[tag] = 'exclude';
|
||
else delete tagFilters[tag];
|
||
renderTags();
|
||
renderModels();
|
||
});
|
||
document.getElementById('tagMatch').addEventListener('click', (e) => {
|
||
const btn = e.target.closest('button[data-match]');
|
||
if (!btn) return;
|
||
prefs.tagMatch = btn.dataset.match;
|
||
savePrefs();
|
||
renderTags();
|
||
renderModels();
|
||
});
|
||
document.getElementById('clearTags').addEventListener('click', () => {
|
||
tagFilters = {};
|
||
renderTags();
|
||
renderModels();
|
||
});
|
||
|
||
searchInput.addEventListener('input', renderModels);
|
||
[['filterFormat', 'format'], ['filterSource', 'source'], ['filterLicense', 'license'],
|
||
['filterPrintable', 'printable'], ['sortBy', 'sort'], ['groupBy', 'group']]
|
||
.forEach(([id, key]) => {
|
||
const el = document.getElementById(id);
|
||
el.value = prefs[key] || ''; // a preference added later starts undefined
|
||
el.addEventListener('change', () => { prefs[key] = el.value; savePrefs(); renderModels(); });
|
||
});
|
||
|
||
// "/" jumps to search.
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
|
||
if (e.target.closest && e.target.closest('input, textarea, select, [contenteditable]')) return;
|
||
if (document.querySelector('.modal.active') || document.getElementById('filterBar').hidden) return;
|
||
e.preventDefault();
|
||
searchInput.focus();
|
||
});
|
||
|
||
document.querySelectorAll('.view-btn').forEach((btn) => {
|
||
btn.addEventListener('click', (e) => {
|
||
prefs.view = e.target.dataset.view;
|
||
savePrefs();
|
||
renderModels();
|
||
});
|
||
});
|
||
|
||
fileGrid.addEventListener('click', (e) => {
|
||
const card = e.target.closest('[data-model-id]');
|
||
const actionBtn = e.target.closest('[data-action]');
|
||
if (actionBtn) {
|
||
const group = actionBtn.closest('.dup-group');
|
||
switch (actionBtn.dataset.action) {
|
||
case 'restore': restoreModel(card.dataset.modelId); break;
|
||
case 'purge': purgeModel(card.dataset.modelId); break;
|
||
case 'keep': handleKeepDuplicate(group.dataset.signature, Number(card.dataset.modelId)); break;
|
||
case 'dismiss': handleDismissDuplicates(group.dataset.signature); break;
|
||
}
|
||
return;
|
||
}
|
||
if (card) openModelDetail(card.dataset.modelId);
|
||
});
|
||
|
||
emptyTrashBtn.addEventListener('click', handleEmptyTrash);
|
||
document.getElementById('deleteFileBtn').addEventListener('click', handleDeleteModel);
|
||
document.getElementById('restoreModelBtn').addEventListener('click', async () => {
|
||
if (await restoreModel(fileDetailModal.dataset.currentModelId)) closeModal(fileDetailModal);
|
||
});
|
||
document.getElementById('purgeModelBtn').addEventListener('click', async () => {
|
||
if (await purgeModel(fileDetailModal.dataset.currentModelId)) closeModal(fileDetailModal);
|
||
});
|
||
document.getElementById('detailNotes').addEventListener('input', queueNotesSave);
|
||
document.getElementById('detailNotes').addEventListener('blur', flushNotes);
|
||
document.getElementById('detailFileTabs').addEventListener('click', (e) => {
|
||
const tab = e.target.closest('.file-tab');
|
||
const item = tab && detailItems.find((i) => itemKey(i) === tab.dataset.key);
|
||
if (!item) return;
|
||
renderViewerTabs(itemKey(item));
|
||
loadViewer(item);
|
||
});
|
||
document.getElementById('detailPlates').addEventListener('click', (e) => {
|
||
const chip = e.target.closest('.plate-chip');
|
||
const item = chip && detailItems.find((i) => itemKey(i) === activeItemKey);
|
||
if (!item) return;
|
||
item.plate = chip.dataset.plate === 'all' ? null : Number(chip.dataset.plate);
|
||
loadViewer(item);
|
||
});
|
||
document.getElementById('searchMode').addEventListener('click', (e) => {
|
||
const btn = e.target.closest('button[data-mode]');
|
||
if (!btn) return;
|
||
prefs.searchMode = btn.dataset.mode;
|
||
savePrefs();
|
||
syncSearchMode();
|
||
renderModels();
|
||
searchInput.focus();
|
||
});
|
||
syncSearchMode();
|
||
document.getElementById('aiTagBtn').addEventListener('click', handleAiTagModel);
|
||
document.getElementById('tokenForm').addEventListener('submit', handleTokenSubmit);
|
||
document.getElementById('cancelToken').addEventListener('click', () => closeModal(tokenModal));
|
||
document.getElementById('detailRenamed').addEventListener('click', (e) => {
|
||
if (e.target.closest('#undoRenameBtn')) handleUndoRename();
|
||
});
|
||
document.getElementById('aiTestBtn').addEventListener('click', handleAiTest);
|
||
document.getElementById('aiProvider').addEventListener('change', () => {
|
||
// A new provider starts from its own defaults.
|
||
['aiModel', 'aiBaseUrl', 'aiPriceInput', 'aiPriceOutput'].forEach((id) => { document.getElementById(id).value = ''; });
|
||
syncAiSettingsHints();
|
||
});
|
||
fileGrid.addEventListener('click', handleAiPanelClick);
|
||
fileGrid.addEventListener('submit', (e) => {
|
||
if (e.target.id === 'aiStartForm') handleAiStart(e);
|
||
if (e.target.id === 'ruleForm') handleAddRule(e);
|
||
if (e.target.id === 'watchForm') handleAddWatch(e);
|
||
});
|
||
fileGrid.addEventListener('change', (e) => {
|
||
if (e.target.name === 'aiScope') updateAiEstimate();
|
||
if (e.target.id === 'aiDescribe') document.getElementById('aiOverwrite').disabled = !e.target.checked;
|
||
if (e.target.id === 'autotagEnabled') handleAutotagToggle(e.target.checked);
|
||
});
|
||
document.getElementById('openInSlicerBtn').addEventListener('click', () => {
|
||
const item = detailItems.find((i) => itemKey(i) === activeItemKey);
|
||
if (item) openInSlicer(item);
|
||
});
|
||
document.getElementById('changeSlicerBtn').addEventListener('click', () => openSlicerModal());
|
||
document.getElementById('chooseSlicerBtn').addEventListener('click', () => openSlicerModal());
|
||
document.getElementById('slicerForm').addEventListener('submit', handleSaveSlicer);
|
||
document.getElementById('slicerForm').addEventListener('change', syncSlicerForm);
|
||
document.getElementById('cancelSlicer').addEventListener('click', () => closeModal(slicerModal));
|
||
document.getElementById('slicerNotice').addEventListener('click', (e) => {
|
||
const btn = e.target.closest('[data-notice-action]');
|
||
if (btn && noticeActions[btn.dataset.noticeAction]) noticeActions[btn.dataset.noticeAction]();
|
||
});
|
||
fileGrid.addEventListener('dragstart', (e) => {
|
||
const card = e.target.closest('.file-card[draggable="true"]');
|
||
const target = card && modelDragTarget(findModel(card.dataset.modelId));
|
||
if (target) setDragOut(e.dataTransfer, target);
|
||
});
|
||
fileDetailModal.addEventListener('dragstart', (e) => {
|
||
const el = e.target.closest && e.target.closest('[data-drag-url]');
|
||
if (el) setDragOut(e.dataTransfer, { url: el.dataset.dragUrl, name: el.dataset.dragName });
|
||
});
|
||
document.getElementById('dragOutBtn').addEventListener('click', (e) => followLink(e.currentTarget.dataset.dragUrl));
|
||
document.getElementById('detailFileInfo').addEventListener('click', (e) => {
|
||
const slice = e.target.closest('[data-slice-kind]');
|
||
if (slice) {
|
||
openInSlicer(handoffItem(slice.dataset.sliceKind, Number(slice.dataset.sliceId), slice.dataset.archive));
|
||
return;
|
||
}
|
||
const btn = e.target.closest('[data-view-entry]');
|
||
if (!btn) return;
|
||
const archiveId = Number(btn.dataset.archive);
|
||
const entry = (archiveEntryCache.get(archiveId) || []).find((x) => String(x.id) === btn.dataset.viewEntry);
|
||
if (!entry) return;
|
||
openViewerItem({ kind: 'entry', id: entry.id, format: entry.format, name: entry.path.split('/').pop(), archiveId });
|
||
document.getElementById('detailViewer').scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||
});
|
||
|
||
setupTagAutocomplete(document.getElementById('fileTags'));
|
||
setupTagAutocomplete(document.getElementById('editTags'));
|
||
document.getElementById('regenThumbBtn').addEventListener('click', handleRegenThumbnail);
|
||
document.getElementById('refreshLicenseBtn').addEventListener('click', handleRefreshMetadata);
|
||
document.getElementById('editModelBtn').addEventListener('click', openEditModal);
|
||
document.getElementById('downloadFilesBtn').addEventListener('click', handleDownloadFiles);
|
||
document.getElementById('editForm').addEventListener('submit', handleEditModel);
|
||
document.getElementById('cancelEdit').addEventListener('click', () => closeModal(editModal));
|
||
document.getElementById('addFilesBtn').addEventListener('click', () => document.getElementById('addFilesInput').click());
|
||
document.getElementById('addFilesInput').addEventListener('change', handleAddFiles);
|
||
}
|
||
|
||
// The page you're on lives in the address — #filament, #collection/12, and
|
||
// /model/34 on the end while a model is open — so a refresh or a bookmark comes
|
||
// back to it, and Back and Forward move between pages and models.
|
||
let shownModelId = null; // the model open in the detail view
|
||
let modelStepPushed = false; // opening it here added a Back step
|
||
|
||
function scopeHash(scope, modelId = null) {
|
||
const page = scope === 'all' ? '' : SPECIAL_SCOPES.includes(scope) ? scope : `collection/${scope}`;
|
||
const parts = [page, modelId ? `model/${modelId}` : ''].filter(Boolean);
|
||
return parts.length ? `#${parts.join('/')}` : '';
|
||
}
|
||
|
||
function hashParts() {
|
||
const h = decodeURIComponent(location.hash.slice(1));
|
||
const m = h.match(/^(.*?)\/?model\/(\d+)$/);
|
||
return { page: m ? m[1] : h, model: m ? Number(m[2]) : null };
|
||
}
|
||
|
||
function scopeFromHash() {
|
||
const { page } = hashParts();
|
||
const collection = page.match(/^collection\/(\d+)$/);
|
||
if (collection) return collection[1];
|
||
return SPECIAL_SCOPES.includes(page) ? page : 'all';
|
||
}
|
||
|
||
const modelFromHash = () => hashParts().model;
|
||
|
||
// Keeps the address in step with what's shown; `push` makes a Back step.
|
||
function syncAddress(push = false) {
|
||
const hash = scopeHash(String(selectedScope), shownModelId);
|
||
if (location.hash === hash || (!hash && !location.hash)) return;
|
||
history[push ? 'pushState' : 'replaceState']({ model: shownModelId }, '', `${location.pathname}${location.search}${hash}`);
|
||
}
|
||
|
||
async function selectScope(scope, { push = true } = {}) {
|
||
selectedScope = scope;
|
||
syncAddress(push);
|
||
if (scope === 'trash') await loadTrash();
|
||
renderModels();
|
||
}
|
||
|
||
window.addEventListener('popstate', () => {
|
||
if (!appStarted) return;
|
||
const scope = scopeFromHash();
|
||
const model = modelFromHash();
|
||
if (String(scope) !== String(selectedScope)) selectScope(scope, { push: false });
|
||
if (model && model !== shownModelId) {
|
||
openModelDetail(model, { push: false });
|
||
} else if (!model && shownModelId) {
|
||
modelStepPushed = false; // Back already took the step away
|
||
closeModal(fileDetailModal);
|
||
}
|
||
});
|
||
|
||
// Tracks the file currently shown in the detail viewer (for thumbnail regen).
|
||
let currentDetailFile = null;
|
||
let currentDetailThumb = null; // model image, used as a viewer fallback
|
||
|
||
function closeModal(modal) {
|
||
modal.classList.remove('active');
|
||
if (modal === slicerModal) pendingHandoff = null;
|
||
if (modal === fileDetailModal && shownModelId) {
|
||
shownModelId = null;
|
||
// Undo the step opening it added, so Back doesn't reopen what was just closed.
|
||
if (modelStepPushed && history.state && history.state.model) history.back();
|
||
else syncAddress();
|
||
modelStepPushed = false;
|
||
}
|
||
if (modal === fileDetailModal) {
|
||
hideSlicerNotice();
|
||
flushNotes();
|
||
if (window.ModelViewer) window.ModelViewer.dispose();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- notes
|
||
// Notes autosave shortly after typing stops (and on blur / close). The pending
|
||
// edit remembers its model, so switching models mid-edit can't misfile it.
|
||
let pendingNotes = null;
|
||
let notesTimer = null;
|
||
|
||
function setNotesStatus(text) {
|
||
document.getElementById('notesStatus').textContent = text;
|
||
}
|
||
|
||
function queueNotesSave() {
|
||
pendingNotes = { id: fileDetailModal.dataset.currentModelId, value: document.getElementById('detailNotes').value };
|
||
setNotesStatus('editing…');
|
||
clearTimeout(notesTimer);
|
||
notesTimer = setTimeout(flushNotes, 800);
|
||
}
|
||
|
||
async function flushNotes() {
|
||
clearTimeout(notesTimer);
|
||
const pending = pendingNotes;
|
||
pendingNotes = null;
|
||
if (!pending) return;
|
||
const model = findModel(pending.id);
|
||
if (model && (model.notes || '') === pending.value) { setNotesStatus(''); return; }
|
||
setNotesStatus('saving…');
|
||
try {
|
||
const res = await fetch(`/api/models/${pending.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ notes: pending.value }),
|
||
});
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
replaceModel(await res.json());
|
||
if (fileDetailModal.dataset.currentModelId === String(pending.id)) setNotesStatus('saved');
|
||
} catch (err) {
|
||
setNotesStatus('not saved — ' + err.message);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- modals
|
||
function openUploadModal() {
|
||
document.getElementById('fileProjects').innerHTML =
|
||
projects.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('');
|
||
uploadModal.classList.add('active');
|
||
}
|
||
|
||
function openProjectModal() {
|
||
document.getElementById('projectForm').reset();
|
||
projectModal.classList.add('active');
|
||
}
|
||
|
||
async function markSetupComplete() {
|
||
try {
|
||
await fetch('/api/setup/complete', { method: 'POST' });
|
||
config.setupComplete = true;
|
||
} catch { /* non-fatal */ }
|
||
}
|
||
|
||
async function handleFinishSetup(e) {
|
||
e.preventDefault();
|
||
const status = document.getElementById('setupStatus');
|
||
const payload = {};
|
||
document.querySelectorAll('#setupForm [data-setting]').forEach((input) => {
|
||
if (input.value.trim()) payload[input.dataset.setting] = input.value.trim();
|
||
});
|
||
try {
|
||
if (Object.keys(payload).length) {
|
||
const res = await fetch('/api/settings', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
}
|
||
await markSetupComplete();
|
||
closeModal(setupModal);
|
||
await loadData();
|
||
} catch (err) {
|
||
status.textContent = 'Could not save: ' + err.message;
|
||
}
|
||
}
|
||
|
||
async function handleSkipSetup() {
|
||
await markSetupComplete();
|
||
closeModal(setupModal);
|
||
}
|
||
|
||
async function openSettingsModal() {
|
||
document.getElementById('settingsForm').reset();
|
||
renderSettingsSlicer();
|
||
document.getElementById('settingsStatus').textContent = '';
|
||
try {
|
||
const settings = await (await fetch('/api/settings')).json();
|
||
document.querySelectorAll('.setting-status').forEach((el) => {
|
||
const s = settings[el.dataset.status];
|
||
if (!s) { el.textContent = '—'; return; }
|
||
el.textContent = s.set
|
||
? `set ${s.hint || ''}${s.source === 'env' ? ' (from .env — edit here to override)' : ''}`
|
||
: 'not set';
|
||
el.className = `setting-status ${s.set ? 'is-set' : 'is-unset'}`;
|
||
});
|
||
// Prefill non-secret fields (e.g. the FlareSolverr URL) with their current value.
|
||
document.querySelectorAll('#settingsForm [data-setting]').forEach((input) => {
|
||
const s = settings[input.dataset.setting];
|
||
if (s && !s.secret && s.value) input.value = s.value;
|
||
});
|
||
document.getElementById('aiTestResult').textContent = '';
|
||
await syncAiSettingsHints();
|
||
await renderLicenseRules();
|
||
} catch (err) {
|
||
document.getElementById('settingsStatus').textContent = 'Could not load settings: ' + err.message;
|
||
}
|
||
settingsModal.classList.add('active');
|
||
}
|
||
|
||
function openImportModal() {
|
||
document.getElementById('importForm').reset();
|
||
document.getElementById('importStatus').textContent = '';
|
||
const tvEnabled = config.importers && config.importers.thingiverse;
|
||
const flareEnabled = config.importers && config.importers.flaresolverr;
|
||
const mw = flareEnabled ? 'MakerWorld' : 'MakerWorld (needs FlareSolverr in Settings)';
|
||
document.getElementById('importHint').innerHTML = tvEnabled
|
||
? `Thingiverse imports files automatically. Printables, ${mw} & other sites import the title, description & preview image — then add the files you downloaded.`
|
||
: `Printables, ${mw} & other sites import the title, description & preview image — then add the files you downloaded. Add a Thingiverse token in Settings for automatic file downloads.`;
|
||
document.getElementById('importSubmit').disabled = false;
|
||
importModal.classList.add('active');
|
||
}
|
||
|
||
function openModelDetail(modelId, { push = true } = {}) {
|
||
const model = findModel(modelId);
|
||
if (!model) return;
|
||
// Reopening the same model (to refresh it) leaves the address alone.
|
||
if (shownModelId !== Number(model.id)) {
|
||
const replacing = shownModelId !== null; // one model to the next: no extra Back step
|
||
shownModelId = Number(model.id);
|
||
syncAddress(push && !replacing);
|
||
if (push && !replacing) modelStepPushed = true;
|
||
}
|
||
flushNotes(); // save any edit still pending for the previously open model
|
||
fileDetailModal.dataset.currentModelId = modelId;
|
||
currentDetailThumb = model.thumbnailUrl || null;
|
||
|
||
document.getElementById('detailFileName').textContent = model.name;
|
||
document.getElementById('detailDescription').textContent = model.description || 'No description';
|
||
document.getElementById('detailNotes').value = model.notes || '';
|
||
setNotesStatus('');
|
||
|
||
const trashed = Boolean(model.deleted_at);
|
||
document.getElementById('detailActions').hidden = trashed;
|
||
document.getElementById('trashActions').hidden = !trashed;
|
||
|
||
// Source
|
||
const src = document.getElementById('detailSource');
|
||
if (model.source_url) {
|
||
const designer = model.designer ? ` by ${escapeHtml(model.designer.name)}` : '';
|
||
src.innerHTML = `<a href="${escapeHtml(model.source_url)}" target="_blank" rel="noopener">${escapeHtml(model.source_site || 'link')}${designer}</a>`;
|
||
} else {
|
||
src.textContent = 'Manual upload';
|
||
}
|
||
|
||
renderDetailLicense(model);
|
||
renderRenamedNote(model);
|
||
document.getElementById('refreshLicenseBtn').style.display = model.source_url ? '' : 'none';
|
||
|
||
// "Download files from source" — only for sites we can auto-download from.
|
||
const autoDownloadSites = ['thingiverse', 'printables', 'makerworld'];
|
||
const dlBtn = document.getElementById('downloadFilesBtn');
|
||
if (model.source_url && autoDownloadSites.includes(model.source_site)) {
|
||
dlBtn.style.display = '';
|
||
dlBtn.textContent = model.files.length === 0 ? 'Download files from source' : 'Re-check source for files';
|
||
} else {
|
||
dlBtn.style.display = 'none';
|
||
}
|
||
|
||
// Tags / projects / print settings
|
||
renderDetailTags(model);
|
||
const ps = model.projects || [];
|
||
document.getElementById('detailProjects').innerHTML = ps.length
|
||
? ps.map((p) => `<div>${escapeHtml(p.name)}</div>`).join('') : 'Not in any collection';
|
||
const settings = model.print_settings || {};
|
||
const settingRows = Object.entries(settings).filter(([, v]) => v);
|
||
document.getElementById('detailPrintSettings').innerHTML = settingRows.length
|
||
? settingRows.map(([k, v]) => `<div><strong>${escapeHtml(k)}:</strong> ${escapeHtml(String(v))}</div>`).join('')
|
||
: 'No print settings';
|
||
|
||
// File list (+ a "download all as zip" action when there's more than one file)
|
||
const fileInfoEl = document.getElementById('detailFileInfo');
|
||
if (model.files.length === 0) {
|
||
const autoSite = ['thingiverse', 'printables', 'makerworld'].includes(model.source_site);
|
||
const hint = !model.source_url
|
||
? 'No files yet — use “Add files” below.'
|
||
: autoSite
|
||
? 'No files yet — use “Download files from source” below.'
|
||
: 'Manual source — download the files from the source page, then use “Add files” below.';
|
||
fileInfoEl.innerHTML = `<div class="lic-note">${hint}</div>`;
|
||
} else {
|
||
const downloadAll = model.files.length > 1
|
||
? `<a class="btn btn-secondary btn-sm download-all" href="/api/models/${model.id}/download">Download all ${model.files.length} files (.zip)</a>`
|
||
: '';
|
||
fileInfoEl.innerHTML = downloadAll + model.files.map((f) => `
|
||
<div class="file-row"${dragAttrs(`/api/files/${f.id}/raw`, f.originalName)}>
|
||
<span>${escapeHtml(f.originalName)} <em>(${formatFileSize(f.size)}${f.volume ? `, ${Number(f.volume).toFixed(2)} cm³` : ''})</em></span>
|
||
<span class="row-actions">
|
||
${sliceButtonHtml('file', f.id, f.format)}
|
||
<a href="/api/files/${f.id}/raw" download draggable="false">download</a>
|
||
</span>
|
||
</div>
|
||
${ARCHIVE_FORMATS.includes(f.format) ? `<div class="entry-list" data-entries-for="${f.id}"><div class="entry-head">Reading archive…</div></div>` : ''}`).join('');
|
||
model.files.filter((f) => ARCHIVE_FORMATS.includes(f.format)).forEach((f) => loadArchiveEntries(model.id, f.id));
|
||
}
|
||
|
||
renderProjectPanels(null);
|
||
|
||
// Viewer tabs: 3D files first, then pictures/PDFs/text (entries inside
|
||
// archives get a tab when opened from the file list).
|
||
const { RENDERABLE, DOCUMENT_FORMATS } = window.ModelViewer;
|
||
detailItems = [
|
||
...model.files.filter((f) => RENDERABLE.includes(f.format)),
|
||
...model.files.filter((f) => DOCUMENT_FORMATS.includes(f.format) && f.format !== 'mtl'),
|
||
].map((f) => ({ kind: 'file', id: f.id, format: f.format, name: f.originalName }));
|
||
renderViewerTabs(detailItems.length ? itemKey(detailItems[0]) : null);
|
||
|
||
fileDetailModal.classList.add('active');
|
||
currentDetailFile = null;
|
||
if (detailItems.length) {
|
||
loadViewer(detailItems[0]);
|
||
} else {
|
||
// A model that's only archives previews a file from inside once they're listed.
|
||
const hasArchive = model.files.some((f) => ARCHIVE_FORMATS.includes(f.format));
|
||
document.getElementById('regenThumbBtn').style.display = 'none';
|
||
window.ModelViewer.render(document.getElementById('detailViewer'), '', 'none', {
|
||
fallbackImage: currentDetailThumb,
|
||
message: hasArchive ? 'Opening a file from the archive…' : 'Nothing here can be previewed yet — add files below.',
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- viewer items
|
||
// An item is something the viewer can show: { kind: 'file' | 'entry', id, format, name, archiveId? }.
|
||
const ARCHIVE_FORMATS = ['zip', '7z', 'rar', 'tar', 'tgz'];
|
||
let detailItems = [];
|
||
let activeItemKey = null;
|
||
const archiveEntryCache = new Map(); // archive file id -> entries
|
||
const itemKey = (item) => `${item.kind}:${item.id}`;
|
||
|
||
function renderViewerTabs(activeKey) {
|
||
if (activeKey !== activeItemKey) hideSlicerNotice();
|
||
activeItemKey = activeKey;
|
||
renderSlicerBar();
|
||
document.getElementById('detailFileTabs').innerHTML = detailItems.map((item) => `
|
||
<button class="file-tab${itemKey(item) === activeKey ? ' active' : ''}" data-key="${itemKey(item)}"${dragAttrs(rawUrlFor(item), item.name)}>${escapeHtml(item.name)}</button>`).join('');
|
||
}
|
||
|
||
function openViewerItem(item) {
|
||
if (!detailItems.some((i) => itemKey(i) === itemKey(item))) detailItems.push(item);
|
||
renderViewerTabs(itemKey(item));
|
||
loadViewer(item);
|
||
}
|
||
|
||
// What the viewer should load: 3MF goes through the server's GLB conversion,
|
||
// documents are served inline, everything else raw.
|
||
function viewerSourceFor(item) {
|
||
const base = `/api/${item.kind === 'entry' ? 'entries' : 'files'}/${item.id}`;
|
||
if (item.format === '3mf') return { url: `${base}/glb${item.plate ? `?plate=${item.plate}` : ''}`, format: 'glb' };
|
||
if (window.ModelViewer.DOCUMENT_FORMATS.includes(item.format)) return { url: `${base}/raw?inline=1`, format: item.format };
|
||
return { url: `${base}/raw`, format: item.format };
|
||
}
|
||
|
||
// Finds files shipped alongside a model by name — OBJ materials, FBX/MTL
|
||
// textures. Entries of the same archive win, then the model's own files.
|
||
function resolverFor(model, archiveEntries = null) {
|
||
const byName = new Map();
|
||
const key = (name) => String(name).split(/[\\/]/).pop().toLowerCase();
|
||
const add = (name, url) => { if (!byName.has(key(name))) byName.set(key(name), url); };
|
||
(archiveEntries || []).forEach((e) => add(e.path, `/api/entries/${e.id}/raw`));
|
||
((model && model.files) || []).forEach((f) => add(f.originalName, `/api/files/${f.id}/raw`));
|
||
return (name) => byName.get(key(name)) || null;
|
||
}
|
||
|
||
// For a model with nothing to preview directly: open the most useful file
|
||
// inside its archive — a slicer project, then other 3D files, then pictures/PDFs.
|
||
const ENTRY_PREVIEW_ORDER = ['3mf', 'stl', 'obj', 'step', 'stp', 'fbx', '3dm', 'jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
|
||
function previewFirstEntry(archiveId, entries) {
|
||
const rank = (e) => (ENTRY_PREVIEW_ORDER.includes(e.format) ? ENTRY_PREVIEW_ORDER.indexOf(e.format) : Infinity);
|
||
const best = [...entries].sort((a, b) => rank(a) - rank(b))[0];
|
||
if (!best || rank(best) === Infinity) {
|
||
window.ModelViewer.render(document.getElementById('detailViewer'), '', 'none',
|
||
{ fallbackImage: currentDetailThumb, message: 'Nothing inside this archive can be previewed.' });
|
||
return;
|
||
}
|
||
openViewerItem({ kind: 'entry', id: best.id, format: best.format, name: best.path.split('/').pop(), archiveId });
|
||
}
|
||
|
||
async function loadArchiveEntries(modelId, fileId) {
|
||
const container = () => (fileDetailModal.dataset.currentModelId === String(modelId)
|
||
? document.querySelector(`[data-entries-for="${fileId}"]`) : null);
|
||
try {
|
||
const res = await fetch(`/api/files/${fileId}/entries`);
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error);
|
||
if (!container()) return;
|
||
if (data.status === 'pending') {
|
||
setTimeout(() => { if (container()) loadArchiveEntries(modelId, fileId); }, 2000);
|
||
return;
|
||
}
|
||
if (data.status === 'error') {
|
||
container().innerHTML = `<div class="entry-head">Couldn't read this archive: ${escapeHtml(data.error || 'unknown error')}</div>`;
|
||
return;
|
||
}
|
||
archiveEntryCache.set(fileId, data.entries);
|
||
const { PREVIEWABLE } = window.ModelViewer;
|
||
container().innerHTML = `<div class="entry-head">${data.entries.length} file${data.entries.length === 1 ? '' : 's'} inside</div>`
|
||
+ data.entries.map((e) => {
|
||
const name = e.path.split('/').pop();
|
||
const folder = e.path.replace(/^\.\//, '').slice(0, -name.length);
|
||
return `
|
||
<div class="entry-row"${dragAttrs(`/api/entries/${e.id}/raw`, name)}>
|
||
<span title="${escapeHtml(e.path)}">${escapeHtml(name)}${folder ? `<span class="entry-folder">${escapeHtml(folder)}</span>` : ''} <em>${formatFileSize(e.size)}</em></span>
|
||
<span class="row-actions">
|
||
${PREVIEWABLE.includes(e.format) && e.format !== 'mtl' ? `<button type="button" class="link-btn" data-view-entry="${e.id}" data-archive="${fileId}">view</button>` : ''}
|
||
${sliceButtonHtml('entry', e.id, e.format, fileId)}
|
||
<a href="/api/entries/${e.id}/raw" download draggable="false">download</a>
|
||
</span>
|
||
</div>`;
|
||
}).join('');
|
||
if (!detailItems.length) previewFirstEntry(fileId, data.entries);
|
||
} catch (err) {
|
||
if (container()) container().innerHTML = `<div class="entry-head">Couldn't list this archive: ${escapeHtml(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- JSON helpers
|
||
async function getJson(url, options) {
|
||
const res = await fetch(url, options);
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||
return data;
|
||
}
|
||
|
||
function postJson(url, body = {}, method = 'POST') {
|
||
return getJson(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||
}
|
||
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
const money = (n) => (n == null ? '—' : n > 0 && n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`);
|
||
const count = (n) => Number(n || 0).toLocaleString();
|
||
const plural = (n, word) => `${count(n)} ${word}${Number(n) === 1 ? '' : 's'}`;
|
||
|
||
// ---------------------------------------------------------------- search by meaning
|
||
// Meaning search asks the server, which ranks models with a local embedding model.
|
||
// Word matches show while it answers.
|
||
const MEANING_MIN_SCORE = 0.55;
|
||
const MEANING_SPREAD = 0.1; // how far below the best match results may go
|
||
let meaningSearch = { query: '', results: null, error: null };
|
||
let meaningPending = null;
|
||
let meaningTimer = null;
|
||
|
||
function syncSearchMode() {
|
||
document.querySelectorAll('#searchMode button').forEach((b) => b.classList.toggle('active', b.dataset.mode === prefs.searchMode));
|
||
searchInput.placeholder = prefs.searchMode === 'meaning'
|
||
? 'Describe what you’re looking for, e.g. “wall hook for headphones” ( / )'
|
||
: 'Search names, tags, notes, files, designers… ( / )';
|
||
}
|
||
|
||
function meaningResults() {
|
||
const q = searchInput.value.trim();
|
||
if (prefs.searchMode !== 'meaning' || !q) return null;
|
||
if (meaningSearch.query === q) return meaningSearch.results; // null after an error: words only
|
||
if (meaningPending !== q) {
|
||
meaningPending = q;
|
||
clearTimeout(meaningTimer);
|
||
meaningTimer = setTimeout(async () => {
|
||
try {
|
||
const data = await getJson(`/api/search?q=${encodeURIComponent(q)}`);
|
||
meaningSearch = { query: q, results: new Map(data.results.map((r) => [r.id, r.score])), error: null };
|
||
} catch (err) {
|
||
meaningSearch = { query: q, results: null, error: err.message };
|
||
}
|
||
meaningPending = null;
|
||
if (searchInput.value.trim() === q) renderModels();
|
||
}, 250);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function searchNote() {
|
||
const q = searchInput.value.trim();
|
||
if (prefs.searchMode !== 'meaning' || !q) return '';
|
||
if (meaningSearch.query !== q) return ' · searching by meaning…';
|
||
return meaningSearch.error ? ' · meaning search unavailable, matching words' : ' · by meaning';
|
||
}
|
||
|
||
// ---------------------------------------------------------------- AI & automatic tags
|
||
let aiPoll = null;
|
||
let aiState = { runs: [] };
|
||
let aiProviders = null;
|
||
const AI_KEY_LINKS = {
|
||
deepseek: 'https://platform.deepseek.com/api_keys',
|
||
openrouter: 'https://openrouter.ai/settings/keys',
|
||
openai: 'https://platform.openai.com/api-keys',
|
||
};
|
||
const RUN_STATUS = { running: 'Tagging', paused: 'Paused', stopped: 'Stopped', done: 'Finished', failed: 'Stopped' };
|
||
|
||
function renderDetailTags(model) {
|
||
const sources = model.tag_sources || {};
|
||
const titles = { ai: 'Added by AI', rule: 'Added by an automatic tag rule' };
|
||
document.getElementById('detailTags').innerHTML = (model.tags && model.tags.length)
|
||
? model.tags.map((t) => {
|
||
const source = sources[t];
|
||
return `<span class="tag${titles[source] ? ` tag-${source}` : ''}"${titles[source] ? ` title="${titles[source]}"` : ''}>${escapeHtml(t)}</span>`;
|
||
}).join('')
|
||
: 'No tags';
|
||
}
|
||
|
||
function renderRenamedNote(model) {
|
||
const el = document.getElementById('detailRenamed');
|
||
el.hidden = !model.original_name;
|
||
el.innerHTML = model.original_name
|
||
? `✦ Renamed by AI from “${escapeHtml(model.original_name)}”. <button type="button" class="link-btn" id="undoRenameBtn">Undo</button>`
|
||
: '';
|
||
}
|
||
|
||
async function handleUndoRename() {
|
||
const id = fileDetailModal.dataset.currentModelId;
|
||
const model = findModel(id);
|
||
if (!model || !model.original_name) return;
|
||
try {
|
||
const updated = await postJson(`/api/models/${id}`, { name: model.original_name }, 'PUT');
|
||
replaceModel(updated);
|
||
document.getElementById('detailFileName').textContent = updated.name;
|
||
renderRenamedNote(updated);
|
||
renderModels();
|
||
} catch (err) {
|
||
alert(`Couldn’t undo the rename: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async function syncAiSettingsHints() {
|
||
try {
|
||
if (!aiProviders) aiProviders = (await getJson('/api/ai/status')).providers;
|
||
} catch { return; }
|
||
const key = document.getElementById('aiProvider').value;
|
||
const preset = aiProviders[key] || {};
|
||
const prices = preset.prices || {};
|
||
document.getElementById('aiModel').placeholder = preset.model
|
||
|| (key === 'openrouter' ? 'provider/model, from openrouter.ai/models' : key === 'custom' ? 'e.g. llava' : 'Model name');
|
||
document.getElementById('aiBaseUrl').placeholder = preset.baseUrl || 'e.g. http://localhost:11434/v1';
|
||
document.getElementById('aiPriceInput').placeholder = prices.input != null ? String(prices.input) : 'optional';
|
||
document.getElementById('aiPriceOutput').placeholder = prices.output != null ? String(prices.output) : 'optional';
|
||
const link = document.getElementById('aiKeyLink');
|
||
link.hidden = !AI_KEY_LINKS[key];
|
||
if (AI_KEY_LINKS[key]) {
|
||
link.href = AI_KEY_LINKS[key];
|
||
link.textContent = `Get your ${preset.label} key.`;
|
||
}
|
||
}
|
||
|
||
async function handleAiTest() {
|
||
const out = document.getElementById('aiTestResult');
|
||
const btn = document.getElementById('aiTestBtn');
|
||
out.className = 'form-hint ai-test-result';
|
||
out.textContent = 'Saving and asking the AI to tag a sample model…';
|
||
btn.disabled = true;
|
||
try {
|
||
await postJson('/api/settings', settingsPayload(), 'PUT');
|
||
const r = await postJson('/api/ai/test');
|
||
out.classList.add('is-ok');
|
||
out.innerHTML = `✓ Working: <strong>${escapeHtml(r.model)}</strong> answered in ${(r.ms / 1000).toFixed(1)} s with
|
||
<em>${r.tags.map(escapeHtml).join(', ') || 'no tags'}</em>. Images: ${escapeHtml(r.images)}.
|
||
${r.cost != null ? `That cost ${money(r.cost)}.` : ''}`;
|
||
} catch (err) {
|
||
out.classList.add('is-error');
|
||
out.textContent = `✗ ${err.message}`;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// Tags the open model on its own, then refreshes its tags and description.
|
||
async function handleAiTagModel() {
|
||
const id = Number(fileDetailModal.dataset.currentModelId);
|
||
const btn = document.getElementById('aiTagBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Tagging…';
|
||
try {
|
||
let run = await postJson('/api/ai/runs', { scope: 'ids', ids: [id] });
|
||
const started = Date.now();
|
||
while (run.status === 'running') {
|
||
if (Date.now() - started > 180000) throw new Error('It’s taking a while; follow it on the AI & auto-tags page.');
|
||
await sleep(1500);
|
||
run = await getJson(`/api/ai/runs/${run.id}`);
|
||
}
|
||
const item = (run.recent || []).find((i) => i.model_id === id);
|
||
if (run.error) throw new Error(run.error);
|
||
if (item && item.status === 'error') throw new Error(item.error);
|
||
await loadData();
|
||
const model = findModel(id);
|
||
if (model && String(fileDetailModal.dataset.currentModelId) === String(id)) {
|
||
renderDetailTags(model);
|
||
document.getElementById('detailDescription').textContent = model.description || 'No description';
|
||
document.getElementById('detailFileName').textContent = model.name;
|
||
renderRenamedNote(model);
|
||
}
|
||
} catch (err) {
|
||
alert(`AI tagging didn’t work: ${err.message}`);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = '✦ Tag with AI';
|
||
}
|
||
}
|
||
|
||
function aiPanelHtml() {
|
||
return `
|
||
<section class="ai-card">
|
||
<div class="ai-card-head"><h3>AI tags & descriptions</h3></div>
|
||
<p class="form-hint">Each model's name, file names<span id="aiImagesNote"></span> go to your AI provider, which
|
||
suggests tags and a short description. Tags are only ever added. Descriptions are filled in where empty,
|
||
unless you choose to replace them.</p>
|
||
<div id="aiSetup"></div>
|
||
<form id="aiStartForm" class="ai-start">
|
||
<div class="ai-scope">
|
||
<label><input type="radio" name="aiScope" value="untagged" checked> Models not tagged by AI yet
|
||
<span class="count-inline" id="aiUntaggedCount"></span></label>
|
||
<label><input type="radio" name="aiScope" value="all"> All models
|
||
<span class="count-inline" id="aiAllCount"></span></label>
|
||
</div>
|
||
<fieldset class="ai-tasks">
|
||
<legend>What to do</legend>
|
||
<label><input type="checkbox" id="aiDescribe" checked> Add tags and fill in empty descriptions</label>
|
||
<label class="ai-subtask"><input type="checkbox" id="aiOverwrite"> …and replace descriptions that are already filled in</label>
|
||
<label><input type="checkbox" id="aiRename"> Give models with unclear names a clearer one (undo on the model's page)</label>
|
||
<label><input type="checkbox" id="aiCollections"> Suggest collections for you to review</label>
|
||
</fieldset>
|
||
<label>Stop when the run has cost
|
||
<span class="money-input">$<input type="number" id="aiMaxCost" min="0" step="0.01" placeholder="no limit"></span></label>
|
||
<div class="ai-start-row">
|
||
<button type="submit" class="btn btn-primary" id="aiStartBtn">Start tagging</button>
|
||
<span id="aiEstimate" class="form-hint"></span>
|
||
</div>
|
||
</form>
|
||
<div id="aiRuns" class="ai-runs"></div>
|
||
</section>
|
||
<section class="ai-card">
|
||
<div class="ai-card-head"><h3>Suggested collections</h3></div>
|
||
<p class="form-hint">Ideas from runs with “Suggest collections” ticked. Nothing changes until you accept one.</p>
|
||
<div id="aiSuggestions" class="form-hint">Loading…</div>
|
||
</section>
|
||
<section class="ai-card">
|
||
<div class="ai-card-head"><h3>Search by meaning</h3></div>
|
||
<p class="form-hint">Switch the search box to <strong>Meaning</strong> to find models by what they are, so
|
||
“wall hook” also finds a “coat hanger bracket”. It runs on your server with a small built-in model:
|
||
free, and nothing is sent anywhere.</p>
|
||
<div id="aiSearch" class="form-hint"></div>
|
||
</section>
|
||
<section class="ai-card" id="aiRulesCard"></section>`;
|
||
}
|
||
|
||
function runHtml(run, detail) {
|
||
const processed = run.done + run.failed;
|
||
const pct = run.total ? Math.round((processed / run.total) * 100) : 0;
|
||
const options = run.options || {};
|
||
const cost = options.pricesKnown ? money(run.cost_usd) : 'cost unknown (add prices in Settings)';
|
||
const canStop = run.status === 'running' || run.status === 'paused';
|
||
// 'done' too: a run that finished with failures still has something to retry.
|
||
const canResume = ['paused', 'stopped', 'failed', 'done'].includes(run.status) && (processed < run.total || run.failed > 0);
|
||
const feed = detail && detail.recent.length ? `
|
||
<ul class="ai-feed">${detail.recent.map((i) => `
|
||
<li>
|
||
<button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.name || `Model ${i.model_id}`)}</button>
|
||
${i.renamed_to ? `<span class="renamed-from">was “${escapeHtml(i.renamed_from)}”</span>` : ''}
|
||
${i.status === 'done'
|
||
? (i.tags || []).map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join('')
|
||
+ ((i.collections || []).length ? `<span class="suggested-collections">→ ${i.collections.map(escapeHtml).join(', ')}</span>` : '')
|
||
: `<span class="ai-run-error">${escapeHtml(i.error || 'failed')}</span>`}
|
||
</li>`).join('')}
|
||
</ul>` : '';
|
||
// Failures are listed in full: the feed above only holds the last dozen items,
|
||
// so anything that failed early would otherwise be impossible to track down.
|
||
const failed = detail && (detail.failures || []).length ? `
|
||
<details class="ai-failures">
|
||
<summary>${plural(detail.failures.length, 'model')} failed</summary>
|
||
<ul class="ai-feed">${detail.failures.map((i) => `
|
||
<li>
|
||
<button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.name || `Model ${i.model_id}`)}</button>
|
||
<span class="ai-run-error">${escapeHtml(i.error || 'failed')}</span>
|
||
</li>`).join('')}</ul>
|
||
</details>` : '';
|
||
return `
|
||
<div class="ai-run">
|
||
<div class="ai-run-head">
|
||
<span class="pill pill-${run.status}">${RUN_STATUS[run.status] || escapeHtml(run.status)}</span>
|
||
<span>${count(processed)} of ${plural(run.total, 'model')}${run.failed ? ` · ${count(run.failed)} failed` : ''}</span>
|
||
<span class="ai-run-meta">${escapeHtml(run.provider || '')} · ${escapeHtml(run.model || '')} · ${formatDate(run.created_at)}</span>
|
||
<span class="ai-run-actions">
|
||
${canStop ? `<button type="button" class="btn btn-secondary btn-sm" data-ai="stop" data-id="${run.id}">Stop</button>` : ''}
|
||
${canResume ? `<button type="button" class="btn btn-secondary btn-sm" data-ai="resume" data-id="${run.id}">${run.failed && processed >= run.total ? 'Retry failed' : 'Resume'}</button>` : ''}
|
||
</span>
|
||
</div>
|
||
<div class="progress"><span style="width:${pct}%"></span></div>
|
||
<div class="ai-run-stats">${count(run.prompt_tokens)} tokens in · ${count(run.completion_tokens)} out · ${cost}${options.maxCost ? ` (limit $${options.maxCost})` : ''}</div>
|
||
${run.error ? `<div class="ai-run-error">${escapeHtml(run.error)}</div>` : ''}
|
||
${run.note ? `<div class="form-hint">${escapeHtml(run.note)}</div>` : ''}
|
||
${feed}
|
||
${failed}
|
||
</div>`;
|
||
}
|
||
|
||
function autotagCardHtml(t) {
|
||
const groups = new Map();
|
||
for (const rule of t.builtIn) {
|
||
if (!groups.has(rule.group)) groups.set(rule.group, []);
|
||
groups.get(rule.group).push(rule);
|
||
}
|
||
return `
|
||
<div class="ai-card-head">
|
||
<h3>Automatic tags</h3>
|
||
<label class="switch"><input type="checkbox" id="autotagEnabled"${t.enabled ? ' checked' : ''}> ${t.enabled ? 'On' : 'Off'}</label>
|
||
</div>
|
||
<p class="form-hint">Adds a tag when whole words in a model's name, file names or archive contents match a rule:
|
||
“hook” matches <em>Wall_Hooks.stl</em> but not <em>facebook.stl</em>. Tags you add yourself are never changed.</p>
|
||
<div id="autotagPending" class="form-hint"></div>
|
||
${t.enabled ? '' : `
|
||
<div class="ai-callout">Automatic tagging is off. Tags it already added are kept.
|
||
<button type="button" class="btn btn-secondary btn-sm" data-ai="remove-rule-tags">Remove those tags</button></div>`}
|
||
<div class="rule-groups">${[...groups.entries()].map(([group, rules]) => `
|
||
<div class="rule-group">
|
||
<h4>${escapeHtml(group)}</h4>
|
||
<div class="rule-tags">${rules.map((r) => `<span class="tag" title="Matches: ${escapeHtml(r.phrases.join(', '))}">${escapeHtml(r.tag)}</span>`).join('')}</div>
|
||
</div>`).join('')}
|
||
</div>
|
||
<h4 class="rule-subhead">Your rules</h4>
|
||
<div class="rule-list">${t.rules.length ? t.rules.map((r) => `
|
||
<div class="rule-row">
|
||
<span><strong>${escapeHtml(r.phrase)}</strong> → <span class="tag">${escapeHtml(r.tag)}</span></span>
|
||
<button type="button" class="link-btn" data-ai="delete-rule" data-id="${r.id}">remove</button>
|
||
</div>`).join('') : '<p class="form-hint">None yet. Add a word to look for and the tag it should add.</p>'}
|
||
</div>
|
||
<form id="ruleForm" class="rule-form">
|
||
<input type="text" id="rulePhrase" placeholder="Word or phrase, e.g. benchy" aria-label="Word or phrase" required>
|
||
<span aria-hidden="true">→</span>
|
||
<input type="text" id="ruleTag" placeholder="Tag, e.g. calibration" aria-label="Tag to add" required>
|
||
<button type="submit" class="btn btn-secondary btn-sm">Add rule</button>
|
||
</form>
|
||
<div class="ai-start-row">
|
||
<button type="button" class="btn btn-secondary btn-sm" data-ai="reapply">Re-apply rules to every model</button>
|
||
</div>`;
|
||
}
|
||
|
||
// Builds the page once; later calls (polls, data reloads) only refresh the live
|
||
// parts so nothing you're typing is lost.
|
||
async function renderAiPanel() {
|
||
fileGrid.className = 'ai-panel';
|
||
resultCount.textContent = '';
|
||
if (document.getElementById('aiStartForm')) { refreshAiPanel(); return; }
|
||
fileGrid.innerHTML = '<div class="empty-state"><p>Loading…</p></div>';
|
||
try {
|
||
aiState.tagRules = await getJson('/api/autotag');
|
||
} catch (err) {
|
||
if (selectedScope === 'ai') fileGrid.innerHTML = `<div class="empty-state"><h3>Couldn’t load this page</h3><p>${escapeHtml(err.message)}</p></div>`;
|
||
return;
|
||
}
|
||
if (selectedScope !== 'ai') return;
|
||
fileGrid.innerHTML = aiPanelHtml();
|
||
renderRulesCard();
|
||
renderCollectionSuggestions();
|
||
await refreshAiPanel();
|
||
updateAiEstimate();
|
||
}
|
||
|
||
function renderRulesCard() {
|
||
const card = document.getElementById('aiRulesCard');
|
||
if (card && aiState.tagRules) card.innerHTML = autotagCardHtml(aiState.tagRules);
|
||
renderAiLive();
|
||
}
|
||
|
||
async function refreshAiPanel() {
|
||
clearTimeout(aiPoll);
|
||
if (selectedScope !== 'ai') return;
|
||
let status;
|
||
let runs;
|
||
let detail = null;
|
||
let details = {};
|
||
try {
|
||
[status, runs] = await Promise.all([getJson('/api/ai/status'), getJson('/api/ai/runs')]);
|
||
const focus = runs.find((r) => r.status === 'running') || runs[0];
|
||
if (focus) detail = await getJson(`/api/ai/runs/${focus.id}`);
|
||
if (detail) details[detail.id] = detail;
|
||
// Runs that failed something need their detail too, or the list of failures
|
||
// has nothing to draw: without this only the newest run ever gets one.
|
||
for (const r of runs.slice(0, 5)) {
|
||
if (r.failed > 0 && !details[r.id]) details[r.id] = await getJson(`/api/ai/runs/${r.id}`);
|
||
}
|
||
} catch {
|
||
aiPoll = setTimeout(refreshAiPanel, 5000);
|
||
return;
|
||
}
|
||
if (selectedScope !== 'ai' || !document.getElementById('aiRuns')) return;
|
||
const running = runs.some((r) => r.status === 'running');
|
||
const changed = aiState.expectChange || (aiState.runs || []).some((r) => r.status === 'running');
|
||
aiState = { ...aiState, status, runs, detail, details };
|
||
renderAiLive();
|
||
if (changed && !running) {
|
||
aiState.expectChange = false;
|
||
loadData(); // the run changed tags and descriptions
|
||
renderCollectionSuggestions();
|
||
}
|
||
if (running || status.search.pending || status.autotag.pending) {
|
||
aiPoll = setTimeout(refreshAiPanel, running ? 2000 : 4000);
|
||
}
|
||
}
|
||
|
||
function renderAiLive() {
|
||
const { status, runs = [], detail, details = {} } = aiState;
|
||
if (!status || !document.getElementById('aiRuns')) return;
|
||
const c = status.config;
|
||
document.getElementById('aiSetup').innerHTML = status.problem
|
||
? `<div class="ai-callout is-warning"><strong>Not set up yet.</strong> ${escapeHtml(status.problem)}
|
||
<button type="button" class="btn btn-primary btn-sm" data-ai="settings">Open Settings</button></div>`
|
||
: `<div class="ai-callout">Using <strong>${escapeHtml(c.label)}</strong> · <code>${escapeHtml(c.model)}</code> ·
|
||
thumbnails ${c.sendImages ? 'on' : 'off'}
|
||
<button type="button" class="btn-link" data-ai="settings">Change</button></div>`;
|
||
document.getElementById('aiImagesNote').textContent = c.sendImages ? ' and thumbnail' : '';
|
||
document.getElementById('aiUntaggedCount').textContent = count(status.counts.untagged);
|
||
document.getElementById('aiAllCount').textContent = count(status.counts.all);
|
||
const running = runs.some((r) => r.status === 'running');
|
||
const start = document.getElementById('aiStartBtn');
|
||
start.disabled = Boolean(status.problem) || running;
|
||
start.title = running ? 'A run is going: wait for it, or stop it' : '';
|
||
document.getElementById('aiRuns').innerHTML = runs.length
|
||
? runs.slice(0, 5).map((r) => runHtml(r, details[r.id] || (detail && detail.id === r.id ? detail : null))).join('')
|
||
: '<p class="form-hint">No runs yet.</p>';
|
||
const s = status.search;
|
||
document.getElementById('aiSearch').textContent = s.error
|
||
? `Indexing is paused: ${s.error}`
|
||
: `${plural(s.indexed, 'model')} indexed${s.pending ? `, ${count(s.pending)} to go…` : '.'}`;
|
||
const pending = document.getElementById('autotagPending');
|
||
if (pending) {
|
||
pending.textContent = status.autotag.enabled && status.autotag.pending
|
||
? `Checking ${plural(status.autotag.pending, 'model')}…` : '';
|
||
}
|
||
}
|
||
|
||
async function renderCollectionSuggestions() {
|
||
const el = document.getElementById('aiSuggestions');
|
||
if (!el) return;
|
||
let groups;
|
||
try {
|
||
groups = await getJson('/api/collection-suggestions');
|
||
} catch (err) {
|
||
el.textContent = `Couldn’t load suggestions: ${err.message}`;
|
||
return;
|
||
}
|
||
if (!document.getElementById('aiSuggestions')) return;
|
||
el.className = groups.length ? 'suggestion-list' : 'form-hint';
|
||
el.innerHTML = groups.length
|
||
? groups.map((g) => {
|
||
const n = plural(g.models.length, 'model');
|
||
return `
|
||
<div class="suggestion" data-suggestion="${escapeHtml(g.name)}">
|
||
<div class="suggestion-head">
|
||
<strong>${escapeHtml(g.name)}</strong>
|
||
<span class="form-hint">${g.project_id ? 'your collection' : 'new collection'} · ${n}</span>
|
||
<span class="suggestion-actions">
|
||
<button type="button" class="btn btn-primary btn-sm" data-ai="accept-suggestion">${g.project_id ? `Add ${n}` : `Create & add ${n}`}</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" data-ai="dismiss-suggestion">Dismiss</button>
|
||
</span>
|
||
</div>
|
||
<div class="suggestion-models">${g.models.map((m) => `<button type="button" class="link-btn" data-open-model="${m.id}">${escapeHtml(m.name)}</button>`).join(', ')}</div>
|
||
</div>`;
|
||
}).join('')
|
||
: 'No suggestions waiting. Tick “Suggest collections” when you start a run.';
|
||
}
|
||
|
||
async function updateAiEstimate() {
|
||
const el = document.getElementById('aiEstimate');
|
||
const choice = fileGrid.querySelector('input[name="aiScope"]:checked');
|
||
if (!el || !choice) return;
|
||
try {
|
||
const est = await postJson('/api/ai/estimate', { scope: choice.value });
|
||
el.textContent = !est.count
|
||
? 'Nothing to tag.'
|
||
: est.cost == null
|
||
? `${plural(est.count, 'model')}. Add prices in Settings to see what it will cost.`
|
||
: `Roughly ${money(est.cost)} for ${plural(est.count, 'model')}.`;
|
||
} catch {
|
||
el.textContent = '';
|
||
}
|
||
}
|
||
|
||
async function handleAiStart(e) {
|
||
e.preventDefault();
|
||
const scope = fileGrid.querySelector('input[name="aiScope"]:checked').value;
|
||
const maxCost = document.getElementById('aiMaxCost').value;
|
||
const checked = (id) => document.getElementById(id).checked;
|
||
if (!checked('aiDescribe') && !checked('aiRename') && !checked('aiCollections')) {
|
||
alert('Choose at least one thing for the AI to do.');
|
||
return;
|
||
}
|
||
const btn = document.getElementById('aiStartBtn');
|
||
btn.disabled = true;
|
||
try {
|
||
await postJson('/api/ai/runs', {
|
||
scope,
|
||
describe: checked('aiDescribe'),
|
||
overwriteDescriptions: checked('aiDescribe') && checked('aiOverwrite'),
|
||
renameUnclear: checked('aiRename'),
|
||
suggestCollections: checked('aiCollections'),
|
||
maxCost: maxCost ? Number(maxCost) : null,
|
||
});
|
||
aiState.expectChange = true;
|
||
await refreshAiPanel();
|
||
updateAiEstimate();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function handleAiPanelClick(e) {
|
||
const open = e.target.closest('[data-open-model]');
|
||
if (open) { openModelDetail(Number(open.dataset.openModel)); return; }
|
||
const watchBtn = e.target.closest('[data-watch]');
|
||
if (watchBtn) { handleWatchAction(watchBtn); return; }
|
||
const btn = e.target.closest('[data-ai]');
|
||
if (!btn) return;
|
||
const id = btn.dataset.id;
|
||
try {
|
||
switch (btn.dataset.ai) {
|
||
case 'settings':
|
||
await openSettingsModal();
|
||
document.getElementById('aiSettings').scrollIntoView({ block: 'start' });
|
||
break;
|
||
case 'stop':
|
||
btn.disabled = true;
|
||
await postJson(`/api/ai/runs/${id}/stop`);
|
||
aiState.expectChange = true;
|
||
await refreshAiPanel();
|
||
break;
|
||
case 'resume':
|
||
btn.disabled = true;
|
||
await postJson(`/api/ai/runs/${id}/resume`);
|
||
await refreshAiPanel();
|
||
break;
|
||
case 'accept-suggestion': {
|
||
btn.disabled = true;
|
||
const result = await postJson('/api/collection-suggestions/accept', { name: btn.closest('[data-suggestion]').dataset.suggestion });
|
||
await loadData(); // the sidebar gains the collection
|
||
renderCollectionSuggestions();
|
||
resultCount.textContent = `Added ${plural(result.added, 'model')} to ${result.project.name}`;
|
||
break;
|
||
}
|
||
case 'dismiss-suggestion':
|
||
btn.disabled = true;
|
||
await postJson('/api/collection-suggestions/dismiss', { name: btn.closest('[data-suggestion]').dataset.suggestion });
|
||
renderCollectionSuggestions();
|
||
break;
|
||
case 'delete-rule':
|
||
aiState.tagRules = await getJson(`/api/autotag/rules/${id}`, { method: 'DELETE' });
|
||
renderRulesCard();
|
||
loadData();
|
||
break;
|
||
case 'reapply':
|
||
aiState.tagRules = await postJson('/api/autotag/reapply');
|
||
renderRulesCard();
|
||
loadData();
|
||
break;
|
||
case 'remove-rule-tags':
|
||
if (!confirm('Remove every tag that automatic tagging added? Tags you or the AI added stay.')) return;
|
||
aiState.tagRules = await postJson('/api/autotag', { enabled: false, removeTags: true }, 'PUT');
|
||
renderRulesCard();
|
||
loadData();
|
||
break;
|
||
}
|
||
} catch (err) {
|
||
alert(err.message);
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function handleAutotagToggle(enabled) {
|
||
try {
|
||
aiState.tagRules = await postJson('/api/autotag', { enabled }, 'PUT');
|
||
renderRulesCard();
|
||
loadData();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
}
|
||
}
|
||
|
||
async function handleAddRule(e) {
|
||
e.preventDefault();
|
||
const phrase = document.getElementById('rulePhrase').value;
|
||
const tag = document.getElementById('ruleTag').value;
|
||
try {
|
||
aiState.tagRules = await postJson('/api/autotag/rules', { phrase, tag });
|
||
renderRulesCard();
|
||
loadData();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- slicer hand-off
|
||
// A browser can't see which apps are installed, so the slicer is chosen once per
|
||
// browser. Slicers that accept an open-link download the file from a short-lived
|
||
// link; the others get an ordinary download to open from there.
|
||
const SLICEABLE = ['3mf', 'stl', 'obj', 'step', 'stp'];
|
||
const SLICERS = {
|
||
bambu: {
|
||
name: 'Bambu Studio', family: 'bambu', button: 'Open in Bambu Studio',
|
||
blurb: 'Opens the file straight in Bambu Studio.',
|
||
link: (url) => `bambustudioopen://${encodeURIComponent(url)}`,
|
||
hint: 'Bambu Studio asks before opening files that aren’t from MakerWorld; choose Yes.',
|
||
},
|
||
orca: {
|
||
name: 'OrcaSlicer', family: 'orca', button: 'Open in OrcaSlicer',
|
||
blurb: 'Opens the file straight in OrcaSlicer.',
|
||
link: (url) => `orcaslicer://open?file=${encodeURIComponent(url)}`,
|
||
},
|
||
prusa: {
|
||
name: 'PrusaSlicer', family: 'prusa', button: 'Download for PrusaSlicer',
|
||
blurb: 'Downloads the file. PrusaSlicer only opens links from Printables and Thingiverse.',
|
||
link: null,
|
||
},
|
||
cura: {
|
||
name: 'UltiMaker Cura', family: 'cura', button: 'Download for Cura',
|
||
blurb: 'Downloads the file. Cura can’t be opened from a link.',
|
||
link: null,
|
||
},
|
||
custom: {
|
||
name: 'Another slicer', family: null, button: 'Open in slicer',
|
||
blurb: 'Any slicer with its own open-link, using a template you enter.',
|
||
link: (url) => (prefs.slicerTemplate || '').replaceAll('{url}', encodeURIComponent(url)).replaceAll('{rawurl}', url),
|
||
},
|
||
download: {
|
||
name: 'Just download', family: null, button: 'Download file',
|
||
blurb: 'Saves the file so you can open it in any app yourself.',
|
||
link: null,
|
||
},
|
||
};
|
||
const SLICER_FAMILIES = { bambu: 'Bambu Studio', orca: 'OrcaSlicer', prusa: 'PrusaSlicer', cura: 'Cura' };
|
||
|
||
// Which slicer saved a 3MF, from its Application metadata ("BambuStudio-02.01.01.52").
|
||
function slicerFamily(application) {
|
||
const app = String(application || '').toLowerCase().replace(/[\s_-]/g, '');
|
||
if (app.startsWith('bambustudio')) return 'bambu';
|
||
if (app.startsWith('orcaslicer')) return 'orca';
|
||
if (app.startsWith('prusaslicer')) return 'prusa';
|
||
if (app.includes('cura')) return 'cura';
|
||
return null;
|
||
}
|
||
|
||
function validSlicerTemplate(template) {
|
||
return /^[a-z][a-z0-9+.-]*:/i.test(template)
|
||
&& !/^(javascript|data|vbscript|file|blob):/i.test(template)
|
||
&& /\{(raw)?url\}/.test(template);
|
||
}
|
||
|
||
function sliceButtonHtml(kind, id, format, archiveId = '') {
|
||
if (!SLICEABLE.includes(format)) return '';
|
||
return `<button type="button" class="link-btn" data-slice-kind="${kind}" data-slice-id="${id}" data-archive="${archiveId}" title="Open in your slicer">slicer</button>`;
|
||
}
|
||
|
||
// The viewer item for a file or entry, so project info already fetched is reused.
|
||
function handoffItem(kind, id, archiveId) {
|
||
const existing = detailItems.find((i) => i.kind === kind && i.id === id);
|
||
if (existing) return existing;
|
||
if (kind === 'entry') {
|
||
const entry = (archiveEntryCache.get(Number(archiveId)) || []).find((x) => x.id === id);
|
||
return entry && { kind, id, format: entry.format, name: entry.path.split('/').pop(), archiveId: Number(archiveId) };
|
||
}
|
||
const model = findModel(fileDetailModal.dataset.currentModelId);
|
||
const file = model && model.files.find((f) => f.id === id);
|
||
return file && { kind, id, format: file.format, name: file.originalName };
|
||
}
|
||
|
||
function renderSlicerBar() {
|
||
const item = detailItems.find((i) => itemKey(i) === activeItemKey);
|
||
const handle = document.getElementById('dragOutBtn');
|
||
handle.hidden = !(item && CAN_DRAG_OUT);
|
||
if (item) {
|
||
handle.dataset.dragUrl = rawUrlFor(item);
|
||
handle.dataset.dragName = item.name;
|
||
handle.title = `Drag ${item.name} onto your desktop or a folder to save it`;
|
||
}
|
||
const bar = document.getElementById('slicerBar');
|
||
bar.hidden = !(item && SLICEABLE.includes(item.format));
|
||
const slicer = SLICERS[prefs.slicer];
|
||
document.getElementById('openInSlicerBtn').textContent = slicer ? slicer.button : 'Open in slicer';
|
||
document.getElementById('changeSlicerBtn').textContent = slicer ? 'change slicer' : 'choose slicer';
|
||
}
|
||
|
||
function renderSettingsSlicer() {
|
||
const slicer = SLICERS[prefs.slicer];
|
||
document.getElementById('settingsSlicerName').textContent = slicer ? slicer.name : 'no slicer yet';
|
||
}
|
||
|
||
let pendingHandoff = null; // the file to open once a slicer has been chosen
|
||
let noticeActions = {};
|
||
|
||
function showSlicerNotice(text, actions = [], warning = false) {
|
||
const el = document.getElementById('slicerNotice');
|
||
noticeActions = {};
|
||
el.innerHTML = `<div>${escapeHtml(text)}</div>` + (actions.length
|
||
? `<div class="slicer-notice-actions">${actions.map((a, i) => {
|
||
noticeActions[i] = a.run;
|
||
return `<button type="button" class="btn ${a.primary ? 'btn-primary' : 'btn-secondary'} btn-sm" data-notice-action="${i}">${escapeHtml(a.label)}</button>`;
|
||
}).join('')}</div>`
|
||
: '');
|
||
el.classList.toggle('is-warning', warning);
|
||
el.hidden = false;
|
||
}
|
||
|
||
function hideSlicerNotice() {
|
||
const el = document.getElementById('slicerNotice');
|
||
el.hidden = true;
|
||
el.innerHTML = '';
|
||
noticeActions = {};
|
||
}
|
||
|
||
// Follows a link without leaving the page: the OS hands custom schemes to their
|
||
// app, and the file endpoints answer with a download.
|
||
function followLink(href) {
|
||
const a = document.createElement('a');
|
||
a.href = href;
|
||
a.hidden = true;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
}
|
||
|
||
const rawUrlFor = (item) => `/api/${item.kind === 'entry' ? 'entries' : 'files'}/${item.id}/raw`;
|
||
|
||
async function openInSlicer(item, { confirmed = false } = {}) {
|
||
if (!item) return;
|
||
const slicer = SLICERS[prefs.slicer];
|
||
if (!slicer) {
|
||
pendingHandoff = item;
|
||
openSlicerModal();
|
||
return;
|
||
}
|
||
|
||
// Project 3MFs carry the slicer that saved them; moving one to another slicer
|
||
// can lose plates, filaments or profiles, so ask first.
|
||
if (item.format === '3mf' && slicer.family && !confirmed) {
|
||
if (!item.project) {
|
||
const started = Date.now();
|
||
await loadProjectInfo(item);
|
||
// Launching an app needs a recent click; after a slow read, ask for another.
|
||
if (Date.now() - started > 1500) {
|
||
showSlicerNotice(`${item.name} is ready.`, [{ label: slicer.button, primary: true, run: () => openInSlicer(item) }]);
|
||
return;
|
||
}
|
||
}
|
||
const saved = slicerFamily(item.project && item.project.application);
|
||
if (saved && saved !== slicer.family) {
|
||
showSlicerNotice(
|
||
`${item.name} is a ${SLICER_FAMILIES[saved]} project. ${slicer.name} may not keep all of its settings (plates, filaments, print profiles).`,
|
||
[
|
||
{ label: `${slicer.button} anyway`, primary: true, run: () => openInSlicer(item, { confirmed: true }) },
|
||
{ label: 'Download instead', run: () => { hideSlicerNotice(); followLink(rawUrlFor(item)); } },
|
||
],
|
||
true,
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!slicer.link) {
|
||
followLink(rawUrlFor(item));
|
||
showSlicerNotice(`Downloading ${item.name}${slicer.family ? ` — open it in ${slicer.name} from your downloads` : ''}.`);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api/handoff', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ kind: item.kind, id: item.id }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error);
|
||
followLink(slicer.link(new URL(data.path, window.location.origin).href));
|
||
showSlicerNotice(
|
||
`Opening ${item.name} in ${slicer.family ? slicer.name : 'your slicer'}… ${slicer.hint ? `${slicer.hint} ` : ''}Nothing happening? Check the slicer is installed and up to date.`,
|
||
[{ label: 'Download instead', run: () => { hideSlicerNotice(); followLink(rawUrlFor(item)); } }],
|
||
);
|
||
} catch (err) {
|
||
showSlicerNotice(`Couldn’t open this in your slicer: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
function openSlicerModal() {
|
||
const current = prefs.slicer || '';
|
||
document.getElementById('slicerOptions').innerHTML = Object.entries(SLICERS).map(([key, s]) => `
|
||
<label class="slicer-option">
|
||
<input type="radio" name="slicer" value="${key}"${key === current ? ' checked' : ''}>
|
||
<span><strong>${escapeHtml(s.name)}</strong><em>${escapeHtml(s.blurb)}</em></span>
|
||
</label>`).join('');
|
||
document.getElementById('slicerTemplate').value = prefs.slicerTemplate || '';
|
||
document.getElementById('slicerFormError').textContent = '';
|
||
syncSlicerForm();
|
||
slicerModal.classList.add('active');
|
||
}
|
||
|
||
function syncSlicerForm() {
|
||
const choice = document.querySelector('#slicerOptions input:checked');
|
||
document.getElementById('slicerTemplateGroup').hidden = !choice || choice.value !== 'custom';
|
||
}
|
||
|
||
function handleSaveSlicer(e) {
|
||
e.preventDefault();
|
||
const choice = document.querySelector('#slicerOptions input:checked');
|
||
const error = document.getElementById('slicerFormError');
|
||
if (!choice) { error.textContent = 'Pick a slicer.'; return; }
|
||
const template = document.getElementById('slicerTemplate').value.trim();
|
||
if (choice.value === 'custom' && !validSlicerTemplate(template)) {
|
||
error.textContent = 'Enter an app link containing {url}, like myslicer://open?file={url}.';
|
||
return;
|
||
}
|
||
prefs.slicer = choice.value;
|
||
if (choice.value === 'custom') prefs.slicerTemplate = template;
|
||
savePrefs();
|
||
const next = pendingHandoff;
|
||
closeModal(slicerModal);
|
||
renderSlicerBar();
|
||
renderSettingsSlicer();
|
||
if (next) openInSlicer(next); // still inside this click, so the slicer may launch
|
||
}
|
||
|
||
// ---------------------------------------------------------------- drag-out
|
||
// Chromium browsers save a dragged element as a file when it carries DownloadURL
|
||
// ("type:name:url"); the download goes through the browser, session included.
|
||
// Safari and Firefox have no equivalent. Only Chromium has navigator.userAgentData.
|
||
const CAN_DRAG_OUT = 'userAgentData' in navigator;
|
||
|
||
// draggable + data for anything that drags out as a file.
|
||
function dragAttrs(url, name) {
|
||
return CAN_DRAG_OUT ? ` draggable="true" data-drag-url="${escapeHtml(url)}" data-drag-name="${escapeHtml(name)}"` : '';
|
||
}
|
||
function setDragOut(dataTransfer, { url, name }) {
|
||
dataTransfer.effectAllowed = 'copy';
|
||
dataTransfer.setData('DownloadURL', `application/octet-stream:${String(name).replace(/[:\\/]/g, '-')}:${new URL(url, window.location.origin).href}`);
|
||
}
|
||
|
||
// A model drags out as its only file, or as a zip of all of them.
|
||
function modelDragTarget(model) {
|
||
if (!model || !model.files.length) return null;
|
||
if (model.files.length === 1) return { url: `/api/files/${model.files[0].id}/raw`, name: model.files[0].originalName };
|
||
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;
|
||
}
|
||
const yours = model.filaments_status === 'edited';
|
||
const rows = p.slots.map((s, i) => `
|
||
<div class="slot-row">
|
||
<span class="spool-dot" style="background:${escapeHtml(s.colour || '#888888')}"></span>
|
||
<span>Slot ${i + 1} · ${escapeHtml(s.material)}</span>
|
||
<span class="form-hint">${escapeHtml(s.colour || 'any colour')}${(() => {
|
||
const named = s.colour ? paletteName(s.colour, s.material) : null;
|
||
return named ? ` · ${escapeHtml(named)}` : '';
|
||
})()}</span>
|
||
${s.filled
|
||
? `<span class="print-badge ok">${escapeHtml(s.by)}</span>`
|
||
: `<span class="print-badge no">none</span> ${s.nearest
|
||
? `<span class="form-hint">nearest: ${escapeHtml(s.nearest.name)}</span>` : ''}`}
|
||
</div>`).join('');
|
||
el.innerHTML = `<div>${p.ok
|
||
? '<span class="print-badge ok">✔ printable now</span>'
|
||
: `<span class="print-badge no">needs ${p.unfilled.length} colour${p.unfilled.length === 1 ? '' : 's'}</span>`}
|
||
${yours ? '<span class="print-badge yours" title="You set these slots; the project’s own list is different">your list</span>' : ''}</div>
|
||
<div class="slot-list">${rows}</div>
|
||
<div class="lic-note">A spool fills one slot, and its colour has to be close to what the slot asks for.</div>
|
||
<div class="slot-actions">
|
||
<button type="button" class="btn btn-secondary btn-sm" data-slots="edit">Edit slots</button>
|
||
${yours ? '<button type="button" class="btn btn-secondary btn-sm" data-slots="revert">Use the project’s list</button>' : ''}
|
||
</div>`;
|
||
el.querySelector('[data-slots="edit"]').addEventListener('click', () => editSlots(model, p.slots));
|
||
const revert = el.querySelector('[data-slots="revert"]');
|
||
if (revert) revert.addEventListener('click', () => revertSlots(model.id));
|
||
}
|
||
|
||
// Editing turns the list into fields: material and colour per slot, with slots
|
||
// you can add or drop. Saved slots are marked as yours, so the background
|
||
// reader stops overwriting them from the project file.
|
||
function editSlots(model, slots) {
|
||
const el = document.getElementById('detailPrintable');
|
||
// A new slot starts as "any colour": adding one by hand usually means you
|
||
// care about the material, not the shade.
|
||
const rowHtml = (s = { material: 'PLA', colour: null }) => `
|
||
<div class="slot-row slot-edit">
|
||
<input type="text" class="slot-material" value="${escapeHtml(s.material || 'PLA')}" aria-label="Material" placeholder="PLA">
|
||
<input type="color" class="slot-colour" value="${escapeHtml((s.colour || '#ffffff').toLowerCase())}" aria-label="Colour"${s.colour ? '' : ' disabled'}>
|
||
<label class="slot-any" title="Any colour of this material will do">
|
||
<input type="checkbox" class="slot-any-box"${s.colour ? '' : ' checked'}> any colour
|
||
</label>
|
||
<button type="button" class="link-btn" data-slot-drop>remove</button>
|
||
</div>`;
|
||
el.innerHTML = `
|
||
<div class="slot-list" id="slotEditList">${slots.map(rowHtml).join('')}</div>
|
||
<div class="slot-actions">
|
||
<button type="button" class="btn btn-secondary btn-sm" id="slotAdd">Add a slot</button>
|
||
<button type="button" class="btn btn-primary btn-sm" id="slotSave">Save</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="slotCancel">Cancel</button>
|
||
</div>
|
||
<div class="lic-note" id="slotStatus">Colour is matched to your spools; a slot left at a colour you don’t own shows as missing.</div>`;
|
||
|
||
const list = document.getElementById('slotEditList');
|
||
const bindRow = (row) => {
|
||
row.querySelector('[data-slot-drop]').addEventListener('click', () => row.remove());
|
||
const any = row.querySelector('.slot-any-box');
|
||
const colour = row.querySelector('.slot-colour');
|
||
any.addEventListener('change', () => { colour.disabled = any.checked; });
|
||
};
|
||
[...list.children].forEach(bindRow);
|
||
document.getElementById('slotAdd').addEventListener('click', () => {
|
||
list.insertAdjacentHTML('beforeend', rowHtml());
|
||
bindRow(list.lastElementChild);
|
||
});
|
||
document.getElementById('slotCancel').addEventListener('click', () => renderDetailPrintable(findModel(model.id) || model));
|
||
document.getElementById('slotSave').addEventListener('click', async () => {
|
||
const filaments = [...list.children].map((row) => ({
|
||
type: row.querySelector('.slot-material').value.trim(),
|
||
// "any colour" sends nothing, which the matcher reads as any spool of
|
||
// that material.
|
||
colour: row.querySelector('.slot-any-box').checked ? null : row.querySelector('.slot-colour').value,
|
||
})).filter((f) => f.type);
|
||
const status = document.getElementById('slotStatus');
|
||
try {
|
||
await postJson(`/api/models/${model.id}/filaments`, { filaments }, 'PUT');
|
||
await loadData();
|
||
openModelDetail(model.id);
|
||
} catch (err) {
|
||
status.classList.add('is-error');
|
||
status.textContent = err.message;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function revertSlots(id) {
|
||
try {
|
||
await getJson(`/api/models/${id}/filaments`, { method: 'DELETE' });
|
||
await loadData();
|
||
openModelDetail(id);
|
||
} catch (err) {
|
||
alert(err.message);
|
||
}
|
||
}
|
||
|
||
function renderDetailLicense(model) {
|
||
renderDetailPrintable(model);
|
||
const com = model.commercial_use; // true / false / null
|
||
const badge = com === true
|
||
? '<span class="lic-badge ok">Commercial use allowed</span>'
|
||
: com === false
|
||
? '<span class="lic-badge no">Non-commercial only</span>'
|
||
: '<span class="lic-badge unknown">Commercial use unclear</span>';
|
||
document.getElementById('detailLicense').innerHTML =
|
||
`<div>${model.license ? escapeHtml(model.license) : 'Not specified'} ${badge}</div>` +
|
||
'<div class="lic-note">A guess from the license name — always confirm on the source page before selling.</div>';
|
||
}
|
||
|
||
function openEditModal() {
|
||
const id = fileDetailModal.dataset.currentModelId;
|
||
const model = findModel(id);
|
||
if (!model) return;
|
||
editModal.dataset.editId = id;
|
||
|
||
document.getElementById('editName').value = model.name || '';
|
||
document.getElementById('editDescription').value = model.description || '';
|
||
document.getElementById('editTags').value = (model.tags || []).join(', ');
|
||
document.getElementById('editLicense').value = model.license || '';
|
||
|
||
const ps = model.print_settings || {};
|
||
document.getElementById('editMaterial').value = ps.material || '';
|
||
document.getElementById('editLayerHeight').value = ps.layerHeight || '';
|
||
document.getElementById('editInfill').value = ps.infill || '';
|
||
document.getElementById('editSupports').value = ps.supports || '';
|
||
|
||
const current = new Set((model.projects || []).map((p) => String(p.id)));
|
||
document.getElementById('editProjects').innerHTML = projects
|
||
.map((p) => `<option value="${p.id}"${current.has(String(p.id)) ? ' selected' : ''}>${escapeHtml(p.name)}</option>`)
|
||
.join('');
|
||
|
||
editModal.classList.add('active');
|
||
}
|
||
|
||
async function handleEditModel(e) {
|
||
e.preventDefault();
|
||
const id = editModal.dataset.editId;
|
||
const body = {
|
||
name: document.getElementById('editName').value,
|
||
description: document.getElementById('editDescription').value,
|
||
license: document.getElementById('editLicense').value || null,
|
||
tags: splitTags(document.getElementById('editTags').value),
|
||
projects: Array.from(document.getElementById('editProjects').selectedOptions).map((o) => Number(o.value)),
|
||
print_settings: {
|
||
material: document.getElementById('editMaterial').value,
|
||
layerHeight: document.getElementById('editLayerHeight').value,
|
||
infill: document.getElementById('editInfill').value,
|
||
supports: document.getElementById('editSupports').value,
|
||
},
|
||
};
|
||
try {
|
||
const res = await fetch(`/api/models/${id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
closeModal(editModal);
|
||
await loadData();
|
||
openModelDetail(id); // refresh the (still-open) detail view
|
||
} catch (err) {
|
||
alert('Failed to save changes: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function handleDownloadFiles() {
|
||
const id = fileDetailModal.dataset.currentModelId;
|
||
const btn = document.getElementById('downloadFilesBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Downloading… (this can take a moment)';
|
||
try {
|
||
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
|
||
const data = await res.json();
|
||
if (!res.ok && data.reason === 'makerworld_token') {
|
||
resetDownloadFilesBtn(id);
|
||
promptMakerworldToken(data.error, () => retryModelDownload(id));
|
||
return;
|
||
}
|
||
if (!res.ok) throw new Error(data.error);
|
||
await loadData();
|
||
openModelDetail(id);
|
||
if (data.added === 0) alert('No new files were found (the source returned nothing new).');
|
||
} catch (err) {
|
||
alert('Download failed: ' + err.message);
|
||
resetDownloadFilesBtn(id);
|
||
}
|
||
}
|
||
|
||
function resetDownloadFilesBtn(id) {
|
||
const btn = document.getElementById('downloadFilesBtn');
|
||
const model = findModel(id);
|
||
btn.disabled = false;
|
||
btn.textContent = model && model.files.length ? 'Re-check source for files' : 'Download files from source';
|
||
}
|
||
|
||
// ---------------------------------------------------------------- MakerWorld token prompt
|
||
// MakerWorld tokens expire. When one is refused, ask for a fresh one right away and
|
||
// retry what failed — a model's files, or a watched collection.
|
||
// retry: async () => ({ ok, message } | { ok: false, reason, error })
|
||
let tokenPromptRetry = null;
|
||
|
||
function promptMakerworldToken(reason, retry, action = 'Save & download files') {
|
||
tokenPromptRetry = retry;
|
||
document.getElementById('tokenSubmit').textContent = action;
|
||
document.getElementById('tokenReason').textContent = reason;
|
||
document.getElementById('tokenInput').value = '';
|
||
const status = document.getElementById('tokenStatus');
|
||
status.textContent = '';
|
||
status.classList.remove('is-error');
|
||
document.getElementById('tokenSubmit').disabled = false;
|
||
tokenModal.classList.add('active');
|
||
document.getElementById('tokenInput').focus();
|
||
}
|
||
|
||
async function handleTokenSubmit(e) {
|
||
e.preventDefault();
|
||
const token = document.getElementById('tokenInput').value.trim();
|
||
if (!token) return;
|
||
const retry = tokenPromptRetry;
|
||
const status = document.getElementById('tokenStatus');
|
||
const submit = document.getElementById('tokenSubmit');
|
||
const fail = (text) => {
|
||
status.classList.add('is-error');
|
||
status.textContent = text;
|
||
submit.disabled = false;
|
||
};
|
||
submit.disabled = true;
|
||
status.classList.remove('is-error');
|
||
status.textContent = 'Saving your token and trying again… this can take a minute.';
|
||
try {
|
||
await postJson('/api/settings', { makerworld_token: token }, 'PUT');
|
||
} catch (err) {
|
||
fail(`Couldn’t save the token: ${err.message}`);
|
||
return;
|
||
}
|
||
try {
|
||
const result = await retry();
|
||
if (!result.ok) {
|
||
fail(result.reason === 'makerworld_token'
|
||
? 'MakerWorld still didn’t accept that token. Check you copied the whole value of the token cookie while signed in, then try again.'
|
||
: `Your token is saved, but it didn’t work: ${result.error}`);
|
||
return;
|
||
}
|
||
status.textContent = result.message || 'Token saved.';
|
||
setTimeout(() => closeModal(tokenModal), 900);
|
||
} catch (err) {
|
||
fail(`Your token is saved, but it didn’t work: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
// Retries a model's file download after a new token; used by the prompt.
|
||
async function retryModelDownload(id) {
|
||
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) return { ok: false, reason: data.reason, error: data.error };
|
||
await loadData();
|
||
if (fileDetailModal.classList.contains('active') && fileDetailModal.dataset.currentModelId === String(id)) openModelDetail(id);
|
||
return { ok: true, message: data.added ? `Downloaded ${plural(data.added, 'file')}.` : 'Token saved. No new files were found.' };
|
||
}
|
||
|
||
// ---------------------------------------------------------------- filament
|
||
// The spools you have. What a model needs is read from its 3MF in the
|
||
// background, so "what can I print now" compares two lists rather than opening
|
||
// files. Everything here binds straight after rendering — no delegation — so a
|
||
// button that appears always has its handler.
|
||
|
||
function filamentPanelHtml() {
|
||
return `
|
||
<section class="ai-card">
|
||
<div class="ai-card-head"><h3>Filament on hand</h3></div>
|
||
<p class="form-hint">Add the spools you have. Models that came from a Bambu Studio or OrcaSlicer project
|
||
know which filaments they ask for, so Printhive can tell you what's printable without a trip to the shelf.
|
||
Colour isn't matched — a different colour still prints.</p>
|
||
<form id="spoolForm" class="watch-form">
|
||
<input type="text" id="spoolPick" list="spoolPickList" placeholder="Pick a Bambu filament…"
|
||
aria-label="Pick a filament from the maker's list" autocomplete="off">
|
||
<datalist id="spoolPickList"></datalist>
|
||
<input type="text" id="spoolMaterial" placeholder="Material (PLA, PETG…)" aria-label="Material" required>
|
||
<input type="text" id="spoolColour" placeholder="Colour name" aria-label="Colour">
|
||
<input type="color" id="spoolHex" value="#ffffff" aria-label="Colour swatch" title="The spool's colour — matched against what each slot asks for">
|
||
<input type="text" id="spoolBrand" placeholder="Brand" aria-label="Brand">
|
||
<input type="number" id="spoolRemaining" placeholder="Grams left" aria-label="Grams left" min="0" step="10">
|
||
<button type="submit" class="btn btn-primary" id="spoolSubmit">Add spool</button>
|
||
</form>
|
||
<div id="spoolStatus" class="form-hint"></div>
|
||
<div id="spoolList" class="watch-list"></div>
|
||
</section>`;
|
||
}
|
||
|
||
// "<printer serial>/<unit>/<slot>" → "in A1, AMS 1, slot 4" or "on the A1's external holder".
|
||
function slotLabel(slot) {
|
||
const [serial, unit, idx] = String(slot || '').split('/');
|
||
if (unit === undefined) return '';
|
||
const p = printerList.find((x) => x.serial === serial);
|
||
const name = p ? printerName(p) : '';
|
||
if (unit === 'ext') return name ? `on the ${name}’s external holder` : 'on the external holder';
|
||
return `in ${name ? `${name}, ` : ''}${amsUnitLabel(Number(unit))}, slot ${Number(idx) + 1}`;
|
||
}
|
||
|
||
// Printers number AMS units in ranges: 0–15 for an AMS or AMS 2 Pro, 128 and up
|
||
// for an AMS HT, and newer printers (the A2L, for one) put an AMS lite at 16.
|
||
function amsUnitLabel(id) {
|
||
if (id >= 128) return `AMS HT ${id - 127}`;
|
||
if (id >= 16) return `AMS ${id - 15}`;
|
||
return `AMS ${id + 1}`;
|
||
}
|
||
|
||
function spoolHtml(s) {
|
||
const size = s.total_g ? `${count(s.total_g)} g spool` : '';
|
||
const left = s.remaining_g == null ? 'amount left unknown' : `${count(s.remaining_g)} g left`;
|
||
// What the printer read off the spool's RFID tag.
|
||
const tag = [
|
||
s.tag_uid && `RFID tag <code>${escapeHtml(s.tag_uid)}</code>`,
|
||
s.product_code && `product <code>${escapeHtml(s.product_code)}</code>`,
|
||
s.code && `filament code <code>${escapeHtml(s.code)}</code>`,
|
||
].filter(Boolean).join(' · ');
|
||
return `
|
||
<div class="watch spool" data-spool-id="${s.id}">
|
||
<div class="watch-head">
|
||
<span class="spool-dot" style="background:${escapeHtml(s.colour_hex || '#888')}"></span>
|
||
<strong>${escapeHtml(s.material)}</strong>
|
||
<span>${escapeHtml(s.colour || '')}</span>
|
||
<span class="form-hint">${escapeHtml(s.brand || '')}${s.ams_slot ? ` · ${escapeHtml(slotLabel(s.ams_slot))}` : ''}${
|
||
s.rfid_uuid ? ' · <span title="Found by the printer from its RFID tag">RFID</span>' : ''}</span>
|
||
<span class="watch-actions">
|
||
<label class="form-hint">grams left
|
||
<input type="number" class="spool-remaining" value="${s.remaining_g ?? ''}" min="0" step="10" aria-label="Grams left">
|
||
</label>
|
||
<button type="button" class="btn btn-secondary btn-sm" data-spool="remove">Remove</button>
|
||
</span>
|
||
</div>
|
||
<div class="watch-meta">${escapeHtml([size, left].filter(Boolean).join(' · '))}</div>
|
||
${tag ? `<div class="watch-meta spool-tag">${tag}</div>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
async function renderFilamentPanel() {
|
||
fileGrid.className = 'ai-panel';
|
||
resultCount.textContent = '';
|
||
if (!document.getElementById('spoolForm')) {
|
||
fileGrid.innerHTML = filamentPanelHtml();
|
||
document.getElementById('spoolForm').addEventListener('submit', handleAddSpool);
|
||
|
||
// Picking a known filament fills in the rest, so nobody types hex by hand.
|
||
const pick = document.getElementById('spoolPick');
|
||
document.getElementById('spoolPickList').innerHTML = palette
|
||
.map((f) => `<option value="${escapeHtml(`${f.material} ${f.type} — ${f.name}${f.code ? ` (${f.code})` : ''}`)}"></option>`)
|
||
.join('');
|
||
pick.addEventListener('input', () => {
|
||
const chosen = palette.find((f) => `${f.material} ${f.type} — ${f.name}${f.code ? ` (${f.code})` : ''}` === pick.value);
|
||
if (!chosen) return;
|
||
document.getElementById('spoolMaterial').value = chosen.material;
|
||
document.getElementById('spoolColour').value = chosen.name;
|
||
document.getElementById('spoolHex').value = (chosen.colours[0] || '#ffffff').toLowerCase();
|
||
pick.dataset.code = chosen.code || '';
|
||
});
|
||
}
|
||
await refreshSpools();
|
||
}
|
||
|
||
async function refreshSpools() {
|
||
if (selectedScope !== 'filament') return;
|
||
const list = document.getElementById('spoolList');
|
||
if (!list) return;
|
||
let data;
|
||
try {
|
||
data = await getJson('/api/filament');
|
||
} catch (err) {
|
||
list.innerHTML = `<p class="form-hint">Couldn’t load your spools: ${escapeHtml(err.message)}</p>`;
|
||
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
|
||
? data.spools.map(spoolHtml).join('')
|
||
+ (data.pending ? `<p class="form-hint">${plural(data.pending, 'model')} still to be read for filament.</p>` : '')
|
||
: '<p class="form-hint">No spools yet. Add what’s on the shelf above.</p>';
|
||
|
||
// Bound here, on the elements just drawn, so nothing depends on delegation.
|
||
for (const row of list.querySelectorAll('[data-spool-id]')) {
|
||
const id = Number(row.dataset.spoolId);
|
||
row.querySelector('[data-spool="remove"]').addEventListener('click', () => removeSpool(id, row));
|
||
row.querySelector('.spool-remaining').addEventListener('change', (e) => setSpoolRemaining(id, e.target.value));
|
||
}
|
||
}
|
||
|
||
async function handleAddSpool(e) {
|
||
e.preventDefault();
|
||
const status = document.getElementById('spoolStatus');
|
||
const submit = document.getElementById('spoolSubmit');
|
||
const material = document.getElementById('spoolMaterial').value.trim();
|
||
if (!material) return;
|
||
submit.disabled = true;
|
||
status.classList.remove('is-error');
|
||
status.textContent = '';
|
||
try {
|
||
const remaining = document.getElementById('spoolRemaining').value;
|
||
const pick = document.getElementById('spoolPick');
|
||
await postJson('/api/filament', {
|
||
material,
|
||
colour: document.getElementById('spoolColour').value.trim(),
|
||
colour_hex: document.getElementById('spoolHex').value,
|
||
brand: document.getElementById('spoolBrand').value.trim(),
|
||
remaining_g: remaining === '' ? null : Number(remaining),
|
||
code: (pick && pick.dataset.code) || null, // what the AMS reports, when picked from the list
|
||
});
|
||
document.getElementById('spoolForm').reset();
|
||
document.getElementById('spoolMaterial').focus();
|
||
await refreshSpools();
|
||
} catch (err) {
|
||
status.classList.add('is-error');
|
||
status.textContent = err.message;
|
||
} finally {
|
||
submit.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function removeSpool(id, row) {
|
||
if (!confirm('Remove this spool?')) return;
|
||
try {
|
||
await getJson(`/api/filament/${id}`, { method: 'DELETE' });
|
||
await refreshSpools();
|
||
} catch (err) {
|
||
const status = document.getElementById('spoolStatus');
|
||
status.classList.add('is-error');
|
||
status.textContent = err.message;
|
||
if (row) row.classList.remove('is-busy');
|
||
}
|
||
}
|
||
|
||
async function setSpoolRemaining(id, value) {
|
||
const status = document.getElementById('spoolStatus');
|
||
try {
|
||
await postJson(`/api/filament/${id}`, { remaining_g: value === '' ? null : Number(value) }, 'PUT');
|
||
status.classList.remove('is-error');
|
||
status.textContent = 'Saved.';
|
||
await refreshSpools();
|
||
} catch (err) {
|
||
status.classList.add('is-error');
|
||
status.textContent = err.message;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- printers
|
||
// Each printer is a card: a drawing of its model, a nickname, and how to reach
|
||
// it. Access codes are sent to the server but never come back — a card only
|
||
// knows whether one is stored.
|
||
// A function, not a constant, so it can be used before this line has run.
|
||
function printerModels() {
|
||
return window.PRINTER_MODELS || { models: [], amsTypes: [], find: () => null, art: () => '' };
|
||
}
|
||
|
||
function setCount(id, n) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = n;
|
||
}
|
||
|
||
// The nickname when there is one, otherwise the model.
|
||
function printerName(p) {
|
||
return (p.nickname || '').trim() || p.model || (p.host ? `Printer at ${p.host}` : 'Printer');
|
||
}
|
||
|
||
// The first AMS unit's four slots, in order, for colouring in the drawing.
|
||
function drawnSlots(p) {
|
||
const units = (p.loaded || []).filter((t) => t.unit != null);
|
||
if (!units.length) return [];
|
||
const first = Math.min(...units.map((t) => t.unit));
|
||
const slots = [];
|
||
for (const t of units) if (t.unit === first && t.slot >= 0 && t.slot < 4) slots[t.slot] = t;
|
||
return slots;
|
||
}
|
||
|
||
// What's in each slot, as last reported, under the drawing.
|
||
function loadedHtml(p) {
|
||
if (!p.loaded) return p.id ? '<p class="form-hint">Nothing reported yet — “Test & sync” to read it.</p>' : '';
|
||
// A tagged spool is Bambu's, so its colour has a name: from the tracked spool,
|
||
// or the Bambu colour it matches. An untagged one could be any brand, so it
|
||
// gets the colour the printer reports and nothing more.
|
||
const colourName = (t) => {
|
||
if (!t.tagged) return t.colour || '';
|
||
const s = spools.find((x) => x.ams_slot === `${p.serial}/${t.unit == null ? 'ext' : t.unit}/${t.slot == null ? 0 : t.slot}`);
|
||
return (s && s.colour) || (t.colour ? paletteName(t.colour, t.material) : '') || '';
|
||
};
|
||
const chip = (t, label) => {
|
||
const what = t.empty ? 'empty' : `${escapeHtml([t.material || '?', colourName(t)].filter(Boolean).join(' '))}${t.tagged ? '' : ' <span title="No RFID tag, so not tracked on the Filament page">(untagged)</span>'}`;
|
||
const dot = t.empty
|
||
? '<span class="spool-dot is-empty"></span>'
|
||
: `<span class="spool-dot" style="background:${/^#[0-9a-f]{6}$/i.test(t.colour || '') ? t.colour : '#888888'}"></span>`;
|
||
return `<li title="${escapeHtml(label)}">${dot}<span class="loaded-slot">${escapeHtml(label)}</span> ${what}</li>`;
|
||
};
|
||
const rows = [...p.loaded]
|
||
.sort((a, b) => (a.unit ?? 1e9) - (b.unit ?? 1e9) || (a.slot ?? 0) - (b.slot ?? 0))
|
||
.map((t) => chip(t, t.unit == null ? 'External' : `${amsUnitLabel(t.unit)} · ${(t.slot ?? 0) + 1}`));
|
||
const when = p.liveLoaded ? '<p class="form-hint">Live from the printer</p>' : p.loaded_at ? `<p class="form-hint">As of ${escapeHtml(new Date(p.loaded_at).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}</p>` : '';
|
||
return rows.length ? `<ul class="loaded-list">${rows.join('')}</ul>${when}` : `<p class="form-hint">Nothing loaded.</p>${when}`;
|
||
}
|
||
|
||
function printerCardHtml(p) {
|
||
const key = p.id || 'new';
|
||
const models = ['<option value="">Choose a model…</option>']
|
||
.concat(printerModels().models.map((m) => `<option value="${escapeHtml(m.id)}"${m.id === p.model ? ' selected' : ''}>${escapeHtml(m.id)}</option>`))
|
||
.join('');
|
||
const amsTypes = printerModels().amsTypes
|
||
.map((a) => `<option value="${a.id}"${a.id === p.ams_type ? ' selected' : ''}>${escapeHtml(a.label)}</option>`)
|
||
.join('');
|
||
return `
|
||
<section class="ai-card printer-card" data-printer-id="${key}">
|
||
<div class="printer-side">
|
||
<div class="printer-art" aria-hidden="true">${printerModels().art(p.model, drawnSlots(p))}</div>
|
||
${loadedHtml(p)}
|
||
</div>
|
||
<form class="printer-form" autocomplete="off">
|
||
<h3 class="printer-title">${escapeHtml(p.id ? printerName(p) : 'New printer')}</h3>
|
||
${p.id ? '<div class="printer-status" aria-live="polite"></div>' : ''}
|
||
${p.id && !p.model ? '<p class="form-hint printer-ask">Which model is this? Pick it below and save.</p>' : ''}
|
||
<label>Nickname
|
||
<input type="text" name="nickname" maxlength="60" value="${escapeHtml(p.nickname || '')}"
|
||
placeholder="${escapeHtml(p.model || 'Leave blank to use the model')}">
|
||
</label>
|
||
<label>Model
|
||
<select name="model">${models}</select>
|
||
</label>
|
||
<label>AMS
|
||
<select name="ams_type">${p.ams_type ? '' : '<option value="" selected>Choose…</option>'}${amsTypes}</select>
|
||
</label>
|
||
<label>Address
|
||
<input type="text" name="host" maxlength="255" value="${escapeHtml(p.host || '')}" placeholder="192.168.1.50">
|
||
</label>
|
||
<label>Serial number
|
||
<input type="text" name="serial" maxlength="40" value="${escapeHtml(p.serial || '')}" placeholder="From the printer’s screen">
|
||
</label>
|
||
<label>Access code
|
||
<input type="password" name="access_code" maxlength="40" autocomplete="new-password"
|
||
placeholder="${p.codeSet ? 'Stored — type to replace' : 'From the printer’s network settings'}">
|
||
</label>
|
||
<div class="printer-actions">
|
||
<button type="submit" class="btn btn-primary btn-sm">${p.id ? 'Save' : 'Add printer'}</button>
|
||
${p.id ? '<button type="button" class="btn btn-secondary btn-sm" data-printer="test">Test & sync</button>' : ''}
|
||
<button type="button" class="btn btn-secondary btn-sm" data-printer="${p.id ? 'remove' : 'cancel'}">${p.id ? 'Remove' : 'Cancel'}</button>
|
||
</div>
|
||
<div class="form-hint ai-test-result printer-result"></div>
|
||
</form>
|
||
</section>`;
|
||
}
|
||
|
||
async function renderPrintersPanel() {
|
||
fileGrid.className = 'ai-panel';
|
||
resultCount.textContent = '';
|
||
try {
|
||
printerList = await getJson('/api/printers');
|
||
} catch (err) {
|
||
fileGrid.innerHTML = `<div class="empty-state"><h3>Couldn’t load your printers</h3><p>${escapeHtml(err.message)}</p></div>`;
|
||
return;
|
||
}
|
||
setCount('printerCount', printerList.length);
|
||
fileGrid.innerHTML = `
|
||
<section class="ai-card">
|
||
<div class="ai-card-head">
|
||
<h3>Your printers</h3>
|
||
<button type="button" class="btn btn-primary btn-sm" id="addPrinterBtn">Add printer</button>
|
||
</div>
|
||
<p class="form-hint">Printhive stays connected to each printer on your network and shows what it’s doing as it
|
||
happens. It only listens — it never sends a print or changes a setting — so this works in cloud mode too. When
|
||
a spool goes in or comes out, the Filament page follows within seconds. The address, serial number and access code are on the printer’s screen under network settings.</p>
|
||
</section>
|
||
<div id="printerCards" class="printer-cards">
|
||
${printerList.length ? printerList.map(printerCardHtml).join('')
|
||
: '<p class="form-hint">No printers yet — add one to get started.</p>'}
|
||
</div>`;
|
||
document.getElementById('addPrinterBtn').addEventListener('click', addPrinterCard);
|
||
document.querySelectorAll('.printer-card').forEach(bindPrinterCard);
|
||
pollPrinters();
|
||
}
|
||
|
||
// ------------------------------------------------ live printer status
|
||
let printersPoll = null;
|
||
const PRINTER_POLL_MS = 3000;
|
||
|
||
const PRINT_STATES = {
|
||
IDLE: 'Idle', INIT: 'Starting up', PREPARE: 'Preparing', SLICING: 'Slicing', RUNNING: 'Printing',
|
||
PAUSE: 'Paused', FINISH: 'Finished', FAILED: 'Failed', OFFLINE: 'Offline',
|
||
};
|
||
|
||
function minutesText(min) {
|
||
if (min == null) return '';
|
||
const m = Math.max(0, Math.round(min));
|
||
return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
|
||
}
|
||
|
||
const temp = (now, target) => {
|
||
if (now == null) return null;
|
||
const t = Math.round(now);
|
||
return target ? `${t} / ${Math.round(target)} °C` : `${t} °C`;
|
||
};
|
||
|
||
// These report a placeholder chamber temperature (5 °C) rather than a reading.
|
||
const NO_CHAMBER_SENSOR = ['A1 mini', 'A1', 'A2L', 'P1P', 'P1S'];
|
||
|
||
function printerStatusHtml(snap, model) {
|
||
if (!snap || !snap.live) {
|
||
return '<div class="status-head"><span class="status-pill is-off">Not connected</span> <span class="form-hint">Fill in the address, serial number and access code to connect.</span></div>';
|
||
}
|
||
if (!snap.connected) {
|
||
return `<div class="status-head"><span class="status-pill is-off">Can’t reach it</span> <span class="form-hint">${escapeHtml(snap.error || 'Trying again every 15 seconds — is it switched on?')}</span></div>`;
|
||
}
|
||
const s = snap.status || {};
|
||
if (!s.state) return '<div class="status-head"><span class="status-pill">Connected</span> <span class="form-hint">Waiting for its first report…</span></div>';
|
||
|
||
const active = ['RUNNING', 'PAUSE', 'PREPARE', 'SLICING'].includes(s.state);
|
||
const kind = { RUNNING: 'is-busy', PAUSE: 'is-warn', PREPARE: 'is-busy', FAILED: 'is-bad', FINISH: 'is-ok' }[s.state] || '';
|
||
const head = [`<span class="status-pill ${kind}">${escapeHtml(PRINT_STATES[s.state] || s.state)}</span>`];
|
||
if (s.file && s.state !== 'IDLE') head.push(`<span class="status-file" title="${escapeHtml(s.file)}">${escapeHtml(s.file)}</span>`);
|
||
|
||
const lines = [`<div class="status-head">${head.join(' ')}</div>`];
|
||
if (active && s.progress != null) {
|
||
const done = s.remainingMin ? new Date(Date.now() + s.remainingMin * 60000)
|
||
.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : null;
|
||
const facts = [
|
||
`${s.progress}%`,
|
||
s.remainingMin != null && `${minutesText(s.remainingMin)} left${done ? ` (done about ${done})` : ''}`,
|
||
s.totalLayers ? `layer ${s.layer ?? 0} of ${s.totalLayers}` : null,
|
||
s.speed && s.speed !== 'Standard' ? `${s.speed} speed` : null,
|
||
].filter(Boolean);
|
||
lines.push(`<div class="status-bar"><span style="width:${Math.min(100, Math.max(0, s.progress))}%"></span></div>`);
|
||
lines.push(`<div class="status-facts">${facts.map(escapeHtml).join(' · ')}</div>`);
|
||
}
|
||
const temps = [
|
||
temp(s.nozzle, s.nozzleTarget) && `Nozzle ${temp(s.nozzle, s.nozzleTarget)}`,
|
||
temp(s.bed, s.bedTarget) && `Bed ${temp(s.bed, s.bedTarget)}`,
|
||
s.chamber > 5 && !NO_CHAMBER_SENSOR.includes(model) ? `Chamber ${temp(s.chamber)}` : null,
|
||
].filter(Boolean);
|
||
if (temps.length) lines.push(`<div class="status-facts">${temps.map(escapeHtml).join(' · ')}</div>`);
|
||
|
||
const problems = [];
|
||
if (s.printError) problems.push(`Error ${s.printError.slice(0, 4)}_${s.printError.slice(4)}`);
|
||
for (const h of s.hms || []) problems.push(`HMS ${h}`);
|
||
if (problems.length) {
|
||
lines.push(`<div class="status-problems" title="Look these codes up in Bambu Lab’s wiki or on the printer’s screen">⚠ ${problems.map(escapeHtml).join(' · ')}</div>`);
|
||
}
|
||
return lines.join('');
|
||
}
|
||
|
||
async function pollPrinters() {
|
||
clearTimeout(printersPoll);
|
||
if (selectedScope !== 'printers' || !document.getElementById('printerCards')) return;
|
||
let live = {};
|
||
try {
|
||
live = await getJson('/api/printers/live');
|
||
} catch {
|
||
live = null; // keep what's shown and try again
|
||
}
|
||
if (selectedScope !== 'printers') return;
|
||
if (live) {
|
||
for (const card of document.querySelectorAll('.printer-card[data-printer-id]')) {
|
||
const id = Number(card.dataset.printerId);
|
||
if (!id) continue;
|
||
const snap = live[id];
|
||
const box = card.querySelector('.printer-status');
|
||
if (box) {
|
||
const html = printerStatusHtml(snap, card.querySelector('select[name="model"]').value);
|
||
if (box.dataset.html !== html) { box.innerHTML = html; box.dataset.html = html; }
|
||
}
|
||
// A spool swap shows as soon as the printer reports it.
|
||
if (snap && snap.loaded) {
|
||
const key = JSON.stringify(snap.loaded);
|
||
if (card.dataset.loadedKey !== key) {
|
||
card.dataset.loadedKey = key;
|
||
const p = { ...(printerList.find((x) => x.id === id) || {}), loaded: snap.loaded, liveLoaded: true };
|
||
card.querySelector('.printer-side').innerHTML = `
|
||
<div class="printer-art" aria-hidden="true">${printerModels().art(card.querySelector('select[name="model"]').value, drawnSlots(p))}</div>
|
||
${loadedHtml(p)}`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Paused while the tab is hidden; picked up again when it's shown.
|
||
if (!document.hidden) printersPoll = setTimeout(pollPrinters, PRINTER_POLL_MS);
|
||
}
|
||
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (!document.hidden && selectedScope === 'printers') pollPrinters();
|
||
});
|
||
|
||
function addPrinterCard() {
|
||
const cards = document.getElementById('printerCards');
|
||
if (cards.querySelector('[data-printer-id="new"]')) {
|
||
cards.querySelector('[data-printer-id="new"] input[name="nickname"]').focus();
|
||
return;
|
||
}
|
||
if (!printerList.length) cards.innerHTML = '';
|
||
cards.insertAdjacentHTML('beforeend', printerCardHtml({}));
|
||
const card = cards.lastElementChild;
|
||
bindPrinterCard(card);
|
||
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
card.querySelector('select[name="model"]').focus();
|
||
}
|
||
|
||
function bindPrinterCard(card) {
|
||
const form = card.querySelector('form');
|
||
const id = card.dataset.printerId === 'new' ? null : Number(card.dataset.printerId);
|
||
const out = card.querySelector('.printer-result');
|
||
const say = (text, kind) => {
|
||
out.className = `form-hint ai-test-result printer-result${kind ? ` is-${kind}` : ''}`;
|
||
out.innerHTML = text;
|
||
};
|
||
|
||
// Choosing a model redraws the picture and suggests the AMS that usually comes with it.
|
||
const modelSelect = form.elements.model;
|
||
const amsSelect = form.elements.ams_type;
|
||
let amsTouched = Boolean(id && amsSelect.value);
|
||
amsSelect.addEventListener('change', () => { amsTouched = true; });
|
||
const refreshTitle = () => {
|
||
const name = form.elements.nickname.value.trim() || modelSelect.value;
|
||
card.querySelector('.printer-title').textContent = name || 'New printer';
|
||
form.elements.nickname.placeholder = modelSelect.value || 'Leave blank to use the model';
|
||
};
|
||
modelSelect.addEventListener('change', () => {
|
||
const current = printerList.find((x) => x.id === id) || {};
|
||
card.querySelector('.printer-art').innerHTML = printerModels().art(modelSelect.value, drawnSlots(current));
|
||
const m = printerModels().find(modelSelect.value);
|
||
if (m && !amsTouched) {
|
||
const blank = amsSelect.querySelector('option[value=""]');
|
||
if (blank) blank.remove();
|
||
amsSelect.value = m.ams;
|
||
}
|
||
refreshTitle();
|
||
});
|
||
form.elements.nickname.addEventListener('input', refreshTitle);
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const body = {
|
||
nickname: form.elements.nickname.value,
|
||
model: modelSelect.value,
|
||
ams_type: amsSelect.value,
|
||
host: form.elements.host.value,
|
||
serial: form.elements.serial.value,
|
||
};
|
||
// Left blank, the stored code is kept.
|
||
if (form.elements.access_code.value.trim()) body.access_code = form.elements.access_code.value.trim();
|
||
const submit = form.querySelector('button[type="submit"]');
|
||
submit.disabled = true;
|
||
try {
|
||
const saved = id
|
||
? await postJson(`/api/printers/${id}`, body, 'PUT')
|
||
: await postJson('/api/printers', body);
|
||
form.elements.access_code.value = '';
|
||
if (!id) {
|
||
await renderPrintersPanel();
|
||
const fresh = document.querySelector(`[data-printer-id="${saved.id}"] .printer-result`);
|
||
if (fresh) {
|
||
fresh.classList.add('is-ok');
|
||
fresh.textContent = '✓ Added. Use “Test & sync” to check the connection.';
|
||
}
|
||
return;
|
||
}
|
||
printerList = printerList.map((p) => (p.id === saved.id ? saved : p));
|
||
form.elements.access_code.placeholder = saved.codeSet ? 'Stored — type to replace' : 'From the printer’s network settings';
|
||
say('✓ Saved.', 'ok');
|
||
} catch (err) {
|
||
say(`✗ ${escapeHtml(err.message)}`, 'error');
|
||
} finally {
|
||
submit.disabled = false;
|
||
}
|
||
});
|
||
|
||
const cancel = card.querySelector('[data-printer="cancel"]');
|
||
if (cancel) {
|
||
cancel.addEventListener('click', () => {
|
||
card.remove();
|
||
if (!printerList.length) renderPrintersPanel();
|
||
});
|
||
}
|
||
|
||
const remove = card.querySelector('[data-printer="remove"]');
|
||
if (remove) {
|
||
remove.addEventListener('click', async () => {
|
||
if (!confirm(`Remove ${printerName(printerList.find((p) => p.id === id) || {})}? Its spools stay on the Filament page.`)) return;
|
||
try {
|
||
await getJson(`/api/printers/${id}`, { method: 'DELETE' });
|
||
await renderPrintersPanel();
|
||
} catch (err) {
|
||
say(`✗ ${escapeHtml(err.message)}`, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
const test = card.querySelector('[data-printer="test"]');
|
||
if (test) test.addEventListener('click', () => testPrinter(id, test, say, card));
|
||
}
|
||
|
||
// Listens to one printer for a few seconds, says what it reported, and updates
|
||
// the spools it has loaded. Only reads; the printer is never sent a command.
|
||
async function testPrinter(id, btn, say, card) {
|
||
say('Listening to the printer for a few seconds…');
|
||
btn.disabled = true;
|
||
const trayHtml = (t) => `<span class="spool-dot" style="background:${escapeHtml(t.colour || '#888888')}"></span>
|
||
${escapeHtml(t.material || '?')}${t.remain != null ? ` ${t.remain}%` : ''}`;
|
||
try {
|
||
const r = await postJson(`/api/printers/${id}/test`);
|
||
if (!r.connected) throw new Error(r.error || 'Couldn’t connect to the printer.');
|
||
const p = printerList.find((x) => x.id === id) || {};
|
||
const lines = [`✓ Connected in ${(r.ms / 1000).toFixed(1)} s; ${plural(r.reports, 'report')} received.`];
|
||
if (r.printer && r.printer.state) lines.push(`The printer is ${escapeHtml(String(r.printer.state).toLowerCase())}.`);
|
||
if (r.error) lines.push(`But: ${escapeHtml(r.error)}`);
|
||
const loaded = (r.ams || []).filter((t) => !t.empty);
|
||
if (r.ams && r.ams.length) {
|
||
// An AMS lite's percentage is only full-or-empty, so it isn't shown.
|
||
const show = p.ams_type === 'ams_lite' ? (t) => trayHtml({ ...t, remain: null }) : trayHtml;
|
||
lines.push(`AMS: ${loaded.length} of ${r.ams.length} slots loaded${loaded.length ? ` — ${loaded.map(show).join(', ')}` : ''}.`);
|
||
}
|
||
if (r.external && !r.external.empty) lines.push(`External spool: ${trayHtml({ ...r.external, remain: null })}.`);
|
||
if (r.sync) {
|
||
const s = r.sync;
|
||
const parts = [];
|
||
if (s.created) parts.push(`${plural(s.created, 'spool')} added`);
|
||
if (s.adopted) parts.push(`${plural(s.adopted, 'spool')} matched to ones you’d added`);
|
||
if (s.updated) parts.push(`${plural(s.updated, 'spool')} already known`);
|
||
if (s.unloaded) parts.push(`${plural(s.unloaded, 'spool')} marked as taken out`);
|
||
if (parts.length) lines.push(`Filament page: ${parts.join(', ')}.`);
|
||
const n = (s.untracked || []).length;
|
||
if (n) lines.push(`${plural(n, 'spool')} without an RFID tag ${n === 1 ? 'isn’t' : 'aren’t'} tracked automatically — add ${n === 1 ? 'it' : 'them'} by hand.`);
|
||
}
|
||
if (!r.reports) {
|
||
lines.push('But it sent nothing back — in cloud mode the printer may be refusing the request for a full report.');
|
||
}
|
||
say(lines.join(' '), r.reports ? 'ok' : 'error');
|
||
// Redraw this card with what was just read, keeping any unsaved edits.
|
||
printerList = await getJson('/api/printers');
|
||
const fresh = printerList.find((x) => x.id === id);
|
||
if (fresh && card) {
|
||
card.querySelector('.printer-side').innerHTML = `
|
||
<div class="printer-art" aria-hidden="true">${printerModels().art(card.querySelector('select[name="model"]').value, drawnSlots(fresh))}</div>
|
||
${loadedHtml(fresh)}`;
|
||
}
|
||
} catch (err) {
|
||
say(`✗ ${escapeHtml(err.message)}`, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- watched collections
|
||
let watchPoll = null;
|
||
|
||
function watchPanelHtml() {
|
||
const intervals = [1, 3, 6, 12, 24, 48]
|
||
.map((h) => `<option value="${h}"${h === 6 ? ' selected' : ''}>${h === 1 ? 'every hour' : `every ${h} hours`}</option>`).join('');
|
||
return `
|
||
<section class="ai-card">
|
||
<div class="ai-card-head"><h3>Watch a MakerWorld collection</h3></div>
|
||
<p class="form-hint">Printhive checks the collection and imports models added to it, into a collection of the same
|
||
name here. Private collections work too, using your MakerWorld token. Models you remove there stay here.
|
||
A check brings in each model's page — name, description, picture, licence. Files are left for you to fetch with
|
||
“Get files”, unless you switch downloads on: MakerWorld refuses quick bursts, so a check that downloads spaces
|
||
them about 20 seconds apart and takes correspondingly longer.</p>
|
||
<form id="watchForm" class="watch-form">
|
||
<input type="url" id="watchUrl" placeholder="https://makerworld.com/en/collections/…" aria-label="Collection link" required>
|
||
<label>Check <select id="watchInterval" aria-label="How often to check">${intervals}</select></label>
|
||
<label><input type="checkbox" id="watchAi"> Tag new models with AI</label>
|
||
<label><input type="checkbox" id="watchFiles"> Download files too (about 20s per model)</label>
|
||
<button type="submit" class="btn btn-primary" id="watchSubmit">Watch collection</button>
|
||
</form>
|
||
<div id="watchStatus" class="form-hint"></div>
|
||
<div id="watchList" class="watch-list"></div>
|
||
</section>`;
|
||
}
|
||
|
||
const WATCH_STATUS = { ok: 'Up to date', checking: 'Checking…', error: 'Problem' };
|
||
|
||
// What went wrong with the last thing you asked a watch to do, shown on its row
|
||
// until the next attempt. Beats an alert() you can't read once it's dismissed.
|
||
const watchNotices = new Map();
|
||
|
||
function watchHtml(w) {
|
||
const when = w.last_checked_at ? `checked ${formatDate(w.last_checked_at)}` : 'not checked yet';
|
||
const listed = w.total != null && w.listed != null && w.listed < w.total
|
||
? ` · MakerWorld listed ${w.listed} of ${w.total}` : w.total != null ? ` · ${plural(w.total, 'model')} there` : '';
|
||
const recent = (w.recent || []).filter((i) => i.status !== 'existing');
|
||
return `
|
||
<div class="watch${w.enabled ? '' : ' is-paused'}" data-watch-id="${w.id}">
|
||
<div class="watch-head">
|
||
<a href="${escapeHtml(w.url)}" target="_blank" rel="noopener">${escapeHtml(w.title || 'MakerWorld collection')}</a>
|
||
<span class="pill pill-${w.last_status === 'error' ? 'failed' : w.last_status === 'checking' ? 'running' : 'done'}">
|
||
${w.enabled ? (WATCH_STATUS[w.last_status] || 'Waiting') : 'Paused'}</span>
|
||
<span class="form-hint">→ ${escapeHtml(w.project_name || 'no collection')}${w.ai_tag ? ' · AI tagging on' : ''}</span>
|
||
<span class="watch-actions">
|
||
<button type="button" class="btn btn-secondary btn-sm" data-watch="check">Check now</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" data-watch="toggle">${w.enabled ? 'Pause' : 'Resume'}</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" data-watch="filemode">${w.fetch_files ? 'Files: on' : 'Files: off'}</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" data-watch="remove">Stop watching</button>
|
||
</span>
|
||
</div>
|
||
<div class="watch-meta">${escapeHtml(when)} · every ${w.interval_hours}h · ${plural(w.imported_count, 'model')} imported${w.partial_count ? ` · ${w.partial_count} still without files` : ''}${w.error_count ? ` · ${w.error_count} failed` : ''}${listed}</div>
|
||
${w.last_error ? `<div class="ai-run-error">${escapeHtml(w.last_error)}
|
||
${w.last_error_reason === 'makerworld_token' ? '<button type="button" class="btn btn-primary btn-sm" data-watch="token">Update token</button>' : ''}</div>` : ''}
|
||
${watchNotices.has(w.id) ? `<div class="ai-run-error">${escapeHtml(watchNotices.get(w.id))}</div>` : ''}
|
||
${recent.length ? `<div class="watch-recent">${recent.map((i) => `
|
||
<span>${i.model_id
|
||
? `<button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.model_name || i.title || `Model ${i.model_id}`)}</button>`
|
||
: escapeHtml(i.title || i.design_id)}${i.error ? ` <span class="ai-run-error">${escapeHtml(i.error)}</span>` : ''}${(i.error || i.status === 'partial') && i.model_id
|
||
? ` <button type="button" class="link-btn" data-watch="files" data-model-id="${i.model_id}">Get files</button>` : ''}</span>`).join('')}</div>` : ''}
|
||
${(w.pending || []).length ? `<details class="watch-pending">
|
||
<summary>${plural(w.partial_count || w.pending.length, 'model')} still without files</summary>
|
||
<div class="watch-recent">${w.pending.map((i) => `
|
||
<span><button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.title || `Model ${i.model_id}`)}</button>
|
||
<button type="button" class="link-btn" data-watch="files" data-model-id="${i.model_id}">Get files</button></span>`).join('')}</div>
|
||
</details>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
async function renderWatchPanel() {
|
||
fileGrid.className = 'ai-panel';
|
||
resultCount.textContent = '';
|
||
if (!document.getElementById('watchForm')) fileGrid.innerHTML = watchPanelHtml();
|
||
await refreshWatches();
|
||
}
|
||
|
||
async function refreshWatches() {
|
||
clearTimeout(watchPoll);
|
||
if (selectedScope !== 'watch') return;
|
||
let watches;
|
||
try {
|
||
watches = await getJson('/api/watches');
|
||
} catch (err) {
|
||
const list = document.getElementById('watchList');
|
||
if (list) list.innerHTML = `<p class="form-hint">Couldn’t load watched collections: ${escapeHtml(err.message)}</p>`;
|
||
return;
|
||
}
|
||
const list = document.getElementById('watchList');
|
||
if (!list || selectedScope !== 'watch') return;
|
||
list.innerHTML = watches.length
|
||
? watches.map(watchHtml).join('')
|
||
: '<p class="form-hint">Nothing watched yet. Paste a MakerWorld collection link above.</p>';
|
||
// Keep the page current even between checks: one may start from the schedule,
|
||
// another tab, or another browser. refreshWatches stops itself when you leave.
|
||
const checking = watches.some((w) => w.last_status === 'checking');
|
||
watchPoll = setTimeout(refreshWatches, checking ? 5000 : 20000);
|
||
}
|
||
|
||
async function handleAddWatch(e) {
|
||
e.preventDefault();
|
||
const status = document.getElementById('watchStatus');
|
||
const submit = document.getElementById('watchSubmit');
|
||
const url = document.getElementById('watchUrl').value.trim();
|
||
submit.disabled = true;
|
||
status.classList.remove('is-error');
|
||
status.textContent = 'Reading that collection…';
|
||
try {
|
||
const watch = await postJson('/api/watches', {
|
||
url,
|
||
aiTag: document.getElementById('watchAi').checked,
|
||
fetchFiles: document.getElementById('watchFiles').checked,
|
||
intervalHours: Number(document.getElementById('watchInterval').value),
|
||
});
|
||
document.getElementById('watchUrl').value = '';
|
||
status.textContent = `Watching “${watch.title}” — importing what's in it now.`;
|
||
await loadData();
|
||
refreshWatches();
|
||
} catch (err) {
|
||
status.classList.add('is-error');
|
||
status.textContent = err.message;
|
||
if (/token/i.test(err.message)) {
|
||
promptMakerworldToken(err.message, async () => {
|
||
try {
|
||
const watch = await postJson('/api/watches', {
|
||
url,
|
||
aiTag: document.getElementById('watchAi').checked,
|
||
fetchFiles: document.getElementById('watchFiles').checked,
|
||
intervalHours: Number(document.getElementById('watchInterval').value),
|
||
});
|
||
await loadData();
|
||
refreshWatches();
|
||
return { ok: true, message: `Watching “${watch.title}”.` };
|
||
} catch (retryErr) {
|
||
return { ok: false, reason: /token/i.test(retryErr.message) ? 'makerworld_token' : null, error: retryErr.message };
|
||
}
|
||
}, 'Save & watch collection');
|
||
}
|
||
} finally {
|
||
submit.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function handleWatchAction(btn) {
|
||
const id = Number(btn.closest('[data-watch-id]').dataset.watchId);
|
||
const action = btn.dataset.watch;
|
||
btn.disabled = true;
|
||
watchNotices.delete(id); // this attempt replaces whatever the last one said
|
||
try {
|
||
if (action === 'check') {
|
||
await postJson(`/api/watches/${id}/check`);
|
||
} else if (action === 'toggle') {
|
||
const paused = btn.textContent.trim() === 'Pause';
|
||
await postJson(`/api/watches/${id}`, { enabled: !paused }, 'PUT');
|
||
} else if (action === 'remove') {
|
||
if (!confirm('Stop watching this collection?\n\nThe models it already imported are kept.')) { btn.disabled = false; return; }
|
||
await getJson(`/api/watches/${id}`, { method: 'DELETE' });
|
||
await loadData();
|
||
} else if (action === 'filemode') {
|
||
const on = btn.textContent.trim() === 'Files: on';
|
||
await postJson(`/api/watches/${id}`, { fetchFiles: !on }, 'PUT');
|
||
} else if (action === 'files') {
|
||
btn.textContent = 'Getting files…';
|
||
await postJson(`/api/models/${Number(btn.dataset.modelId)}/download-files`);
|
||
} else if (action === 'token') {
|
||
promptMakerworldToken('MakerWorld didn’t accept your token when checking this collection.',
|
||
() => retryWatchCheck(id), 'Save & check collection');
|
||
}
|
||
} catch (err) {
|
||
if (err.message && /token/i.test(err.message)) {
|
||
promptMakerworldToken(err.message, () => retryWatchCheck(id), 'Save & check collection');
|
||
} else {
|
||
watchNotices.set(id, err.message);
|
||
}
|
||
}
|
||
refreshWatches();
|
||
}
|
||
|
||
async function retryWatchCheck(id) {
|
||
const res = await fetch(`/api/watches/${id}/check`, { method: 'POST' });
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) return { ok: false, reason: data.reason, error: data.error };
|
||
refreshWatches();
|
||
return { ok: true, message: 'Token saved. Checking the collection now…' };
|
||
}
|
||
|
||
async function handleRefreshMetadata() {
|
||
const modelId = fileDetailModal.dataset.currentModelId;
|
||
const btn = document.getElementById('refreshLicenseBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Refreshing…';
|
||
try {
|
||
const res = await fetch(`/api/models/${modelId}/refresh-metadata`, { method: 'POST' });
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
const updated = await res.json();
|
||
// update in-memory state, the license panel, and the card badge
|
||
replaceModel(updated);
|
||
renderDetailLicense(updated);
|
||
renderModels();
|
||
btn.textContent = 'Updated ✓';
|
||
setTimeout(() => { btn.textContent = 'Refresh from source'; }, 1500);
|
||
} catch (err) {
|
||
btn.textContent = 'Refresh failed';
|
||
console.warn('Refresh metadata failed:', err.message);
|
||
setTimeout(() => { btn.textContent = 'Refresh from source'; }, 1800);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function loadViewer(item) {
|
||
if (item.format === '3mf') {
|
||
await loadProjectInfo(item);
|
||
if (activeItemKey !== itemKey(item)) return; // another tab was opened meanwhile
|
||
}
|
||
renderProjectPanels(item);
|
||
const src = viewerSourceFor(item);
|
||
const model = findModel(fileDetailModal.dataset.currentModelId);
|
||
const resolve = resolverFor(model, item.kind === 'entry' ? archiveEntryCache.get(item.archiveId) : null);
|
||
const is3d = window.ModelViewer.MODEL_FORMATS.includes(src.format);
|
||
currentDetailFile = is3d ? { viewerUrl: src.url, viewerFormat: src.format, resolve } : null;
|
||
document.getElementById('regenThumbBtn').style.display = is3d ? '' : 'none';
|
||
window.ModelViewer.render(document.getElementById('detailViewer'), src.url, src.format,
|
||
{ fallbackImage: currentDetailThumb, resolve });
|
||
}
|
||
|
||
// Plates and filaments of a 3MF slicer project, fetched once per viewer item.
|
||
// Projects with several plates open on their first plate, like the slicer.
|
||
function loadProjectInfo(item) {
|
||
if (!item.projectPromise) {
|
||
item.projectPromise = fetch(`/api/${item.kind === 'entry' ? 'entries' : 'files'}/${item.id}/project`)
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.catch(() => null)
|
||
.then((project) => {
|
||
item.project = project || { plates: [], filaments: [] };
|
||
if (item.plate === undefined && item.project.plates.length > 1) item.plate = item.project.plates[0].index;
|
||
});
|
||
}
|
||
return item.projectPromise;
|
||
}
|
||
|
||
const HEX_COLOUR = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i;
|
||
|
||
function renderProjectPanels(item) {
|
||
const project = item && item.format === '3mf' ? item.project : null;
|
||
const platesEl = document.getElementById('detailPlates');
|
||
if (!project || project.plates.length < 2) {
|
||
platesEl.hidden = true;
|
||
platesEl.innerHTML = '';
|
||
} else {
|
||
platesEl.hidden = false;
|
||
platesEl.innerHTML = project.plates.map((p) => `
|
||
<button type="button" class="plate-chip${item.plate === p.index ? ' active' : ''}" data-plate="${p.index}"${dragAttrs(rawUrlFor(item), item.name)}
|
||
title="${escapeHtml(p.name || `Plate ${p.index}`)} · ${p.objectCount} object${p.objectCount === 1 ? '' : 's'}">
|
||
${p.thumbnailUrl ? `<img src="${escapeHtml(p.thumbnailUrl)}" alt="">` : ''}
|
||
<span>${p.index}${p.name ? ` · ${escapeHtml(p.name)}` : ''}</span>
|
||
</button>`).join('')
|
||
+ `<button type="button" class="plate-chip${item.plate ? '' : ' active'}" data-plate="all"${dragAttrs(rawUrlFor(item), item.name)}><span>All plates</span></button>`;
|
||
}
|
||
|
||
const filaments = ((project && project.filaments) || [])
|
||
.map((f, i) => ({ ...f, slot: i + 1 }))
|
||
.filter((f) => HEX_COLOUR.test(f.colour || ''));
|
||
document.getElementById('detailFilamentsSection').hidden = filaments.length === 0;
|
||
document.getElementById('detailFilaments').innerHTML = filaments.map((f) => `
|
||
<div class="filament">
|
||
<span class="swatch" style="background:${f.colour.slice(0, 7)}"></span>
|
||
<span>Slot ${f.slot}${f.type ? ` · ${escapeHtml(f.type)}` : ''}</span>
|
||
<em>${f.colour.slice(0, 7).toUpperCase()}</em>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function updateCardThumbnail(modelId, thumbnailUrl) {
|
||
const m = findModel(modelId);
|
||
if (m) m.thumbnailUrl = thumbnailUrl;
|
||
const thumb = fileGrid.querySelector(`[data-model-id="${modelId}"] .file-thumbnail`);
|
||
if (thumb && thumbnailUrl) thumb.innerHTML = `<img src="${thumbnailUrl}" alt="" loading="lazy">`;
|
||
}
|
||
|
||
async function handleRegenThumbnail() {
|
||
const modelId = fileDetailModal.dataset.currentModelId;
|
||
if (!currentDetailFile) { alert('No previewable file to render.'); return; }
|
||
const btn = document.getElementById('regenThumbBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Rendering…';
|
||
try {
|
||
const image = await window.ModelViewer.snapshot(currentDetailFile.viewerUrl, currentDetailFile.viewerFormat, 512,
|
||
{ resolve: currentDetailFile.resolve });
|
||
const res = await fetch(`/api/models/${modelId}/thumbnail`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ image }),
|
||
});
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
const updated = await res.json();
|
||
updateCardThumbnail(modelId, updated.thumbnailUrl);
|
||
thumbAttempted.add(Number(modelId));
|
||
btn.textContent = 'Done ✓';
|
||
setTimeout(() => { btn.textContent = 'Regenerate thumbnail'; }, 1500);
|
||
} catch (err) {
|
||
console.warn('Thumbnail regen failed:', err.message);
|
||
btn.textContent = "Can't render this file";
|
||
setTimeout(() => { btn.textContent = 'Regenerate thumbnail'; }, 1800);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function handleAddFiles(e) {
|
||
const input = e.target;
|
||
if (!input.files.length) return;
|
||
const modelId = fileDetailModal.dataset.currentModelId;
|
||
const formData = new FormData();
|
||
Array.from(input.files).forEach((f) => formData.append('files', f));
|
||
try {
|
||
const res = await fetch(`/api/models/${modelId}/files`, { method: 'POST', body: formData });
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
input.value = '';
|
||
await loadData();
|
||
openModelDetail(modelId); // reopen with the new files
|
||
} catch (err) {
|
||
alert('Failed to add files: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- handlers
|
||
async function handleUpload(e) {
|
||
e.preventDefault();
|
||
const formData = new FormData();
|
||
const input = document.getElementById('fileInput');
|
||
Array.from(input.files).forEach((f) => formData.append('files', f));
|
||
formData.append('name', document.getElementById('fileName').value);
|
||
formData.append('description', document.getElementById('fileDescription').value);
|
||
formData.append('tags', JSON.stringify(splitTags(document.getElementById('fileTags').value)));
|
||
formData.append('projects', JSON.stringify(
|
||
Array.from(document.getElementById('fileProjects').selectedOptions).map((o) => Number(o.value))));
|
||
formData.append('printSettings', JSON.stringify({
|
||
material: document.getElementById('printMaterial').value,
|
||
layerHeight: document.getElementById('printLayerHeight').value,
|
||
infill: document.getElementById('printInfill').value,
|
||
supports: document.getElementById('printSupports').value,
|
||
}));
|
||
|
||
try {
|
||
const res = await fetch('/api/models', { method: 'POST', body: formData });
|
||
if (res.ok) {
|
||
closeModal(uploadModal);
|
||
document.getElementById('uploadForm').reset();
|
||
await loadData();
|
||
} else {
|
||
alert('Upload failed: ' + (await res.json()).error);
|
||
}
|
||
} catch (err) {
|
||
alert('Upload failed: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function handleImport(e) {
|
||
e.preventDefault();
|
||
const status = document.getElementById('importStatus');
|
||
const submit = document.getElementById('importSubmit');
|
||
status.textContent = 'Importing… fetching metadata and downloading files.';
|
||
submit.disabled = true;
|
||
try {
|
||
const res = await fetch('/api/import', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url: document.getElementById('importUrl').value }),
|
||
});
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
await loadData();
|
||
if (data.metadataOnly && data.filesErrorReason === 'makerworld_token') {
|
||
// Details imported, files refused: offer to fix the token and fetch them now.
|
||
closeModal(importModal);
|
||
openModelDetail(data.id);
|
||
promptMakerworldToken(`Imported “${data.name}”, but its files couldn’t be downloaded: ${data.filesError}`,
|
||
() => retryModelDownload(data.id));
|
||
} else if (data.metadataOnly) {
|
||
status.textContent = data.filesError
|
||
? `Imported "${data.name}", but its files couldn’t be downloaded: ${data.filesError}`
|
||
: data.metadataFetched
|
||
? `Imported "${data.name}". Opening it so you can add the downloaded files…`
|
||
: `Couldn't read that page (the site blocks automated access), so I created a stub named "${data.name}". Opening it so you can add files…`;
|
||
// Give a download problem time to be read before moving on.
|
||
setTimeout(() => { closeModal(importModal); openModelDetail(data.id); }, data.filesError ? 6000 : 1200);
|
||
} else {
|
||
status.textContent = `Imported "${data.name}" with ${data.files.length} file(s).`;
|
||
setTimeout(() => closeModal(importModal), 900);
|
||
}
|
||
} else if (res.status === 409) {
|
||
status.textContent = 'That model is already in your library.';
|
||
} else {
|
||
status.textContent = 'Import failed: ' + data.error;
|
||
}
|
||
} catch (err) {
|
||
status.textContent = 'Import failed: ' + err.message;
|
||
} finally {
|
||
submit.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function renderLicenseRules() {
|
||
const wrap = document.getElementById('licenseRules');
|
||
let rules;
|
||
try { rules = await (await fetch('/api/licenses')).json(); }
|
||
catch { wrap.textContent = 'Could not load licenses.'; return; }
|
||
if (!rules.length) { wrap.innerHTML = '<p class="form-hint">No licenses yet — import some models.</p>'; return; }
|
||
const opt = (v, sel) => `<option value="${v}"${sel ? ' selected' : ''}>`;
|
||
wrap.innerHTML = rules.map((r) => {
|
||
const cur = r.commercial === true ? 'true' : r.commercial === false ? 'false' : '';
|
||
return `<div class="license-row">
|
||
<span class="license-name" title="${escapeHtml(r.name)}">${escapeHtml(r.name)} <em>(${r.model_count})</em></span>
|
||
<select class="license-select" data-license="${escapeHtml(r.name)}">
|
||
${opt('true', cur === 'true')}Sellable</option>
|
||
${opt('false', cur === 'false')}Non-commercial</option>
|
||
${opt('', cur === '')}Unknown</option>
|
||
</select>
|
||
</div>`;
|
||
}).join('');
|
||
wrap.querySelectorAll('.license-select').forEach((sel) => {
|
||
sel.addEventListener('change', () => handleLicenseChange(sel.dataset.license, sel.value));
|
||
});
|
||
}
|
||
|
||
async function handleLicenseChange(name, value) {
|
||
const commercial = value === 'true' ? true : value === 'false' ? false : null;
|
||
try {
|
||
const res = await fetch('/api/licenses', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, commercial }),
|
||
});
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
await loadData(); // model badges reflect the new policy
|
||
} catch (err) {
|
||
alert('Could not update license: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function handleSaveSettings(e) {
|
||
e.preventDefault();
|
||
const status = document.getElementById('settingsStatus');
|
||
const payload = settingsPayload();
|
||
if (Object.keys(payload).length === 0) {
|
||
status.textContent = 'Nothing to save.';
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch('/api/settings', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
status.textContent = 'Saved.';
|
||
await loadData(); // refresh config so import button reflects new token
|
||
setTimeout(() => closeModal(settingsModal), 600);
|
||
} catch (err) {
|
||
status.textContent = 'Save failed: ' + err.message;
|
||
}
|
||
}
|
||
|
||
// Only fields you typed into are sent (blank = leave unchanged), except
|
||
// data-clear-blank ones, where blank means "use the default".
|
||
function settingsPayload() {
|
||
const payload = {};
|
||
document.querySelectorAll('#settingsForm [data-setting]').forEach((input) => {
|
||
const value = input.value.trim();
|
||
if (value) payload[input.dataset.setting] = value;
|
||
else if ('clearBlank' in input.dataset) payload[input.dataset.setting] = null;
|
||
});
|
||
return payload;
|
||
}
|
||
|
||
async function handleClearSetting(key) {
|
||
if (!confirm('Clear this value?')) return;
|
||
const status = document.getElementById('settingsStatus');
|
||
try {
|
||
const res = await fetch('/api/settings', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ [key]: null }),
|
||
});
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
status.textContent = 'Cleared.';
|
||
await loadData();
|
||
await openSettingsModal();
|
||
} catch (err) {
|
||
status.textContent = 'Clear failed: ' + err.message;
|
||
}
|
||
}
|
||
|
||
async function handleRenameCollection(id, currentName) {
|
||
const name = prompt('Rename collection:', currentName);
|
||
if (name === null) return;
|
||
const trimmed = name.trim();
|
||
if (!trimmed || trimmed === currentName) return;
|
||
try {
|
||
const res = await fetch(`/api/projects/${id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: trimmed }),
|
||
});
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
await loadData();
|
||
} catch (err) {
|
||
alert('Failed to rename collection: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function handleDeleteCollection(id, name) {
|
||
if (!confirm(`Delete the collection "${name}"?\n\nThe models in it are kept — only the collection is removed.`)) return;
|
||
try {
|
||
const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
if (String(selectedScope) === String(id)) selectedScope = 'all';
|
||
await loadData();
|
||
} catch (err) {
|
||
alert('Failed to delete collection: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function handleCreateProject(e) {
|
||
e.preventDefault();
|
||
try {
|
||
const res = await fetch('/api/projects', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: document.getElementById('projectName').value,
|
||
description: document.getElementById('projectDescription').value,
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
closeModal(projectModal);
|
||
document.getElementById('projectForm').reset();
|
||
await loadData();
|
||
} else {
|
||
alert('Failed to create collection:' + (await res.json()).error);
|
||
}
|
||
} catch (err) {
|
||
alert('Failed to create collection:' + err.message);
|
||
}
|
||
}
|
||
|
||
async function handleDeleteModel() {
|
||
const id = fileDetailModal.dataset.currentModelId;
|
||
if (!id || !confirm('Move this model to the trash?\n\nYou can restore it from Trash until the trash is emptied.')) return;
|
||
try {
|
||
const res = await fetch(`/api/models/${id}`, { method: 'DELETE' });
|
||
if (res.ok) {
|
||
closeModal(fileDetailModal);
|
||
await loadData();
|
||
} else {
|
||
alert('Failed to move to trash: ' + (await res.json()).error);
|
||
}
|
||
} catch (err) {
|
||
alert('Failed to move to trash: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- utils
|
||
// All distinct tags currently in the library, sorted.
|
||
function knownTags() {
|
||
const set = new Set();
|
||
models.forEach((m) => (m.tags || []).forEach((t) => set.add(t)));
|
||
return [...set].sort((a, b) => a.localeCompare(b));
|
||
}
|
||
|
||
// Autocomplete for a comma-separated tag input: suggests existing tags that
|
||
// match the token currently being typed (after the last comma).
|
||
function setupTagAutocomplete(input) {
|
||
if (!input) return;
|
||
const box = document.createElement('div');
|
||
box.className = 'tag-suggest';
|
||
box.style.display = 'none';
|
||
input.parentNode.style.position = 'relative';
|
||
input.parentNode.appendChild(box);
|
||
let active = -1;
|
||
|
||
const parts = () => input.value.split(',');
|
||
const currentToken = () => parts().pop().trim().toLowerCase();
|
||
const hide = () => { box.style.display = 'none'; active = -1; };
|
||
|
||
function choose(tag) {
|
||
const p = parts().map((x) => x.trim());
|
||
p[p.length - 1] = tag;
|
||
input.value = p.join(', ') + ', ';
|
||
hide();
|
||
input.focus();
|
||
}
|
||
|
||
function render() {
|
||
const tok = currentToken();
|
||
if (!tok) return hide();
|
||
const taken = new Set(parts().map((x) => x.trim().toLowerCase()));
|
||
const matches = knownTags().filter((t) => t.toLowerCase().includes(tok) && !taken.has(t.toLowerCase())).slice(0, 8);
|
||
if (!matches.length) return hide();
|
||
active = -1;
|
||
box.innerHTML = matches.map((t) => `<div class="tag-suggest-item" data-tag="${escapeHtml(t)}">${escapeHtml(t)}</div>`).join('');
|
||
box.style.top = `${input.offsetTop + input.offsetHeight + 2}px`;
|
||
box.style.left = `${input.offsetLeft}px`;
|
||
box.style.width = `${input.offsetWidth}px`;
|
||
box.style.display = '';
|
||
}
|
||
|
||
input.addEventListener('input', render);
|
||
input.addEventListener('focus', render);
|
||
input.addEventListener('blur', () => setTimeout(hide, 150));
|
||
input.addEventListener('keydown', (e) => {
|
||
const items = [...box.querySelectorAll('.tag-suggest-item')];
|
||
if (box.style.display === 'none' || !items.length) return;
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % items.length; }
|
||
else if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + items.length) % items.length; }
|
||
else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); choose(items[active].dataset.tag); return; }
|
||
else if (e.key === 'Escape') { hide(); return; }
|
||
else return;
|
||
items.forEach((it, i) => it.classList.toggle('active', i === active));
|
||
});
|
||
box.addEventListener('mousedown', (e) => {
|
||
const it = e.target.closest('.tag-suggest-item');
|
||
if (it) { e.preventDefault(); choose(it.dataset.tag); }
|
||
});
|
||
}
|
||
|
||
function splitTags(value) {
|
||
return value.split(',').map((t) => t.trim()).filter(Boolean);
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text == null ? '' : text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function formatFileSize(bytes) {
|
||
bytes = Number(bytes) || 0;
|
||
if (bytes === 0) return '0 Bytes';
|
||
const k = 1024;
|
||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||
}
|
||
|
||
function formatDate(dateString) {
|
||
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||
}
|