All checks were successful
build-and-push / docker (push) Successful in 27s
Suggests existing library tags matching the token being typed (after the last comma), with click or arrow-key/Enter selection; excludes tags already entered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1021 lines
41 KiB
JavaScript
1021 lines
41 KiB
JavaScript
// State
|
|
let models = [];
|
|
let projects = [];
|
|
let config = { importers: {} };
|
|
let selectedProject = 'all';
|
|
let selectedTags = [];
|
|
let currentView = 'grid';
|
|
|
|
// DOM
|
|
const fileGrid = document.getElementById('fileGrid');
|
|
const projectsList = document.getElementById('projectsList');
|
|
const tagsList = document.getElementById('tagsList');
|
|
const searchInput = document.getElementById('searchInput');
|
|
const uploadModal = document.getElementById('uploadModal');
|
|
const projectModal = document.getElementById('projectModal');
|
|
const importModal = document.getElementById('importModal');
|
|
const settingsModal = document.getElementById('settingsModal');
|
|
const setupModal = document.getElementById('setupModal');
|
|
const editModal = document.getElementById('editModal');
|
|
const fileDetailModal = document.getElementById('fileDetailModal');
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
setupAuthListeners();
|
|
bootstrap();
|
|
});
|
|
|
|
// Decide what to show: the app (if signed in), the reset form (if ?reset=...),
|
|
// or the sign-in / create-account screen.
|
|
async function bootstrap() {
|
|
const resetToken = new URLSearchParams(location.search).get('reset');
|
|
if (resetToken) { showAuth('reset'); return; }
|
|
let status;
|
|
try {
|
|
status = await (await fetch('/api/auth/status')).json();
|
|
} catch {
|
|
status = { authenticated: false, needsSetup: false };
|
|
}
|
|
if (status.authenticated) startApp();
|
|
else showAuth(status.needsSetup ? 'register' : 'login');
|
|
}
|
|
|
|
let appStarted = false;
|
|
function startApp() {
|
|
document.getElementById('authScreen').style.display = 'none';
|
|
document.querySelector('.container').style.display = '';
|
|
if (!appStarted) { setupEventListeners(); appStarted = true; }
|
|
loadData();
|
|
}
|
|
|
|
function showAuth(mode) {
|
|
document.querySelector('.container').style.display = 'none';
|
|
document.getElementById('authScreen').style.display = 'flex';
|
|
['login', 'register', 'forgot', 'reset'].forEach((m) => {
|
|
document.getElementById('auth-' + m).style.display = m === mode ? '' : 'none';
|
|
});
|
|
document.getElementById('authError').textContent = '';
|
|
}
|
|
|
|
function setupAuthListeners() {
|
|
document.getElementById('auth-login').addEventListener('submit', handleLogin);
|
|
document.getElementById('auth-register').addEventListener('submit', handleRegister);
|
|
document.getElementById('auth-forgot').addEventListener('submit', handleForgot);
|
|
document.getElementById('auth-reset').addEventListener('submit', handleReset);
|
|
document.querySelectorAll('.auth-nav').forEach((b) => {
|
|
b.addEventListener('click', () => showAuth(b.dataset.auth));
|
|
});
|
|
}
|
|
|
|
async function authPost(url, body) {
|
|
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || 'Something went wrong.');
|
|
return data;
|
|
}
|
|
function authError(msg) { document.getElementById('authError').textContent = msg; }
|
|
|
|
async function handleLogin(e) {
|
|
e.preventDefault();
|
|
try {
|
|
await authPost('/api/auth/login', {
|
|
email: document.getElementById('loginEmail').value,
|
|
password: document.getElementById('loginPassword').value,
|
|
});
|
|
location.reload();
|
|
} catch (err) { authError(err.message); }
|
|
}
|
|
|
|
async function handleRegister(e) {
|
|
e.preventDefault();
|
|
try {
|
|
await authPost('/api/auth/register', {
|
|
email: document.getElementById('regEmail').value,
|
|
password: document.getElementById('regPassword').value,
|
|
});
|
|
location.reload();
|
|
} catch (err) { authError(err.message); }
|
|
}
|
|
|
|
async function handleForgot(e) {
|
|
e.preventDefault();
|
|
try {
|
|
await authPost('/api/auth/forgot', { email: document.getElementById('forgotEmail').value });
|
|
authError('If that email has an account and email is configured, a reset link is on its way.');
|
|
} catch (err) { authError(err.message); }
|
|
}
|
|
|
|
async function handleReset(e) {
|
|
e.preventDefault();
|
|
const token = new URLSearchParams(location.search).get('reset');
|
|
try {
|
|
await authPost('/api/auth/reset', { token, password: document.getElementById('resetPassword').value });
|
|
window.history.replaceState({}, '', location.pathname); // drop ?reset=
|
|
showAuth('login');
|
|
authError('Password updated — you can sign in now.');
|
|
} catch (err) { authError(err.message); }
|
|
}
|
|
|
|
async function handleLogout() {
|
|
try { await fetch('/api/auth/logout', { method: 'POST' }); } catch { /* ignore */ }
|
|
location.reload();
|
|
}
|
|
|
|
async function loadData() {
|
|
try {
|
|
const [modelsRes, projectsRes, configRes] = await Promise.all([
|
|
fetch('/api/models'),
|
|
fetch('/api/projects'),
|
|
fetch('/api/config'),
|
|
]);
|
|
models = await modelsRes.json();
|
|
projects = await projectsRes.json();
|
|
config = await configRes.json();
|
|
|
|
renderProjects();
|
|
renderTags();
|
|
renderModels();
|
|
updateCounts();
|
|
generateMissingThumbnails(); // fire-and-forget; updates cards as they finish
|
|
|
|
// First-run onboarding: prompt for import sources once.
|
|
if (config.setupComplete === false && !setupModal.classList.contains('active')) {
|
|
setupModal.classList.add('active');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading data:', error);
|
|
}
|
|
}
|
|
|
|
// Render thumbnails in the browser for models that don't have one yet, then
|
|
// persist them. Runs one at a time to avoid thrashing the GPU / WebGL contexts.
|
|
const thumbAttempted = new Set();
|
|
async function generateMissingThumbnails() {
|
|
if (!window.ModelViewer) return;
|
|
for (const m of models) {
|
|
if (m.thumbnailUrl || thumbAttempted.has(m.id)) continue;
|
|
const file = (m.files || []).find((f) => window.ModelViewer.RENDERABLE.includes(f.format));
|
|
if (!file) continue;
|
|
thumbAttempted.add(m.id);
|
|
try {
|
|
const src = viewerSourceFor(file.id, file.format);
|
|
const image = await window.ModelViewer.snapshot(src.url, src.format, 512);
|
|
if (!image) continue;
|
|
const res = await fetch(`/api/models/${m.id}/thumbnail`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ image }),
|
|
});
|
|
if (!res.ok) continue;
|
|
const updated = await res.json();
|
|
m.thumbnailUrl = updated.thumbnailUrl;
|
|
const thumb = fileGrid.querySelector(`.file-card[data-model-id="${m.id}"] .file-thumbnail`);
|
|
if (thumb && updated.thumbnailUrl) {
|
|
thumb.innerHTML = `<img src="${updated.thumbnailUrl}" alt="" loading="lazy">`;
|
|
}
|
|
} catch (err) {
|
|
console.warn('Thumbnail generation failed for model', m.id, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- rendering
|
|
function renderProjects() {
|
|
const items = projects.map((p) => `
|
|
<div class="project-item${String(selectedProject) === String(p.id) ? ' active' : ''}" data-project-id="${p.id}">
|
|
<span class="project-name">${escapeHtml(p.name)}</span>
|
|
<span class="project-meta">
|
|
<button class="collection-action collection-rename" title="Rename" aria-label="Rename collection">✎</button>
|
|
<button class="collection-action collection-delete" title="Delete" aria-label="Delete collection">×</button>
|
|
<span class="count" data-project-count="${p.id}">${p.model_count}</span>
|
|
</span>
|
|
</div>`).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) => `
|
|
<span class="tag${selectedTags.includes(tag) ? ' active' : ''}" data-tag="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`).join('');
|
|
}
|
|
|
|
function renderModels() {
|
|
const filtered = getFilteredModels();
|
|
if (filtered.length === 0) {
|
|
fileGrid.innerHTML = `
|
|
<div class="empty-state">
|
|
<h3>No models found</h3>
|
|
<p>Upload files or import from a URL to get started</p>
|
|
</div>`;
|
|
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
|
|
? `<img src="${m.thumbnailUrl}" alt="" loading="lazy">`
|
|
: '<span>📦</span>';
|
|
return `
|
|
<div class="file-card" data-model-id="${m.id}">
|
|
<div class="file-thumbnail">${thumb}</div>
|
|
<div class="file-info">
|
|
<div class="file-name">${escapeHtml(m.name)}</div>
|
|
${m.source_site ? `<span class="source-badge">${escapeHtml(m.source_site)}</span>` : ''}
|
|
${m.commercial_use === true ? '<span class="com-badge ok" title="Commercial use allowed">$ sellable</span>' : m.commercial_use === false ? '<span class="com-badge no" title="Non-commercial only">non-commercial</span>' : ''}
|
|
${m.description ? `<div class="file-description">${escapeHtml(m.description)}</div>` : ''}
|
|
${(m.tags && m.tags.length) ? `<div class="file-tags">${m.tags.map((t) => `<span class="file-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
|
|
<div class="file-meta">${m.files.length} file${m.files.length === 1 ? '' : 's'} • ${formatFileSize(totalSize)} • ${formatDate(m.date_added)}</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function getFilteredModels() {
|
|
let result = models;
|
|
if (selectedProject !== 'all') {
|
|
result = result.filter((m) => (m.projects || []).some((p) => String(p.id) === String(selectedProject)));
|
|
}
|
|
if (selectedTags.length > 0) {
|
|
result = result.filter((m) => selectedTags.some((t) => (m.tags || []).includes(t)));
|
|
}
|
|
const term = searchInput.value.toLowerCase().trim();
|
|
if (term) {
|
|
result = result.filter((m) =>
|
|
m.name.toLowerCase().includes(term) ||
|
|
(m.description && m.description.toLowerCase().includes(term)));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function updateCounts() {
|
|
document.getElementById('allFilesCount').textContent = models.length;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- events
|
|
function setupEventListeners() {
|
|
document.getElementById('uploadBtn').addEventListener('click', openUploadModal);
|
|
document.getElementById('newProjectBtn').addEventListener('click', openProjectModal);
|
|
document.getElementById('importBtn').addEventListener('click', openImportModal);
|
|
document.getElementById('settingsBtn').addEventListener('click', openSettingsModal);
|
|
document.getElementById('logoutBtn').addEventListener('click', handleLogout);
|
|
document.getElementById('settingsForm').addEventListener('submit', handleSaveSettings);
|
|
document.getElementById('cancelSettings').addEventListener('click', () => closeModal(settingsModal));
|
|
document.querySelectorAll('.clear-setting').forEach((btn) => {
|
|
btn.addEventListener('click', () => handleClearSetting(btn.dataset.setting));
|
|
});
|
|
|
|
document.querySelectorAll('.close-btn').forEach((btn) => {
|
|
btn.addEventListener('click', (e) => closeModal(e.target.closest('.modal')));
|
|
});
|
|
document.querySelectorAll('.modal').forEach((modal) => {
|
|
if (modal.id === 'setupModal') return; // must be finished or skipped, not backdrop-dismissed
|
|
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(modal); });
|
|
});
|
|
|
|
document.getElementById('setupForm').addEventListener('submit', handleFinishSetup);
|
|
document.getElementById('skipSetup').addEventListener('click', handleSkipSetup);
|
|
|
|
document.getElementById('uploadForm').addEventListener('submit', handleUpload);
|
|
document.getElementById('cancelUpload').addEventListener('click', () => closeModal(uploadModal));
|
|
document.getElementById('projectForm').addEventListener('submit', handleCreateProject);
|
|
document.getElementById('cancelProject').addEventListener('click', () => closeModal(projectModal));
|
|
document.getElementById('importForm').addEventListener('submit', handleImport);
|
|
document.getElementById('cancelImport').addEventListener('click', () => closeModal(importModal));
|
|
|
|
document.getElementById('fileInput').addEventListener('change', (e) => {
|
|
const f = e.target.files[0];
|
|
const nameField = document.getElementById('fileName');
|
|
if (f && !nameField.value) nameField.value = f.name.replace(/\.[^.]+$/, '');
|
|
});
|
|
|
|
projectsList.addEventListener('click', (e) => {
|
|
const item = e.target.closest('.project-item');
|
|
if (!item) return;
|
|
const id = item.dataset.projectId;
|
|
const name = item.querySelector('.project-name') ? item.querySelector('.project-name').textContent : 'All Models';
|
|
|
|
if (e.target.closest('.collection-rename')) { handleRenameCollection(id, name); return; }
|
|
if (e.target.closest('.collection-delete')) { handleDeleteCollection(id, name); return; }
|
|
|
|
document.querySelectorAll('.project-item').forEach((i) => i.classList.remove('active'));
|
|
item.classList.add('active');
|
|
selectedProject = id;
|
|
document.getElementById('contentTitle').textContent = id === 'all' ? 'All Models' : name;
|
|
renderModels();
|
|
});
|
|
|
|
tagsList.addEventListener('click', (e) => {
|
|
if (!e.target.classList.contains('tag')) return;
|
|
const tag = e.target.dataset.tag;
|
|
e.target.classList.toggle('active');
|
|
if (selectedTags.includes(tag)) selectedTags = selectedTags.filter((t) => t !== tag);
|
|
else selectedTags.push(tag);
|
|
renderModels();
|
|
});
|
|
|
|
searchInput.addEventListener('input', renderModels);
|
|
|
|
document.querySelectorAll('.view-btn').forEach((btn) => {
|
|
btn.addEventListener('click', (e) => {
|
|
document.querySelectorAll('.view-btn').forEach((b) => b.classList.remove('active'));
|
|
e.target.classList.add('active');
|
|
currentView = e.target.dataset.view;
|
|
renderModels();
|
|
});
|
|
});
|
|
|
|
fileGrid.addEventListener('click', (e) => {
|
|
const card = e.target.closest('.file-card');
|
|
if (card) openModelDetail(card.dataset.modelId);
|
|
});
|
|
|
|
document.getElementById('deleteFileBtn').addEventListener('click', handleDeleteModel);
|
|
setupTagAutocomplete(document.getElementById('fileTags'));
|
|
setupTagAutocomplete(document.getElementById('editTags'));
|
|
document.getElementById('regenThumbBtn').addEventListener('click', handleRegenThumbnail);
|
|
document.getElementById('refreshLicenseBtn').addEventListener('click', handleRefreshMetadata);
|
|
document.getElementById('editModelBtn').addEventListener('click', openEditModal);
|
|
document.getElementById('downloadFilesBtn').addEventListener('click', handleDownloadFiles);
|
|
document.getElementById('editForm').addEventListener('submit', handleEditModel);
|
|
document.getElementById('cancelEdit').addEventListener('click', () => closeModal(editModal));
|
|
document.getElementById('addFilesBtn').addEventListener('click', () => document.getElementById('addFilesInput').click());
|
|
document.getElementById('addFilesInput').addEventListener('change', handleAddFiles);
|
|
}
|
|
|
|
// Tracks the file currently shown in the detail viewer (for thumbnail regen).
|
|
let currentDetailFile = null;
|
|
let currentDetailThumb = null; // model image, used as a viewer fallback
|
|
|
|
function closeModal(modal) {
|
|
modal.classList.remove('active');
|
|
if (modal === fileDetailModal && window.ModelViewer) window.ModelViewer.dispose();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- modals
|
|
function openUploadModal() {
|
|
document.getElementById('fileProjects').innerHTML =
|
|
projects.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('');
|
|
uploadModal.classList.add('active');
|
|
}
|
|
|
|
function openProjectModal() {
|
|
document.getElementById('projectForm').reset();
|
|
projectModal.classList.add('active');
|
|
}
|
|
|
|
async function markSetupComplete() {
|
|
try {
|
|
await fetch('/api/setup/complete', { method: 'POST' });
|
|
config.setupComplete = true;
|
|
} catch { /* non-fatal */ }
|
|
}
|
|
|
|
async function handleFinishSetup(e) {
|
|
e.preventDefault();
|
|
const status = document.getElementById('setupStatus');
|
|
const payload = {};
|
|
document.querySelectorAll('#setupForm [data-setting]').forEach((input) => {
|
|
if (input.value.trim()) payload[input.dataset.setting] = input.value.trim();
|
|
});
|
|
try {
|
|
if (Object.keys(payload).length) {
|
|
const res = await fetch('/api/settings', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) throw new Error((await res.json()).error);
|
|
}
|
|
await markSetupComplete();
|
|
closeModal(setupModal);
|
|
await loadData();
|
|
} catch (err) {
|
|
status.textContent = 'Could not save: ' + err.message;
|
|
}
|
|
}
|
|
|
|
async function handleSkipSetup() {
|
|
await markSetupComplete();
|
|
closeModal(setupModal);
|
|
}
|
|
|
|
async function openSettingsModal() {
|
|
document.getElementById('settingsForm').reset();
|
|
document.getElementById('settingsStatus').textContent = '';
|
|
try {
|
|
const settings = await (await fetch('/api/settings')).json();
|
|
document.querySelectorAll('.setting-status').forEach((el) => {
|
|
const s = settings[el.dataset.status];
|
|
if (!s) { el.textContent = '—'; return; }
|
|
el.textContent = s.set
|
|
? `set ${s.hint || ''}${s.source === 'env' ? ' (from .env — edit here to override)' : ''}`
|
|
: 'not set';
|
|
el.className = `setting-status ${s.set ? 'is-set' : 'is-unset'}`;
|
|
});
|
|
// Prefill non-secret fields (e.g. the FlareSolverr URL) with their current value.
|
|
document.querySelectorAll('#settingsForm [data-setting]').forEach((input) => {
|
|
const s = settings[input.dataset.setting];
|
|
if (s && !s.secret && s.value) input.value = s.value;
|
|
});
|
|
await renderLicenseRules();
|
|
} catch (err) {
|
|
document.getElementById('settingsStatus').textContent = 'Could not load settings: ' + err.message;
|
|
}
|
|
settingsModal.classList.add('active');
|
|
}
|
|
|
|
function openImportModal() {
|
|
document.getElementById('importForm').reset();
|
|
document.getElementById('importStatus').textContent = '';
|
|
const tvEnabled = config.importers && config.importers.thingiverse;
|
|
const flareEnabled = config.importers && config.importers.flaresolverr;
|
|
const mw = flareEnabled ? 'MakerWorld' : 'MakerWorld (needs FlareSolverr in Settings)';
|
|
document.getElementById('importHint').innerHTML = tvEnabled
|
|
? `Thingiverse imports files automatically. Printables, ${mw} & other sites import the title, description & preview image — then add the files you downloaded.`
|
|
: `Printables, ${mw} & other sites import the title, description & preview image — then add the files you downloaded. Add a Thingiverse token in Settings for automatic file downloads.`;
|
|
document.getElementById('importSubmit').disabled = false;
|
|
importModal.classList.add('active');
|
|
}
|
|
|
|
function openModelDetail(modelId) {
|
|
const model = models.find((m) => String(m.id) === String(modelId));
|
|
if (!model) return;
|
|
fileDetailModal.dataset.currentModelId = modelId;
|
|
currentDetailThumb = model.thumbnailUrl || null;
|
|
|
|
document.getElementById('detailFileName').textContent = model.name;
|
|
document.getElementById('detailDescription').textContent = model.description || 'No description';
|
|
|
|
// Source
|
|
const src = document.getElementById('detailSource');
|
|
if (model.source_url) {
|
|
const designer = model.designer ? ` by ${escapeHtml(model.designer.name)}` : '';
|
|
src.innerHTML = `<a href="${escapeHtml(model.source_url)}" target="_blank" rel="noopener">${escapeHtml(model.source_site || 'link')}${designer}</a>`;
|
|
} 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) => `<span class="tag">${escapeHtml(t)}</span>`).join('') : 'No tags';
|
|
const ps = model.projects || [];
|
|
document.getElementById('detailProjects').innerHTML = ps.length
|
|
? ps.map((p) => `<div>${escapeHtml(p.name)}</div>`).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]) => `<div><strong>${escapeHtml(k)}:</strong> ${escapeHtml(String(v))}</div>`).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 = `<div class="lic-note">${hint}</div>`;
|
|
} else {
|
|
const downloadAll = model.files.length > 1
|
|
? `<a class="btn btn-secondary btn-sm download-all" href="/api/models/${model.id}/download">Download all ${model.files.length} files (.zip)</a>`
|
|
: '';
|
|
fileInfoEl.innerHTML = downloadAll + model.files.map((f) => `
|
|
<div class="file-row">
|
|
<span>${escapeHtml(f.originalName)} <em>(${formatFileSize(f.size)}${f.volume ? `, ${Number(f.volume).toFixed(2)} cm³` : ''})</em></span>
|
|
<a href="/api/files/${f.id}/raw" download>download</a>
|
|
</div>`).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) => `
|
|
<button class="file-tab${i === 0 ? ' active' : ''}" data-file-id="${f.id}" data-format="${f.format}">${escapeHtml(f.originalName)}</button>`).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
|
|
? '<span class="lic-badge ok">Commercial use allowed</span>'
|
|
: com === false
|
|
? '<span class="lic-badge no">Non-commercial only</span>'
|
|
: '<span class="lic-badge unknown">Commercial use unclear</span>';
|
|
document.getElementById('detailLicense').innerHTML =
|
|
`<div>${model.license ? escapeHtml(model.license) : 'Not specified'} ${badge}</div>` +
|
|
'<div class="lic-note">A guess from the license name — always confirm on the source page before selling.</div>';
|
|
}
|
|
|
|
function openEditModal() {
|
|
const id = fileDetailModal.dataset.currentModelId;
|
|
const model = models.find((m) => String(m.id) === String(id));
|
|
if (!model) return;
|
|
editModal.dataset.editId = id;
|
|
|
|
document.getElementById('editName').value = model.name || '';
|
|
document.getElementById('editDescription').value = model.description || '';
|
|
document.getElementById('editTags').value = (model.tags || []).join(', ');
|
|
document.getElementById('editLicense').value = model.license || '';
|
|
|
|
const ps = model.print_settings || {};
|
|
document.getElementById('editMaterial').value = ps.material || '';
|
|
document.getElementById('editLayerHeight').value = ps.layerHeight || '';
|
|
document.getElementById('editInfill').value = ps.infill || '';
|
|
document.getElementById('editSupports').value = ps.supports || '';
|
|
|
|
const current = new Set((model.projects || []).map((p) => String(p.id)));
|
|
document.getElementById('editProjects').innerHTML = projects
|
|
.map((p) => `<option value="${p.id}"${current.has(String(p.id)) ? ' selected' : ''}>${escapeHtml(p.name)}</option>`)
|
|
.join('');
|
|
|
|
editModal.classList.add('active');
|
|
}
|
|
|
|
async function handleEditModel(e) {
|
|
e.preventDefault();
|
|
const id = editModal.dataset.editId;
|
|
const body = {
|
|
name: document.getElementById('editName').value,
|
|
description: document.getElementById('editDescription').value,
|
|
license: document.getElementById('editLicense').value || null,
|
|
tags: splitTags(document.getElementById('editTags').value),
|
|
projects: Array.from(document.getElementById('editProjects').selectedOptions).map((o) => Number(o.value)),
|
|
print_settings: {
|
|
material: document.getElementById('editMaterial').value,
|
|
layerHeight: document.getElementById('editLayerHeight').value,
|
|
infill: document.getElementById('editInfill').value,
|
|
supports: document.getElementById('editSupports').value,
|
|
},
|
|
};
|
|
try {
|
|
const res = await fetch(`/api/models/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) throw new Error((await res.json()).error);
|
|
closeModal(editModal);
|
|
await loadData();
|
|
openModelDetail(id); // refresh the (still-open) detail view
|
|
} catch (err) {
|
|
alert('Failed to save changes: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function handleDownloadFiles() {
|
|
const id = fileDetailModal.dataset.currentModelId;
|
|
const btn = document.getElementById('downloadFilesBtn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Downloading… (this can take a moment)';
|
|
try {
|
|
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error);
|
|
await loadData();
|
|
openModelDetail(id);
|
|
if (data.added === 0) alert('No new files were found (the source returned nothing new).');
|
|
} catch (err) {
|
|
alert('Download failed: ' + err.message);
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function handleRefreshMetadata() {
|
|
const modelId = fileDetailModal.dataset.currentModelId;
|
|
const btn = document.getElementById('refreshLicenseBtn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Refreshing…';
|
|
try {
|
|
const res = await fetch(`/api/models/${modelId}/refresh-metadata`, { method: 'POST' });
|
|
if (!res.ok) throw new Error((await res.json()).error);
|
|
const updated = await res.json();
|
|
// update in-memory state, the license panel, and the card badge
|
|
const idx = models.findIndex((m) => String(m.id) === String(modelId));
|
|
if (idx !== -1) models[idx] = updated;
|
|
renderDetailLicense(updated);
|
|
renderModels();
|
|
btn.textContent = 'Updated ✓';
|
|
setTimeout(() => { btn.textContent = 'Refresh from source'; }, 1500);
|
|
} catch (err) {
|
|
btn.textContent = 'Refresh failed';
|
|
console.warn('Refresh metadata failed:', err.message);
|
|
setTimeout(() => { btn.textContent = 'Refresh from source'; }, 1800);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function loadViewer(fileId, format) {
|
|
const src = viewerSourceFor(fileId, format);
|
|
currentDetailFile = { id: fileId, viewerUrl: src.url, viewerFormat: src.format };
|
|
document.getElementById('regenThumbBtn').style.display = '';
|
|
window.ModelViewer.render(document.getElementById('detailViewer'), src.url, src.format, { fallbackImage: currentDetailThumb });
|
|
}
|
|
|
|
function updateCardThumbnail(modelId, thumbnailUrl) {
|
|
const m = models.find((x) => String(x.id) === String(modelId));
|
|
if (m) m.thumbnailUrl = thumbnailUrl;
|
|
const thumb = fileGrid.querySelector(`.file-card[data-model-id="${modelId}"] .file-thumbnail`);
|
|
if (thumb && thumbnailUrl) thumb.innerHTML = `<img src="${thumbnailUrl}" alt="" loading="lazy">`;
|
|
}
|
|
|
|
async function handleRegenThumbnail() {
|
|
const modelId = fileDetailModal.dataset.currentModelId;
|
|
if (!currentDetailFile) { alert('No previewable file to render.'); return; }
|
|
const btn = document.getElementById('regenThumbBtn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Rendering…';
|
|
try {
|
|
const image = await window.ModelViewer.snapshot(currentDetailFile.viewerUrl, currentDetailFile.viewerFormat, 512);
|
|
const res = await fetch(`/api/models/${modelId}/thumbnail`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ image }),
|
|
});
|
|
if (!res.ok) throw new Error((await res.json()).error);
|
|
const updated = await res.json();
|
|
updateCardThumbnail(modelId, updated.thumbnailUrl);
|
|
thumbAttempted.add(Number(modelId));
|
|
btn.textContent = 'Done ✓';
|
|
setTimeout(() => { btn.textContent = 'Regenerate thumbnail'; }, 1500);
|
|
} catch (err) {
|
|
console.warn('Thumbnail regen failed:', err.message);
|
|
btn.textContent = "Can't render this file";
|
|
setTimeout(() => { btn.textContent = 'Regenerate thumbnail'; }, 1800);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function handleAddFiles(e) {
|
|
const input = e.target;
|
|
if (!input.files.length) return;
|
|
const modelId = fileDetailModal.dataset.currentModelId;
|
|
const formData = new FormData();
|
|
Array.from(input.files).forEach((f) => formData.append('files', f));
|
|
try {
|
|
const res = await fetch(`/api/models/${modelId}/files`, { method: 'POST', body: formData });
|
|
if (!res.ok) throw new Error((await res.json()).error);
|
|
input.value = '';
|
|
await loadData();
|
|
openModelDetail(modelId); // reopen with the new files
|
|
} catch (err) {
|
|
alert('Failed to add files: ' + err.message);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- handlers
|
|
async function handleUpload(e) {
|
|
e.preventDefault();
|
|
const formData = new FormData();
|
|
const input = document.getElementById('fileInput');
|
|
Array.from(input.files).forEach((f) => formData.append('files', f));
|
|
formData.append('name', document.getElementById('fileName').value);
|
|
formData.append('description', document.getElementById('fileDescription').value);
|
|
formData.append('tags', JSON.stringify(splitTags(document.getElementById('fileTags').value)));
|
|
formData.append('projects', JSON.stringify(
|
|
Array.from(document.getElementById('fileProjects').selectedOptions).map((o) => Number(o.value))));
|
|
formData.append('printSettings', JSON.stringify({
|
|
material: document.getElementById('printMaterial').value,
|
|
layerHeight: document.getElementById('printLayerHeight').value,
|
|
infill: document.getElementById('printInfill').value,
|
|
supports: document.getElementById('printSupports').value,
|
|
}));
|
|
|
|
try {
|
|
const res = await fetch('/api/models', { method: 'POST', body: formData });
|
|
if (res.ok) {
|
|
closeModal(uploadModal);
|
|
document.getElementById('uploadForm').reset();
|
|
await loadData();
|
|
} else {
|
|
alert('Upload failed: ' + (await res.json()).error);
|
|
}
|
|
} catch (err) {
|
|
alert('Upload failed: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function handleImport(e) {
|
|
e.preventDefault();
|
|
const status = document.getElementById('importStatus');
|
|
const submit = document.getElementById('importSubmit');
|
|
status.textContent = 'Importing… fetching metadata and downloading files.';
|
|
submit.disabled = true;
|
|
try {
|
|
const res = await fetch('/api/import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url: document.getElementById('importUrl').value }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
await loadData();
|
|
if (data.metadataOnly) {
|
|
status.textContent = data.metadataFetched
|
|
? `Imported "${data.name}". Opening it so you can add the downloaded files…`
|
|
: `Couldn't read that page (the site blocks automated access), so I created a stub named "${data.name}". Opening it so you can add files…`;
|
|
setTimeout(() => { closeModal(importModal); openModelDetail(data.id); }, 1200);
|
|
} else {
|
|
status.textContent = `Imported "${data.name}" with ${data.files.length} file(s).`;
|
|
setTimeout(() => closeModal(importModal), 900);
|
|
}
|
|
} else if (res.status === 409) {
|
|
status.textContent = 'That model is already in your library.';
|
|
} else {
|
|
status.textContent = 'Import failed: ' + data.error;
|
|
}
|
|
} catch (err) {
|
|
status.textContent = 'Import failed: ' + err.message;
|
|
} finally {
|
|
submit.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function renderLicenseRules() {
|
|
const wrap = document.getElementById('licenseRules');
|
|
let rules;
|
|
try { rules = await (await fetch('/api/licenses')).json(); }
|
|
catch { wrap.textContent = 'Could not load licenses.'; return; }
|
|
if (!rules.length) { wrap.innerHTML = '<p class="form-hint">No licenses yet — import some models.</p>'; return; }
|
|
const opt = (v, sel) => `<option value="${v}"${sel ? ' selected' : ''}>`;
|
|
wrap.innerHTML = rules.map((r) => {
|
|
const cur = r.commercial === true ? 'true' : r.commercial === false ? 'false' : '';
|
|
return `<div class="license-row">
|
|
<span class="license-name" title="${escapeHtml(r.name)}">${escapeHtml(r.name)} <em>(${r.model_count})</em></span>
|
|
<select class="license-select" data-license="${escapeHtml(r.name)}">
|
|
${opt('true', cur === 'true')}Sellable</option>
|
|
${opt('false', cur === 'false')}Non-commercial</option>
|
|
${opt('', cur === '')}Unknown</option>
|
|
</select>
|
|
</div>`;
|
|
}).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);
|
|
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
|
|
// 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) => `<div class="tag-suggest-item" data-tag="${escapeHtml(t)}">${escapeHtml(t)}</div>`).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' });
|
|
}
|