Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions frontend/src/_util/recent-pages-history.js
Original file line number Diff line number Diff line change
@@ -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
};
69 changes: 68 additions & 1 deletion frontend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<app-component />'
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -233,6 +296,10 @@ router.beforeEach((to, from, next) => {
}
});

router.afterEach((to) => {
trackRecentPage(to);
});

app.config.globalProperties = { format, arrayUtils, $toast: toast };
app.use(router);

Expand Down
22 changes: 21 additions & 1 deletion frontend/src/modal/modal.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

.modal-mask {
position: fixed;
z-index: 9998;
z-index: 10050;
top: 0;
left: 0;
width: 100%;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -79,6 +98,7 @@

.modal-container .modal-exit {
position: absolute;
z-index: 2;
right: 0.25em;
top: 0.25em;
cursor: pointer;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/modal/modal.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<div class="modal-mask">
<div class="modal-wrapper">
<div class="modal-container" :class="containerClass">
<div class="modal-body">
<div class="modal-body" :class="bodyClass">
<slot name="body">
</slot>
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/modal/modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ appendCSS(require('./modal.css'));

module.exports = app => app.component('modal', {
template,
props: ['containerClass'],
props: ['containerClass', 'bodyClass'],
mounted() {
window.addEventListener('keydown', this.onEscape);
},
Expand Down
74 changes: 72 additions & 2 deletions frontend/src/navbar/navbar.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<div>
<div class="navbar w-full bg-page border-b border-edge !h-[55px] hidden md:grid grid-cols-[1fr_auto_1fr] items-stretch px-4">
<!-- Left: Logo + Brand -->
<div class="flex items-center gap-2 self-center">
Expand Down Expand Up @@ -67,6 +68,19 @@
</nav>
<!-- Right: Env Badge + User -->
<div class="flex items-center justify-end gap-3 self-center">
<button
type="button"
class="inline-flex items-center justify-center rounded-md p-2 text-content-tertiary hover:bg-muted hover:text-content-secondary"
title="Recent pages"
aria-label="Recent pages"
aria-haspopup="dialog"
:aria-expanded="showRecentPagesModal ? 'true' : 'false'"
@click="openRecentPagesModal"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-5 w-5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<div
v-if="!!state.nodeEnv"
title="NODE_ENV"
Expand Down Expand Up @@ -149,9 +163,22 @@
<span class="text-base font-semibold text-content">Mongoose Studio</span>
</router-link>
</div>
<div class="flex items-center">
<div class="flex items-center gap-1 pr-2">
<button
type="button"
class="rounded-md p-2 text-content-tertiary hover:bg-muted hover:text-content-secondary md:hidden"
title="Recent pages"
aria-label="Recent pages"
aria-haspopup="dialog"
:aria-expanded="showRecentPagesModal ? 'true' : 'false'"
@click="openRecentPagesModal"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<!-- Mobile menu toggle, controls the 'mobileMenuOpen' state. -->
<button type="button" id="open-mobile-menu" class="-ml-2 rounded-md p-2 pr-4 text-gray-400">
<button type="button" id="open-mobile-menu" class="-ml-2 rounded-md p-2 text-gray-400">
<span class="sr-only">Open menu</span>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
Expand Down Expand Up @@ -266,3 +293,46 @@
</nav>
</div>
</div>

<modal
v-if="showRecentPagesModal"
containerClass="!max-w-md !w-[min(94vw,28rem)] !rounded-lg !border !border-edge !bg-surface !p-0 !shadow-xl dark:!bg-shark-900 text-content"
bodyClass="modal-recent-pages-body !p-4 !pt-5"
>
<template v-slot:body>
<div class="modal-exit" @click="closeRecentPagesModal" role="button" aria-label="Close recent pages">&times;</div>
<div
class="shrink-0 -mx-1 mb-3 flex items-center justify-between gap-3 border-b border-edge bg-surface px-1 pb-3 dark:bg-shark-900"
>
<h2 class="m-0 text-lg font-semibold text-content">Recent pages</h2>
<button
type="button"
class="rounded px-3 py-1.5 text-sm font-medium text-content-secondary hover:bg-muted"
@click="clearRecentPagesHistory"
:disabled="recentPagesList.length === 0"
>
Clear
</button>
</div>
<div class="modal-recent-pages-scroll px-0.5">
<div
v-if="recentPagesList.length === 0"
class="rounded border border-dashed border-edge px-3 py-6 text-center text-sm text-content-tertiary"
>
No recent pages yet.
</div>
<button
v-for="entry in recentPagesList"
:key="entry.path + '-' + entry.visitedAt"
type="button"
class="mb-1 w-full rounded px-3 py-2.5 text-left hover:bg-muted"
:class="entry.path === $route.fullPath ? 'bg-primary-subtle' : ''"
@click="goToRecentPage(entry)"
>
<div class="truncate text-sm font-medium text-content">{{ entry.label }}</div>
<div class="truncate font-mono text-xs text-content-tertiary">{{ entry.path }}</div>
</button>
</div>
</template>
</modal>
</div>
Loading
Loading