// 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: '', slicer: '', slicerTemplate: '' };
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');
const slicerModal = document.getElementById('slicerModal');
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]) => `
`;
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 `
`).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 = `
`
: '');
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]) => `
`).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` };
}
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 =
`