diff --git a/frontend/src/_util/recent-pages-history.js b/frontend/src/_util/recent-pages-history.js new file mode 100644 index 00000000..2aba7887 --- /dev/null +++ b/frontend/src/_util/recent-pages-history.js @@ -0,0 +1,116 @@ +'use strict'; + +const RECENT_PAGES_STORAGE_KEY = 'studio:recent-pages-history'; +const RECENT_PAGES_CHANGED_EVENT = 'studio-recent-pages-changed'; +const RECENT_PAGES_LOCK_NAME = `studio-lock:${RECENT_PAGES_STORAGE_KEY}`; +const MAX_RECENT_PAGES = 10; + +/** Collapses hash-style prefixes (e.g. `/#/`) to a normal path; keeps query string. */ +function normalizeTrackedPath(fullPath) { + if (!fullPath || typeof fullPath !== 'string') { + return '/'; + } + const qIndex = fullPath.indexOf('?'); + const rawMain = qIndex === -1 ? fullPath : fullPath.slice(0, qIndex); + const qs = qIndex === -1 ? '' : fullPath.slice(qIndex); + + let pathOnly = rawMain.trim().replace(/#/g, ''); + pathOnly = pathOnly.replace(/^\/+/u, '/'); + if (pathOnly !== '/' && /\/$/u.test(pathOnly)) { + pathOnly = pathOnly.replace(/\/+$/u, ''); + } + if (pathOnly === '' || pathOnly === '/') { + pathOnly = '/'; + } else if (!pathOnly.startsWith('/')) { + pathOnly = `/${pathOnly}`; + } + + const out = qs ? pathOnly + qs : pathOnly; + return out; +} + +function safeReadRecentPages() { + if (typeof window === 'undefined' || !window.localStorage) { + return []; + } + try { + const raw = window.localStorage.getItem(RECENT_PAGES_STORAGE_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .map(entry => { + if (!entry || typeof entry.path !== 'string') { + return null; + } + return { ...entry, path: normalizeTrackedPath(entry.path) }; + }) + .filter( + entry => + entry && + typeof entry.label === 'string' && + typeof entry.visitedAt === 'number' && + entry.path !== '' + ) + .slice(0, MAX_RECENT_PAGES); + } catch { + return []; + } +} + +function notifyRecentPagesChangedFromThisTab() { + if (typeof window === 'undefined') { + return; + } + try { + window.dispatchEvent(new CustomEvent(RECENT_PAGES_CHANGED_EVENT)); + } catch { + /* ignore */ + } +} + +function saveRecentPages(entries) { + if (typeof window === 'undefined' || !window.localStorage) { + return; + } + window.localStorage.setItem( + RECENT_PAGES_STORAGE_KEY, + JSON.stringify(entries.slice(0, MAX_RECENT_PAGES)) + ); + notifyRecentPagesChangedFromThisTab(); +} + +function withRecentPagesStorageLock(syncFn) { + if (typeof window === 'undefined' || !window.localStorage) { + syncFn(); + return Promise.resolve(); + } + + try { + const locksApi = + typeof navigator !== 'undefined' && navigator.locks !== undefined ? navigator.locks : null; + + if (locksApi !== null && typeof locksApi.request === 'function') { + return locksApi.request(RECENT_PAGES_LOCK_NAME, syncFn); + } + } catch { + /* fall through */ + } + + syncFn(); + return Promise.resolve(); +} + +module.exports = { + RECENT_PAGES_STORAGE_KEY, + RECENT_PAGES_CHANGED_EVENT, + MAX_RECENT_PAGES, + normalizeTrackedPath, + safeReadRecentPages, + saveRecentPages, + withRecentPagesStorageLock +}; diff --git a/frontend/src/index.js b/frontend/src/index.js index 0a88f1d1..4a487e3d 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -15,8 +15,64 @@ const { routes, hasAccess } = require('./routes'); const Toast = require('vue-toastification').default; const { useToast } = require('vue-toastification'); const appendCSS = require('./appendCSS'); +const { + safeReadRecentPages, + saveRecentPages, + MAX_RECENT_PAGES, + normalizeTrackedPath, + withRecentPagesStorageLock +} = require('./_util/recent-pages-history'); appendCSS(require('vue-toastification/dist/index.css')); +const TRACKED_RECENT_PAGE_ROUTE_NAMES = new Set(['model', 'document', 'dashboard', 'chat']); + +function formatHistoryLabel(route) { + if (!route || typeof route.path !== 'string') { + return 'Unknown page'; + } + if (route.name === 'model') { + return route.params?.model ? `Model: ${route.params.model}` : 'Model'; + } + if (route.name === 'document') { + const model = route.params?.model ? `${route.params.model} ` : ''; + const documentId = route.params?.documentId ? String(route.params.documentId) : ''; + const shortDocumentId = documentId ? documentId.slice(0, 8) : ''; + return `Document: ${model}${shortDocumentId}`.trim(); + } + if (route.name === 'dashboard') { + return route.params?.dashboardId ? `Dashboard: ${route.params.dashboardId}` : 'Dashboard'; + } + if (route.name === 'chat') { + return route.params?.threadId ? `Chat: ${route.params.threadId}` : 'Chat'; + } + const normalizedPath = route.path.replace(/^\//, ''); + return normalizedPath || 'Home'; +} + +function trackRecentPage(route) { + if (!route || typeof route.path !== 'string') { + return; + } + if (!TRACKED_RECENT_PAGE_ROUTE_NAMES.has(route.name)) { + return; + } + if (typeof route.path === 'string' && route.path.includes('code=')) { + return; + } + const path = normalizeTrackedPath(route.fullPath || route.path || '/'); + const label = formatHistoryLabel(route); + const visitedAt = Date.now(); + + void withRecentPagesStorageLock(() => { + const existing = safeReadRecentPages(); + const deduped = existing.filter(entry => normalizeTrackedPath(entry.path) !== path); + const next = [...deduped, { path, label, visitedAt }] + .sort((a, b) => b.visitedAt - a.visitedAt) + .slice(0, MAX_RECENT_PAGES); + saveRecentPages(next); + }); +} + const app = Vue.createApp({ template: '' }); @@ -169,7 +225,14 @@ app.component('app-component', { const authError = Vue.ref(null); const modelSchemaPaths = Vue.ref(null); - const state = Vue.reactive({ user, roles, status, nodeEnv, authError, modelSchemaPaths }); + const state = Vue.reactive({ + user, + roles, + status, + nodeEnv, + authError, + modelSchemaPaths + }); Vue.provide('state', state); return state; @@ -233,6 +296,10 @@ router.beforeEach((to, from, next) => { } }); +router.afterEach((to) => { + trackRecentPage(to); +}); + app.config.globalProperties = { format, arrayUtils, $toast: toast }; app.use(router); diff --git a/frontend/src/modal/modal.css b/frontend/src/modal/modal.css index 5088b7bc..2a6fb072 100644 --- a/frontend/src/modal/modal.css +++ b/frontend/src/modal/modal.css @@ -2,7 +2,7 @@ .modal-mask { position: fixed; - z-index: 9998; + z-index: 10050; top: 0; left: 0; width: 100%; @@ -50,6 +50,25 @@ overflow: auto; } +/* Recent pages: one scroll region — inner .modal-recent-pages-scroll only (avoids double scrollbars with .modal-body overflow). */ +.modal-body.modal-recent-pages-body { + box-sizing: border-box; + display: flex; + flex-direction: column; + margin: 0; + height: min(48vh, 18rem); + max-height: min(48vh, 18rem); + overflow: hidden; +} + +.modal-body.modal-recent-pages-body .modal-recent-pages-scroll { + flex: 1 1 0; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + .modal__button--default { float: right; } @@ -79,6 +98,7 @@ .modal-container .modal-exit { position: absolute; + z-index: 2; right: 0.25em; top: 0.25em; cursor: pointer; diff --git a/frontend/src/modal/modal.html b/frontend/src/modal/modal.html index bfb1c34d..95e0adcb 100644 --- a/frontend/src/modal/modal.html +++ b/frontend/src/modal/modal.html @@ -2,7 +2,7 @@