// 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 tagFilters = {}; // tag -> 'include' | 'exclude' let duplicateData = null; // last /api/duplicates response let duplicatePoll = null; // View preferences, remembered per browser. const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license: '', sort: 'newest', group: '' }; const PREFS_KEY = 'printhive.prefs'; const SPECIAL_SCOPES = ['all', 'duplicates', 'trash']; // 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'); 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; } loadData(); } 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] = await Promise.all([ fetch('/api/models'), fetch('/api/projects'), fetch('/api/config'), fetch('/api/library/stats'), ]); models = await modelsRes.json(); projects = await projectsRes.json(); config = await configRes.json(); stats = statsRes.ok ? await statsRes.json() : stats; 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); 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'); 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) => `
${escapeHtml(p.name)} ${p.model_count}
`).join('') : '

No collections yet.

'; } 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 `${escapeHtml(tag)}`; }).join('') : '

No tags yet.

'; 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 = `` + options.map(([value, label]) => ``).join(''); el.value = prefs[prefKey]; } function scopeTitle() { if (selectedScope === 'all') return 'All Models'; if (selectedScope === 'duplicates') return 'Duplicates'; if (selectedScope === 'trash') return 'Trash'; const p = projects.find((x) => String(x.id) === String(selectedScope)); return p ? p.name : 'Collection'; } function updateScopeChrome() { contentTitle.textContent = scopeTitle(); const dupes = selectedScope === 'duplicates'; document.getElementById('filterBar').hidden = dupes; document.querySelector('.view-options').hidden = dupes; 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') { renderDuplicates(); return; } clearTimeout(duplicatePoll); const list = getFilteredModels(); resultCount.textContent = `${list.length} model${list.length === 1 ? '' : 's'}`; 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]) => `
${escapeHtml(label)} ${ms.length}
` + 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) => `

${title}

${text}

`; 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 ? `` : '📦'; } function cardHtml(m) { const trashed = Boolean(m.deleted_at); const when = trashed ? `trashed ${formatDate(m.deleted_at)}` : formatDate(m.date_added); return `
${thumbHtml(m)}
${escapeHtml(m.name)}
${m.source_site ? `${escapeHtml(m.source_site)}` : ''} ${m.commercial_use === true ? '$ sellable' : m.commercial_use === false ? 'non-commercial' : ''} ${m.description ? `
${escapeHtml(m.description)}
` : ''} ${(m.tags && m.tags.length) ? `
${m.tags.map((t) => `${escapeHtml(t)}`).join('')}
` : ''}
${fileCountLabel(m)} • ${formatFileSize(totalSize(m))} • ${when}
${trashed ? `
` : ''}
`; } 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); const terms = searchInput.value.toLowerCase().split(/\s+/).filter(Boolean); if (terms.length) result = result.filter((m) => terms.every((t) => searchText(m).includes(t))); return [...result].sort(SORTS[prefs.sort] || SORTS.newest); } function updateCounts() { document.getElementById('allFilesCount').textContent = models.length; 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 = '

Looking for duplicates…

'; 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 = `

Couldn't check for duplicates

${escapeHtml(err.message)}

`; 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 ? `
Fingerprinting ${pending} file${pending === 1 ? '' : 's'}… this list updates automatically.
` : ''; fileGrid.innerHTML = pendingNote + (groups.length ? groups.map(duplicateGroupHtml).join('') : '

No duplicates found

Files are compared by content and by shape, so renamed or re-exported copies show up here too.

'); 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 `

${title} · ${g.models.length} models

${hint}

${g.models.map((m) => duplicateCardHtml(g, m)).join('')}
`; } function duplicateCardHtml(g, m) { const recommended = m.id === g.recommendedId; const unique = m.matchableFiles - m.matchedFiles; return `
${thumbHtml(m)}
${recommended ? 'Recommended keep' : ''}
${escapeHtml(m.name)}
${escapeHtml(sourceLabel(sourceKey(m)))} • ${formatDate(m.date_added)}
${m.matchedFiles} of ${m.matchableFiles} files match${unique ? ` • ${unique} unique` : ''}
`; } 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'], ['sortBy', 'sort'], ['groupBy', 'group']] .forEach(([id, key]) => { const el = document.getElementById(id); el.value = prefs[key]; 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('detailFileInfo').addEventListener('click', (e) => { 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); } async function selectScope(scope) { selectedScope = scope; if (scope === 'trash') await loadTrash(); renderModels(); } // 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 === fileDetailModal) { 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) => ``).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(); 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; }); 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) { const model = findModel(modelId); if (!model) return; 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 = `${escapeHtml(model.source_site || 'link')}${designer}`; } else { src.textContent = 'Manual upload'; } renderDetailLicense(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 document.getElementById('detailTags').innerHTML = (model.tags && model.tags.length) ? model.tags.map((t) => `${escapeHtml(t)}`).join('') : 'No tags'; const ps = model.projects || []; document.getElementById('detailProjects').innerHTML = ps.length ? ps.map((p) => `
${escapeHtml(p.name)}
`).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]) => `
${escapeHtml(k)}: ${escapeHtml(String(v))}
`).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 = `
${hint}
`; } else { const downloadAll = model.files.length > 1 ? `Download all ${model.files.length} files (.zip)` : ''; fileInfoEl.innerHTML = downloadAll + model.files.map((f) => `
${escapeHtml(f.originalName)} (${formatFileSize(f.size)}${f.volume ? `, ${Number(f.volume).toFixed(2)} cm³` : ''}) download
${ARCHIVE_FORMATS.includes(f.format) ? `
Reading archive…
` : ''}`).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) { activeItemKey = activeKey; document.getElementById('detailFileTabs').innerHTML = detailItems.map((item) => ` `).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 = `
Couldn't read this archive: ${escapeHtml(data.error || 'unknown error')}
`; return; } archiveEntryCache.set(fileId, data.entries); const { PREVIEWABLE } = window.ModelViewer; container().innerHTML = `
${data.entries.length} file${data.entries.length === 1 ? '' : 's'} inside
` + data.entries.map((e) => { const name = e.path.split('/').pop(); const folder = e.path.replace(/^\.\//, '').slice(0, -name.length); return `
${escapeHtml(name)}${folder ? `${escapeHtml(folder)}` : ''} ${formatFileSize(e.size)} ${PREVIEWABLE.includes(e.format) && e.format !== 'mtl' ? `` : ''} download
`; }).join(''); if (!detailItems.length) previewFirstEntry(fileId, data.entries); } catch (err) { if (container()) container().innerHTML = `
Couldn't list this archive: ${escapeHtml(err.message)}
`; } } function renderDetailLicense(model) { const com = model.commercial_use; // true / false / null const badge = com === true ? 'Commercial use allowed' : com === false ? 'Non-commercial only' : 'Commercial use unclear'; document.getElementById('detailLicense').innerHTML = `
${model.license ? escapeHtml(model.license) : 'Not specified'} ${badge}
` + '
A guess from the license name — always confirm on the source page before selling.
'; } 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) => ``) .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) 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); btn.disabled = false; } } 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) => ` `).join('') + ``; } 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) => `
Slot ${f.slot}${f.type ? ` · ${escapeHtml(f.type)}` : ''} ${f.colour.slice(0, 7).toUpperCase()}
`).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 = ``; } 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) { status.textContent = 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…`; setTimeout(() => { closeModal(importModal); openModelDetail(data.id); }, 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 = '

No licenses yet — import some models.

'; return; } const opt = (v, sel) => `