// 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
// 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);
}
}
async function loadTrash() {
const res = await fetch('/api/models?view=trash');
trashModels = res.ok ? await res.json() : [];
}
// 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();
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 = `