// 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({ 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 = `${anyLabel} `
+ options.map(([value, label]) => `${escapeHtml(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]) => ``
+ 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) => ``;
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 ? `
Restore
Delete forever
` : ''}
`;
}
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 = '';
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}
Not duplicates
${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 ` : ''}
Keep this one
`;
}
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) => `${escapeHtml(p.name)} `).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) ? `` : ''}`).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 {
document.getElementById('regenThumbBtn').style.display = 'none';
window.ModelViewer.render(document.getElementById('detailViewer'), '', 'none', { fallbackImage: currentDetailThumb });
}
}
// ---------------------------------------------------------------- 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) => `
${escapeHtml(item.name)} `).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;
}
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) => `
${escapeHtml(e.path.replace(/^\.\//, ''))} ${formatFileSize(e.size)}
${PREVIEWABLE.includes(e.format) && e.format !== 'mtl' ? `view ` : ''}
download
`).join('');
} 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) => `${escapeHtml(p.name)} `)
.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) => `
${p.thumbnailUrl ? ` ` : ''}
${p.index}${p.name ? ` · ${escapeHtml(p.name)}` : ''}
`).join('')
+ `All plates `;
}
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) => ``;
wrap.innerHTML = rules.map((r) => {
const cur = r.commercial === true ? 'true' : r.commercial === false ? 'false' : '';
return `
${escapeHtml(r.name)} (${r.model_count})
${opt('true', cur === 'true')}Sellable
${opt('false', cur === 'false')}Non-commercial
${opt('', cur === '')}Unknown
`;
}).join('');
wrap.querySelectorAll('.license-select').forEach((sel) => {
sel.addEventListener('change', () => handleLicenseChange(sel.dataset.license, sel.value));
});
}
async function handleLicenseChange(name, value) {
const commercial = value === 'true' ? true : value === 'false' ? false : null;
try {
const res = await fetch('/api/licenses', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, commercial }),
});
if (!res.ok) throw new Error((await res.json()).error);
await loadData(); // model badges reflect the new policy
} catch (err) {
alert('Could not update license: ' + err.message);
}
}
async function handleSaveSettings(e) {
e.preventDefault();
const status = document.getElementById('settingsStatus');
// Only send fields the user actually typed into (blank = leave unchanged).
const payload = {};
document.querySelectorAll('#settingsForm [data-setting]').forEach((input) => {
if (input.value.trim()) payload[input.dataset.setting] = input.value.trim();
});
if (Object.keys(payload).length === 0) {
status.textContent = 'Nothing to save.';
return;
}
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error((await res.json()).error);
status.textContent = 'Saved.';
await loadData(); // refresh config so import button reflects new token
setTimeout(() => closeModal(settingsModal), 600);
} catch (err) {
status.textContent = 'Save failed: ' + err.message;
}
}
async function handleClearSetting(key) {
if (!confirm('Clear this value?')) return;
const status = document.getElementById('settingsStatus');
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: null }),
});
if (!res.ok) throw new Error((await res.json()).error);
status.textContent = 'Cleared.';
await loadData();
await openSettingsModal();
} catch (err) {
status.textContent = 'Clear failed: ' + err.message;
}
}
async function handleRenameCollection(id, currentName) {
const name = prompt('Rename collection:', currentName);
if (name === null) return;
const trimmed = name.trim();
if (!trimmed || trimmed === currentName) return;
try {
const res = await fetch(`/api/projects/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }),
});
if (!res.ok) throw new Error((await res.json()).error);
await loadData();
} catch (err) {
alert('Failed to rename collection: ' + err.message);
}
}
async function handleDeleteCollection(id, name) {
if (!confirm(`Delete the collection "${name}"?\n\nThe models in it are kept — only the collection is removed.`)) return;
try {
const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json()).error);
if (String(selectedScope) === String(id)) selectedScope = 'all';
await loadData();
} catch (err) {
alert('Failed to delete collection: ' + err.message);
}
}
async function handleCreateProject(e) {
e.preventDefault();
try {
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: document.getElementById('projectName').value,
description: document.getElementById('projectDescription').value,
}),
});
if (res.ok) {
closeModal(projectModal);
document.getElementById('projectForm').reset();
await loadData();
} else {
alert('Failed to create collection:' + (await res.json()).error);
}
} catch (err) {
alert('Failed to create collection:' + err.message);
}
}
async function handleDeleteModel() {
const id = fileDetailModal.dataset.currentModelId;
if (!id || !confirm('Move this model to the trash?\n\nYou can restore it from Trash until the trash is emptied.')) return;
try {
const res = await fetch(`/api/models/${id}`, { method: 'DELETE' });
if (res.ok) {
closeModal(fileDetailModal);
await loadData();
} else {
alert('Failed to move to trash: ' + (await res.json()).error);
}
} catch (err) {
alert('Failed to move to trash: ' + err.message);
}
}
// ---------------------------------------------------------------- utils
// All distinct tags currently in the library, sorted.
function knownTags() {
const set = new Set();
models.forEach((m) => (m.tags || []).forEach((t) => set.add(t)));
return [...set].sort((a, b) => a.localeCompare(b));
}
// Autocomplete for a comma-separated tag input: suggests existing tags that
// match the token currently being typed (after the last comma).
function setupTagAutocomplete(input) {
if (!input) return;
const box = document.createElement('div');
box.className = 'tag-suggest';
box.style.display = 'none';
input.parentNode.style.position = 'relative';
input.parentNode.appendChild(box);
let active = -1;
const parts = () => input.value.split(',');
const currentToken = () => parts().pop().trim().toLowerCase();
const hide = () => { box.style.display = 'none'; active = -1; };
function choose(tag) {
const p = parts().map((x) => x.trim());
p[p.length - 1] = tag;
input.value = p.join(', ') + ', ';
hide();
input.focus();
}
function render() {
const tok = currentToken();
if (!tok) return hide();
const taken = new Set(parts().map((x) => x.trim().toLowerCase()));
const matches = knownTags().filter((t) => t.toLowerCase().includes(tok) && !taken.has(t.toLowerCase())).slice(0, 8);
if (!matches.length) return hide();
active = -1;
box.innerHTML = matches.map((t) => `${escapeHtml(t)}
`).join('');
box.style.top = `${input.offsetTop + input.offsetHeight + 2}px`;
box.style.left = `${input.offsetLeft}px`;
box.style.width = `${input.offsetWidth}px`;
box.style.display = '';
}
input.addEventListener('input', render);
input.addEventListener('focus', render);
input.addEventListener('blur', () => setTimeout(hide, 150));
input.addEventListener('keydown', (e) => {
const items = [...box.querySelectorAll('.tag-suggest-item')];
if (box.style.display === 'none' || !items.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % items.length; }
else if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + items.length) % items.length; }
else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); choose(items[active].dataset.tag); return; }
else if (e.key === 'Escape') { hide(); return; }
else return;
items.forEach((it, i) => it.classList.toggle('active', i === active));
});
box.addEventListener('mousedown', (e) => {
const it = e.target.closest('.tag-suggest-item');
if (it) { e.preventDefault(); choose(it.dataset.tag); }
});
}
function splitTags(value) {
return value.split(',').map((t) => t.trim()).filter(Boolean);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text == null ? '' : text;
return div.innerHTML;
}
function formatFileSize(bytes) {
bytes = Number(bytes) || 0;
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
function formatDate(dateString) {
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}