diff --git a/public/app.js b/public/app.js index 1660e51..d035f69 100644 --- a/public/app.js +++ b/public/app.js @@ -71,7 +71,13 @@ function startApp() { document.getElementById('authScreen').style.display = 'none'; document.querySelector('.container').style.display = ''; if (!appStarted) { setupEventListeners(); appStarted = true; } - loadData(); + selectedScope = scopeFromHash(); + const model = modelFromHash(); // read now: drawing the page rewrites the address + loadData().then(() => { + if (!model) return; + if (findModel(model)) openModelDetail(model, { push: false }); + else syncAddress(); // gone, or in the trash: drop it from the address + }); } function showAuth(mode) { @@ -460,8 +466,9 @@ function scopeTitle() { } function updateScopeChrome() { + syncAddress(); // e.g. a deleted collection falling back to All Models contentTitle.textContent = scopeTitle(); - const panel = ['duplicates', 'ai', 'watch', 'printers'].includes(selectedScope); // pages without the model grid + const panel = ['duplicates', 'ai', 'watch', 'filament', 'printers'].includes(selectedScope); // pages without the model grid document.getElementById('filterBar').hidden = panel; document.querySelector('.view-options').hidden = panel; emptyTrashBtn.hidden = !(selectedScope === 'trash' && trashModels.length); @@ -977,12 +984,60 @@ function setupEventListeners() { document.getElementById('addFilesInput').addEventListener('change', handleAddFiles); } -async function selectScope(scope) { +// The page you're on lives in the address — #filament, #collection/12, and +// /model/34 on the end while a model is open — so a refresh or a bookmark comes +// back to it, and Back and Forward move between pages and models. +let shownModelId = null; // the model open in the detail view +let modelStepPushed = false; // opening it here added a Back step + +function scopeHash(scope, modelId = null) { + const page = scope === 'all' ? '' : SPECIAL_SCOPES.includes(scope) ? scope : `collection/${scope}`; + const parts = [page, modelId ? `model/${modelId}` : ''].filter(Boolean); + return parts.length ? `#${parts.join('/')}` : ''; +} + +function hashParts() { + const h = decodeURIComponent(location.hash.slice(1)); + const m = h.match(/^(.*?)\/?model\/(\d+)$/); + return { page: m ? m[1] : h, model: m ? Number(m[2]) : null }; +} + +function scopeFromHash() { + const { page } = hashParts(); + const collection = page.match(/^collection\/(\d+)$/); + if (collection) return collection[1]; + return SPECIAL_SCOPES.includes(page) ? page : 'all'; +} + +const modelFromHash = () => hashParts().model; + +// Keeps the address in step with what's shown; `push` makes a Back step. +function syncAddress(push = false) { + const hash = scopeHash(String(selectedScope), shownModelId); + if (location.hash === hash || (!hash && !location.hash)) return; + history[push ? 'pushState' : 'replaceState']({ model: shownModelId }, '', `${location.pathname}${location.search}${hash}`); +} + +async function selectScope(scope, { push = true } = {}) { selectedScope = scope; + syncAddress(push); if (scope === 'trash') await loadTrash(); renderModels(); } +window.addEventListener('popstate', () => { + if (!appStarted) return; + const scope = scopeFromHash(); + const model = modelFromHash(); + if (String(scope) !== String(selectedScope)) selectScope(scope, { push: false }); + if (model && model !== shownModelId) { + openModelDetail(model, { push: false }); + } else if (!model && shownModelId) { + modelStepPushed = false; // Back already took the step away + closeModal(fileDetailModal); + } +}); + // 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 @@ -990,6 +1045,13 @@ let currentDetailThumb = null; // model image, used as a viewer fallback function closeModal(modal) { modal.classList.remove('active'); if (modal === slicerModal) pendingHandoff = null; + if (modal === fileDetailModal && shownModelId) { + shownModelId = null; + // Undo the step opening it added, so Back doesn't reopen what was just closed. + if (modelStepPushed && history.state && history.state.model) history.back(); + else syncAddress(); + modelStepPushed = false; + } if (modal === fileDetailModal) { hideSlicerNotice(); flushNotes(); @@ -1125,9 +1187,16 @@ function openImportModal() { importModal.classList.add('active'); } -function openModelDetail(modelId) { +function openModelDetail(modelId, { push = true } = {}) { const model = findModel(modelId); if (!model) return; + // Reopening the same model (to refresh it) leaves the address alone. + if (shownModelId !== Number(model.id)) { + const replacing = shownModelId !== null; // one model to the next: no extra Back step + shownModelId = Number(model.id); + syncAddress(push && !replacing); + if (push && !replacing) modelStepPushed = true; + } flushNotes(); // save any edit still pending for the previously open model fileDetailModal.dataset.currentModelId = modelId; currentDetailThumb = model.thumbnailUrl || null;