// State let models = []; let projects = []; let config = { importers: {} }; let selectedProject = 'all'; let selectedTags = []; let currentView = 'grid'; // DOM const fileGrid = document.getElementById('fileGrid'); const projectsList = document.getElementById('projectsList'); const tagsList = document.getElementById('tagsList'); const searchInput = document.getElementById('searchInput'); 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', () => { setupAuthListeners(); bootstrap(); }); // 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] = await Promise.all([ fetch('/api/models'), fetch('/api/projects'), fetch('/api/config'), ]); models = await modelsRes.json(); projects = await projectsRes.json(); config = await configRes.json(); renderProjects(); renderTags(); renderModels(); updateCounts(); generateMissingThumbnails(); // fire-and-forget; updates cards as they finish // 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); } } // Render thumbnails in the browser for models that don't have one yet, then // persist them. Runs one at a time to avoid thrashing the GPU / WebGL contexts. 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) => window.ModelViewer.RENDERABLE.includes(f.format)); if (!file) continue; thumbAttempted.add(m.id); try { const src = viewerSourceFor(file.id, file.format); const image = await window.ModelViewer.snapshot(src.url, src.format, 512); 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(); m.thumbnailUrl = updated.thumbnailUrl; const thumb = fileGrid.querySelector(`.file-card[data-model-id="${m.id}"] .file-thumbnail`); if (thumb && updated.thumbnailUrl) { thumb.innerHTML = ``; } } catch (err) { console.warn('Thumbnail generation failed for model', m.id, err); } } } // ---------------------------------------------------------------- rendering function renderProjects() { const items = projects.map((p) => `
${escapeHtml(p.name)} ${p.model_count}
`).join(''); const allItem = projectsList.querySelector('[data-project-id="all"]'); allItem.classList.toggle('active', selectedProject === 'all'); projectsList.innerHTML = ''; projectsList.appendChild(allItem); projectsList.insertAdjacentHTML('beforeend', items); } function renderTags() { const all = new Set(); models.forEach((m) => (m.tags || []).forEach((t) => all.add(t))); tagsList.innerHTML = Array.from(all).sort().map((tag) => ` ${escapeHtml(tag)}`).join(''); } function renderModels() { const filtered = getFilteredModels(); if (filtered.length === 0) { fileGrid.innerHTML = `

No models found

Upload files or import from a URL to get started

`; return; } fileGrid.className = currentView === 'grid' ? 'file-grid' : 'file-grid list-view'; fileGrid.innerHTML = filtered.map((m) => { const totalSize = (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0); const thumb = m.thumbnailUrl ? `` : '📦'; return `
${thumb}
${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('')}
` : ''}
${m.files.length} file${m.files.length === 1 ? '' : 's'} • ${formatFileSize(totalSize)} • ${formatDate(m.date_added)}
`; }).join(''); } function getFilteredModels() { let result = models; if (selectedProject !== 'all') { result = result.filter((m) => (m.projects || []).some((p) => String(p.id) === String(selectedProject))); } if (selectedTags.length > 0) { result = result.filter((m) => selectedTags.some((t) => (m.tags || []).includes(t))); } const term = searchInput.value.toLowerCase().trim(); if (term) { result = result.filter((m) => m.name.toLowerCase().includes(term) || (m.description && m.description.toLowerCase().includes(term))); } return result; } function updateCounts() { document.getElementById('allFilesCount').textContent = models.length; } // ---------------------------------------------------------------- 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(/\.[^.]+$/, ''); }); projectsList.addEventListener('click', (e) => { const item = e.target.closest('.project-item'); if (!item) return; const id = item.dataset.projectId; const name = item.querySelector('.project-name') ? item.querySelector('.project-name').textContent : 'All Models'; if (e.target.closest('.collection-rename')) { handleRenameCollection(id, name); return; } if (e.target.closest('.collection-delete')) { handleDeleteCollection(id, name); return; } document.querySelectorAll('.project-item').forEach((i) => i.classList.remove('active')); item.classList.add('active'); selectedProject = id; document.getElementById('contentTitle').textContent = id === 'all' ? 'All Models' : name; renderModels(); }); tagsList.addEventListener('click', (e) => { if (!e.target.classList.contains('tag')) return; const tag = e.target.dataset.tag; e.target.classList.toggle('active'); if (selectedTags.includes(tag)) selectedTags = selectedTags.filter((t) => t !== tag); else selectedTags.push(tag); renderModels(); }); searchInput.addEventListener('input', renderModels); document.querySelectorAll('.view-btn').forEach((btn) => { btn.addEventListener('click', (e) => { document.querySelectorAll('.view-btn').forEach((b) => b.classList.remove('active')); e.target.classList.add('active'); currentView = e.target.dataset.view; renderModels(); }); }); fileGrid.addEventListener('click', (e) => { const card = e.target.closest('.file-card'); if (card) openModelDetail(card.dataset.modelId); }); document.getElementById('deleteFileBtn').addEventListener('click', handleDeleteModel); 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); } // 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 && window.ModelViewer) window.ModelViewer.dispose(); } // ---------------------------------------------------------------- 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 = models.find((m) => String(m.id) === String(modelId)); if (!model) return; fileDetailModal.dataset.currentModelId = modelId; currentDetailThumb = model.thumbnailUrl || null; document.getElementById('detailFileName').textContent = model.name; document.getElementById('detailDescription').textContent = model.description || 'No description'; // 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
`).join(''); } // Viewer tabs const renderable = model.files.filter((f) => window.ModelViewer.RENDERABLE.includes(f.format)); const tabs = document.getElementById('detailFileTabs'); tabs.innerHTML = renderable.map((f, i) => ` `).join(''); tabs.querySelectorAll('.file-tab').forEach((tab) => { tab.addEventListener('click', () => { tabs.querySelectorAll('.file-tab').forEach((t) => t.classList.remove('active')); tab.classList.add('active'); loadViewer(tab.dataset.fileId, tab.dataset.format); }); }); fileDetailModal.classList.add('active'); currentDetailFile = null; if (renderable.length) { loadViewer(renderable[0].id, renderable[0].format); } else { document.getElementById('regenThumbBtn').style.display = 'none'; window.ModelViewer.render(document.getElementById('detailViewer'), '', 'none', { fallbackImage: currentDetailThumb }); } } // Maps a file to what the viewer should load: 3MF goes through the server's // GLB conversion; everything else is served raw. function viewerSourceFor(fileId, format) { return format === '3mf' ? { url: `/api/files/${fileId}/glb`, format: 'glb' } : { url: `/api/files/${fileId}/raw`, format }; } 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 = models.find((m) => String(m.id) === String(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 const idx = models.findIndex((m) => String(m.id) === String(modelId)); if (idx !== -1) models[idx] = 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; } } function loadViewer(fileId, format) { const src = viewerSourceFor(fileId, format); currentDetailFile = { id: fileId, viewerUrl: src.url, viewerFormat: src.format }; document.getElementById('regenThumbBtn').style.display = ''; window.ModelViewer.render(document.getElementById('detailViewer'), src.url, src.format, { fallbackImage: currentDetailThumb }); } function updateCardThumbnail(modelId, thumbnailUrl) { const m = models.find((x) => String(x.id) === String(modelId)); if (m) m.thumbnailUrl = thumbnailUrl; const thumb = fileGrid.querySelector(`.file-card[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); 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) => `