// State
let models = [];
let projects = [];
let config = { importers: {} };
let selectedProject = 'all';
let selectedTags = [];
let currentView = 'grid';
// DOM
const fileGrid = document.getElementById('fileGrid');
const projectsList = document.getElementById('projectsList');
const tagsList = document.getElementById('tagsList');
const searchInput = document.getElementById('searchInput');
const uploadModal = document.getElementById('uploadModal');
const projectModal = document.getElementById('projectModal');
const importModal = document.getElementById('importModal');
const settingsModal = document.getElementById('settingsModal');
const editModal = document.getElementById('editModal');
const fileDetailModal = document.getElementById('fileDetailModal');
document.addEventListener('DOMContentLoaded', () => {
loadData();
setupEventListeners();
});
async function loadData() {
try {
const [modelsRes, projectsRes, configRes] = await Promise.all([
fetch('/api/models'),
fetch('/api/projects'),
fetch('/api/config'),
]);
models = await modelsRes.json();
projects = await projectsRes.json();
config = await configRes.json();
renderProjects();
renderTags();
renderModels();
updateCounts();
generateMissingThumbnails(); // fire-and-forget; updates cards as they finish
} catch (error) {
console.error('Error loading data:', error);
}
}
// Render thumbnails in the browser for models that don't have one yet, then
// persist them. Runs one at a time to avoid thrashing the GPU / WebGL contexts.
const thumbAttempted = new Set();
async function generateMissingThumbnails() {
if (!window.ModelViewer) return;
for (const m of models) {
if (m.thumbnailUrl || thumbAttempted.has(m.id)) continue;
const file = (m.files || []).find((f) => window.ModelViewer.RENDERABLE.includes(f.format));
if (!file) continue;
thumbAttempted.add(m.id);
try {
const src = viewerSourceFor(file.id, file.format);
const image = await window.ModelViewer.snapshot(src.url, src.format, 512);
if (!image) continue;
const res = await fetch(`/api/models/${m.id}/thumbnail`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image }),
});
if (!res.ok) continue;
const updated = await res.json();
m.thumbnailUrl = updated.thumbnailUrl;
const thumb = fileGrid.querySelector(`.file-card[data-model-id="${m.id}"] .file-thumbnail`);
if (thumb && updated.thumbnailUrl) {
thumb.innerHTML = ` `;
}
} catch (err) {
console.warn('Thumbnail generation failed for model', m.id, err);
}
}
}
// ---------------------------------------------------------------- rendering
function renderProjects() {
const items = projects.map((p) => `
${escapeHtml(p.name)}
✎
×
${p.model_count}
`).join('');
const allItem = projectsList.querySelector('[data-project-id="all"]');
allItem.classList.toggle('active', selectedProject === 'all');
projectsList.innerHTML = '';
projectsList.appendChild(allItem);
projectsList.insertAdjacentHTML('beforeend', items);
}
function renderTags() {
const all = new Set();
models.forEach((m) => (m.tags || []).forEach((t) => all.add(t)));
tagsList.innerHTML = Array.from(all).sort().map((tag) => `
${escapeHtml(tag)} `).join('');
}
function renderModels() {
const filtered = getFilteredModels();
if (filtered.length === 0) {
fileGrid.innerHTML = `
No models found
Upload files or import from a URL to get started
`;
return;
}
fileGrid.className = currentView === 'grid' ? 'file-grid' : 'file-grid list-view';
fileGrid.innerHTML = filtered.map((m) => {
const totalSize = (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0);
const thumb = m.thumbnailUrl
? ` `
: '📦 ';
return `
${thumb}
${escapeHtml(m.name)}
${m.source_site ? `
${escapeHtml(m.source_site)} ` : ''}
${m.commercial_use === true ? '
$ sellable ' : m.commercial_use === false ? '
non-commercial ' : ''}
${m.description ? `
${escapeHtml(m.description)}
` : ''}
${(m.tags && m.tags.length) ? `
${m.tags.map((t) => `${escapeHtml(t)} `).join('')}
` : ''}
${m.files.length} file${m.files.length === 1 ? '' : 's'} • ${formatFileSize(totalSize)} • ${formatDate(m.date_added)}
`;
}).join('');
}
function getFilteredModels() {
let result = models;
if (selectedProject !== 'all') {
result = result.filter((m) => (m.projects || []).some((p) => String(p.id) === String(selectedProject)));
}
if (selectedTags.length > 0) {
result = result.filter((m) => selectedTags.some((t) => (m.tags || []).includes(t)));
}
const term = searchInput.value.toLowerCase().trim();
if (term) {
result = result.filter((m) =>
m.name.toLowerCase().includes(term) ||
(m.description && m.description.toLowerCase().includes(term)));
}
return result;
}
function updateCounts() {
document.getElementById('allFilesCount').textContent = models.length;
}
// ---------------------------------------------------------------- events
function setupEventListeners() {
document.getElementById('uploadBtn').addEventListener('click', openUploadModal);
document.getElementById('newProjectBtn').addEventListener('click', openProjectModal);
document.getElementById('importBtn').addEventListener('click', openImportModal);
document.getElementById('settingsBtn').addEventListener('click', openSettingsModal);
document.getElementById('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) => {
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(modal); });
});
document.getElementById('uploadForm').addEventListener('submit', handleUpload);
document.getElementById('cancelUpload').addEventListener('click', () => closeModal(uploadModal));
document.getElementById('projectForm').addEventListener('submit', handleCreateProject);
document.getElementById('cancelProject').addEventListener('click', () => closeModal(projectModal));
document.getElementById('importForm').addEventListener('submit', handleImport);
document.getElementById('cancelImport').addEventListener('click', () => closeModal(importModal));
document.getElementById('fileInput').addEventListener('change', (e) => {
const f = e.target.files[0];
const nameField = document.getElementById('fileName');
if (f && !nameField.value) nameField.value = f.name.replace(/\.[^.]+$/, '');
});
projectsList.addEventListener('click', (e) => {
const item = e.target.closest('.project-item');
if (!item) return;
const id = item.dataset.projectId;
const name = item.querySelector('.project-name') ? item.querySelector('.project-name').textContent : 'All Models';
if (e.target.closest('.collection-rename')) { handleRenameCollection(id, name); return; }
if (e.target.closest('.collection-delete')) { handleDeleteCollection(id, name); return; }
document.querySelectorAll('.project-item').forEach((i) => i.classList.remove('active'));
item.classList.add('active');
selectedProject = id;
document.getElementById('contentTitle').textContent = id === 'all' ? 'All Models' : name;
renderModels();
});
tagsList.addEventListener('click', (e) => {
if (!e.target.classList.contains('tag')) return;
const tag = e.target.dataset.tag;
e.target.classList.toggle('active');
if (selectedTags.includes(tag)) selectedTags = selectedTags.filter((t) => t !== tag);
else selectedTags.push(tag);
renderModels();
});
searchInput.addEventListener('input', renderModels);
document.querySelectorAll('.view-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.view-btn').forEach((b) => b.classList.remove('active'));
e.target.classList.add('active');
currentView = e.target.dataset.view;
renderModels();
});
});
fileGrid.addEventListener('click', (e) => {
const card = e.target.closest('.file-card');
if (card) openModelDetail(card.dataset.modelId);
});
document.getElementById('deleteFileBtn').addEventListener('click', handleDeleteModel);
document.getElementById('regenThumbBtn').addEventListener('click', handleRegenThumbnail);
document.getElementById('refreshLicenseBtn').addEventListener('click', handleRefreshMetadata);
document.getElementById('editModelBtn').addEventListener('click', openEditModal);
document.getElementById('downloadFilesBtn').addEventListener('click', handleDownloadFiles);
document.getElementById('editForm').addEventListener('submit', handleEditModel);
document.getElementById('cancelEdit').addEventListener('click', () => closeModal(editModal));
document.getElementById('addFilesBtn').addEventListener('click', () => document.getElementById('addFilesInput').click());
document.getElementById('addFilesInput').addEventListener('change', handleAddFiles);
}
// Tracks the file currently shown in the detail viewer (for thumbnail regen).
let currentDetailFile = null;
let currentDetailThumb = null; // model image, used as a viewer fallback
function closeModal(modal) {
modal.classList.remove('active');
if (modal === fileDetailModal && window.ModelViewer) window.ModelViewer.dispose();
}
// ---------------------------------------------------------------- modals
function openUploadModal() {
document.getElementById('fileProjects').innerHTML =
projects.map((p) => `${escapeHtml(p.name)} `).join('');
uploadModal.classList.add('active');
}
function openProjectModal() {
document.getElementById('projectForm').reset();
projectModal.classList.add('active');
}
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;
});
} catch (err) {
document.getElementById('settingsStatus').textContent = 'Could not load settings: ' + err.message;
}
settingsModal.classList.add('active');
}
function openImportModal() {
document.getElementById('importForm').reset();
document.getElementById('importStatus').textContent = '';
const tvEnabled = config.importers && config.importers.thingiverse;
const flareEnabled = config.importers && config.importers.flaresolverr;
const mw = flareEnabled ? 'MakerWorld' : 'MakerWorld (needs FlareSolverr in Settings)';
document.getElementById('importHint').innerHTML = tvEnabled
? `Thingiverse imports files automatically. Printables, ${mw} & other sites import the title, description & preview image — then add the files you downloaded.`
: `Printables, ${mw} & other sites import the title, description & preview image — then add the files you downloaded. Add a Thingiverse token in Settings for automatic file downloads.`;
document.getElementById('importSubmit').disabled = false;
importModal.classList.add('active');
}
function openModelDetail(modelId) {
const model = models.find((m) => String(m.id) === String(modelId));
if (!model) return;
fileDetailModal.dataset.currentModelId = modelId;
currentDetailThumb = model.thumbnailUrl || null;
document.getElementById('detailFileName').textContent = model.name;
document.getElementById('detailDescription').textContent = model.description || 'No description';
// Source
const src = document.getElementById('detailSource');
if (model.source_url) {
const designer = model.designer ? ` by ${escapeHtml(model.designer.name)}` : '';
src.innerHTML = `${escapeHtml(model.source_site || 'link')}${designer} `;
} else {
src.textContent = 'Manual upload';
}
renderDetailLicense(model);
document.getElementById('refreshLicenseBtn').style.display = model.source_url ? '' : 'none';
// "Download files from source" — only for sites we can auto-download from.
const autoDownloadSites = ['thingiverse', 'printables', 'makerworld'];
const dlBtn = document.getElementById('downloadFilesBtn');
if (model.source_url && autoDownloadSites.includes(model.source_site)) {
dlBtn.style.display = '';
dlBtn.textContent = model.files.length === 0 ? 'Download files from source' : 'Re-check source for files';
} else {
dlBtn.style.display = 'none';
}
// Tags / projects / print settings
document.getElementById('detailTags').innerHTML = (model.tags && model.tags.length)
? model.tags.map((t) => `${escapeHtml(t)} `).join('') : 'No tags';
const ps = model.projects || [];
document.getElementById('detailProjects').innerHTML = ps.length
? ps.map((p) => `${escapeHtml(p.name)}
`).join('') : 'Not in any collection';
const settings = model.print_settings || {};
const settingRows = Object.entries(settings).filter(([, v]) => v);
document.getElementById('detailPrintSettings').innerHTML = settingRows.length
? settingRows.map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}
`).join('')
: 'No print settings';
// File list (+ a "download all as zip" action when there's more than one file)
const fileInfoEl = document.getElementById('detailFileInfo');
if (model.files.length === 0) {
const autoSite = ['thingiverse', 'printables', 'makerworld'].includes(model.source_site);
const hint = !model.source_url
? 'No files yet — use “Add files” below.'
: autoSite
? 'No files yet — use “Download files from source” below.'
: 'Manual source — download the files from the source page, then use “Add files” below.';
fileInfoEl.innerHTML = `${hint}
`;
} else {
const downloadAll = model.files.length > 1
? `Download all ${model.files.length} files (.zip) `
: '';
fileInfoEl.innerHTML = downloadAll + model.files.map((f) => `
${escapeHtml(f.originalName)} (${formatFileSize(f.size)}${f.volume ? `, ${Number(f.volume).toFixed(2)} cmÂł` : ''})
download
`).join('');
}
// Viewer tabs
const renderable = model.files.filter((f) => window.ModelViewer.RENDERABLE.includes(f.format));
const tabs = document.getElementById('detailFileTabs');
tabs.innerHTML = renderable.map((f, i) => `
${escapeHtml(f.originalName)} `).join('');
tabs.querySelectorAll('.file-tab').forEach((tab) => {
tab.addEventListener('click', () => {
tabs.querySelectorAll('.file-tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
loadViewer(tab.dataset.fileId, tab.dataset.format);
});
});
fileDetailModal.classList.add('active');
currentDetailFile = null;
if (renderable.length) {
loadViewer(renderable[0].id, renderable[0].format);
} else {
document.getElementById('regenThumbBtn').style.display = 'none';
window.ModelViewer.render(document.getElementById('detailViewer'), '', 'none', { fallbackImage: currentDetailThumb });
}
}
// Maps a file to what the viewer should load: 3MF goes through the server's
// GLB conversion; everything else is served raw.
function viewerSourceFor(fileId, format) {
return format === '3mf'
? { url: `/api/files/${fileId}/glb`, format: 'glb' }
: { url: `/api/files/${fileId}/raw`, format };
}
function renderDetailLicense(model) {
const com = model.commercial_use; // true / false / null
const badge = com === true
? 'Commercial use allowed '
: com === false
? 'Non-commercial only '
: 'Commercial use unclear ';
document.getElementById('detailLicense').innerHTML =
`${model.license ? escapeHtml(model.license) : 'Not specified'} ${badge}
` +
'A guess from the license name — always confirm on the source page before selling.
';
}
function openEditModal() {
const id = fileDetailModal.dataset.currentModelId;
const model = models.find((m) => String(m.id) === String(id));
if (!model) return;
editModal.dataset.editId = id;
document.getElementById('editName').value = model.name || '';
document.getElementById('editDescription').value = model.description || '';
document.getElementById('editTags').value = (model.tags || []).join(', ');
document.getElementById('editLicense').value = model.license || '';
document.getElementById('editCommercial').value =
model.commercial_use === true ? 'true' : model.commercial_use === false ? 'false' : '';
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 commercial = document.getElementById('editCommercial').value;
const body = {
name: document.getElementById('editName').value,
description: document.getElementById('editDescription').value,
license: document.getElementById('editLicense').value || null,
commercial_use: commercial === '' ? null : commercial === 'true',
tags: splitTags(document.getElementById('editTags').value),
projects: Array.from(document.getElementById('editProjects').selectedOptions).map((o) => Number(o.value)),
print_settings: {
material: document.getElementById('editMaterial').value,
layerHeight: document.getElementById('editLayerHeight').value,
infill: document.getElementById('editInfill').value,
supports: document.getElementById('editSupports').value,
},
};
try {
const res = await fetch(`/api/models/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error((await res.json()).error);
closeModal(editModal);
await loadData();
openModelDetail(id); // refresh the (still-open) detail view
} catch (err) {
alert('Failed to save changes: ' + err.message);
}
}
async function handleDownloadFiles() {
const id = fileDetailModal.dataset.currentModelId;
const btn = document.getElementById('downloadFilesBtn');
btn.disabled = true;
btn.textContent = 'Downloading… (this can take a moment)';
try {
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
await loadData();
openModelDetail(id);
if (data.added === 0) alert('No new files were found (the source returned nothing new).');
} catch (err) {
alert('Download failed: ' + err.message);
btn.disabled = false;
}
}
async function handleRefreshMetadata() {
const modelId = fileDetailModal.dataset.currentModelId;
const btn = document.getElementById('refreshLicenseBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
try {
const res = await fetch(`/api/models/${modelId}/refresh-metadata`, { method: 'POST' });
if (!res.ok) throw new Error((await res.json()).error);
const updated = await res.json();
// update in-memory state, the license panel, and the card badge
const idx = models.findIndex((m) => String(m.id) === String(modelId));
if (idx !== -1) models[idx] = updated;
renderDetailLicense(updated);
renderModels();
btn.textContent = 'Updated âś“';
setTimeout(() => { btn.textContent = 'Refresh from source'; }, 1500);
} catch (err) {
btn.textContent = 'Refresh failed';
console.warn('Refresh metadata failed:', err.message);
setTimeout(() => { btn.textContent = 'Refresh from source'; }, 1800);
} finally {
btn.disabled = false;
}
}
function loadViewer(fileId, format) {
const src = viewerSourceFor(fileId, format);
currentDetailFile = { id: fileId, viewerUrl: src.url, viewerFormat: src.format };
document.getElementById('regenThumbBtn').style.display = '';
window.ModelViewer.render(document.getElementById('detailViewer'), src.url, src.format, { fallbackImage: currentDetailThumb });
}
function updateCardThumbnail(modelId, thumbnailUrl) {
const m = models.find((x) => String(x.id) === String(modelId));
if (m) m.thumbnailUrl = thumbnailUrl;
const thumb = fileGrid.querySelector(`.file-card[data-model-id="${modelId}"] .file-thumbnail`);
if (thumb && thumbnailUrl) thumb.innerHTML = ` `;
}
async function handleRegenThumbnail() {
const modelId = fileDetailModal.dataset.currentModelId;
if (!currentDetailFile) { alert('No previewable file to render.'); return; }
const btn = document.getElementById('regenThumbBtn');
btn.disabled = true;
btn.textContent = 'Rendering…';
try {
const image = await window.ModelViewer.snapshot(currentDetailFile.viewerUrl, currentDetailFile.viewerFormat, 512);
const res = await fetch(`/api/models/${modelId}/thumbnail`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image }),
});
if (!res.ok) throw new Error((await res.json()).error);
const updated = await res.json();
updateCardThumbnail(modelId, updated.thumbnailUrl);
thumbAttempted.add(Number(modelId));
btn.textContent = 'Done âś“';
setTimeout(() => { btn.textContent = 'Regenerate thumbnail'; }, 1500);
} catch (err) {
console.warn('Thumbnail regen failed:', err.message);
btn.textContent = "Can't render this file";
setTimeout(() => { btn.textContent = 'Regenerate thumbnail'; }, 1800);
} finally {
btn.disabled = false;
}
}
async function handleAddFiles(e) {
const input = e.target;
if (!input.files.length) return;
const modelId = fileDetailModal.dataset.currentModelId;
const formData = new FormData();
Array.from(input.files).forEach((f) => formData.append('files', f));
try {
const res = await fetch(`/api/models/${modelId}/files`, { method: 'POST', body: formData });
if (!res.ok) throw new Error((await res.json()).error);
input.value = '';
await loadData();
openModelDetail(modelId); // reopen with the new files
} catch (err) {
alert('Failed to add files: ' + err.message);
}
}
// ---------------------------------------------------------------- handlers
async function handleUpload(e) {
e.preventDefault();
const formData = new FormData();
const input = document.getElementById('fileInput');
Array.from(input.files).forEach((f) => formData.append('files', f));
formData.append('name', document.getElementById('fileName').value);
formData.append('description', document.getElementById('fileDescription').value);
formData.append('tags', JSON.stringify(splitTags(document.getElementById('fileTags').value)));
formData.append('projects', JSON.stringify(
Array.from(document.getElementById('fileProjects').selectedOptions).map((o) => Number(o.value))));
formData.append('printSettings', JSON.stringify({
material: document.getElementById('printMaterial').value,
layerHeight: document.getElementById('printLayerHeight').value,
infill: document.getElementById('printInfill').value,
supports: document.getElementById('printSupports').value,
}));
try {
const res = await fetch('/api/models', { method: 'POST', body: formData });
if (res.ok) {
closeModal(uploadModal);
document.getElementById('uploadForm').reset();
await loadData();
} else {
alert('Upload failed: ' + (await res.json()).error);
}
} catch (err) {
alert('Upload failed: ' + err.message);
}
}
async function handleImport(e) {
e.preventDefault();
const status = document.getElementById('importStatus');
const submit = document.getElementById('importSubmit');
status.textContent = 'Importing… fetching metadata and downloading files.';
submit.disabled = true;
try {
const res = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: document.getElementById('importUrl').value }),
});
const data = await res.json();
if (res.ok) {
await loadData();
if (data.metadataOnly) {
status.textContent = data.metadataFetched
? `Imported "${data.name}". Opening it so you can add the downloaded files…`
: `Couldn't read that page (the site blocks automated access), so I created a stub named "${data.name}". Opening it so you can add files…`;
setTimeout(() => { closeModal(importModal); openModelDetail(data.id); }, 1200);
} else {
status.textContent = `Imported "${data.name}" with ${data.files.length} file(s).`;
setTimeout(() => closeModal(importModal), 900);
}
} else if (res.status === 409) {
status.textContent = 'That model is already in your library.';
} else {
status.textContent = 'Import failed: ' + data.error;
}
} catch (err) {
status.textContent = 'Import failed: ' + err.message;
} finally {
submit.disabled = false;
}
}
async function 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);
if (String(selectedProject) === String(id)) {
document.getElementById('contentTitle').textContent = trimmed;
}
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(selectedProject) === String(id)) {
selectedProject = 'all';
document.getElementById('contentTitle').textContent = 'All Models';
}
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('Delete this model and all its files?')) return;
try {
const res = await fetch(`/api/models/${id}`, { method: 'DELETE' });
if (res.ok) {
closeModal(fileDetailModal);
await loadData();
} else {
alert('Failed to delete: ' + (await res.json()).error);
}
} catch (err) {
alert('Failed to delete: ' + err.message);
}
}
// ---------------------------------------------------------------- utils
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' });
}