From 2f5d3b426b1ca5aa74cc9f68e27a6df6c7ea55a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:25:02 +0000 Subject: [PATCH] Feat(tools): open the Advanced Tools as pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every way into a tool — the homepage cards, the nav dropdown and mobile sheet, the keyboard shortcuts, ASN Info's profile link, the banners — navigates to /tools/; the bottom drawer, its full-screen toggle and the ?tool= query it was driven by are gone from Advanced.vue. The pages stay alive (App.vue / ToolPage.vue), so browser back and forward move between tools and the homepage, each restored as it was left. Legacy /?tool= links redirect to the page in a router beforeEach (utils/legacy-tool-link.js: known slug → its page with the remaining query, unknown → the homepage). Persona Check drops noStandalone: with the homepage kept alive its dependency commands stay registered, so the page dispatches them in place and only a direct visit navigates home first. The four sign-in tools no longer close a drawer before opening the usage dialog, which PageHeader's User hosts. Changelog entries in the six full locales; frontend/AGENTS.md describes the page model. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco --- frontend/AGENTS.md | 50 ++++--- frontend/components/Advanced.vue | 141 ++---------------- frontend/components/Home.vue | 2 +- frontend/components/Nav.vue | 21 +-- frontend/components/PageHeader.vue | 2 +- frontend/components/ToolPage.vue | 22 +-- frontend/components/ToolsMenu.vue | 7 +- .../components/advanced-tools/AsnProfile.vue | 2 +- .../advanced-tools/EnhancedDnsLeakTest.vue | 8 +- .../advanced-tools/GlobalLatencyTest.vue | 6 +- .../advanced-tools/InvisibilityTest.vue | 8 +- .../components/advanced-tools/IpBlocklist.vue | 14 +- .../advanced-tools/IpCalculator.vue | 8 +- .../components/advanced-tools/MtrTest.vue | 6 +- .../advanced-tools/PersonaCheck.vue | 20 +-- .../advanced-tools/SecurityChecklist.vue | 46 ++---- .../advanced-tools/ServiceStatus.vue | 10 +- .../components/ip-infos/ASNConnectivity.vue | 2 +- frontend/components/ip-infos/ASNInfo.vue | 14 +- .../components/ip-infos/IpDetailPanel.vue | 6 +- .../composables/use-globalping-measurement.js | 5 +- frontend/composables/use-shortcuts.js | 5 +- frontend/data/changelog.json | 22 +++ frontend/data/tools.js | 26 ++-- frontend/locales/en.json | 1 - frontend/locales/fr.json | 1 - frontend/locales/pt-BR.json | 1 - frontend/locales/ru.json | 1 - frontend/locales/zh-TW.json | 1 - frontend/locales/zh.json | 1 - frontend/router/index.js | 26 +++- frontend/utils/banners.js | 2 +- frontend/utils/legacy-tool-link.js | 28 ++++ frontend/utils/tool-availability.js | 12 +- index.html | 3 +- tests/back-target.test.js | 2 +- tests/banners.test.js | 2 +- tests/legacy-tool-link.test.js | 47 ++++++ tests/tool-availability.test.js | 20 +-- 39 files changed, 263 insertions(+), 338 deletions(-) create mode 100644 frontend/utils/legacy-tool-link.js create mode 100644 tests/legacy-tool-link.test.js diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 5fdaa741..f1f5b370 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -15,7 +15,7 @@ frontend/ ├── App.vue / main.js / store.js / router/ / locales/ / style/style.css ├── firebase-init.js ← env-gated lazy Firebase Auth (boot path: utils/auth-hint.js) ├── sentry-init.js ← env-gated Sentry (see "Error monitoring") -├── data/ ← static config (tools registry drives router+cards+drawer) +├── data/ ← static config (tools registry drives pages+cards+menus) ├── lib/ · utils/ · composables/ ← see "Helper placement" └── components/ ← sections + ip-infos/ advanced-tools/ report/ widgets/ svgicons/ ui/ ``` @@ -60,12 +60,32 @@ reserved `auth` / `quota` / `input` (the bus adds `unavailable` / `timeout`). Cross-component triggers use the bus, never template refs (refs stay for UI chrome); tools register at setup, callers `waitForAppCommand` first. -### Advanced Tools gates decide listing only +### Advanced Tools are pages, kept alive -`data/tools.js` gates (`requiresOriginalSite`, `requiresConfig: ''`, -e.g. `asn` → `cloudFlare`) are read only via `isToolAvailable()` / -`listedTools()`, by the card grid, the nav and `ToolsMenu`; deep links -(`?tool=`, `/tools/:slug`) are never gated. +- Every tool is a page at `/tools/:slug` (`ToolPage.vue` over the + `data/tools.js` registry), and every opener routes there: cards are real + `` (a plain click `router.push`es, modifier clicks stay the + browser's), Nav, `ToolsMenu`, shortcuts (`Advanced.vue`'s exposed + `openTool`), in-app links. `/?tool=` survives only as a replace-redirect + in the router's `beforeEach` (`utils/legacy-tool-link.js`). No tool opens as + an overlay. +- Gates (`requiresOriginalSite`, `requiresConfig: ''`, e.g. `asn` → + `cloudFlare`) decide listing only, via `isToolAvailable()` / `listedTools()` + (card grid, Nav, `ToolsMenu`); a `/tools/:slug` link is never gated. +- `App.vue` keeps Home and `ToolPage` alive (`KeepAlive`); `ToolPage` keeps up + to 8 tools alive per slug, dropped on sign-out / account switch + (`utils/tool-cache.js`). Back / forward is plain history; the header's back + steps back when the previous entry is Home (`utils/back-target.js`), so Home + returns as left, scroll included. +- A cached page never unmounts, so whatever acts on the route, the document + head, window events or the store's one-shot triggers gates on + `use-route-active.js` (`useRouteActive` / `useActiveValue` / + `useActiveEventListener`), not on mount / unmount. Overlays on a page being + left are closed by `use-overlay-shortcuts.js` — their portal would outlive it. +- Homepage state reaches a tool page through the store, the app-events + collectors and the command bus: a cached Home keeps its commands registered, + so a caller dispatches in place and goes home first only when + `hasAppCommand()` says Home never mounted (Persona Check). ### ASN Profile @@ -87,16 +107,6 @@ suspended while an overlay is open — keyed off form: the `ui/` roots `Dialog` `Sheet` / `Drawer` call `use-overlay-shortcuts.js`, so anything built on them inherits it; overlays nest; Esc and native scrolling still work. -### Kept-alive pages pause while hidden - -`App.vue` keeps Home and `ToolPage` alive (`KeepAlive`); `ToolPage` keeps up to -8 tools alive per slug, dropped on sign-out / account switch -(`utils/tool-cache.js`). A cached page never unmounts, so whatever acts on the -route, the document head, window events or the store's one-shot triggers gates -on `use-route-active.js` (`useRouteActive` / `useActiveValue` / -`useActiveEventListener`), not on mount / unmount. Overlays on a page being left -are closed by `use-overlay-shortcuts.js` — their portal would outlive it. - ### Error monitoring (Sentry) is env-gated and invisible to app code No `VITE_SENTRY_DSN_FRONTEND`, or a DEV build → no Sentry loaded (gated dynamic @@ -149,7 +159,8 @@ Every "business state → color" mapping goes through `use-status-tone.js` - **Shareable tool input** — watch `useActiveValue(() => route.query.q, { pathOf: () => route.path })` (immediate, so mount, later changes and a `q` changed while cached all run; another page's `q` never does), `router.replace` - it on every run (AsnProfile); both `/tools/` and `?tool=` URLs. + it on every run (AsnProfile); the URL is `/tools/?q=`, which a legacy + `/?tool=&q=` redirects to with `q` kept. - **Fixed option sets** — a closed list wider than one line is a `Select`. - **Qualifier + input + run** — `Select` + `Input` in one `ButtonGroup`, the run Button in a second (DnsResolver); trigger `w-auto shrink-0`; never wraps. @@ -162,8 +173,9 @@ Every "business state → color" mapping goes through `use-status-tone.js` the page on screen. - **Responsive hide** — `.hidden` is `!important` (`style/style.css`), so `hidden sm:flex` never shows: write `max-sm:hidden`. -- **Drawer vs Sheet** — bottom Drawer for Advanced Tools and full-bleed - expansions of an inline visual; side panels use `Sheet`. +- **Drawer vs Sheet** — bottom Drawer only for a full-bleed expansion of an + inline visual (ASNConnectivity's graph); side panels use `Sheet`; a tool is a + page, never either. - **Motion** — hover lift `transition-transform duration-300 ease-out hover:-translate-y-1.5`; loading is ``, never pulse-dots. diff --git a/frontend/components/Advanced.vue b/frontend/components/Advanced.vue index b0751000..c0f474f0 100644 --- a/frontend/components/Advanced.vue +++ b/frontend/components/Advanced.vue @@ -14,11 +14,10 @@ + card is a real to the tool's /tools/:slug page, so ⌘/Ctrl-click, + middle-click and "open in new tab" stay with the browser; a plain + left-click (or Enter / Space) is routed in-app instead, which keeps + this page alive behind the tool. -->
- - - - - - -
-
- -
-
- - - @@ -217,28 +128,4 @@ defineExpose({ :global(.dark) .jn-adv-card:hover .jn-emoji { text-shadow: 0 0 10pt rgb(255 255 255 / 0.15); } - -/* Drawer content area width (desktop) */ -.jn-canvas-width { - width: fit-content; - margin: auto; - max-width: 1400px; -} - -.jn-drawer-header { - border-bottom: 1px solid var(--border); -} - -/* Drawer root container needs flex-col, so that the header is fixed + content scrollable */ -.jn-tools-drawer { - display: flex; - flex-direction: column; -} - -/* Full screen toggle height transition */ -:global(.jn-tools-drawer) { - transition: - transform 0.5s cubic-bezier(0.32, 0.72, 0, 1), - height 0.3s cubic-bezier(0.32, 0.72, 0, 1) !important; -} diff --git a/frontend/components/Home.vue b/frontend/components/Home.vue index 1cdd7b3f..c129bc8e 100644 --- a/frontend/components/Home.vue +++ b/frontend/components/Home.vue @@ -26,7 +26,7 @@ diff --git a/frontend/components/PageHeader.vue b/frontend/components/PageHeader.vue index 79b44fb4..3ec01be3 100644 --- a/frontend/components/PageHeader.vue +++ b/frontend/components/PageHeader.vue @@ -44,7 +44,7 @@
- +
+
+ without its expand-to-drawer (the page has the room). --> diff --git a/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue b/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue index dea36539..249c0db6 100644 --- a/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue +++ b/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue @@ -266,7 +266,6 @@ diff --git a/frontend/components/ip-infos/IpDetailPanel.vue b/frontend/components/ip-infos/IpDetailPanel.vue index ec87baf8..22cec002 100644 --- a/frontend/components/ip-infos/IpDetailPanel.vue +++ b/frontend/components/ip-infos/IpDetailPanel.vue @@ -384,9 +384,9 @@ const props = defineProps({ }); // Consumers rendering this panel inside a dialog listen to close themselves -// first — the Benefits & Usage dialog (`view-usage`) or an Advanced Tools -// drawer (`open-tool`, from ASN Info's profile link) would otherwise stack -// on top of them. +// first — before the Benefits & Usage dialog would stack on top of them +// (`view-usage`), or the ASN Profile page replaces the page under them +// (`open-tool`, from ASN Info's profile link). const emit = defineEmits(['view-usage', 'open-tool']); const openUsageDialog = () => { diff --git a/frontend/composables/use-globalping-measurement.js b/frontend/composables/use-globalping-measurement.js index 93c73c70..f5238e5c 100644 --- a/frontend/composables/use-globalping-measurement.js +++ b/frontend/composables/use-globalping-measurement.js @@ -8,9 +8,8 @@ // Responsibilities: // - wrap POST + polling via fetchWithTimeout (prevents indefinite hang) // - track the pending setTimeout / AbortController so onScopeDispose can -// cancel in-flight work when the drawer / route unmounts — this fixes -// the previous bug where timers continued firing and mutating refs on -// unmounted components +// cancel in-flight work when the owning component unmounts, so no timer +// keeps firing and mutating refs of an unmounted component // // Callbacks (all optional): // - onResults(data): called on every successful poll (including diff --git a/frontend/composables/use-shortcuts.js b/frontend/composables/use-shortcuts.js index 1e9bec9b..7cc3b70d 100644 --- a/frontend/composables/use-shortcuts.js +++ b/frontend/composables/use-shortcuts.js @@ -14,7 +14,8 @@ // Config-gated keys fill in reactively when /api/configs lands // // Note: -// - scrolling + navigation actions use scrollToElement + advancedToolsRef.openTool(slug); +// - section keys scroll with scrollToElement; tool keys open the tool's page +// through advancedToolsRef.openTool(slug); // test-running keys dispatch commands on utils/app-commands.js // - `h` key infoMask switch only executes when isInfosLoaded is true // - the map is live only while Home is the page on screen: Home stays alive @@ -52,8 +53,8 @@ const buildShortcutConfig = ({ refs, store, t, configs, userPreferences }) => { toggleInfoMask, } = refs; + // Tool keys open the tool's page (Advanced.vue's openTool pushes the route). const goToAdvancedTool = (slug, trackName) => { - scrollToElement('AdvancedTools', 80); advancedToolsRef.value.openTool(slug); trackEvent('Nav', 'NavClick', trackName); }; diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 4473a9d1..974597f5 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -2224,6 +2224,17 @@ "pt-BR": "Nova ferramenta: Verificação de listas negras de IP — veja se um IP está em cerca de 80 DNSBLs em tempo real e listas de inteligência de ameaças" } }, + { + "type": "add", + "change": { + "en": "Tool pages have a header with a menu for switching straight to another tool", + "zh": "工具页面新增页头,可通过菜单直接切换到其他工具", + "zh-TW": "工具頁面新增頁首,可透過選單直接切換到其他工具", + "fr": "Les pages d'outils ont un en-tête avec un menu pour passer directement à un autre outil", + "ru": "На страницах инструментов появилась шапка с меню для быстрого перехода к другому инструменту", + "pt-BR": "As páginas de ferramentas têm um cabeçalho com um menu para ir direto a outra ferramenta" + } + }, { "type": "improve", "change": { @@ -2235,6 +2246,17 @@ "pt-BR": "As Ferramentas avançadas agora são agrupadas em Diagnóstico de rede, Consulta e cálculo e Verificações aprofundadas" } }, + { + "type": "improve", + "change": { + "en": "Advanced Tools now open as pages of their own: browser back and forward move between them and the homepage, and returning restores each page as you left it, results included", + "zh": "高级工具改为以独立页面打开:可用浏览器的后退与前进在工具和首页之间切换,返回时页面保持离开时的样子,测试结果也一并保留", + "zh-TW": "進階工具改為以獨立頁面開啟:可用瀏覽器的上一頁與下一頁在工具和首頁之間切換,返回時頁面保持離開時的樣子,測試結果也一併保留", + "fr": "Les Outils avancés s'ouvrent désormais dans leur propre page : les boutons Précédent et Suivant du navigateur passent de l'un à l'autre et à la page d'accueil, et chaque page revient telle que vous l'avez laissée, résultats compris", + "ru": "Расширенные инструменты теперь открываются на отдельных страницах: кнопки браузера «Назад» и «Вперёд» переключают между ними и главной страницей, а при возвращении каждая страница остаётся такой, какой вы её оставили, вместе с результатами", + "pt-BR": "As Ferramentas avançadas agora abrem em páginas próprias: os botões Voltar e Avançar do navegador alternam entre elas e a página inicial, e cada página volta como você a deixou, com os resultados" + } + }, { "type": "improve", "change": { diff --git a/frontend/data/tools.js b/frontend/data/tools.js index fe9cd7b3..36b3c9fa 100644 --- a/frontend/data/tools.js +++ b/frontend/data/tools.js @@ -1,23 +1,24 @@ // Single source of truth for the Advanced Tools. // -// This one registry drives every place a tool is referenced: the vue-router -// routes (router/index.js), the Advanced.vue card groups + bottom drawer, the -// standalone /tools/:slug pages, the nav menu and the keyboard shortcuts -// (use-shortcuts.js). +// This one registry drives every place a tool is referenced: its page at +// /tools/:slug (ToolPage.vue), the Advanced.vue card groups, the nav menu, +// the page header's tools menu, the keyboard shortcuts (use-shortcuts.js) and +// the legacy `/?tool=` redirect (utils/legacy-tool-link.js). // // Entry shape: -// slug — stable URL/identifier (drawer query + /tools/:slug) -// emoji — card glyph + drawer header glyph +// slug — stable URL/identifier (/tools/:slug) +// emoji — card glyph + page / menu glyph // titleKey / noteKey — i18n keys for the tool title and one-line description // category — TOOL_CATEGORIES id: the card group it renders in -// component — lazy import of the tool's .vue (drawer + standalone) +// component — lazy import of the tool's .vue (rendered by ToolPage) // requiresOriginalSite — gate: only shown on the original site (private API + // sign-in required); omitted == public tool // requiresConfig — gate: only shown when this `/api/configs` flag is // true (e.g. 'cloudFlare' for the Radar-backed tools) // -// Gates decide listing only (card grid + nav), via isToolAvailable() in -// utils/tool-availability.js; deep links (?tool= / /tools/:slug) ignore them. +// Gates decide listing only (card grid, nav, tools menu), via +// isToolAvailable() in utils/tool-availability.js; a /tools/:slug link opens +// the tool regardless. export const ADVANCED_TOOLS = [ { slug: 'pingtest', emoji: '⏱️', titleKey: 'pingtest.Title', noteKey: 'advancedtools.PingTestNote', category: 'network', component: () => import('@/components/advanced-tools/GlobalLatencyTest.vue') }, @@ -35,12 +36,11 @@ export const ADVANCED_TOOLS = [ { slug: 'invisibilitytest', emoji: '🫣', titleKey: 'invisibilitytest.Title', noteKey: 'advancedtools.InvisibilityTest', category: 'deep', component: () => import('@/components/advanced-tools/InvisibilityTest.vue'), requiresOriginalSite: true }, { slug: 'enhanceddnsleaktest', emoji: '🌀', titleKey: 'enhanceddnsleaktest.Title', noteKey: 'advancedtools.EnhancedDnsLeakTest', category: 'deep', component: () => import('@/components/advanced-tools/EnhancedDnsLeakTest.vue'), requiresOriginalSite: true }, { slug: 'blocklist', emoji: '🚫', titleKey: 'ipblocklist.Title', noteKey: 'advancedtools.IpBlocklist', category: 'deep', component: () => import('@/components/advanced-tools/IpBlocklist.vue'), requiresOriginalSite: true }, - // noStandalone: the check requires the homepage tests' results, and running - // them navigates home — a /tools/ page for it would immediately bounce away. - { slug: 'personacheck', emoji: '🎭', titleKey: 'personacheck.Title', noteKey: 'advancedtools.PersonaCheck', category: 'deep', component: () => import('@/components/advanced-tools/PersonaCheck.vue'), requiresOriginalSite: true, noStandalone: true }, + { slug: 'personacheck', emoji: '🎭', titleKey: 'personacheck.Title', noteKey: 'advancedtools.PersonaCheck', category: 'deep', component: () => import('@/components/advanced-tools/PersonaCheck.vue'), requiresOriginalSite: true }, ]; -// Fast slug → entry lookup (drawer + standalone page resolve a tool by slug). +// Fast slug → entry lookup (the tool page and the legacy redirect resolve a +// tool by slug). export const TOOL_BY_SLUG = new Map(ADVANCED_TOOLS.map((tool) => [tool.slug, tool])); // Card groups, in render order. On a self-hosted deployment the 'deep' tools diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 2f0affcc..88e95944 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -975,7 +975,6 @@ } }, "advancedtools": { - "OpenInNewTab": "Open in new tab", "BackToHome": "Back to Home", "Title": "Advanced Tools", "Note": "People who want the very best from their network usually reach for extra tools to fine-tune their connection.", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 363fe39c..4a47c3f0 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -975,7 +975,6 @@ } }, "advancedtools": { - "OpenInNewTab": "Ouvrir dans un nouvel onglet", "BackToHome": "Retour à l'accueil", "Title": "Outils avancés", "Note": "Ceux qui exigent le meilleur de leur réseau utilisent souvent davantage d'outils pour optimiser leur connexion.", diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json index ebb9ec09..903014ef 100644 --- a/frontend/locales/pt-BR.json +++ b/frontend/locales/pt-BR.json @@ -975,7 +975,6 @@ } }, "advancedtools": { - "OpenInNewTab": "Abrir em nova aba", "BackToHome": "Voltar para o início", "Title": "Ferramentas avançadas", "Note": "Quem exige o máximo da qualidade da rede costuma usar mais ferramentas para otimizar a própria conexão.", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index c7a745aa..f753327a 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -975,7 +975,6 @@ } }, "advancedtools": { - "OpenInNewTab": "Открыть в новой вкладке", "BackToHome": "На главную", "Title": "Расширенные инструменты", "Note": "Те, кто требователен к качеству сети, обычно используют больше инструментов, чтобы оптимизировать своё подключение.", diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json index d3b8ee42..d4d8b679 100644 --- a/frontend/locales/zh-TW.json +++ b/frontend/locales/zh-TW.json @@ -975,7 +975,6 @@ } }, "advancedtools": { - "OpenInNewTab": "在新標籤頁開啟", "BackToHome": "回到首頁", "Title": "進階工具", "Note": "對網路品質追求極致的人,往往會使用更多工具來最佳化自己的網路連線。", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index fe152daa..0c3d599c 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -975,7 +975,6 @@ } }, "advancedtools": { - "OpenInNewTab": "在新标签页打开", "BackToHome": "回到首页", "Title": "高级工具", "Note": "对网络质量追求极致的人,往往会使用更多工具来优化自己的网络连接。", diff --git a/frontend/router/index.js b/frontend/router/index.js index 9d80de9c..adc9e89a 100644 --- a/frontend/router/index.js +++ b/frontend/router/index.js @@ -1,13 +1,18 @@ import { createRouter, createWebHistory } from 'vue-router'; import Home from '@/components/Home.vue'; +import { resolveLegacyToolLink } from '@/utils/legacy-tool-link.js'; -// Real pages: -// / → the homepage. Advanced tools open as an in-page drawer, -// driven by the `?tool=` query (handled in Advanced.vue). -// /tools/:slug → a standalone full page for one tool (shareable + SEO). +// Pages: +// / → the homepage (tests, sections, Advanced Tools cards). +// /tools/:slug → one Advanced Tool (shareable + crawlable). Every opener — +// cards, nav, the page header's tools menu, shortcuts, +// in-app links — routes here. +// /privacy → the privacy policy. // /r/:id → read-only shared diagnostic report (KV-backed, noindex). -// The tool pages render the SAME tool components as the drawer; only the -// wrapper differs. +// App.vue keeps the homepage and the tool page alive across navigation, so +// back / forward between them is plain history that restores both as left. +// The drawer-era `/?tool=` links redirect to /tools/ +// (utils/legacy-tool-link.js). // // Home is imported eagerly (it's the default landing); everything else is // lazy so it stays out of the homepage bundle. @@ -28,12 +33,17 @@ const router = createRouter({ history: createWebHistory(), routes, scrollBehavior(to, from, savedPosition) { - // Opening/closing the drawer only flips the query on the home route — don't - // scroll the homepage in that case. Genuine page changes go to the top. + // A query-only change on the same page (a tool writing back its `?q=`) + // keeps the scroll; history steps restore it; new pages start at the top. if (to.path === from.path) return false; if (savedPosition) return savedPosition; return { top: 0 }; }, }); +// `/?tool=` → /tools/ (or `/` for an unknown slug); a page load +// replaces the entry, so the legacy URL leaves no trace in history. null lets +// every other navigation through. +router.beforeEach((to) => resolveLegacyToolLink(to) ?? undefined); + export default router; diff --git a/frontend/utils/banners.js b/frontend/utils/banners.js index 63666809..b1adc9af 100644 --- a/frontend/utils/banners.js +++ b/frontend/utils/banners.js @@ -30,7 +30,7 @@ // utm: { source: '…', … }, // with `url` only; keys WITHOUT the utm_ // // prefix — bannerLink adds it, plus // // utm_content= -// to: '/?tool=invisibilitytest', // internal: router.push target, no utm +// to: '/tools/invisibilitytest', // internal: router.push target, no utm // sheet: 'pulse', // internal: opens a store-managed side // // panel (store.setOpenSheet name) // requireSettled: true, // default true when absent: wait for the diff --git a/frontend/utils/legacy-tool-link.js b/frontend/utils/legacy-tool-link.js new file mode 100644 index 00000000..01d579aa --- /dev/null +++ b/frontend/utils/legacy-tool-link.js @@ -0,0 +1,28 @@ +// Legacy Advanced Tools links: tools once opened as a homepage drawer driven +// by `/?tool=`, and those links live on in bookmarks, shares and search +// results. The router's beforeEach (router/index.js) sends them to the tool's +// page. The redirect keeps the navigation's own mode: a page load (the router's +// first navigation) and a history step replace the entry, so back never +// returns to the old URL; an in-app push stays a push, keeping the page it +// left in history. +// +// resolveLegacyToolLink({ path, query, hash }) +// → null nothing to rewrite +// → { path, query, hash? } +// +// Only the homepage carries the param. A known slug lands on /tools/; +// an unknown (or malformed: repeated, empty) one lands on the homepage. Every +// other query param (e.g. `q`, utm tags) and the hash ride along. + +import { TOOL_BY_SLUG } from '../data/tools.js'; + +const HOME_PATH = '/'; + +export const resolveLegacyToolLink = ({ path, query = {}, hash = '' } = {}) => { + if (path !== HOME_PATH || !query || !Object.hasOwn(query, 'tool')) return null; + const { tool, ...rest } = query; + const known = typeof tool === 'string' && TOOL_BY_SLUG.has(tool); + const location = { path: known ? `/tools/${tool}` : HOME_PATH, query: rest }; + if (hash) location.hash = hash; + return location; +}; diff --git a/frontend/utils/tool-availability.js b/frontend/utils/tool-availability.js index de791508..a061decd 100644 --- a/frontend/utils/tool-availability.js +++ b/frontend/utils/tool-availability.js @@ -2,12 +2,10 @@ // whether a tool is listed in the card grid, the nav and the tools menu. A // tool can require the original site (`requiresOriginalSite`) and/or one // `/api/configs` flag (`requiresConfig`). Configs arrive asynchronously (`{}` until the fetch -// lands), so a gated tool reads as unavailable until then. Deep links -// (`?tool=` drawer, `/tools/:slug`) are not gated. +// lands), so a gated tool reads as unavailable until then. A /tools/:slug +// link is not gated. // -// listedTools() applies the gates to a whole registry for a menu; -// `standaloneOnly` also drops the `noStandalone` tools, for callers that can -// only open a tool as its /tools/:slug page. +// listedTools() applies the gates to a whole registry for a menu. export const isToolAvailable = (tool, configs) => { if (!tool) return false; @@ -16,6 +14,4 @@ export const isToolAvailable = (tool, configs) => { return true; }; -export const listedTools = (tools, configs, { standaloneOnly = false } = {}) => tools.filter((tool) => ( - isToolAvailable(tool, configs) && !(standaloneOnly && tool.noStandalone) -)); +export const listedTools = (tools, configs) => tools.filter((tool) => isToolAvailable(tool, configs)); diff --git a/index.html b/index.html index f5b2d5e7..f95faf10 100644 --- a/index.html +++ b/index.html @@ -469,7 +469,8 @@ + the page background when a bottom drawer is open (the expanded ASN + connectivity graph, ip-infos/ASNConnectivity.vue). -->
diff --git a/tests/back-target.test.js b/tests/back-target.test.js index e544859c..5a051924 100644 --- a/tests/back-target.test.js +++ b/tests/back-target.test.js @@ -12,7 +12,7 @@ describe('resolveBackTarget()', () => { }); it('treats a homepage entry with a query or hash as the homepage', () => { - assert.equal(resolveBackTarget({ back: '/?tool=whois' }), 'back'); + assert.equal(resolveBackTarget({ back: '/?utm_source=x' }), 'back'); assert.equal(resolveBackTarget({ back: '/#AdvancedTools' }), 'back'); }); diff --git a/tests/banners.test.js b/tests/banners.test.js index 47e5d9e3..e71ece42 100644 --- a/tests/banners.test.js +++ b/tests/banners.test.js @@ -30,7 +30,7 @@ const external = { const internal = { id: 'invisibility', icon: 'Shield', - to: '/?tool=invisibilitytest', + to: '/tools/invisibilitytest', copy: { en: { title: 'Go deeper', note: 'A note.', cta: 'Try it' } }, track: 'WebRTCInvisibility', }; diff --git a/tests/legacy-tool-link.test.js b/tests/legacy-tool-link.test.js new file mode 100644 index 00000000..4d2f7d96 --- /dev/null +++ b/tests/legacy-tool-link.test.js @@ -0,0 +1,47 @@ +// resolveLegacyToolLink() — the router redirect that sends the drawer-era +// `/?tool=` links to the tool's /tools/ page. + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { resolveLegacyToolLink } from '../frontend/utils/legacy-tool-link.js'; + +describe('resolveLegacyToolLink()', () => { + it('sends a known slug to its page', () => { + assert.deepEqual(resolveLegacyToolLink({ path: '/', query: { tool: 'whois' } }), + { path: '/tools/whois', query: {} }); + }); + + it('keeps the other query params and the hash', () => { + assert.deepEqual( + resolveLegacyToolLink({ path: '/', query: { tool: 'asn', q: 'AS13335', utm_source: 'x' }, hash: '#top' }), + { path: '/tools/asn', query: { q: 'AS13335', utm_source: 'x' }, hash: '#top' }, + ); + }); + + it('drops an unknown slug and stays on the homepage', () => { + assert.deepEqual(resolveLegacyToolLink({ path: '/', query: { tool: 'nope', x: '1' } }), + { path: '/', query: { x: '1' } }); + }); + + it('treats a repeated, empty or valueless param as unknown', () => { + for (const tool of [['whois', 'asn'], ['whois'], '', null, undefined]) { + assert.deepEqual(resolveLegacyToolLink({ path: '/', query: { tool } }), + { path: '/', query: {} }, String(tool)); + } + }); + + it('leaves a homepage URL without the param alone', () => { + assert.equal(resolveLegacyToolLink({ path: '/', query: {} }), null); + assert.equal(resolveLegacyToolLink({ path: '/', query: { q: 'AS13335' } }), null); + assert.equal(resolveLegacyToolLink({ path: '/' }), null); + }); + + it('leaves every other path alone', () => { + assert.equal(resolveLegacyToolLink({ path: '/tools/whois', query: { tool: 'asn' } }), null); + assert.equal(resolveLegacyToolLink({ path: '/privacy', query: { tool: 'whois' } }), null); + }); + + it('survives a missing argument', () => { + assert.equal(resolveLegacyToolLink(), null); + }); +}); diff --git a/tests/tool-availability.test.js b/tests/tool-availability.test.js index cc2a827e..950dbdeb 100644 --- a/tests/tool-availability.test.js +++ b/tests/tool-availability.test.js @@ -57,27 +57,19 @@ describe('isToolAvailable', () => { }); describe('listedTools', () => { - const PAGELESS = { slug: 'pageless', noStandalone: true }; - const GATED_PAGELESS = { slug: 'gated-pageless', requiresOriginalSite: true, noStandalone: true }; - const tools = [PUBLIC, PAGELESS, ORIGINAL, GATED_PAGELESS]; + const tools = [PUBLIC, ORIGINAL, RADAR, BOTH]; const slugs = (list) => list.map((tool) => tool.slug); it('applies the gates in registry order', () => { - assert.deepEqual(slugs(listedTools(tools, {})), ['public', 'pageless']); - assert.deepEqual(slugs(listedTools(tools, { originalSite: true })), - ['public', 'pageless', 'original', 'gated-pageless']); + assert.deepEqual(slugs(listedTools(tools, {})), ['public']); + assert.deepEqual(slugs(listedTools(tools, { originalSite: true })), ['public', 'original']); + assert.deepEqual(slugs(listedTools(tools, { originalSite: true, cloudFlare: true })), + ['public', 'original', 'radar', 'both']); }); - it('standaloneOnly also drops the noStandalone tools', () => { - assert.deepEqual(slugs(listedTools(tools, { originalSite: true }, { standaloneOnly: true })), - ['public', 'original']); - assert.deepEqual(slugs(listedTools(tools, {}, { standaloneOnly: true })), ['public']); - }); - - it('the registry lists Persona Check only for callers that open the drawer', () => { + it('lists every registered tool, Persona Check included, once all gates pass', () => { const configs = { originalSite: true, cloudFlare: true }; assert.ok(slugs(listedTools(ADVANCED_TOOLS, configs)).includes('personacheck')); - assert.ok(!slugs(listedTools(ADVANCED_TOOLS, configs, { standaloneOnly: true })).includes('personacheck')); assert.equal(listedTools(ADVANCED_TOOLS, configs).length, ADVANCED_TOOLS.length); }); });