Feat(tools): open the Advanced Tools as pages

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/<slug>; 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=<slug> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
This commit is contained in:
Claude committed 2026-10-05 14:25:02 +00:00
1 parent 728dd7d087
commit 2f5d3b426b
39 files changed
+263 -338

No files matched your search

+31 -19
View File
@@ -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: '<configs key>'`,
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 href>` (a plain click `router.push`es, modifier clicks stay the
browser's), Nav, `ToolsMenu`, shortcuts (`Advanced.vue`'s exposed
`openTool`), in-app links. `/?tool=<slug>` 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: '<configs key>'`, 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/<slug>?q=`, which a legacy
`/?tool=<slug>&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 `<Spinner />`, never pulse-dots.
+14 -127
View File
@@ -14,11 +14,10 @@
</header>
<!-- Card groups, one per category (data/tools.js TOOL_CATEGORIES). Each
card is a real <a> to the standalone /tools/:slug
page, so ⌘/Ctrl-click, middle-click and "open in new tab" all work.
A plain left-click (or Enter / Space) is intercepted to open the
in-page drawer instead. A dedicated ↗ corner button always opens the
standalone page in a new tab. -->
card is a real <a> 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. -->
<div class="mt-4 space-y-6">
<div v-for="group in cardGroups" :key="group.id">
<h3 class="mb-3 text-sm font-medium tracking-wide text-muted-foreground">
@@ -28,7 +27,7 @@
<Card v-for="card in group.tools" :key="card.slug"
:data-adv-slug="card.slug"
class="keyboard-shortcut-card jn-card jn-adv-card group relative overflow-visible transition-transform duration-300 ease-out hover:-translate-y-1.5 data-[keyboard-hover=true]:ring-2 data-[keyboard-hover=true]:ring-green-500/50">
<a :href="card.noStandalone ? `/?tool=${card.slug}` : `/tools/${card.slug}`"
<a :href="`/tools/${card.slug}`"
class="block cursor-pointer no-underline text-inherit"
@click="onCardClick($event, card.slug)"
@keydown.enter.prevent="openTool(card.slug)"
@@ -53,77 +52,29 @@
</div>
</div>
<!-- Tool details Drawer -->
<Drawer :open="isOpen" @update:open="onOpenChange" :dismissible="true">
<DrawerContent :title="activeTool ? t(activeTool.titleKey) : t('advancedtools.Title')"
:safe-area-top="isMobile || isFullScreen"
:class="['jn-tools-drawer overflow-hidden', (isMobile || isFullScreen) ? 'h-full rounded-none' : 'h-[85vh]']">
<!-- Drawer internal header -->
<div class="flex items-center gap-2 px-4 pt-1 pb-3 jn-drawer-header shrink-0">
<button v-if="!isMobile" type="button"
class="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="fullScreen" :aria-label="isFullScreen ? 'Exit full screen' : 'Full screen'">
<Maximize v-if="!isFullScreen" class="size-4" />
<Minimize v-else class="size-4" />
</button>
<span v-if="activeTool" class="flex-1 text-base md:text-lg font-medium truncate"
:class="isMobile ? 'text-left' : 'text-center'">
<span class="mr-1">{{ activeTool.emoji }}</span>{{ t(activeTool.titleKey) }}
</span>
<span v-else class="flex-1" />
<!-- Open the current tool as a standalone page (hidden in a
PWA window, which has no new-tab affordance, and for
noStandalone tools, which have no such page) -->
<a v-if="activeTool && !isPwa && !activeTool.noStandalone" :href="`/tools/${activeTool.slug}`"
target="_blank" rel="noopener"
class="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
:title="t('advancedtools.OpenInNewTab')" :aria-label="t('advancedtools.OpenInNewTab')">
<SquareArrowOutUpRight class="size-4" />
</a>
<DrawerClose
class="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" />
</div>
<!-- Content area (scrollable) -->
<div class="flex-1 overflow-y-auto px-1 md:px-2 pb-6" ref="scrollContainer">
<div :class="isMobile ? 'w-full px-3' : 'jn-canvas-width px-6'">
<component :is="activeComponent" v-if="activeComponent" />
</div>
</div>
</DrawerContent>
</Drawer>
<!-- Section banner slot (data-driven; see InfoBanner.vue) -->
<InfoBanner section="advanced" />
</section>
</template>
<script setup>
import { ref, computed, onMounted, defineAsyncComponent } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { ADVANCED_TOOLS, TOOL_BY_SLUG, groupToolsByCategory } from '@/data/tools.js';
import { ADVANCED_TOOLS, groupToolsByCategory } from '@/data/tools.js';
import { isToolAvailable } from '@/utils/tool-availability.js';
import { isRunningAsPwa } from '@/utils/pwa.js';
import { Drawer, DrawerContent, DrawerClose } from '@/components/ui/drawer';
import { Card, CardContent } from '@/components/ui/card';
import { ToolLoadingSkeleton } from '@/components/ui/tool-loading-skeleton';
import InfoBanner from '@/components/widgets/InfoBanner.vue';
import { Maximize, Minimize, PanelBottomOpen, SquareArrowOutUpRight } from '@lucide/vue';
import { PanelBottomOpen } from '@lucide/vue';
const { t } = useI18n();
const store = useMainStore();
const isMobile = computed(() => store.isMobile);
// Running as an installed PWA → hide the "open in new tab" affordance (a PWA
// window has no tab strip; it would pop out to the browser). See utils/pwa.js.
const isPwa = isRunningAsPwa();
const configs = computed(() => store.configs);
const userPreferences = computed(() => store.userPreferences);
const isSimpleMode = computed(() => userPreferences.value.simpleMode);
const scrollContainer = ref(null);
const route = useRoute();
const router = useRouter();
// Card groups behind the deployment gates (original site / configs flag, see
@@ -133,67 +84,27 @@ const cardGroups = computed(() => groupToolsByCategory(
ADVANCED_TOOLS.filter((tool) => isToolAvailable(tool, configs.value)),
));
// ── Drawer state, driven by the `?tool=<slug>` query on the home route ───────
// `?tool=whois` ⇒ the drawer is open showing Whois. Closing clears the query.
const activeTool = computed(() => {
const slug = route.query.tool;
return (typeof slug === 'string' && TOOL_BY_SLUG.get(slug)) || null;
});
const isOpen = computed(() => !!activeTool.value);
// Resolve each tool's lazy component once and cache it, so re-renders don't
// rebuild the async wrapper (which would remount the tool). The skeleton
// covers the chunk download; `delay` keeps fast loads flash-free.
const asyncToolCache = new Map();
const activeComponent = computed(() => {
const tool = activeTool.value;
if (!tool) return null;
if (!asyncToolCache.has(tool.slug)) {
asyncToolCache.set(tool.slug, defineAsyncComponent({
loader: tool.component,
loadingComponent: ToolLoadingSkeleton,
delay: 200,
}));
}
return asyncToolCache.get(tool.slug);
});
const isFullScreen = ref(false);
// Open a tool in the in-page drawer (just sets the query; isOpen reacts).
// Open a tool's page. Also the keyboard shortcuts' entry point (exposed below).
const openTool = (slug) => {
router.push({ path: '/', query: { tool: slug } });
router.push(`/tools/${slug}`);
const name = slug.charAt(0).toUpperCase() + slug.slice(1);
trackEvent('Nav', 'NavClick', name);
};
// Card left-click: open the drawer. Modifier / middle clicks fall through to
// the <a href> default so the browser opens the standalone page in a new tab.
// Card left-click: route in-app. Modifier / middle clicks fall through to the
// <a href> default so the browser opens the page in a new tab.
const onCardClick = (e, slug) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return;
e.preventDefault();
openTool(slug);
};
const onOpenChange = (val) => {
// Drawer closed (drag / overlay / Esc / close button) → drop the ?tool query.
if (!val) {
if (route.query.tool) router.push({ path: '/', query: {} });
isFullScreen.value = false;
}
};
// Full screen toggle: height determined by DrawerContent's class
const fullScreen = () => {
isFullScreen.value = !isFullScreen.value;
};
onMounted(() => {
store.setMountingStatus('AdvancedTools', true);
});
defineExpose({
openTool, fullScreen,
openTool,
});
</script>
@@ -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;
}
</style>
+1 -1
View File
@@ -26,7 +26,7 @@
</template>
<script setup>
// The homepage. Holds every top-level section plus the Advanced Tools drawer.
// The homepage. Holds every top-level section plus the Advanced Tools cards.
// Split out of App.vue when the app moved to history-mode routing: App is now a
// thin shell, and this component is what /'s <router-view> renders. The truly
// global widgets (tooltip provider, toast, PWA, theme) stay in App.
+6 -15
View File
@@ -92,7 +92,7 @@
</div>
</nav>
<!-- Mobile navigation drawer. Flex column so the link list scrolls instead
<!-- Mobile navigation sheet. Flex column so the link list scrolls instead
of clipping on short screens when Advanced Tools is expanded. -->
<Sheet v-if="isMobile" :open="isNavMenuOpen" @update:open="onNavMenuChange">
<SheetContent side="left" class="w-80 p-0 flex flex-col gap-0" :title="t('nav.Navigation')">
@@ -251,19 +251,11 @@ const scrollToSection = (el, offset = 70) => {
window.scrollTo({ top: y, behavior: 'smooth' });
};
// Open a tool from the nav: scroll to the Advanced Tools section, then raise the
// drawer (driven by the `?tool=` query Advanced.vue watches). Scrolls twice — the
// mobile nav Sheet locks body scroll until it closes, so the first scroll is a
// no-op there and the deferred one lands after the Sheet is gone.
let openToolTimer = null;
// Open a tool's page from the nav, closing the mobile nav Sheet first (a
// no-op on desktop) so it isn't left open behind the tool.
const openTool = (slug) => {
store.setOpenSheet(null); // close the mobile nav Sheet (no-op on desktop)
scrollToSection('AdvancedTools');
clearTimeout(openToolTimer);
openToolTimer = setTimeout(() => {
scrollToSection('AdvancedTools');
router.push({ path: '/', query: { tool: slug } });
}, 300);
store.setOpenSheet(null);
router.push(`/tools/${slug}`);
const name = slug.charAt(0).toUpperCase() + slug.slice(1);
trackEvent('Nav', 'NavClick', name);
};
@@ -286,7 +278,7 @@ const onScroll = () => {
if (y <= SHOW_AT_TOP) {
isNavHidden.value = false;
} else if (Math.abs(dy) > SCROLL_DELTA) {
// Keep nav visible while the menu drawer is open so its close
// Keep nav visible while the menu sheet is open so its close
// affordance stays in place.
if (dy > 0 && !isNavMenuOpen.value) {
isNavHidden.value = true;
@@ -325,7 +317,6 @@ onMounted(() => {
onBeforeUnmount(() => {
setScrollListener(false);
clearTimeout(openToolTimer);
});
</script>
+1 -1
View File
@@ -44,7 +44,7 @@
<!-- Tools menu: after the brand from `sm` up, at the right edge below. -->
<div class="shrink-0 max-sm:order-1">
<ToolsMenu standalone-only :current="currentSlug" @select="openTool" />
<ToolsMenu :current="currentSlug" @select="openTool" />
</div>
<!-- Page title: the free width between the menus; empty pages keep the
+7 -15
View File
@@ -1,9 +1,8 @@
<template>
<!-- Full page for a single advanced tool, reached at /tools/:slug
(new-window / shareable / crawlable). Renders the exact same tool
component the drawer does, just inside a minimal page chrome instead of
the homepage + drawer. App.vue keeps this page alive, and the inner
KeepAlive below keeps every visited tool alive per slug. -->
<!-- The page of one advanced tool, at /tools/:slug — where every opener
(cards, nav, tools menu, shortcuts, links) lands. App.vue keeps this
page alive, and the inner KeepAlive below keeps every visited tool
alive per slug. -->
<div class="flex min-h-screen flex-col">
<!-- Page header: back, brand, tools menu, current tool, account menu. It
also hosts the User dialogs (Benefits & Usage) the sign-in gated
@@ -69,10 +68,7 @@ const store = useMainStore();
const routeSlug = computed(() => (route.name === 'tool' ? route.params.slug : null));
const slug = computed((previous) => routeSlug.value ?? previous ?? null);
// noStandalone tools are blacklisted from this page entirely — they depend on
// homepage state, so the drawer on the homepage is their only home.
const registered = computed(() => TOOL_BY_SLUG.get(slug.value) || null);
const tool = computed(() => (registered.value && !registered.value.noStandalone ? registered.value : null));
const tool = computed(() => TOOL_BY_SLUG.get(slug.value) || null);
const toolComponent = computed(() => (tool.value ? asyncToolFor(tool.value) : null));
// Per-tool head: localized title + description, self-referential canonical.
@@ -85,14 +81,10 @@ useDocumentMeta(() => {
};
});
// Unknown slug → bounce to the homepage rather than show an empty shell; a
// blacklisted tool keeps its destination by reopening as the homepage drawer.
// Unknown slug → bounce to the homepage rather than show an empty shell.
// Runs on every arrival at a tool route, including a reused instance.
watch(routeSlug, (next) => {
if (!next || tool.value) return;
router.replace(registered.value?.noStandalone
? { path: '/', query: { tool: registered.value.slug } }
: '/');
if (next && !tool.value) router.replace('/');
}, { immediate: true });
// Auth epoch: bumped when the signed-in account goes away (utils/tool-cache.js),
+2 -5
View File
@@ -51,10 +51,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { ChevronDown, LayoutGrid } from '@lucide/vue';
const props = defineProps({
// Hide the tools that have no /tools/:slug page (noStandalone), for callers
// that open a tool as a page.
standaloneOnly: { type: Boolean, default: false },
defineProps({
// Slug of the tool on screen, marked in the list.
current: { type: String, default: '' },
// Menu alignment against the trigger.
@@ -67,6 +64,6 @@ const { t } = useI18n();
const store = useMainStore();
const groups = computed(() => groupToolsByCategory(
listedTools(ADVANCED_TOOLS, store.configs, { standaloneOnly: props.standaloneOnly }),
listedTools(ADVANCED_TOOLS, store.configs),
));
</script>
@@ -331,7 +331,7 @@
<!-- Upstream topology: the IP-card component on its cache
contract, borderless (this card draws the border) and
without its drawer (the page is a drawer already). -->
without its expand-to-drawer (the page has the room). -->
<ASNConnectivity v-if="hasTopology(graph)" :asn="String(profileAsn)"
:asnConnectivityInfos="connectivityInfos" :expandable="false" :bordered="false" />
@@ -266,7 +266,6 @@
<script setup>
import { ref, reactive, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
@@ -294,15 +293,10 @@ import { Play, Check, X, Hourglass, Info, Server, Hash, Route, MapPin, ShieldChe
const { t } = useI18n();
const store = useMainStore();
const route = useRoute();
const router = useRouter();
const lang = computed(() => store.lang);
// Open the Benefits & Usage dialog. Inside the Advanced Tools drawer the
// dialog would stack on top of it — close the drawer first (it's driven by
// the ?tool query; standalone pages don't carry it, nothing to close).
// Open the Benefits & Usage dialog (hosted by the page header's User).
const openUsageDialog = () => {
if (route.query.tool) router.push({ path: '/', query: {} });
store.setTriggerUserBenefits(true);
};
@@ -6,9 +6,9 @@
<p v-if="!isMobile">{{ t('pingtest.Note2') }}</p>
</div>
<!-- Input area. With stored IPs (homepage drawer) the user can pick one
OR switch to manual entry; on the standalone page allIPs is empty,
so it's manual entry only. -->
<!-- Input area. With stored IPs (the homepage's IP cards have run) the
user can pick one OR switch to manual entry; on a direct visit to
the tool's page allIPs is empty, so it's manual entry only. -->
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label :for="manualMode ? 'pingIPManual' : 'pingIP'" class="font-medium">
@@ -174,7 +174,6 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
@@ -188,14 +187,9 @@ import { CircleCheck, CircleX, Hourglass, Info, ListChecks, Lock, Shield, Play }
const { t } = useI18n();
const store = useMainStore();
const route = useRoute();
const router = useRouter();
// Open the Benefits & Usage dialog. Inside the Advanced Tools drawer the
// dialog would stack on top of it — close the drawer first (it's driven by
// the ?tool query; standalone pages don't carry it, nothing to close).
// Open the Benefits & Usage dialog (hosted by the page header's User).
const openUsageDialog = () => {
if (route.query.tool) router.push({ path: '/', query: {} });
store.setTriggerUserBenefits(true);
};
@@ -6,9 +6,9 @@
<p>{{ t('ipblocklist.Note2') }}</p>
</div>
<!-- Input area. With stored IPs (homepage drawer) the user can pick one
OR switch to manual entry; on the standalone page allIPs is empty,
so it's manual entry only. -->
<!-- Input area. With stored IPs (the homepage's IP cards have run) the
user can pick one OR switch to manual entry; on a direct visit to
the tool's page allIPs is empty, so it's manual entry only. -->
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label :for="manualMode ? 'blocklistIPManual' : 'blocklistIP'" class="font-medium">
@@ -205,7 +205,6 @@
// users, original site only). Verdict / row split live in
// utils/features/ip-blocklist.js.
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
@@ -230,14 +229,9 @@ import {
const { t, te } = useI18n();
const store = useMainStore();
const lang = computed(() => store.lang);
const route = useRoute();
const router = useRouter();
// Open the Benefits & Usage dialog. Inside the Advanced Tools drawer the
// dialog would stack on top of it — close the drawer first (it's driven by
// the ?tool query; standalone pages don't carry it, nothing to close).
// Open the Benefits & Usage dialog (hosted by the page header's User).
const openUsageDialog = () => {
if (route.query.tool) router.push({ path: '/', query: {} });
store.setTriggerUserBenefits(true);
};
@@ -3,10 +3,10 @@
MAC Lookup's job) dispatched to the matching card under ./ip-calculator/.
Pure local computation via utils/features/ip-calc.js.
The query rides the URL as `?q=` on both /tools/ipcalculator and
/?tool=ipcalculator, written back on every run so results are shareable.
Two collapsed folds under the input hold example pills (one per accepted
syntax) and, on the home page, the visitor's own IPs (store.allIPs). -->
The query rides the URL as `?q=` on /tools/ipcalculator, written back on
every run so results are shareable. Two collapsed folds under the input
hold example pills (one per accepted syntax) and, once the homepage has
detected them, the visitor's own IPs (store.allIPs). -->
<template>
<div class="ip-calculator-section my-4 space-y-4">
<!-- Top note -->
@@ -6,9 +6,9 @@
<p v-if="!isMobile">{{ t('mtrtest.Note2') }}</p>
</div>
<!-- Input area. With stored IPs (homepage drawer) the user can pick one
OR switch to manual entry; on the standalone page allIPs is empty,
so it's manual entry only. -->
<!-- Input area. With stored IPs (the homepage's IP cards have run) the
user can pick one OR switch to manual entry; on a direct visit to
the tool's page allIPs is empty, so it's manual entry only. -->
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label :for="manualMode ? 'mtrIPManual' : 'mtrIP'" class="font-medium">
@@ -264,12 +264,12 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useRouter } from 'vue-router';
import { REGEXP_ONLY_DIGITS } from 'vue-input-otp';
import { useMainStore } from '@/store';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events.js';
import { dispatchAppCommand, waitForAppCommand } from '@/utils/app-commands.js';
import { dispatchAppCommand, hasAppCommand, waitForAppCommand } from '@/utils/app-commands.js';
import { authenticatedFetch, fetchErrorLabel } from '@/utils/authenticated-fetch';
import { buildObservation, usePersonaSnapshots } from '@/composables/use-persona-collector.js';
import { localProfile } from '@/utils/persona/local-profile.js';
@@ -286,7 +286,6 @@ import PersonaReport from '@/components/advanced-tools/PersonaReport.vue';
const { t } = useI18n();
const store = useMainStore();
const route = useRoute();
const router = useRouter();
const { snapshots, missingSources } = usePersonaSnapshots();
@@ -384,11 +383,14 @@ const runDependencies = async () => {
if (!missing.length) return;
runningDependencies.value = true;
try {
// Off the homepage, the owners aren't mounted yet — navigate first,
// then wait for each command to be registered before dispatching.
if (route.name !== 'home') await router.push('/');
await Promise.allSettled(missing.map(async (source) => {
const command = DEPENDENCY_COMMANDS[source];
// The owners are the homepage sections, registered while Home is
// mounted — kept alive behind this page once it has been visited, so
// the tests run in place and report back over the app-events bus.
// On a direct visit Home has never mounted: go home first, then wait
// for each command to be registered before dispatching.
const commands = missing.map((source) => DEPENDENCY_COMMANDS[source]);
if (!commands.every(hasAppCommand)) await router.push('/');
await Promise.allSettled(commands.map(async (command) => {
await waitForAppCommand(command, { timeoutMs: DEPENDENCY_RUN_TIMEOUT });
await dispatchAppCommand(command, {}, { timeoutMs: DEPENDENCY_RUN_TIMEOUT });
}));
@@ -408,8 +410,8 @@ const quotaExceeded = ref(false);
const canRun = computed(() =>
signedIn.value && hasProfile.value && !missingSources.value.length);
// Open the Benefits & Usage dialog (hosted by the page header's User).
const openUsageDialog = () => {
if (route.query.tool) router.push({ path: '/', query: {} });
store.setTriggerUserBenefits(true);
};
@@ -120,7 +120,7 @@
</nav>
<!-- Right 2/3: current category details -->
<div id="checklist" class="md:col-span-2">
<div id="checklist" class="md:col-span-2 scroll-mt-[calc(4rem+env(safe-area-inset-top))]">
<Card>
<CardContent class="p-4 md:p-6 space-y-4">
<!-- Category title + description + expandable intro -->
@@ -443,45 +443,17 @@ const changeList = (listName, shouldScroll = true) => {
if (previousList !== currentList.value) {
trackEvent('SecurityChecklist', 'SecurityChecklist', 'ChangeList');
}
// Scroll the details area into view when switching category — but *only*
// by moving the drawer's own scroll container, never by letting the
// scroll request bubble to <body>.
//
// The earlier version used `scrollIntoView({ block: 'nearest' })`. On
// desktop the target element (#checklist) was already visible in the
// md 3-column grid, so the call no-op'd and everything worked. On iOS
// Safari however, the drawer library (vaul) scroll-locks the page by
// setting `body { position: fixed; top: -Npx }`. When scrollIntoView
// walked up past the drawer's own scrollable div to any ancestor that
// still negotiated with the browser's root scroller, its implicit
// side-effect of "settle page scroll" knocked vaul's recorded offset
// out of sync — the entire drawer visually slid up by the scroll
// distance (header disappearing off the top, blank space at bottom).
//
// Manually walking to the nearest scrollable ancestor and calling
// `scrollBy` on that element keeps the effect fully contained inside
// the drawer, and the PC path still short-circuits via the
// "already-visible" check.
// Bring the details into view when switching category on a narrow
// screen, where they stack below the category list. `scroll-mt-*` on
// #checklist keeps them clear of the sticky page header; on md+ they
// sit beside the list with their top on screen, so nothing moves.
nextTick(() => {
const el = document.getElementById('checklist');
if (!el) return;
let scrollParent = el.parentElement;
while (scrollParent) {
const { overflowY } = getComputedStyle(scrollParent);
if (overflowY === 'auto' || overflowY === 'scroll') break;
scrollParent = scrollParent.parentElement;
}
if (!scrollParent) return;
const containerRect = scrollParent.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const alreadyVisible =
elRect.top >= containerRect.top &&
elRect.bottom <= containerRect.bottom;
if (alreadyVisible) return;
scrollParent.scrollBy({
top: elRect.top - containerRect.top - 8,
behavior: 'smooth',
});
const { top } = el.getBoundingClientRect();
const headerBottom = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
if (top >= headerBottom && top < window.innerHeight * 0.75) return;
el.scrollIntoView({ block: 'start', behavior: 'smooth' });
});
}
};
@@ -1,9 +1,9 @@
<template>
<!-- Service Status advanced tool: shows whether a set of well-known
products are currently up, expandable per-card into sub-services and
recent incidents. Lives inside the Advanced Tools drawer (its title is
rendered by the drawer header), so this template starts at the note row
rather than a section <h2>. -->
recent incidents. Rendered by the tool page (ToolPage.vue owns the
title), so this template starts at the note row rather than a section
<h2>. -->
<div class="service-status-section my-4 space-y-4">
<!-- Top note -->
<p class="text-sm text-muted-foreground leading-relaxed">{{ t('serviceStatus.Note') }}</p>
@@ -165,8 +165,8 @@
// /api/service-status/detail → one provider's sub-services + incidents (on expand)
// so the initial load stays light and detail is pulled only when a card opens.
//
// Moved out of the homepage into the Advanced Tools drawer: it mounts fresh
// each time the tool is opened (loadOverview on mount) and is no longer wired
// An Advanced Tool, not a homepage section: it loads on its first mount
// (loadOverview), stays cached with its page afterwards, and is not wired
// into the homepage's global-refresh orchestrator — the bottom Refresh button
// here is the only re-pull path.
import { ref, reactive, computed, onMounted } from 'vue';
@@ -189,7 +189,7 @@ const props = defineProps({
// Numeric string — parent strips the "AS" prefix to keep cache keys consistent.
asn: { type: String, required: true },
asnConnectivityInfos: { type: Object, required: true },
// false hides the expand-to-drawer button (ASN Profile already sits in a drawer).
// false hides the expand-to-drawer button (ASN Profile's page has the room).
expandable: { type: Boolean, default: true },
// false drops only the outer border (tint and padding stay) — for a host
// card that already draws one (ASN Profile).
+7 -7
View File
@@ -27,8 +27,8 @@
<!-- Traffic shares (pair bars) -->
<AsnTrafficShares :info="asnInfos[asn]" />
<!-- Full profile: a real link to the standalone page (new tab on
modifier clicks); a plain click opens the drawer instead -->
<!-- Full profile: a real link to the ASN Profile page (new tab on
modifier clicks); a plain click routes in-app -->
<div v-if="profileHref" class="pt-1">
<Button as-child variant="outline" size="sm" class="h-7 cursor-pointer gap-1.5 text-xs">
<a :href="profileHref" @click="openProfile">
@@ -75,8 +75,8 @@ const props = defineProps({
asnInfos: { type: Object, required: true }
});
// Fired before a plain-click open, so a host dialog (QueryIP) can close
// first instead of stacking under the drawer.
// Fired before a plain-click open, so a host dialog (QueryIP) closes before
// the page changes under it.
const emit = defineEmits(['open-profile']);
// Extract basic information (non-pair data)
@@ -94,8 +94,8 @@ const basicInfo = computed(() => {
});
// ASN Profile entry: hidden when the tool is gated off (no Cloudflare key)
// or the ASN doesn't parse. Opens like an Advanced Tools card: the `?tool=`
// drawer, with `q` making the tool run the lookup on mount.
// or the ASN doesn't parse. Opens like an Advanced Tools card: the tool's
// page, with `q` making the tool run the lookup.
const router = useRouter();
const profileAsn = computed(() => {
if (!isToolAvailable(TOOL_BY_SLUG.get('asn'), store.configs)) return null;
@@ -109,6 +109,6 @@ const openProfile = (e) => {
e.preventDefault();
trackEvent('IPCheck', 'ASNProfileClick', 'Open ASN Profile');
emit('open-profile');
router.push({ path: '/', query: { tool: 'asn', q: profileAsn.value } });
router.push({ path: '/tools/asn', query: { q: profileAsn.value } });
};
</script>
@@ -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 = () => {
@@ -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
+3 -2
View File
@@ -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);
};
+22
View File
@@ -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": {
+13 -13
View File
@@ -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=<slug>` 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
-1
View File
@@ -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.",
-1
View File
@@ -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.",
-1
View File
@@ -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.",
-1
View File
@@ -975,7 +975,6 @@
}
},
"advancedtools": {
"OpenInNewTab": "Открыть в новой вкладке",
"BackToHome": "На главную",
"Title": "Расширенные инструменты",
"Note": "Те, кто требователен к качеству сети, обычно используют больше инструментов, чтобы оптимизировать своё подключение.",
-1
View File
@@ -975,7 +975,6 @@
}
},
"advancedtools": {
"OpenInNewTab": "在新標籤頁開啟",
"BackToHome": "回到首頁",
"Title": "進階工具",
"Note": "對網路品質追求極致的人,往往會使用更多工具來最佳化自己的網路連線。",
-1
View File
@@ -975,7 +975,6 @@
}
},
"advancedtools": {
"OpenInNewTab": "在新标签页打开",
"BackToHome": "回到首页",
"Title": "高级工具",
"Note": "对网络质量追求极致的人,往往会使用更多工具来优化自己的网络连接。",
+18 -8
View File
@@ -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=<slug>` 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=<slug>` links redirect to /tools/<slug>
// (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=<slug>` → /tools/<slug> (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;
+1 -1
View File
@@ -30,7 +30,7 @@
// utm: { source: '…', … }, // with `url` only; keys WITHOUT the utm_
// // prefix — bannerLink adds it, plus
// // utm_content=<lang>
// 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
+28
View File
@@ -0,0 +1,28 @@
// Legacy Advanced Tools links: tools once opened as a homepage drawer driven
// by `/?tool=<slug>`, 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/<slug>;
// 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;
};
+4 -8
View File
@@ -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));
+2 -1
View File
@@ -469,7 +469,8 @@
</noscript>
<!-- Vue mounts here. `vaul-drawer-wrapper` is the marker vaul-vue uses to scale
the page background when a bottom drawer is open (Advanced Tools panel). -->
the page background when a bottom drawer is open (the expanded ASN
connectivity graph, ip-infos/ASNConnectivity.vue). -->
<div id="app" vaul-drawer-wrapper></div>
<script type="module" src="/frontend/main.js"></script>
</body>
+1 -1
View File
@@ -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');
});
+1 -1
View File
@@ -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',
};
+47
View File
@@ -0,0 +1,47 @@
// resolveLegacyToolLink() — the router redirect that sends the drawer-era
// `/?tool=<slug>` links to the tool's /tools/<slug> 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);
});
});
+6 -14
View File
@@ -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);
});
});