mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
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:
39 files changed
+263
-338
No files matched your search
+31
-19
@@ -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,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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -975,7 +975,6 @@
|
||||
}
|
||||
},
|
||||
"advancedtools": {
|
||||
"OpenInNewTab": "Открыть в новой вкладке",
|
||||
"BackToHome": "На главную",
|
||||
"Title": "Расширенные инструменты",
|
||||
"Note": "Те, кто требователен к качеству сети, обычно используют больше инструментов, чтобы оптимизировать своё подключение.",
|
||||
|
||||
@@ -975,7 +975,6 @@
|
||||
}
|
||||
},
|
||||
"advancedtools": {
|
||||
"OpenInNewTab": "在新標籤頁開啟",
|
||||
"BackToHome": "回到首頁",
|
||||
"Title": "進階工具",
|
||||
"Note": "對網路品質追求極致的人,往往會使用更多工具來最佳化自己的網路連線。",
|
||||
|
||||
@@ -975,7 +975,6 @@
|
||||
}
|
||||
},
|
||||
"advancedtools": {
|
||||
"OpenInNewTab": "在新标签页打开",
|
||||
"BackToHome": "回到首页",
|
||||
"Title": "高级工具",
|
||||
"Note": "对网络质量追求极致的人,往往会使用更多工具来优化自己的网络连接。",
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user