mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
Chore: trim the branch — leftovers, redundancy, comments
A pass over every file the branch touched, after the iOS animation hunt left tries and reverts behind it. Removed what nothing reaches: the view-transition rule for a transition no code starts, an unreachable guard in the transition hook, a no-op KeepAlive key branch, a sign-in guard the menu already enforces, usage-dialog wrappers that only forwarded to the store, a duplicated modifier-click check and card filter (isPlainClick / listedTools), a test that hand-rolled listedTools, a drawer-era fixture parameter. auth-flow.js folded into auth-redirect.js. Comments cut to what the code cannot say itself — file headers of a few lines, the measured iOS reasons, non-obvious orderings — and stale ones fixed (Nav under KeepAlive, Home.vue reusing DEFAULT_META, "from Nav", enabledCards). AGENTS.md sections tightened to the code as it is now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
This commit is contained in:
62 files changed
+293
-625
No files matched your search
+8
-11
@@ -38,17 +38,14 @@ VITE_PULSE_BEACON_URL=""
|
|||||||
VITE_CURL_IPV4_DOMAIN=""
|
VITE_CURL_IPV4_DOMAIN=""
|
||||||
VITE_CURL_IPV6_DOMAIN=""
|
VITE_CURL_IPV6_DOMAIN=""
|
||||||
VITE_CURL_IPV64_DOMAIN=""
|
VITE_CURL_IPV64_DOMAIN=""
|
||||||
# Firebase Auth — optional; sign-in is offered only when all three are set.
|
# Firebase Auth — optional; sign-in is offered when API key, auth domain and
|
||||||
# VITE_FIREBASE_AUTH_DOMAIN is the login domain browsers use (it may be shared
|
# project id are all set. VITE_FIREBASE_AUTH_DOMAIN is the browsers' login
|
||||||
# with other services, e.g. "auth.ipcheck.ing"); each OAuth provider's
|
# domain (e.g. "auth.ipcheck.ing"); each OAuth provider's callback URL is
|
||||||
# callback URL is https://<that domain>/__/auth/handler
|
# https://<that domain>/__/auth/handler.
|
||||||
# VITE_FIREBASE_PWA_AUTH_DOMAIN — optional, used only inside the installed PWA,
|
# VITE_FIREBASE_PWA_AUTH_DOMAIN — optional, installed PWA only: the site's own
|
||||||
# typically the site's own host (e.g. "ipcheck.ing"). The PWA needs a
|
# host (e.g. "ipcheck.ing"), served by the /__/auth proxy. Add it to Firebase
|
||||||
# same-origin auth handler: with VITE_FIREBASE_PROJECT_ID set, the frontend
|
# Authentication → Authorized domains and https://<it>/__/auth/handler to
|
||||||
# and Vite dev servers proxy /__/auth/* to Firebase. List the host in Firebase
|
# Google's OAuth client; GitHub (one callback) is then hidden in the PWA.
|
||||||
# Authentication → Authorized domains and add https://<site host>/__/auth/handler
|
|
||||||
# to Google's OAuth client. GitHub allows one callback, so GitHub sign-in is
|
|
||||||
# hidden in the PWA when this is set. Empty → the PWA uses the default too.
|
|
||||||
VITE_FIREBASE_API_KEY=""
|
VITE_FIREBASE_API_KEY=""
|
||||||
VITE_FIREBASE_AUTH_DOMAIN=""
|
VITE_FIREBASE_AUTH_DOMAIN=""
|
||||||
VITE_FIREBASE_PWA_AUTH_DOMAIN=""
|
VITE_FIREBASE_PWA_AUTH_DOMAIN=""
|
||||||
|
|||||||
+8
-8
@@ -160,14 +160,14 @@ upstreams; those get only what's explicitly needed.
|
|||||||
### Firebase Auth handler proxy
|
### Firebase Auth handler proxy
|
||||||
|
|
||||||
`frontend-server.js` (production) and `vite.config.js` (dev) reverse-proxy
|
`frontend-server.js` (production) and `vite.config.js` (dev) reverse-proxy
|
||||||
`/__/auth/*` to `<projectId>.firebaseapp.com` (target from
|
`/__/auth/*` to `<projectId>.firebaseapp.com` (`common/firebase-auth-proxy.js`)
|
||||||
`common/firebase-auth-proxy.js`), mounted only when `VITE_FIREBASE_PROJECT_ID`
|
when `VITE_FIREBASE_PROJECT_ID` is set. `VITE_FIREBASE_PWA_AUTH_DOMAIN` points
|
||||||
is set. `VITE_FIREBASE_PWA_AUTH_DOMAIN` points the installed PWA at the site's
|
the installed PWA at the site's own host, so its sign-in handler runs
|
||||||
own host, so its sign-in handler runs first-party — inside the PWA's webview
|
first-party — inside the PWA's webview and Safari's storage partition — and
|
||||||
and Safari's storage partition — and `getRedirectResult` finds the result;
|
`getRedirectResult` finds the result; browsers keep the shared
|
||||||
browsers keep the shared `VITE_FIREBASE_AUTH_DOMAIN` (GitHub's single callback
|
`VITE_FIREBASE_AUTH_DOMAIN` (GitHub's single callback lives there, so the PWA
|
||||||
lives there, so the PWA then offers Google only). It is not an `/api` route: no
|
then offers Google only). Not an `/api` route: no guards, caching or
|
||||||
guards, no caching, nothing logged per request.
|
per-request logs.
|
||||||
|
|
||||||
## Edge caching
|
## Edge caching
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
// Firebase Auth handler proxy target.
|
// Firebase Auth handler proxy: `/__/auth/*` on the site's own origin →
|
||||||
//
|
// `<projectId>.firebaseapp.com`, mounted by frontend-server.js (production)
|
||||||
// Firebase's redirect sign-in runs its handler page (`/__/auth/handler`, plus
|
// and vite.config.js (dev). Why: api/AGENTS.md "Firebase Auth handler proxy".
|
||||||
// the `iframe` / `*.js` files it loads) on `authDomain`. Serving those paths
|
|
||||||
// from the site's own origin — a reverse proxy to the project's
|
|
||||||
// `<projectId>.firebaseapp.com` host — keeps the handler first-party, so the
|
|
||||||
// sign-in result lands in the same storage the app reads it back from (the
|
|
||||||
// installed iOS PWA and Safari's storage partitioning otherwise lose it).
|
|
||||||
// Mounted by frontend-server.js (production) and vite.config.js (dev).
|
|
||||||
|
|
||||||
export const FIREBASE_AUTH_PROXY_PATH = '/__/auth';
|
export const FIREBASE_AUTH_PROXY_PATH = '/__/auth';
|
||||||
|
|
||||||
// Origin the proxy forwards to, or null when no project is configured
|
// null without a project id: no Firebase, no proxy.
|
||||||
// (no Firebase → no proxy).
|
|
||||||
export const firebaseAuthProxyTarget = (projectId) => {
|
export const firebaseAuthProxyTarget = (projectId) => {
|
||||||
const id = typeof projectId === 'string' ? projectId.trim() : '';
|
const id = typeof projectId === 'string' ? projectId.trim() : '';
|
||||||
return id ? `https://${id}.firebaseapp.com` : null;
|
return id ? `https://${id}.firebaseapp.com` : null;
|
||||||
|
|||||||
+3
-8
@@ -22,14 +22,9 @@ frontendApp.use('/api', createProxyMiddleware({
|
|||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Firebase Auth handler on our own origin. `/__/auth/*` (handler, iframe and
|
// Firebase's auth handler served first-party (api/AGENTS.md "Firebase Auth
|
||||||
// their scripts) is reverse-proxied to <projectId>.firebaseapp.com so the
|
// handler proxy"); ahead of the static layer and SPA fallback so it never
|
||||||
// sign-in handler runs first-party — inside the installed PWA's own webview
|
// resolves to index.html.
|
||||||
// and within Safari's storage partition, where getRedirectResult can read the
|
|
||||||
// result back. Switched on by VITE_FIREBASE_PROJECT_ID; the PWA uses it when
|
|
||||||
// VITE_FIREBASE_PWA_AUTH_DOMAIN is this site's own host. Mounted ahead of the
|
|
||||||
// static layer and the SPA fallback so the handler page never resolves to
|
|
||||||
// index.html.
|
|
||||||
const firebaseAuthTarget = firebaseAuthProxyTarget(process.env.VITE_FIREBASE_PROJECT_ID);
|
const firebaseAuthTarget = firebaseAuthProxyTarget(process.env.VITE_FIREBASE_PROJECT_ID);
|
||||||
if (firebaseAuthTarget) {
|
if (firebaseAuthTarget) {
|
||||||
frontendApp.use(FIREBASE_AUTH_PROXY_PATH, createProxyMiddleware({
|
frontendApp.use(FIREBASE_AUTH_PROXY_PATH, createProxyMiddleware({
|
||||||
|
|||||||
+22
-31
@@ -13,13 +13,9 @@ over copied-in shadcn-vue primitives. No TypeScript, no `dark:` dual pairs.
|
|||||||
```
|
```
|
||||||
frontend/
|
frontend/
|
||||||
├── App.vue / main.js / store.js / router/ / locales/ / style/style.css
|
├── App.vue / main.js / store.js / router/ / locales/ / style/style.css
|
||||||
│ (App.vue: the site Nav + User / Preferences, then the page)
|
├── firebase-init.js ← env-gated lazy Firebase Auth (boot path: utils/auth-hint.js;
|
||||||
├── firebase-init.js ← env-gated lazy Firebase Auth (boot path: utils/auth-hint.js)
|
│ PWA / blocked-popup redirect sign-in: utils/auth-redirect.js;
|
||||||
│ sign-in: popup in a browser tab, redirect in the installed
|
│ the PWA's own auth domain: utils/auth-domain.js)
|
||||||
│ PWA or on a blocked popup (utils/auth-flow.js); the return
|
|
||||||
│ is consumed at boot via a sessionStorage marker
|
|
||||||
│ (utils/auth-redirect.js); the PWA may use its own auth
|
|
||||||
│ domain, then Google only (utils/auth-domain.js)
|
|
||||||
├── sentry-init.js ← env-gated Sentry (see "Error monitoring")
|
├── sentry-init.js ← env-gated Sentry (see "Error monitoring")
|
||||||
├── data/ ← static config (tools registry drives pages+cards+nav)
|
├── data/ ← static config (tools registry drives pages+cards+nav)
|
||||||
├── lib/ · utils/ · composables/ ← see "Helper placement"
|
├── lib/ · utils/ · composables/ ← see "Helper placement"
|
||||||
@@ -68,13 +64,11 @@ chrome); tools register at setup, callers `waitForAppCommand` first.
|
|||||||
|
|
||||||
### Advanced Tools are pages, kept alive
|
### Advanced Tools are pages, kept alive
|
||||||
|
|
||||||
- Every tool is a page at `/tools/:slug` (`ToolPage.vue` over the
|
- Every tool is a page at `/tools/:slug` (`ToolPage.vue` over `data/tools.js`),
|
||||||
`data/tools.js` registry), and every opener routes there: cards are real
|
and every opener routes there: cards are real `<a href>` (a plain click
|
||||||
`<a href>` (a plain click `router.push`es, modifier clicks stay the
|
`router.push`es, modified clicks stay the browser's), Nav, shortcuts
|
||||||
browser's), Nav, shortcuts (`Advanced.vue`'s exposed
|
(`Advanced.vue`'s exposed `openTool`), in-app links. `/?tool=<slug>` is only
|
||||||
`openTool`), in-app links. `/?tool=<slug>` survives only as a replace-redirect
|
a replace-redirect in the router's `beforeEach` (`utils/legacy-tool-link.js`).
|
||||||
in the router's `beforeEach` (`utils/legacy-tool-link.js`). No tool opens as
|
|
||||||
an overlay.
|
|
||||||
- Gates (`requiresOriginalSite`, `requiresConfig: '<configs key>'`, e.g. `asn` →
|
- Gates (`requiresOriginalSite`, `requiresConfig: '<configs key>'`, e.g. `asn` →
|
||||||
`cloudFlare`) decide listing only, via `isToolAvailable()` / `listedTools()`
|
`cloudFlare`) decide listing only, via `isToolAvailable()` / `listedTools()`
|
||||||
(card grid, Nav); a `/tools/:slug` link is never gated.
|
(card grid, Nav); a `/tools/:slug` link is never gated.
|
||||||
@@ -166,8 +160,7 @@ Every "business state → color" mapping goes through `use-status-tone.js`
|
|||||||
- **Shareable tool input** — watch `useActiveValue(() => route.query.q, {
|
- **Shareable tool input** — watch `useActiveValue(() => route.query.q, {
|
||||||
pathOf: () => route.path })` (immediate, so mount, later changes and a `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`
|
changed while cached all run; another page's `q` never does), `router.replace`
|
||||||
it on every run (AsnProfile); the URL is `/tools/<slug>?q=`, which a legacy
|
it on every run (AsnProfile); the URL is `/tools/<slug>?q=`.
|
||||||
`/?tool=<slug>&q=` redirects to with `q` kept.
|
|
||||||
- **Fixed option sets** — a closed list wider than one line is a `Select`.
|
- **Fixed option sets** — a closed list wider than one line is a `Select`.
|
||||||
- **Qualifier + input + run** — `Select` + `Input` in one `ButtonGroup`, the run
|
- **Qualifier + input + run** — `Select` + `Input` in one `ButtonGroup`, the run
|
||||||
Button in a second (DnsResolver); trigger `w-auto shrink-0`; never wraps.
|
Button in a second (DnsResolver); trigger `w-auto shrink-0`; never wraps.
|
||||||
@@ -176,28 +169,26 @@ Every "business state → color" mapping goes through `use-status-tone.js`
|
|||||||
- **Dialog header** — the `<DialogHeader :icon :title />` primitive.
|
- **Dialog header** — the `<DialogHeader :icon :title />` primitive.
|
||||||
- **Site navigation** — one `Nav.vue` in `App.vue` above every route (fixed;
|
- **Site navigation** — one `Nav.vue` in `App.vue` above every route (fixed;
|
||||||
`index.html` pads the body for it), never per page; `User` / `Preferences`
|
`index.html` pads the body for it), never per page; `User` / `Preferences`
|
||||||
sit beside it, one each. Its sections and tools are peers: what a click
|
sit beside it, one each. What a click does on the current route is
|
||||||
does on the current route is `resolveNavTarget()` (`utils/nav-target.js`:
|
`resolveNavTarget()` (`utils/nav-target.js`: scroll on Home, go home then
|
||||||
scroll on Home, go home then scroll elsewhere, a tool → its page), carried
|
scroll elsewhere, a tool → its page), carried out by `use-nav-target.js`;
|
||||||
out by `use-nav-target.js`; no section is highlighted off Home.
|
no section is highlighted off Home.
|
||||||
- **Page breadcrumb** — every page but Home opens its body with
|
- **Page breadcrumb** — every page but Home opens its body with
|
||||||
`<PageBreadcrumb :items />` above the `<h1>` (← Home / … / current page):
|
`<PageBreadcrumb :items />` above the `<h1>` (← Home / … / current page):
|
||||||
real links, Home via `resolveBackTarget()`, section crumbs via the Nav's
|
real links, Home via `resolveBackTarget()`, section crumbs via the Nav's
|
||||||
helper, the last crumb `aria-current="page"`.
|
helper, the last crumb `aria-current="page"`.
|
||||||
- **Page transitions** — View Transitions keyed by the direction
|
- **Page transitions** — View Transitions hooked in the router's
|
||||||
`utils/page-transition.js` resolves (Home → page slides forward, → Home
|
`beforeResolve` (`utils/page-transition.js`: Home → page slides forward,
|
||||||
back, page → page fades; keyframes in `style/style.css`), hooked in the
|
→ Home back, page → page fades; keyframes in `style/style.css`); none on a
|
||||||
router's `beforeResolve`; none on a query-only change, the first load, or
|
query-only change, the first load, reduced motion or a history step the
|
||||||
under reduced motion. The Nav opts out via
|
browser animated itself. Only the Nav has a `view-transition-name` (it
|
||||||
`view-transition-name`; nothing else gets one. After a history step the
|
stays still). After a history step, `utils/revive-animations.js` replays
|
||||||
page's running CSS animations are cancelled, replayed and set back to
|
the page's running CSS animations: iOS swipe-back leaves them frozen.
|
||||||
their time (`utils/revive-animations.js`): on iOS the swipe-back gesture
|
|
||||||
leaves them visually frozen until their layer is rebuilt.
|
|
||||||
- **Responsive hide** — `.hidden` is `!important` (`style/style.css`), so
|
- **Responsive hide** — `.hidden` is `!important` (`style/style.css`), so
|
||||||
`hidden sm:flex` never shows: write `max-sm:hidden`.
|
`hidden sm:flex` never shows: write `max-sm:hidden`.
|
||||||
- **Drawer vs Sheet** — bottom Drawer only for a full-bleed expansion of an
|
- **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
|
inline visual (ASNConnectivity's graph); side panels use `Sheet`; a tool is
|
||||||
page, never either.
|
a page, never an overlay.
|
||||||
- **Motion** — hover lift `transition-transform duration-300 ease-out
|
- **Motion** — hover lift `transition-transform duration-300 ease-out
|
||||||
hover:-translate-y-1.5`; loading is `<Spinner />`, never pulse-dots.
|
hover:-translate-y-1.5`; loading is `<Spinner />`, never pulse-dots.
|
||||||
|
|
||||||
|
|||||||
+9
-16
@@ -1,21 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Thin app shell: only the globals that must exist on every route live
|
<!-- Thin app shell: the globals every route shares (Nav, account and
|
||||||
here (the site Nav, the account and preferences dialogs, tooltip context,
|
preferences dialogs, tooltip context, toast host, PWA prompt, theme).
|
||||||
toast host, PWA install prompt, theme). Pages swap in via <router-view>
|
Home and ToolPage stay alive, so coming back neither re-runs the tests nor
|
||||||
below the fixed Nav (index.html pads the body for it); the homepage and
|
loses a tool's state; keyed by route name, every tool route shares one
|
||||||
the tool page stay alive in the KeepAlive (never evicted), so leaving and
|
ToolPage, which caches each tool per slug itself. -->
|
||||||
coming back neither re-runs the homepage tests nor loses a tool's state. Every tool route shares the one
|
|
||||||
ToolPage instance (key 'tool'), which caches each tool per slug itself.
|
|
||||||
Cached pages pause while hidden — see composables/use-route-active.js. -->
|
|
||||||
<TooltipProvider :delay-duration="150">
|
<TooltipProvider :delay-duration="150">
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<router-view v-slot="{ Component, route: viewRoute }">
|
<router-view v-slot="{ Component, route: viewRoute }">
|
||||||
<KeepAlive :include="['Home', 'ToolPage']">
|
<KeepAlive :include="['Home', 'ToolPage']">
|
||||||
<component :is="Component" :key="viewRoute.name === 'tool' ? 'tool' : viewRoute.name" />
|
<component :is="Component" :key="viewRoute.name" />
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
</router-view>
|
</router-view>
|
||||||
<!-- Dialog hosts for the Nav's account menu and the sign-in tools'
|
<!-- Dialog hosts for the Nav's account menu and the tools' quota hints. -->
|
||||||
quota hints: Benefits & Usage, Preferences. One each, site-wide. -->
|
|
||||||
<User />
|
<User />
|
||||||
<Preferences />
|
<Preferences />
|
||||||
<Alert />
|
<Alert />
|
||||||
@@ -57,8 +53,7 @@ import { useAchievementEngine } from '@/composables/use-achievement-engine.js';
|
|||||||
import { useReportCollector } from '@/composables/use-report-collector.js';
|
import { useReportCollector } from '@/composables/use-report-collector.js';
|
||||||
import { useAppPersonaCollector } from '@/composables/use-persona-collector.js';
|
import { useAppPersonaCollector } from '@/composables/use-persona-collector.js';
|
||||||
|
|
||||||
// A page change closes the sheet held in store.openSheet (preferences, Earth
|
// A page change closes store.openSheet: a site-wide sheet would stay open over
|
||||||
// Online, the nav menu, …): the site-wide ones would otherwise stay open over
|
|
||||||
// the next page, and one a left page hosted would reopen when it returns.
|
// the next page, and one a left page hosted would reopen when it returns.
|
||||||
const store = useMainStore();
|
const store = useMainStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -69,9 +64,7 @@ watch(() => route.path, () => {
|
|||||||
// Pre-Vue boot overlay → real app hand-off. CSS lives in index.html.
|
// Pre-Vue boot overlay → real app hand-off. CSS lives in index.html.
|
||||||
// #app is revealed IMMEDIATELY at mount: it fades in underneath the opaque
|
// #app is revealed IMMEDIATELY at mount: it fades in underneath the opaque
|
||||||
// overlay while the overlay plays its exit (text fade → logo shrink →
|
// overlay while the overlay plays its exit (text fade → logo shrink →
|
||||||
// removal).
|
// removal). Runs once at root mount, whichever page loads first.
|
||||||
// Runs once at root mount, so it covers both the homepage and a fresh load of
|
|
||||||
// a tool page.
|
|
||||||
const loadingElement = document.getElementById('jn-loading');
|
const loadingElement = document.getElementById('jn-loading');
|
||||||
const appElement = document.getElementById('app');
|
const appElement = document.getElementById('app');
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,8 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Card groups, one per category (data/tools.js TOOL_CATEGORIES). Each
|
<!-- Card groups, one per category (data/tools.js TOOL_CATEGORIES). Each
|
||||||
card is a real <a> to the tool's /tools/:slug page, so ⌘/Ctrl-click,
|
card is a real <a> to /tools/:slug: modified clicks stay the
|
||||||
middle-click and "open in new tab" stay with the browser; a plain
|
browser's, a plain click / Enter / Space routes in-app. -->
|
||||||
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 class="mt-4 space-y-6">
|
||||||
<div v-for="group in cardGroups" :key="group.id">
|
<div v-for="group in cardGroups" :key="group.id">
|
||||||
<h3 class="mb-3 text-sm font-medium tracking-wide text-muted-foreground">
|
<h3 class="mb-3 text-sm font-medium tracking-wide text-muted-foreground">
|
||||||
@@ -46,7 +44,6 @@
|
|||||||
<span class="jn-emoji" aria-hidden="true">{{ card.emoji }}</span>
|
<span class="jn-emoji" aria-hidden="true">{{ card.emoji }}</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,7 +61,8 @@ import { useMainStore } from '@/store';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { trackEvent } from '@/utils/analytics';
|
import { trackEvent } from '@/utils/analytics';
|
||||||
import { ADVANCED_TOOLS, groupToolsByCategory } from '@/data/tools.js';
|
import { ADVANCED_TOOLS, groupToolsByCategory } from '@/data/tools.js';
|
||||||
import { isToolAvailable } from '@/utils/tool-availability.js';
|
import { listedTools } from '@/utils/tool-availability.js';
|
||||||
|
import { isPlainClick } from '@/utils/nav-target.js';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import InfoBanner from '@/components/widgets/InfoBanner.vue';
|
import InfoBanner from '@/components/widgets/InfoBanner.vue';
|
||||||
import { ArrowRight } from '@lucide/vue';
|
import { ArrowRight } from '@lucide/vue';
|
||||||
@@ -81,7 +79,7 @@ const router = useRouter();
|
|||||||
// utils/tool-availability.js). Reactive on configs, so gated cards appear the
|
// utils/tool-availability.js). Reactive on configs, so gated cards appear the
|
||||||
// moment configs land.
|
// moment configs land.
|
||||||
const cardGroups = computed(() => groupToolsByCategory(
|
const cardGroups = computed(() => groupToolsByCategory(
|
||||||
ADVANCED_TOOLS.filter((tool) => isToolAvailable(tool, configs.value)),
|
listedTools(ADVANCED_TOOLS, configs.value),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Open a tool's page. Also the keyboard shortcuts' entry point (exposed below).
|
// Open a tool's page. Also the keyboard shortcuts' entry point (exposed below).
|
||||||
@@ -91,10 +89,8 @@ const openTool = (slug) => {
|
|||||||
trackEvent('Nav', 'NavClick', name);
|
trackEvent('Nav', 'NavClick', name);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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) => {
|
const onCardClick = (e, slug) => {
|
||||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return;
|
if (!isPlainClick(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openTool(slug);
|
openTool(slug);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,14 +23,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
// The homepage. Holds every top-level section plus the Advanced Tools cards;
|
// The homepage (`/`): every top-level section plus the Advanced Tools cards;
|
||||||
// this component is what /'s <router-view> renders. What every route shares
|
// what every route shares lives in App.vue.
|
||||||
// (the Nav, the account and preferences dialogs, tooltip provider, toast, PWA,
|
|
||||||
// theme) lives in App.vue.
|
|
||||||
//
|
//
|
||||||
// Components — the test sections and the always-visible chrome load
|
// Components — the test sections and the always-visible chrome load
|
||||||
// synchronously; everything the first paint can't show (dialogs, drawers,
|
// synchronously; everything the first paint can't show (dialogs, the
|
||||||
// the below-fold Additional/Footer) is an async component so its code stays
|
// below-fold Additional/Footer) is an async component so its code stays
|
||||||
// out of the route chunk and out of the mount's critical path. Their
|
// out of the route chunk and out of the mount's critical path. Their
|
||||||
// template refs are null until the chunk lands — consumers (use-shortcuts)
|
// template refs are null until the chunk lands — consumers (use-shortcuts)
|
||||||
// must optional-chain.
|
// must optional-chain.
|
||||||
@@ -108,8 +106,7 @@ useSectionTracking();
|
|||||||
// Localized homepage head. Provide title/description explicitly via t() rather
|
// Localized homepage head. Provide title/description explicitly via t() rather
|
||||||
// than leaning on use-document-meta's DEFAULT_META snapshot: that snapshot is
|
// than leaning on use-document-meta's DEFAULT_META snapshot: that snapshot is
|
||||||
// taken at module load, before the (now async) locale messages land, so it would
|
// taken at module load, before the (now async) locale messages land, so it would
|
||||||
// pin the head to index.html's English title. Home is kept alive, so coming
|
// pin the head to index.html's English title.
|
||||||
// back from another page re-applies this head on activation.
|
|
||||||
useDocumentMeta(() => ({
|
useDocumentMeta(() => ({
|
||||||
title: t('page.title'),
|
title: t('page.title'),
|
||||||
description: t('page.description'),
|
description: t('page.description'),
|
||||||
|
|||||||
+12
-24
@@ -1,8 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- The iOS status bar is opaque system chrome (index.html sets no
|
<!-- The iOS status bar is opaque (no apple-mobile-web-app-status-bar-style), so the
|
||||||
apple-mobile-web-app-status-bar-style), so the safe-area inset is only
|
inset is non-zero only on landscape notch edges. jn-site-nav: see style.css. -->
|
||||||
non-zero where the page really runs under it (landscape notch edges).
|
|
||||||
The header stays put through page transitions (jn-site-nav in style.css). -->
|
|
||||||
<header
|
<header
|
||||||
class="jn-site-nav fixed top-[env(safe-area-inset-top)] left-0 right-0 z-40 w-full border-b transition-transform duration-300 ease-out will-change-transform"
|
class="jn-site-nav fixed top-[env(safe-area-inset-top)] left-0 right-0 z-40 w-full border-b transition-transform duration-300 ease-out will-change-transform"
|
||||||
:class="{ '-translate-y-full': isNavHidden,
|
:class="{ '-translate-y-full': isNavHidden,
|
||||||
@@ -59,8 +57,7 @@
|
|||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
</NavigationMenuList>
|
</NavigationMenuList>
|
||||||
</NavigationMenu>
|
</NavigationMenu>
|
||||||
<!-- All other sections: scroll there (from home first off the
|
<!-- All other sections -->
|
||||||
homepage — utils/nav-target.js). -->
|
|
||||||
<a v-else href="#" :class="navLinkClass(item)" @click.prevent="goToSection(item)">
|
<a v-else href="#" :class="navLinkClass(item)" @click.prevent="goToSection(item)">
|
||||||
{{ t(`nav.${item}`) }}
|
{{ t(`nav.${item}`) }}
|
||||||
</a>
|
</a>
|
||||||
@@ -146,10 +143,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
// The site navigation bar, rendered once by App.vue above every route. Its
|
// The site navigation bar, rendered once by App.vue above every route; what a
|
||||||
// entries act per route (utils/nav-target.js): a section link scrolls the
|
// section or tool entry does on the current route is utils/nav-target.js.
|
||||||
// homepage, or goes home first from any other page; a tool entry opens the
|
|
||||||
// tool's page.
|
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useMainStore } from '@/store';
|
import { useMainStore } from '@/store';
|
||||||
@@ -190,14 +185,13 @@ const { navigateTo } = useNavTarget();
|
|||||||
|
|
||||||
const isDarkMode = computed(() => store.isDarkMode);
|
const isDarkMode = computed(() => store.isDarkMode);
|
||||||
const isMobile = computed(() => store.isMobile);
|
const isMobile = computed(() => store.isMobile);
|
||||||
// Off the homepage no section is current (its tracker runs on Home only, so
|
// store.currentSection keeps Home's last section, so it's ignored elsewhere.
|
||||||
// store.currentSection keeps the last one seen there).
|
|
||||||
const isHome = computed(() => route.name === 'home');
|
const isHome = computed(() => route.name === 'home');
|
||||||
const currentSection = computed(() => (isHome.value ? store.currentSection : null));
|
const currentSection = computed(() => (isHome.value ? store.currentSection : null));
|
||||||
const currentToolSlug = computed(() => (route.name === 'tool' ? String(route.params.slug) : ''));
|
const currentToolSlug = computed(() => (route.name === 'tool' ? String(route.params.slug) : ''));
|
||||||
const homeHref = router.resolve('/').href;
|
const homeHref = router.resolve('/').href;
|
||||||
const loaded = computed(() => store.allHasLoaded);
|
const loaded = computed(() => store.allHasLoaded);
|
||||||
// The brand shimmers while the homepage tests run; the other pages run none.
|
// The brand shimmers while the homepage tests run.
|
||||||
const shimmer = computed(() => isHome.value && !loaded.value);
|
const shimmer = computed(() => isHome.value && !loaded.value);
|
||||||
|
|
||||||
// Running as an installed PWA (chromeless window) — see utils/pwa.js.
|
// Running as an installed PWA (chromeless window) — see utils/pwa.js.
|
||||||
@@ -205,8 +199,7 @@ const isPwa = isRunningAsPwa();
|
|||||||
|
|
||||||
const navItems = SECTION_IDS;
|
const navItems = SECTION_IDS;
|
||||||
|
|
||||||
// Tools shown in the nav, mirroring Advanced.vue's enabledCards: gated tools
|
// Gated tools stay hidden where the deployment lacks them, as in the card grid.
|
||||||
// stay hidden where the deployment lacks them. Reactive on configs.
|
|
||||||
const configs = computed(() => store.configs);
|
const configs = computed(() => store.configs);
|
||||||
const advancedTools = computed(() => listedTools(ADVANCED_TOOLS, configs.value));
|
const advancedTools = computed(() => listedTools(ADVANCED_TOOLS, configs.value));
|
||||||
|
|
||||||
@@ -259,8 +252,7 @@ const handleLogoClick = (e) => {
|
|||||||
trackEvent('Nav', 'NavClick', 'Logo');
|
trackEvent('Nav', 'NavClick', 'Logo');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Section and tool entries close the mobile nav Sheet first (a no-op on
|
// Section and tool entries close the mobile nav Sheet first (no-op on desktop).
|
||||||
// desktop) so it isn't left open over where they land.
|
|
||||||
const goToSection = (section) => {
|
const goToSection = (section) => {
|
||||||
store.setOpenSheet(null);
|
store.setOpenSheet(null);
|
||||||
navigateTo({ section });
|
navigateTo({ section });
|
||||||
@@ -305,8 +297,7 @@ const onScroll = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// The listener runs on mobile only, on every route; attaching resyncs
|
// Mobile only; attaching resyncs lastScrollY so the first scroll isn't a phantom jump.
|
||||||
// lastScrollY so the first scroll isn't a phantom jump.
|
|
||||||
const setScrollListener = (on) => {
|
const setScrollListener = (on) => {
|
||||||
if (on) {
|
if (on) {
|
||||||
lastScrollY = window.scrollY;
|
lastScrollY = window.scrollY;
|
||||||
@@ -319,12 +310,9 @@ const setScrollListener = (on) => {
|
|||||||
watch(isMobile, (mobile) => {
|
watch(isMobile, (mobile) => {
|
||||||
if (!mobile) isNavHidden.value = false;
|
if (!mobile) isNavHidden.value = false;
|
||||||
setScrollListener(mobile);
|
setScrollListener(mobile);
|
||||||
});
|
}, { immediate: true });
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(fetchGithubStars);
|
||||||
if (isMobile.value) setScrollListener(true);
|
|
||||||
fetchGithubStars();
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
setScrollListener(false);
|
setScrollListener(false);
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Breadcrumb at the top of a page body, above its <h1>:
|
<!-- Markup and classes follow shadcn-vue's Breadcrumb (ui/ has no copy-in). -->
|
||||||
← Home / Advanced Tools / 🛂 ASN Profile
|
|
||||||
Markup and classes follow shadcn-vue's Breadcrumb, written out here as
|
|
||||||
ui/ has no breadcrumb copy-in. Every crumb but the last is a real link
|
|
||||||
to / (modified clicks open a new tab); the last is the page itself. -->
|
|
||||||
<nav aria-label="breadcrumb" class="mb-3">
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
<ol class="text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5">
|
<ol class="text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5">
|
||||||
<!-- Home: back in history when the homepage is the previous entry
|
<!-- Home: back in history when Home is the previous entry, so it returns as left -->
|
||||||
(restores it as it was left), a push of / otherwise. -->
|
|
||||||
<li class="inline-flex items-center gap-1.5">
|
<li class="inline-flex items-center gap-1.5">
|
||||||
<a :href="homeHref" class="inline-flex items-center gap-1 transition-colors hover:text-foreground"
|
<a :href="homeHref" class="inline-flex items-center gap-1 transition-colors hover:text-foreground"
|
||||||
@click="onHome">
|
@click="onHome">
|
||||||
@@ -30,10 +25,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
// Page breadcrumb for the pages other than the homepage (tool pages, /privacy,
|
// Breadcrumb above the <h1> of every page but Home: ← Home / … / current page.
|
||||||
// /r/:id). `items` are the crumbs after Home, already localized; the last one
|
// `items` are the localized crumbs after Home; the last is the current page,
|
||||||
// is the current page, the others name a homepage section to scroll to
|
// the others `{ label, section }` — a homepage section, reached as the Nav does.
|
||||||
// (`{ label, section }`, through the same helper as the Nav).
|
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { ArrowLeft } from '@lucide/vue';
|
import { ArrowLeft } from '@lucide/vue';
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Standalone privacy page at /privacy (shareable + crawlable) -->
|
<!-- Standalone privacy page at /privacy (shareable + crawlable) -->
|
||||||
<div class="flex min-h-[calc(100dvh_-_3.5rem_-_env(safe-area-inset-top))] flex-col">
|
<div class="flex min-h-[calc(100dvh_-_3.5rem_-_env(safe-area-inset-top))] flex-col">
|
||||||
<!-- Content: the breadcrumb, then the policy -->
|
|
||||||
<main class="flex-1 mx-auto w-full max-w-[760px] px-4 md:px-6 py-8">
|
<main class="flex-1 mx-auto w-full max-w-[760px] px-4 md:px-6 py-8">
|
||||||
<PageBreadcrumb :items="[{ label: t('about.Privacy') }]" />
|
<PageBreadcrumb :items="[{ label: t('about.Privacy') }]" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- The page of one advanced tool, at /tools/:slug — where every opener
|
<!-- The page of one advanced tool, /tools/:slug. At least a viewport tall
|
||||||
(cards, nav, shortcuts, links) lands. App.vue keeps this page alive,
|
below the fixed Nav, so the footer sits at the bottom of a short page. -->
|
||||||
and the inner KeepAlive below keeps every visited tool alive per slug.
|
|
||||||
At least a viewport tall below the fixed Nav, so the footer sits at
|
|
||||||
the bottom of a short page. -->
|
|
||||||
<div class="flex min-h-[calc(100dvh_-_3.5rem_-_env(safe-area-inset-top))] flex-col">
|
<div class="flex min-h-[calc(100dvh_-_3.5rem_-_env(safe-area-inset-top))] flex-col">
|
||||||
<!-- Content: the breadcrumb, an <h1> for the tool (SEO), then the tool
|
|
||||||
body itself -->
|
|
||||||
<main class="flex-1">
|
<main class="flex-1">
|
||||||
<div class="mx-auto w-full max-w-[1400px] px-4 md:px-6 py-6">
|
<div class="mx-auto w-full max-w-[1400px] px-4 md:px-6 py-6">
|
||||||
<PageBreadcrumb v-if="tool" :items="breadcrumb" />
|
<PageBreadcrumb v-if="tool" :items="breadcrumb" />
|
||||||
@@ -14,8 +9,7 @@
|
|||||||
<span aria-hidden="true">{{ tool.emoji }}</span>
|
<span aria-hidden="true">{{ tool.emoji }}</span>
|
||||||
{{ t(tool.titleKey) }}
|
{{ t(tool.titleKey) }}
|
||||||
</h1>
|
</h1>
|
||||||
<!-- One cached instance per visited slug; re-keyed by the auth epoch
|
<!-- One cached instance per visited slug -->
|
||||||
so a sign-out or account switch drops them all. -->
|
|
||||||
<KeepAlive :key="authEpoch" :max="8">
|
<KeepAlive :key="authEpoch" :max="8">
|
||||||
<component :is="toolComponent" v-if="toolComponent" :key="tool.slug" />
|
<component :is="toolComponent" v-if="toolComponent" :key="tool.slug" />
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
@@ -40,9 +34,8 @@ import { ToolLoadingSkeleton } from '@/components/ui/tool-loading-skeleton';
|
|||||||
|
|
||||||
defineOptions({ name: 'ToolPage' });
|
defineOptions({ name: 'ToolPage' });
|
||||||
|
|
||||||
// One async wrapper per slug, built once, so re-renders never rebuild it — a
|
// One async wrapper per slug, built once: a new wrapper would remount the tool
|
||||||
// new wrapper would remount the tool and lose its cached state.
|
// and lose its cached state. `delay` keeps fast loads skeleton-free.
|
||||||
// The skeleton covers the chunk download; `delay` keeps fast loads flash-free.
|
|
||||||
const asyncToolCache = new Map();
|
const asyncToolCache = new Map();
|
||||||
const asyncToolFor = (tool) => {
|
const asyncToolFor = (tool) => {
|
||||||
if (!asyncToolCache.has(tool.slug)) {
|
if (!asyncToolCache.has(tool.slug)) {
|
||||||
@@ -60,8 +53,7 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useMainStore();
|
const store = useMainStore();
|
||||||
|
|
||||||
// The slug of the tool route, held while another route shows: this page stays
|
// Held while another route shows: the cached page keeps rendering its tool.
|
||||||
// cached then, and must keep rendering the tool it was showing.
|
|
||||||
const routeSlug = computed(() => (route.name === 'tool' ? route.params.slug : null));
|
const routeSlug = computed(() => (route.name === 'tool' ? route.params.slug : null));
|
||||||
const slug = computed((previous) => routeSlug.value ?? previous ?? null);
|
const slug = computed((previous) => routeSlug.value ?? previous ?? null);
|
||||||
|
|
||||||
@@ -83,14 +75,13 @@ useDocumentMeta(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unknown slug → bounce to the homepage rather than show an empty shell.
|
// Unknown slug → homepage, on every arrival (a cached instance included).
|
||||||
// Runs on every arrival at a tool route, including a reused instance.
|
|
||||||
watch(routeSlug, (next) => {
|
watch(routeSlug, (next) => {
|
||||||
if (next && !tool.value) router.replace('/');
|
if (next && !tool.value) router.replace('/');
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
// Auth epoch: bumped when the signed-in account goes away (utils/tool-cache.js),
|
// Re-keys the KeepAlive when the account goes away (utils/tool-cache.js), so
|
||||||
// which re-keys the inner KeepAlive and so drops every cached tool.
|
// a sign-out or account switch drops every cached tool.
|
||||||
const authEpoch = ref(0);
|
const authEpoch = ref(0);
|
||||||
watch(() => store.user?.uid ?? null, (nextUid, prevUid) => {
|
watch(() => store.user?.uid ?? null, (nextUid, prevUid) => {
|
||||||
if (shouldDropToolCache(prevUid, nextUid)) authEpoch.value += 1;
|
if (shouldDropToolCache(prevUid, nextUid)) authEpoch.value += 1;
|
||||||
|
|||||||
@@ -123,8 +123,6 @@ const store = useMainStore();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { dotClass } = useStatusTone();
|
const { dotClass } = useStatusTone();
|
||||||
|
|
||||||
// One instance for the whole site (App.vue). The pages other than the
|
|
||||||
// homepage skip its loading pipeline, which changes when user info can load;
|
|
||||||
// `matched` is empty until the first navigation resolves the route.
|
// `matched` is empty until the first navigation resolves the route.
|
||||||
const isOffHome = computed(() => route.matched.length > 0 && route.name !== 'home');
|
const isOffHome = computed(() => route.matched.length > 0 && route.name !== 'home');
|
||||||
|
|
||||||
@@ -250,12 +248,8 @@ const updateUserAchievement = async (achievementName) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// User info loads once the visitor is signed in and, on the homepage, its
|
// User info loads once signed in and, on Home, after its tests (allHasLoaded);
|
||||||
// tests have finished (allHasLoaded); the other pages never run them, so
|
// other pages never run them. getUserInfo self-guards against double fetches.
|
||||||
// sign-in alone is enough there. Sign-in can land after allHasLoaded (the
|
|
||||||
// unknown-auth-hint boot probes in the background, main.js) and auth can
|
|
||||||
// resolve before this mounts (immediate). getUserInfo self-guards against
|
|
||||||
// double fetches.
|
|
||||||
watch(() => isSignedIn.value && (store.allHasLoaded || isOffHome.value), (ready) => {
|
watch(() => isSignedIn.value && (store.allHasLoaded || isOffHome.value), (ready) => {
|
||||||
if (ready) getUserInfo();
|
if (ready) getUserInfo();
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
@@ -264,12 +258,12 @@ watch(() => triggerUserBenefits.value, (newVal) => {
|
|||||||
if (newVal) openUserBenefits();
|
if (newVal) openUserBenefits();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The dialog belongs to the page it was opened on: a page change closes it.
|
// A page change closes the dialog.
|
||||||
watch(() => route.path, () => {
|
watch(() => route.path, () => {
|
||||||
isOpen.value = false;
|
isOpen.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// One-shot trigger: cleared here so the next request from Nav / Achievements
|
// One-shot trigger: cleared here so the next request from UserMenu / Achievements
|
||||||
// is a fresh false → true edge rather than a no-op write.
|
// is a fresh false → true edge rather than a no-op write.
|
||||||
watch(() => triggerRemoteUserInfo.value, (newVal) => {
|
watch(() => triggerRemoteUserInfo.value, (newVal) => {
|
||||||
if (!newVal) return;
|
if (!newVal) return;
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Account menu at the right of the site Nav. Two shapes:
|
|
||||||
a Firebase-less self-hosted instance has no user system, so only the
|
|
||||||
preferences cog shows; otherwise a dropdown carries sign-in (signed
|
|
||||||
out) or the account card + achievements (signed in), then preferences,
|
|
||||||
benefits & usage and sign-out. The dialogs it opens are raised through
|
|
||||||
the store: Preferences and User live in App.vue, Achievements on the
|
|
||||||
homepage. -->
|
|
||||||
<!-- Firebase-less: standalone preferences cog. -->
|
<!-- Firebase-less: standalone preferences cog. -->
|
||||||
<JnTooltip v-if="!isFireBaseSet" :text="t('nav.preferences.title')">
|
<JnTooltip v-if="!isFireBaseSet" :text="t('nav.preferences.title')">
|
||||||
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" aria-label="Open preferences"
|
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" aria-label="Open preferences"
|
||||||
@@ -119,8 +112,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
// Account menu (preferences cog or user dropdown) for the site Nav.
|
// Account menu at the right of the site Nav: a preferences cog on a
|
||||||
// Everything it opens goes through store triggers.
|
// Firebase-less instance, the user dropdown otherwise. What it opens is raised
|
||||||
|
// through store triggers (Preferences / User live in App.vue, Achievements on Home).
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
@@ -176,14 +170,12 @@ const getUserInfo = async () => {
|
|||||||
store.setTriggerRemoteUserInfo(true);
|
store.setTriggerRemoteUserInfo(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Preferences: the one site-wide sheet (App.vue).
|
|
||||||
const openPreferences = () => {
|
const openPreferences = () => {
|
||||||
store.toggleSheet('preferences');
|
store.toggleSheet('preferences');
|
||||||
trackEvent('Nav', 'NavClick', 'Preferences');
|
trackEvent('Nav', 'NavClick', 'Preferences');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Achievements live on the homepage only: from any other page, go home first
|
// Achievements live on Home only, so go there first.
|
||||||
// (the kept-alive Home comes back as it was left), then raise the sheet.
|
|
||||||
const openAchievements = async () => {
|
const openAchievements = async () => {
|
||||||
if (route.name !== 'home') {
|
if (route.name !== 'home') {
|
||||||
const failure = await router.push('/');
|
const failure = await router.push('/');
|
||||||
|
|||||||
@@ -536,10 +536,8 @@ const onSubmit = () => {
|
|||||||
if (query.value.trim()) start(query.value);
|
if (query.value.trim()) start(query.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// `?q=` on mount, any later change while the tool is on screen (an ASN link
|
// Shareable `?q=` (use-route-active.js), including an ASN link followed from
|
||||||
// followed from inside it, history navigation), and a `?q=` that changed while
|
// inside the page; our own write-back equals `query`, so it doesn't run twice.
|
||||||
// it was cached away (use-route-active.js). Our own write-back equals `query`
|
|
||||||
// already, so it doesn't run twice.
|
|
||||||
const sharedQuery = useActiveValue(() => route.query.q, { pathOf: () => route.path });
|
const sharedQuery = useActiveValue(() => route.query.q, { pathOf: () => route.path });
|
||||||
watch(sharedQuery, (q) => {
|
watch(sharedQuery, (q) => {
|
||||||
if (typeof q === 'string' && q.trim() && q !== query.value) {
|
if (typeof q === 'string' && q.trim() && q !== query.value) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<span>
|
<span>
|
||||||
{{ t('user.QuotaExceeded') }}
|
{{ t('user.QuotaExceeded') }}
|
||||||
<button type="button" class="underline underline-offset-2 cursor-pointer"
|
<button type="button" class="underline underline-offset-2 cursor-pointer"
|
||||||
@click="openUsageDialog">{{ t('user.ViewUsage') }}</button>
|
@click="store.setTriggerUserBenefits(true)">{{ t('user.ViewUsage') }}</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -295,11 +295,6 @@ const { t } = useI18n();
|
|||||||
const store = useMainStore();
|
const store = useMainStore();
|
||||||
const lang = computed(() => store.lang);
|
const lang = computed(() => store.lang);
|
||||||
|
|
||||||
// Open the Benefits & Usage dialog (App.vue hosts the User dialogs).
|
|
||||||
const openUsageDialog = () => {
|
|
||||||
store.setTriggerUserBenefits(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// NS-capture session config. Token + probe FQDNs are generated client-side;
|
// NS-capture session config. Token + probe FQDNs are generated client-side;
|
||||||
// the NS passively captures any {nonce}.{token}.{DOMAIN} that shows up.
|
// the NS passively captures any {nonce}.{token}.{DOMAIN} that shows up.
|
||||||
const PROBE_DOMAIN = 'leak.ipcheck.ing';
|
const PROBE_DOMAIN = 'leak.ipcheck.ing';
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
<p v-if="!isMobile">{{ t('pingtest.Note2') }}</p>
|
<p v-if="!isMobile">{{ t('pingtest.Note2') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input area. With stored IPs (the homepage's IP cards have run) the
|
<!-- Input area -->
|
||||||
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="space-y-2">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Label :for="manualMode ? 'pingIPManual' : 'pingIP'" class="font-medium">
|
<Label :for="manualMode ? 'pingIPManual' : 'pingIP'" class="font-medium">
|
||||||
@@ -207,9 +205,8 @@ const pickerSections = computed(() => [
|
|||||||
{ key: 'suggested', label: t('globalping.Suggested'), countries: GLOBALPING_SUGGESTED_COUNTRIES },
|
{ key: 'suggested', label: t('globalping.Suggested'), countries: GLOBALPING_SUGGESTED_COUNTRIES },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Manual entry is forced when there are no stored IPs (the standalone page,
|
// Manual entry is forced when no IPs are stored (the homepage never ran);
|
||||||
// where the homepage never ran). When stored IPs exist, the "use stored IP"
|
// otherwise the switch (on by default) picks between dropdown and typing.
|
||||||
// switch (on by default) toggles between the dropdown and manual entry.
|
|
||||||
const useStored = ref(true);
|
const useStored = ref(true);
|
||||||
const manualMode = computed(() => allIPs.value.length === 0 || !useStored.value);
|
const manualMode = computed(() => allIPs.value.length === 0 || !useStored.value);
|
||||||
const manualIP = ref('');
|
const manualIP = ref('');
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<span>
|
<span>
|
||||||
{{ t('user.QuotaExceeded') }}
|
{{ t('user.QuotaExceeded') }}
|
||||||
<button type="button" class="underline underline-offset-2 cursor-pointer"
|
<button type="button" class="underline underline-offset-2 cursor-pointer"
|
||||||
@click="openUsageDialog">{{ t('user.ViewUsage') }}</button>
|
@click="store.setTriggerUserBenefits(true)">{{ t('user.ViewUsage') }}</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,11 +188,6 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const store = useMainStore();
|
const store = useMainStore();
|
||||||
|
|
||||||
// Open the Benefits & Usage dialog (App.vue hosts the User dialogs).
|
|
||||||
const openUsageDialog = () => {
|
|
||||||
store.setTriggerUserBenefits(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkingStatus = ref('idle');
|
const checkingStatus = ref('idle');
|
||||||
const errorMsg = ref('');
|
const errorMsg = ref('');
|
||||||
const quotaExceeded = ref(false);
|
const quotaExceeded = ref(false);
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
<p>{{ t('ipblocklist.Note2') }}</p>
|
<p>{{ t('ipblocklist.Note2') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input area. With stored IPs (the homepage's IP cards have run) the
|
<!-- Input area -->
|
||||||
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="space-y-2">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Label :for="manualMode ? 'blocklistIPManual' : 'blocklistIP'" class="font-medium">
|
<Label :for="manualMode ? 'blocklistIPManual' : 'blocklistIP'" class="font-medium">
|
||||||
@@ -70,7 +68,7 @@
|
|||||||
<span>
|
<span>
|
||||||
{{ t('user.QuotaExceeded') }}
|
{{ t('user.QuotaExceeded') }}
|
||||||
<button type="button" class="underline underline-offset-2 cursor-pointer"
|
<button type="button" class="underline underline-offset-2 cursor-pointer"
|
||||||
@click="openUsageDialog">{{ t('user.ViewUsage') }}</button>
|
@click="store.setTriggerUserBenefits(true)">{{ t('user.ViewUsage') }}</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,11 +228,6 @@ const { t, te } = useI18n();
|
|||||||
const store = useMainStore();
|
const store = useMainStore();
|
||||||
const lang = computed(() => store.lang);
|
const lang = computed(() => store.lang);
|
||||||
|
|
||||||
// Open the Benefits & Usage dialog (App.vue hosts the User dialogs).
|
|
||||||
const openUsageDialog = () => {
|
|
||||||
store.setTriggerUserBenefits(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// The backend waits up to 30s on the upstream; give it room to answer first.
|
// The backend waits up to 30s on the upstream; give it room to answer first.
|
||||||
const CLIENT_TIMEOUT_MS = 35000;
|
const CLIENT_TIMEOUT_MS = 35000;
|
||||||
|
|
||||||
@@ -248,7 +241,7 @@ const TONE_ICON = { abuse: CircleX, anonymity: CircleAlert, notice: Info, clean:
|
|||||||
const LEGEND = ['abuse', 'anonymity', 'notice', 'clean', 'error'];
|
const LEGEND = ['abuse', 'anonymity', 'notice', 'clean', 'error'];
|
||||||
|
|
||||||
// ── Input ────────────────────────────────────────────────────────────────
|
// ── Input ────────────────────────────────────────────────────────────────
|
||||||
// Manual entry is forced when there are no stored IPs (the standalone page);
|
// Manual entry is forced when no IPs are stored (the homepage never ran);
|
||||||
// otherwise the switch (on by default) picks between dropdown and typing.
|
// otherwise the switch (on by default) picks between dropdown and typing.
|
||||||
const allIPs = computed(() => selectableIPs(store.allIPs));
|
const allIPs = computed(() => selectableIPs(store.allIPs));
|
||||||
const useStored = ref(true);
|
const useStored = ref(true);
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
MAC Lookup's job) dispatched to the matching card under ./ip-calculator/.
|
MAC Lookup's job) dispatched to the matching card under ./ip-calculator/.
|
||||||
Pure local computation via utils/features/ip-calc.js.
|
Pure local computation via utils/features/ip-calc.js.
|
||||||
|
|
||||||
The query rides the URL as `?q=` on /tools/ipcalculator, written back on
|
The query rides the URL as `?q=`, written back on every run so results
|
||||||
every run so results are shareable. Two collapsed folds under the input
|
are shareable. Two collapsed folds under the input hold example pills
|
||||||
hold example pills (one per accepted syntax) and, once the homepage has
|
(one per accepted syntax) and the visitor's own IPs (store.allIPs). -->
|
||||||
detected them, the visitor's own IPs (store.allIPs). -->
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ip-calculator-section my-4 space-y-4">
|
<div class="ip-calculator-section my-4 space-y-4">
|
||||||
<!-- Top note -->
|
<!-- Top note -->
|
||||||
@@ -201,10 +200,8 @@ const onPrefixCommit = (prefix) => {
|
|||||||
syncQuery();
|
syncQuery();
|
||||||
};
|
};
|
||||||
|
|
||||||
// `?q=` on mount, any later change while the tool is on screen (history
|
// Shareable `?q=` (use-route-active.js); our own write-back equals the
|
||||||
// navigation), and a `?q=` that changed while it was cached away
|
// trimmed input, so it doesn't run twice.
|
||||||
// (use-route-active.js). Our own write-back equals the trimmed input, so it
|
|
||||||
// doesn't run twice.
|
|
||||||
const sharedQuery = useActiveValue(() => route.query.q, { pathOf: () => route.path });
|
const sharedQuery = useActiveValue(() => route.query.q, { pathOf: () => route.path });
|
||||||
watch(sharedQuery, (q) => {
|
watch(sharedQuery, (q) => {
|
||||||
if (typeof q === 'string' && q.trim() && q !== query.value.trim()) {
|
if (typeof q === 'string' && q.trim() && q !== query.value.trim()) {
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
<p v-if="!isMobile">{{ t('mtrtest.Note2') }}</p>
|
<p v-if="!isMobile">{{ t('mtrtest.Note2') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input area. With stored IPs (the homepage's IP cards have run) the
|
<!-- Input area -->
|
||||||
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="space-y-2">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Label :for="manualMode ? 'mtrIPManual' : 'mtrIP'" class="font-medium">
|
<Label :for="manualMode ? 'mtrIPManual' : 'mtrIP'" class="font-medium">
|
||||||
@@ -235,9 +233,8 @@ const pickerSections = computed(() => [
|
|||||||
{ key: 'suggested', label: t('globalping.Suggested'), countries: GLOBALPING_SUGGESTED_COUNTRIES },
|
{ key: 'suggested', label: t('globalping.Suggested'), countries: GLOBALPING_SUGGESTED_COUNTRIES },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Manual entry is forced when there are no stored IPs (the standalone page,
|
// Manual entry is forced when no IPs are stored (the homepage never ran);
|
||||||
// where the homepage never ran). When stored IPs exist, the "use stored IP"
|
// otherwise the switch (on by default) picks between dropdown and typing.
|
||||||
// switch (on by default) toggles between the dropdown and manual entry.
|
|
||||||
const useStored = ref(true);
|
const useStored = ref(true);
|
||||||
const manualMode = computed(() => allIPs.value.length === 0 || !useStored.value);
|
const manualMode = computed(() => allIPs.value.length === 0 || !useStored.value);
|
||||||
const manualIP = ref('');
|
const manualIP = ref('');
|
||||||
|
|||||||
@@ -233,7 +233,7 @@
|
|||||||
<span>
|
<span>
|
||||||
{{ t('user.QuotaExceeded') }}
|
{{ t('user.QuotaExceeded') }}
|
||||||
<button type="button" class="underline underline-offset-2 cursor-pointer"
|
<button type="button" class="underline underline-offset-2 cursor-pointer"
|
||||||
@click="openUsageDialog">{{ t('user.ViewUsage') }}</button>
|
@click="store.setTriggerUserBenefits(true)">{{ t('user.ViewUsage') }}</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -383,11 +383,8 @@ const runDependencies = async () => {
|
|||||||
if (!missing.length) return;
|
if (!missing.length) return;
|
||||||
runningDependencies.value = true;
|
runningDependencies.value = true;
|
||||||
try {
|
try {
|
||||||
// The owners are the homepage sections, registered while Home is
|
// The owners are homepage sections: a cached Home runs them in place;
|
||||||
// mounted — kept alive behind this page once it has been visited, so
|
// one that never mounted (direct visit) needs a trip home first.
|
||||||
// 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]);
|
const commands = missing.map((source) => DEPENDENCY_COMMANDS[source]);
|
||||||
if (!commands.every(hasAppCommand)) await router.push('/');
|
if (!commands.every(hasAppCommand)) await router.push('/');
|
||||||
await Promise.allSettled(commands.map(async (command) => {
|
await Promise.allSettled(commands.map(async (command) => {
|
||||||
@@ -410,11 +407,6 @@ const quotaExceeded = ref(false);
|
|||||||
const canRun = computed(() =>
|
const canRun = computed(() =>
|
||||||
signedIn.value && hasProfile.value && !missingSources.value.length);
|
signedIn.value && hasProfile.value && !missingSources.value.length);
|
||||||
|
|
||||||
// Open the Benefits & Usage dialog (App.vue hosts the User dialogs).
|
|
||||||
const openUsageDialog = () => {
|
|
||||||
store.setTriggerUserBenefits(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
// Pre-flight gate, same as the other quota-metered tools: when the local
|
// Pre-flight gate, same as the other quota-metered tools: when the local
|
||||||
// snapshot already says the month is spent, every run would 429.
|
// snapshot already says the month is spent, every run would 429.
|
||||||
|
|||||||
@@ -443,10 +443,8 @@ const changeList = (listName, shouldScroll = true) => {
|
|||||||
if (previousList !== currentList.value) {
|
if (previousList !== currentList.value) {
|
||||||
trackEvent('SecurityChecklist', 'SecurityChecklist', 'ChangeList');
|
trackEvent('SecurityChecklist', 'SecurityChecklist', 'ChangeList');
|
||||||
}
|
}
|
||||||
// Bring the details into view when switching category on a narrow
|
// On a narrow screen the details stack below the list: bring them into
|
||||||
// screen, where they stack below the category list. `scroll-mt-*` on
|
// view, clear of the fixed Nav (scroll-mt-* on #checklist).
|
||||||
// #checklist keeps them clear of the fixed Nav; on md+ they
|
|
||||||
// sit beside the list with their top on screen, so nothing moves.
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const el = document.getElementById('checklist');
|
const el = document.getElementById('checklist');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Service Status advanced tool: shows whether a set of well-known
|
<!-- Service Status advanced tool: shows whether a set of well-known
|
||||||
products are currently up, expandable per-card into sub-services and
|
products are currently up, expandable per-card into sub-services and
|
||||||
recent incidents. Rendered by the tool page (ToolPage.vue owns the
|
recent incidents. ToolPage.vue owns the title, so this starts at the
|
||||||
title), so this template starts at the note row rather than a section
|
note row. -->
|
||||||
<h2>. -->
|
|
||||||
<div class="service-status-section my-4 space-y-4">
|
<div class="service-status-section my-4 space-y-4">
|
||||||
<!-- Top note -->
|
<!-- Top note -->
|
||||||
<p class="text-sm text-muted-foreground leading-relaxed">{{ t('serviceStatus.Note') }}</p>
|
<p class="text-sm text-muted-foreground leading-relaxed">{{ t('serviceStatus.Note') }}</p>
|
||||||
@@ -165,10 +164,8 @@
|
|||||||
// /api/service-status/detail → one provider's sub-services + incidents (on expand)
|
// /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.
|
// so the initial load stays light and detail is pulled only when a card opens.
|
||||||
//
|
//
|
||||||
// An Advanced Tool, not a homepage section: it loads on its first mount
|
// Not wired into the homepage's global-refresh orchestrator: it loads on
|
||||||
// (loadOverview), stays cached with its page afterwards, and is not wired
|
// mount, and the bottom Refresh button is the only re-pull path.
|
||||||
// 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';
|
import { ref, reactive, computed, onMounted } from 'vue';
|
||||||
import { useMainStore } from '@/store';
|
import { useMainStore } from '@/store';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|||||||
@@ -27,8 +27,7 @@
|
|||||||
<!-- Traffic shares (pair bars) -->
|
<!-- Traffic shares (pair bars) -->
|
||||||
<AsnTrafficShares :info="asnInfos[asn]" />
|
<AsnTrafficShares :info="asnInfos[asn]" />
|
||||||
|
|
||||||
<!-- Full profile: a real link to the ASN Profile page (new tab on
|
<!-- Full profile: a real link; a plain click routes in-app -->
|
||||||
modifier clicks); a plain click routes in-app -->
|
|
||||||
<div v-if="profileHref" class="pt-1">
|
<div v-if="profileHref" class="pt-1">
|
||||||
<Button as-child variant="outline" size="sm" class="h-7 cursor-pointer gap-1.5 text-xs">
|
<Button as-child variant="outline" size="sm" class="h-7 cursor-pointer gap-1.5 text-xs">
|
||||||
<a :href="profileHref" @click="openProfile">
|
<a :href="profileHref" @click="openProfile">
|
||||||
@@ -58,6 +57,7 @@ import { trackEvent } from '@/utils/analytics';
|
|||||||
import { parseAsnInput } from '@/utils/ip/asn-input.js';
|
import { parseAsnInput } from '@/utils/ip/asn-input.js';
|
||||||
import { isToolAvailable } from '@/utils/tool-availability.js';
|
import { isToolAvailable } from '@/utils/tool-availability.js';
|
||||||
import { TOOL_BY_SLUG } from '@/data/tools.js';
|
import { TOOL_BY_SLUG } from '@/data/tools.js';
|
||||||
|
import { isPlainClick } from '@/utils/nav-target.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { ArrowUpRight } from '@lucide/vue';
|
import { ArrowUpRight } from '@lucide/vue';
|
||||||
@@ -75,8 +75,7 @@ const props = defineProps({
|
|||||||
asnInfos: { type: Object, required: true }
|
asnInfos: { type: Object, required: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fired before a plain-click open, so a host dialog (QueryIP) closes before
|
// Fired before a plain-click open, so a host dialog (QueryIP) closes first.
|
||||||
// the page changes under it.
|
|
||||||
const emit = defineEmits(['open-profile']);
|
const emit = defineEmits(['open-profile']);
|
||||||
|
|
||||||
// Extract basic information (non-pair data)
|
// Extract basic information (non-pair data)
|
||||||
@@ -94,8 +93,7 @@ const basicInfo = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ASN Profile entry: hidden when the tool is gated off (no Cloudflare key)
|
// 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's
|
// or the ASN doesn't parse; `q` makes the tool run the lookup.
|
||||||
// page, with `q` making the tool run the lookup.
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const profileAsn = computed(() => {
|
const profileAsn = computed(() => {
|
||||||
if (!isToolAvailable(TOOL_BY_SLUG.get('asn'), store.configs)) return null;
|
if (!isToolAvailable(TOOL_BY_SLUG.get('asn'), store.configs)) return null;
|
||||||
@@ -105,7 +103,7 @@ const profileAsn = computed(() => {
|
|||||||
const profileHref = computed(() => (profileAsn.value ? `/tools/asn?q=${profileAsn.value}` : ''));
|
const profileHref = computed(() => (profileAsn.value ? `/tools/asn?q=${profileAsn.value}` : ''));
|
||||||
|
|
||||||
const openProfile = (e) => {
|
const openProfile = (e) => {
|
||||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return;
|
if (!isPlainClick(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
trackEvent('IPCheck', 'ASNProfileClick', 'Open ASN Profile');
|
trackEvent('IPCheck', 'ASNProfileClick', 'Open ASN Profile');
|
||||||
emit('open-profile');
|
emit('open-profile');
|
||||||
|
|||||||
@@ -383,10 +383,8 @@ const props = defineProps({
|
|||||||
countryTrafficMode: { type: String, default: 'off' },
|
countryTrafficMode: { type: String, default: 'off' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Consumers rendering this panel inside a dialog listen to close themselves
|
// A host dialog listens to close itself first: before the Benefits & Usage
|
||||||
// first — before the Benefits & Usage dialog would stack on top of them
|
// dialog stacks on it (`view-usage`) or ASN Info's profile link changes page (`open-tool`).
|
||||||
// (`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 emit = defineEmits(['view-usage', 'open-tool']);
|
||||||
|
|
||||||
const openUsageDialog = () => {
|
const openUsageDialog = () => {
|
||||||
|
|||||||
@@ -226,8 +226,7 @@ const store = useMainStore();
|
|||||||
const userPreferences = computed(() => store.userPreferences);
|
const userPreferences = computed(() => store.userPreferences);
|
||||||
const ipDBs = computed(() => store.ipDBs);
|
const ipDBs = computed(() => store.ipDBs);
|
||||||
|
|
||||||
// One sheet for the whole site (App.vue), open while store.openSheet says so;
|
// One site-wide sheet (App.vue, which closes it on a page change).
|
||||||
// App.vue clears that on a page change.
|
|
||||||
const isOpen = computed(() => store.openSheet === 'preferences');
|
const isOpen = computed(() => store.openSheet === 'preferences');
|
||||||
const onOpenChange = (val) => {
|
const onOpenChange = (val) => {
|
||||||
store.setOpenSheet(val ? 'preferences' : null);
|
store.setOpenSheet(val ? 'preferences' : null);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const STYLE_TO_TOAST = {
|
|||||||
'text-info': toast.info,
|
'text-info': toast.info,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// immediate: an alert raised before mount (a failed redirect sign-in at boot) shows too.
|
||||||
watch(alert, (newVal) => {
|
watch(alert, (newVal) => {
|
||||||
if (!newVal || !newVal.alertToShow) {
|
if (!newVal || !newVal.alertToShow) {
|
||||||
return;
|
return;
|
||||||
@@ -31,8 +32,6 @@ watch(alert, (newVal) => {
|
|||||||
description: newVal.alertMessage,
|
description: newVal.alertMessage,
|
||||||
duration: newVal.alertDuration || 2000,
|
duration: newVal.alertDuration || 2000,
|
||||||
});
|
});
|
||||||
// `immediate`: an alert raised before mount (a failed redirect sign-in is
|
|
||||||
// handled in the boot sequence, store.completeRedirectSignIn) is shown too.
|
|
||||||
}, { deep: true, immediate: true });
|
}, { deep: true, immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
//
|
//
|
||||||
// There's no SSR, so this runs client-side: each routed page (Home.vue, the
|
// There's no SSR, so this runs client-side: each routed page (Home.vue, the
|
||||||
// tool page, /privacy, /r/:id) calls useDocumentMeta() with its own values.
|
// tool page, /privacy, /r/:id) calls useDocumentMeta() with its own values.
|
||||||
// Only the page on screen writes: a page kept alive by App.vue's KeepAlive
|
// Only the active route writes, so a kept-alive page re-applies its head on
|
||||||
// stops writing while hidden and re-applies its head when it is activated
|
// return. Reactive: locale switches and slug changes re-apply automatically.
|
||||||
// again (use-route-active.js) — no restore bookkeeping needed. It's reactive
|
|
||||||
// (watchEffect), so locale switches and slug changes re-apply automatically.
|
|
||||||
//
|
//
|
||||||
// index.html ships sensible homepage defaults (used on first paint and as the
|
// index.html ships sensible homepage defaults (used on first paint and as the
|
||||||
// fallback here); this just overrides them once Vue is driving the page.
|
// fallback here); this just overrides them once Vue is driving the page.
|
||||||
@@ -14,8 +12,7 @@ import { watchEffect } from 'vue';
|
|||||||
import { useRouteActive } from './use-route-active.js';
|
import { useRouteActive } from './use-route-active.js';
|
||||||
|
|
||||||
// Snapshot the index.html-provided values once, before any route overrides
|
// Snapshot the index.html-provided values once, before any route overrides
|
||||||
// them — Home.vue reuses these so SPA-navigating back to `/` restores the
|
// them: the fallback for fields a page leaves out.
|
||||||
// homepage head.
|
|
||||||
export const DEFAULT_META = {
|
export const DEFAULT_META = {
|
||||||
title: document.title,
|
title: document.title,
|
||||||
description: document.head.querySelector('meta[name="description"]')?.getAttribute('content') || '',
|
description: document.head.querySelector('meta[name="description"]')?.getAttribute('content') || '',
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
// Carries out a site-navigation click (utils/nav-target.js decides what it
|
// Carries out a site-navigation click (utils/nav-target.js decides what it
|
||||||
// means on the current route): scroll the homepage to a section, go home first
|
// means on the current route): scroll the homepage, go home then scroll, or
|
||||||
// and then scroll, or open a tool's page.
|
// open a tool's page. Going home, the scroll waits a tick and a frame so it
|
||||||
//
|
// runs after the router's scrollBehavior; then, as a freshly mounted homepage
|
||||||
// Going home is a push, so the router's scrollBehavior puts the homepage at
|
// is still resizing above the section, it re-aims while #mainpart resizes,
|
||||||
// the top after the next tick; the section scroll waits for that tick and one
|
// for SETTLE_MS or until the visitor scrolls or presses a key.
|
||||||
// frame, so it runs last, on the activated and laid-out homepage. A homepage
|
|
||||||
// this push mounted is still filling in (its tests and async parts resize
|
|
||||||
// what sits above the section), so the scroll re-aims while #mainpart
|
|
||||||
// resizes, for SETTLE_MS or until the visitor scrolls or presses a key.
|
|
||||||
//
|
|
||||||
// const { navigateTo } = useNavTarget(); // { route, router } injectable
|
|
||||||
// navigateTo({ section: 'Connectivity' }) / navigateTo({ tool: 'whois' })
|
|
||||||
|
|
||||||
import { nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|||||||
@@ -8,10 +8,8 @@
|
|||||||
// Sync flush on purpose: a keystroke arriving in the same tick as the open
|
// Sync flush on purpose: a keystroke arriving in the same tick as the open
|
||||||
// flip must already see the overlay.
|
// flip must already see the overlay.
|
||||||
//
|
//
|
||||||
// Overlays on a page kept alive by a KeepAlive (App.vue / ToolPage.vue) are
|
// An overlay on a kept-alive page counts only while that page is on screen, and
|
||||||
// asked to close when that page is left: their portal sits in <body>, outside
|
// is closed when the page is left: its <body> portal would outlive it.
|
||||||
// the cached page, so an open one would otherwise stay on screen — scroll lock
|
|
||||||
// and all — over the next page. Only an overlay on the page on screen counts.
|
|
||||||
|
|
||||||
import { getCurrentInstance, getCurrentScope, onDeactivated, onScopeDispose, toValue, watch } from 'vue';
|
import { getCurrentInstance, getCurrentScope, onDeactivated, onScopeDispose, toValue, watch } from 'vue';
|
||||||
import { openOverlay, closeOverlay } from '@/utils/shortcut.js';
|
import { openOverlay, closeOverlay } from '@/utils/shortcut.js';
|
||||||
|
|||||||
@@ -1,34 +1,16 @@
|
|||||||
// "Is my page the one on screen?" for components living under the app's
|
// "Is my page the one on screen?" for components under the app's KeepAlive
|
||||||
// <KeepAlive> layers (App.vue caches Home and ToolPage; ToolPage caches each
|
// layers (App.vue caches Home and ToolPage; ToolPage caches each tool). A
|
||||||
// visited tool). A cached page stays mounted while another route shows, so
|
// cached page stays mounted while another route shows, so whatever acts on the
|
||||||
// anything that acts on the route, the document or window-level events must
|
// route, the document or window events pauses while it is deactivated.
|
||||||
// pause while it is deactivated and catch up when it is activated again.
|
// onActivated / onDeactivated fire for every cached ancestor, so the same call
|
||||||
//
|
// works at a page root and deep inside it. Outside a component, always active.
|
||||||
// Vue registers onActivated / onDeactivated on every KeepAlive-cached
|
|
||||||
// ancestor, so the same call works in a page root (Home), deep inside it
|
|
||||||
// (Nav) and inside a cached tool (ToolPage's inner KeepAlive).
|
|
||||||
//
|
|
||||||
// useRouteActive() → ref, true while the calling component's page is
|
|
||||||
// active; always true outside a component instance
|
|
||||||
// or a KeepAlive.
|
|
||||||
// useActiveValue(getter, { pathOf }) → computed that follows `getter`
|
|
||||||
// while active and holds its last value while hidden,
|
|
||||||
// so a watcher on it ignores what other pages do to
|
|
||||||
// shared state (the route's query) and fires on
|
|
||||||
// activation only if the value really changed meanwhile.
|
|
||||||
// useActiveEventListener(target, event, handler, options)
|
|
||||||
// → the listener is attached only while active
|
|
||||||
// (mounted / activated) and detached on deactivation
|
|
||||||
// and unmount.
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
computed, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref,
|
computed, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
|
||||||
// A component can first mount inside a branch that is already cached away
|
// An async child can first mount after its page was cached away; it starts
|
||||||
// (an async child whose chunk lands after its page was left); it starts
|
// inactive. `isDeactivated` is the flag KeepAlive sets on the cached root.
|
||||||
// inactive and waits for the next activation. `isDeactivated` is the flag
|
|
||||||
// Vue's KeepAlive sets on the cached root.
|
|
||||||
const inDeactivatedBranch = (instance) => {
|
const inDeactivatedBranch = (instance) => {
|
||||||
for (let current = instance; current; current = current.parent) {
|
for (let current = instance; current; current = current.parent) {
|
||||||
if (current.isDeactivated) return true;
|
if (current.isDeactivated) return true;
|
||||||
@@ -45,11 +27,9 @@ export const useRouteActive = () => {
|
|||||||
return active;
|
return active;
|
||||||
};
|
};
|
||||||
|
|
||||||
// `pathOf` (optional) reads the current location's path. A navigation updates
|
// A computed that follows `getter` while active and holds its last value while
|
||||||
// the route before the old page is deactivated (that happens after the next
|
// hidden. The route changes a render before the old page is deactivated, so
|
||||||
// render), so `active` alone would let the next page's query through for one
|
// `pathOf` (the current path) also holds it once the path leaves the page's.
|
||||||
// flush; with `pathOf` the value also holds once the path leaves the one the
|
|
||||||
// page was activated on. `active` is injectable for tests.
|
|
||||||
export const useActiveValue = (getter, { pathOf, active = useRouteActive() } = {}) => {
|
export const useActiveValue = (getter, { pathOf, active = useRouteActive() } = {}) => {
|
||||||
let ownPath = pathOf?.();
|
let ownPath = pathOf?.();
|
||||||
if (getCurrentInstance()) onActivated(() => { ownPath = pathOf?.(); });
|
if (getCurrentInstance()) onActivated(() => { ownPath = pathOf?.(); });
|
||||||
@@ -58,6 +38,7 @@ export const useActiveValue = (getter, { pathOf, active = useRouteActive() } = {
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Attached only while active (mounted / activated).
|
||||||
export const useActiveEventListener = (target, event, handler, options) => {
|
export const useActiveEventListener = (target, event, handler, options) => {
|
||||||
const active = useRouteActive();
|
const active = useRouteActive();
|
||||||
let attached = false;
|
let attached = false;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
// Scroll tracking: when page scrolls
|
// Scroll tracking: when page scrolls
|
||||||
// 1) capture the first partially visible section, write to store.changeSection() (for Nav highlighting)
|
// 1) capture the first partially visible section, write to store.changeSection() (for Nav highlighting)
|
||||||
// 2) send one JNScroll event when each section first fully enters the viewport (only once, deduplicated)
|
// 2) send one JNScroll event when each section first fully enters the viewport (only once, deduplicated)
|
||||||
// The listener runs only while the homepage is the page on screen (it stays
|
// Listens only while the (kept-alive) homepage is on screen.
|
||||||
// alive in a KeepAlive while a tool page shows).
|
|
||||||
|
|
||||||
import { useActiveEventListener } from './use-route-active.js';
|
import { useActiveEventListener } from './use-route-active.js';
|
||||||
import { useMainStore } from '@/store';
|
import { useMainStore } from '@/store';
|
||||||
|
|||||||
@@ -18,9 +18,6 @@
|
|||||||
// through advancedToolsRef.openTool(slug);
|
// through advancedToolsRef.openTool(slug);
|
||||||
// test-running keys dispatch commands on utils/app-commands.js
|
// test-running keys dispatch commands on utils/app-commands.js
|
||||||
// - `h` key infoMask switch only executes when isInfosLoaded is true
|
// - `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
|
|
||||||
// in App.vue's KeepAlive, so it is cleared on deactivation and rebuilt on
|
|
||||||
// activation (use-route-active.js).
|
|
||||||
// - every entry here is a home-page action. Overlays (Dialog / Sheet /
|
// - every entry here is a home-page action. Overlays (Dialog / Sheet /
|
||||||
// Drawer) suspend the whole map while they are open — see
|
// Drawer) suspend the whole map while they are open — see
|
||||||
// utils/shortcut.js — so nothing needs a per-key "is something covering
|
// utils/shortcut.js — so nothing needs a per-key "is something covering
|
||||||
@@ -53,7 +50,6 @@ const buildShortcutConfig = ({ refs, store, t, configs, userPreferences }) => {
|
|||||||
toggleInfoMask,
|
toggleInfoMask,
|
||||||
} = refs;
|
} = refs;
|
||||||
|
|
||||||
// Tool keys open the tool's page (Advanced.vue's openTool pushes the route).
|
|
||||||
const goToAdvancedTool = (slug, trackName) => {
|
const goToAdvancedTool = (slug, trackName) => {
|
||||||
advancedToolsRef.value.openTool(slug);
|
advancedToolsRef.value.openTool(slug);
|
||||||
trackEvent('Nav', 'NavClick', trackName);
|
trackEvent('Nav', 'NavClick', trackName);
|
||||||
@@ -276,9 +272,8 @@ export const useShortcuts = ({ refs, store, t, configs, userPreferences }) => {
|
|||||||
// Suspending the map behind an overlay is the primitives' job
|
// Suspending the map behind an overlay is the primitives' job
|
||||||
// (composables/use-overlay-shortcuts.js), so nothing is wired here.
|
// (composables/use-overlay-shortcuts.js), so nothing is wired here.
|
||||||
//
|
//
|
||||||
// Home is the only route that registers shortcuts; drop them when it is
|
// Home is the only route that registers shortcuts; the map is cleared
|
||||||
// left (cached away or unmounted), so keystrokes on another page can't
|
// when it is left (cached away or unmounted) and rebuilt on its return.
|
||||||
// drive the hidden homepage. Coming back rebuilds the map.
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let loaded = false;
|
let loaded = false;
|
||||||
const active = useRouteActive();
|
const active = useRouteActive();
|
||||||
|
|||||||
+5
-10
@@ -1,8 +1,5 @@
|
|||||||
// Single source of truth for the Advanced Tools.
|
// Single source of truth for the Advanced Tools: their /tools/:slug pages
|
||||||
//
|
// (ToolPage.vue), the card groups, the Nav, the keyboard shortcuts and the
|
||||||
// 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
|
|
||||||
// and mobile nav sheet, the keyboard shortcuts (use-shortcuts.js) and the
|
|
||||||
// legacy `/?tool=<slug>` redirect (utils/legacy-tool-link.js).
|
// legacy `/?tool=<slug>` redirect (utils/legacy-tool-link.js).
|
||||||
//
|
//
|
||||||
// Entry shape:
|
// Entry shape:
|
||||||
@@ -16,9 +13,8 @@
|
|||||||
// requiresConfig — gate: only shown when this `/api/configs` flag is
|
// requiresConfig — gate: only shown when this `/api/configs` flag is
|
||||||
// true (e.g. 'cloudFlare' for the Radar-backed tools)
|
// true (e.g. 'cloudFlare' for the Radar-backed tools)
|
||||||
//
|
//
|
||||||
// Gates decide listing only (card grid, nav), via
|
// Gates decide listing only (utils/tool-availability.js); a /tools/:slug link
|
||||||
// isToolAvailable() in utils/tool-availability.js; a /tools/:slug link opens
|
// opens the tool regardless.
|
||||||
// the tool regardless.
|
|
||||||
|
|
||||||
export const ADVANCED_TOOLS = [
|
export const ADVANCED_TOOLS = [
|
||||||
{ slug: 'pingtest', emoji: '⏱️', titleKey: 'pingtest.Title', noteKey: 'advancedtools.PingTestNote', category: 'network', component: () => import('@/components/advanced-tools/GlobalLatencyTest.vue') },
|
{ slug: 'pingtest', emoji: '⏱️', titleKey: 'pingtest.Title', noteKey: 'advancedtools.PingTestNote', category: 'network', component: () => import('@/components/advanced-tools/GlobalLatencyTest.vue') },
|
||||||
@@ -39,8 +35,7 @@ export const ADVANCED_TOOLS = [
|
|||||||
{ slug: 'personacheck', emoji: '🎭', titleKey: 'personacheck.Title', noteKey: 'advancedtools.PersonaCheck', category: 'deep', component: () => import('@/components/advanced-tools/PersonaCheck.vue'), requiresOriginalSite: 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 (the tool page and the legacy redirect resolve a
|
// slug → entry.
|
||||||
// tool by slug).
|
|
||||||
export const TOOL_BY_SLUG = new Map(ADVANCED_TOOLS.map((tool) => [tool.slug, tool]));
|
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
|
// Card groups, in render order. On a self-hosted deployment the 'deep' tools
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ const env = import.meta.env ?? {};
|
|||||||
|
|
||||||
const isFireBaseSet = !!env.VITE_FIREBASE_API_KEY && !!env.VITE_FIREBASE_AUTH_DOMAIN && !!env.VITE_FIREBASE_PROJECT_ID;
|
const isFireBaseSet = !!env.VITE_FIREBASE_API_KEY && !!env.VITE_FIREBASE_AUTH_DOMAIN && !!env.VITE_FIREBASE_PROJECT_ID;
|
||||||
|
|
||||||
// Inputs for utils/auth-domain.js. Browsers sign in through the shared
|
// Inputs for utils/auth-domain.js, read on demand (display mode needs `window`).
|
||||||
// default domain; the installed PWA may use VITE_FIREBASE_PWA_AUTH_DOMAIN
|
|
||||||
// (the site's own host, whose /__/auth proxy keeps the redirect handler
|
|
||||||
// same-origin). Read on demand: the display mode needs `window`.
|
|
||||||
const authDomainInputs = () => ({
|
const authDomainInputs = () => ({
|
||||||
runningAsPwa: isRunningAsPwa(),
|
runningAsPwa: isRunningAsPwa(),
|
||||||
authDomain: env.VITE_FIREBASE_AUTH_DOMAIN,
|
authDomain: env.VITE_FIREBASE_AUTH_DOMAIN,
|
||||||
|
|||||||
@@ -10,11 +10,6 @@ import { installAnimationRevival } from '@/utils/revive-animations.js';
|
|||||||
// cards, nav, shortcuts, in-app links — routes here.
|
// cards, nav, shortcuts, in-app links — routes here.
|
||||||
// /privacy → the privacy policy.
|
// /privacy → the privacy policy.
|
||||||
// /r/:id → read-only shared diagnostic report (KV-backed, noindex).
|
// /r/:id → read-only shared diagnostic report (KV-backed, noindex).
|
||||||
// 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). Page changes slide / fade as View Transitions
|
|
||||||
// (utils/page-transition.js).
|
|
||||||
//
|
//
|
||||||
// Home is imported eagerly (it's the default landing); everything else is
|
// Home is imported eagerly (it's the default landing); everything else is
|
||||||
// lazy so it stays out of the homepage bundle.
|
// lazy so it stays out of the homepage bundle.
|
||||||
@@ -43,17 +38,12 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// `/?tool=<slug>` → /tools/<slug> (or `/` for an unknown slug); a page load
|
// Legacy `/?tool=<slug>` links → /tools/<slug>, replacing the history entry.
|
||||||
// replaces the entry, so the legacy URL leaves no trace in history. null lets
|
|
||||||
// every other navigation through.
|
|
||||||
router.beforeEach((to) => resolveLegacyToolLink(to) ?? undefined);
|
router.beforeEach((to) => resolveLegacyToolLink(to) ?? undefined);
|
||||||
|
|
||||||
// Page changes animate as View Transitions (utils/page-transition.js). Hooked
|
// Hooked here, not in a component, so they exist before the first navigation
|
||||||
// here, not in a component, so the guards exist before the first navigation
|
|
||||||
// and outlive any page.
|
// and outlive any page.
|
||||||
installPageTransitions(router);
|
installPageTransitions(router);
|
||||||
// History steps leave the page's running CSS animations frozen on iOS once
|
|
||||||
// the browser's own gesture animation lands — see utils/revive-animations.js.
|
|
||||||
installAnimationRevival(router);
|
installAnimationRevival(router);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
+7
-16
@@ -3,8 +3,7 @@ import { defineStore } from 'pinia';
|
|||||||
import { loadFirebaseAuth, authDomainInputs } from './firebase-init.js';
|
import { loadFirebaseAuth, authDomainInputs } from './firebase-init.js';
|
||||||
import { signInProvidersFor } from './utils/auth-domain.js';
|
import { signInProvidersFor } from './utils/auth-domain.js';
|
||||||
import { writeAuthHint } from './utils/auth-hint.js';
|
import { writeAuthHint } from './utils/auth-hint.js';
|
||||||
import { resolveSignInFlow, shouldFallBackToRedirect } from './utils/auth-flow.js';
|
import { markPendingRedirectSignIn, shouldFallBackToRedirect, takePendingRedirectSignIn } from './utils/auth-redirect.js';
|
||||||
import { markPendingRedirectSignIn, takePendingRedirectSignIn } from './utils/auth-redirect.js';
|
|
||||||
import { isRunningAsPwa } from './utils/pwa.js';
|
import { isRunningAsPwa } from './utils/pwa.js';
|
||||||
import i18n from './locales/i18n.js';
|
import i18n from './locales/i18n.js';
|
||||||
import { createInitialAchievementsState } from './data/achievements.js';
|
import { createInitialAchievementsState } from './data/achievements.js';
|
||||||
@@ -278,24 +277,18 @@ export const useMainStore = defineStore('main', {
|
|||||||
async signInWithGithub() {
|
async signInWithGithub() {
|
||||||
await this.signInWithProvider('github');
|
await this.signInWithProvider('github');
|
||||||
},
|
},
|
||||||
// Shared sign-in path for both buttons (flow choice: utils/auth-flow.js).
|
// Shared sign-in path for both buttons: popup in a browser tab, then
|
||||||
// Popup in a browser tab: set the user, then reload. Redirect in the
|
// reload; redirect in the installed PWA or when the popup can't open
|
||||||
// installed PWA, or when the popup can't open: mark the pending return
|
// (utils/auth-redirect.js), finished at boot by completeRedirectSignIn.
|
||||||
// and leave the page — completeRedirectSignIn picks it up at boot.
|
|
||||||
async signInWithProvider(providerKey) {
|
async signInWithProvider(providerKey) {
|
||||||
if (!this.signInProviders.includes(providerKey)) {
|
|
||||||
console.warn(`Sign-in with ${providerKey} is not offered here`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const descriptor = SIGN_IN_PROVIDERS[providerKey];
|
const descriptor = SIGN_IN_PROVIDERS[providerKey];
|
||||||
try {
|
try {
|
||||||
const fb = await loadFirebaseAuth();
|
const fb = await loadFirebaseAuth();
|
||||||
if (resolveSignInFlow({ runningAsPwa: isRunningAsPwa() }) === 'popup') {
|
if (!isRunningAsPwa()) {
|
||||||
try {
|
try {
|
||||||
const result = await fb.signInWithPopup(fb.auth, descriptor.build(fb));
|
const result = await fb.signInWithPopup(fb.auth, descriptor.build(fb));
|
||||||
this.user = result.user;
|
this.user = result.user;
|
||||||
writeAuthHint(true);
|
writeAuthHint(true);
|
||||||
// refresh browser after successful login
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -309,10 +302,8 @@ export const useMainStore = defineStore('main', {
|
|||||||
this.handleSignInError(error, descriptor);
|
this.handleSignInError(error, descriptor);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Boot-time half of the redirect flow: consumes the pending marker and
|
// Boot half of the redirect flow: true when it signed the visitor in
|
||||||
// reads the provider's result. Resolves true when it signed the visitor
|
// (state set before mount, no reload).
|
||||||
// in (state set before mount, no reload); false for no marker, no
|
|
||||||
// Firebase, a visitor who backed out at the provider, or an error.
|
|
||||||
async completeRedirectSignIn() {
|
async completeRedirectSignIn() {
|
||||||
const pending = takePendingRedirectSignIn();
|
const pending = takePendingRedirectSignIn();
|
||||||
if (!pending) return false;
|
if (!pending) return false;
|
||||||
|
|||||||
@@ -270,15 +270,10 @@ footer {
|
|||||||
max-height: calc(100dvh - var(--jn-gb-anchor) - 3.25rem - env(safe-area-inset-top) - 1rem) !important;
|
max-height: calc(100dvh - var(--jn-gb-anchor) - 3.25rem - env(safe-area-inset-top) - 1rem) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Page transitions (utils/page-transition.js) — View Transitions keyed by
|
/* Page transitions (utils/page-transition.js), keyed by <html
|
||||||
<html data-page-transition>: "forward" (Home → a page) slides the new page
|
data-page-transition>: "forward" slides the new page in from the right,
|
||||||
in from the right over the old one, which drifts left and dims; "back" is
|
"back" mirrors it, "fade" cross-fades. The Nav is captured on its own and
|
||||||
the mirror, the old page sliding out right over the returning one; "fade"
|
shown in its new state at once, so only the page moves. */
|
||||||
(page → page) cross-fades. The Nav is captured on its own and shown in
|
|
||||||
its new state at once, so only the page below moves; slides blend
|
|
||||||
normally (no plus-lighter brightening where the two pages overlap). The
|
|
||||||
pseudo-elements hang off <html> itself, hence no space before
|
|
||||||
`::view-transition-*`. */
|
|
||||||
.jn-site-nav {
|
.jn-site-nav {
|
||||||
view-transition-name: site-nav;
|
view-transition-name: site-nav;
|
||||||
}
|
}
|
||||||
@@ -288,17 +283,13 @@ footer {
|
|||||||
::view-transition-new(site-nav) {
|
::view-transition-new(site-nav) {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
html:not([data-page-transition])::view-transition-old(root),
|
|
||||||
html:not([data-page-transition])::view-transition-new(root) {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
html[data-page-transition="forward"]::view-transition-old(root),
|
html[data-page-transition="forward"]::view-transition-old(root),
|
||||||
html[data-page-transition="forward"]::view-transition-new(root),
|
html[data-page-transition="forward"]::view-transition-new(root),
|
||||||
html[data-page-transition="back"]::view-transition-old(root),
|
html[data-page-transition="back"]::view-transition-old(root),
|
||||||
html[data-page-transition="back"]::view-transition-new(root) {
|
html[data-page-transition="back"]::view-transition-new(root) {
|
||||||
mix-blend-mode: normal;
|
mix-blend-mode: normal; /* no plus-lighter brightening where the pages overlap */
|
||||||
animation-duration: 260ms;
|
animation-duration: 260ms;
|
||||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
animation-fill-mode: both;
|
animation-fill-mode: both;
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
// Which Firebase auth domain this session signs in through, and which
|
// Which Firebase auth domain this session signs in through, and which
|
||||||
// providers that leaves usable.
|
// providers that leaves: browsers keep the shared default, the installed PWA
|
||||||
// The default domain (VITE_FIREBASE_AUTH_DOMAIN) is a login host shared with
|
// may use VITE_FIREBASE_PWA_AUTH_DOMAIN (why: api/AGENTS.md "Firebase Auth
|
||||||
// other services, so browsers keep it. The installed PWA needs the auth
|
// handler proxy"). GitHub's single callback URL is on the default domain, so a
|
||||||
// handler on the site's own origin (served by the /__/auth proxy) for the
|
// PWA on another domain offers only Google.
|
||||||
// redirect result to survive, so it may use VITE_FIREBASE_PWA_AUTH_DOMAIN
|
|
||||||
// instead. GitHub's OAuth App allows a single callback URL, on the default
|
|
||||||
// domain — a PWA on a different domain can only offer Google.
|
|
||||||
|
|
||||||
const ALL_PROVIDERS = ['google', 'github'];
|
const ALL_PROVIDERS = ['google', 'github'];
|
||||||
const DEFAULT_DOMAIN_ONLY = new Set(['github']);
|
const DEFAULT_DOMAIN_ONLY = new Set(['github']);
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
// Sign-in flow choice: popup or full-page redirect.
|
|
||||||
// An installed PWA (iOS standalone above all) has no popup with a usable
|
|
||||||
// `opener`, so the provider's result could never be posted back — it signs
|
|
||||||
// in by redirect instead, consumed at boot via utils/auth-redirect.js. A
|
|
||||||
// browser tab keeps the popup and falls back to the redirect only when the
|
|
||||||
// popup itself can't open.
|
|
||||||
|
|
||||||
// Popup rejections that mean "this environment can't do popups", as opposed
|
|
||||||
// to the visitor closing or superseding one.
|
|
||||||
const REDIRECT_FALLBACK_CODES = new Set([
|
|
||||||
'auth/popup-blocked',
|
|
||||||
'auth/operation-not-supported-in-this-environment',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const resolveSignInFlow = ({ runningAsPwa }) => (runningAsPwa ? 'redirect' : 'popup');
|
|
||||||
|
|
||||||
export const shouldFallBackToRedirect = (errorCode) => REDIRECT_FALLBACK_CODES.has(errorCode);
|
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
// Pending-redirect sign-in marker: set right before signInWithRedirect leaves
|
// Redirect sign-in: when it replaces the popup, and the pending marker boot
|
||||||
// the page, consumed at boot so only a return from the provider pays for
|
// uses to finish it. The installed PWA has no popup with a usable `opener`, so
|
||||||
|
// it always redirects; a browser tab falls back to it only when the popup
|
||||||
|
// can't open. The marker is set right before signInWithRedirect leaves the
|
||||||
|
// page and consumed at boot, so only a return from the provider pays for
|
||||||
// loading Firebase and calling getRedirectResult. sessionStorage-scoped — the
|
// loading Firebase and calling getRedirectResult. sessionStorage-scoped — the
|
||||||
// redirect comes back into the same tab / PWA webview. Value = provider key
|
// redirect comes back into the same tab / PWA webview. Value = provider key
|
||||||
// ('google' / 'github'), for the error copy.
|
// ('google' / 'github'), for the error copy.
|
||||||
const PENDING_REDIRECT_KEY = 'pendingRedirectSignIn';
|
const PENDING_REDIRECT_KEY = 'pendingRedirectSignIn';
|
||||||
|
|
||||||
|
// Popup rejections meaning "no popups here", not the visitor closing one.
|
||||||
|
const REDIRECT_FALLBACK_CODES = new Set([
|
||||||
|
'auth/popup-blocked',
|
||||||
|
'auth/operation-not-supported-in-this-environment',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const shouldFallBackToRedirect = (errorCode) => REDIRECT_FALLBACK_CODES.has(errorCode);
|
||||||
|
|
||||||
const defaultStorage = () => {
|
const defaultStorage = () => {
|
||||||
try {
|
try {
|
||||||
return globalThis.sessionStorage ?? null;
|
return globalThis.sessionStorage ?? null;
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
// Where the page breadcrumb's "Home" crumb goes. vue-router keeps the
|
// Where the page breadcrumb's "Home" crumb goes: 'back' when vue-router's
|
||||||
// previous in-app location in `history.state.back` (null when the page was
|
// `history.state.back` is the homepage (stepping back restores the kept-alive
|
||||||
// opened directly, from a bookmark or another site). When that entry is the
|
// page and its scroll), otherwise '/' (a push), so the crumb always lands home.
|
||||||
// homepage, stepping back in history restores it as it was left — the kept-
|
|
||||||
// alive page and its scroll position; anything else (no in-app entry, or
|
|
||||||
// another page such as a different tool) pushes `/` so the crumb always
|
|
||||||
// lands where its label says.
|
|
||||||
//
|
|
||||||
// resolveBackTarget(window.history.state) → 'back' | '/'
|
|
||||||
|
|
||||||
const HOME_PATH = '/';
|
const HOME_PATH = '/';
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
// Legacy Advanced Tools links: tools once opened as a homepage drawer driven
|
// Legacy `/?tool=<slug>` links (the drawer era; still in bookmarks, shares and
|
||||||
// by `/?tool=<slug>`, and those links live on in bookmarks, shares and search
|
// search results) → the tool's page, via the router's beforeEach. A known slug
|
||||||
// results. The router's beforeEach (router/index.js) sends them to the tool's
|
// lands on /tools/<slug>, an unknown or malformed one on the homepage; other
|
||||||
// page. The redirect keeps the navigation's own mode: a page load (the router's
|
// query params and the hash ride along. Returns null when there is nothing to
|
||||||
// first navigation) and a history step replace the entry, so back never
|
// rewrite. The redirect keeps the navigation's mode: a page load or history
|
||||||
// returns to the old URL; an in-app push stays a push, keeping the page it
|
// step replaces the entry, an in-app push stays a push.
|
||||||
// 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';
|
import { TOOL_BY_SLUG } from '../data/tools.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,7 @@
|
|||||||
// What a click on a site-navigation entry does on the current route. The Nav
|
// What a click on a site-navigation entry (Nav, mobile nav sheet, breadcrumb)
|
||||||
// (every route), the mobile nav sheet and the page breadcrumb share it:
|
// does on the current route: `{ tool }` → { kind: 'tool', slug }; `{ section }`
|
||||||
//
|
// → 'scroll' on Home, 'home-then-scroll' elsewhere; anything else → null.
|
||||||
// resolveNavTarget({ item: { section: 'Connectivity' }, routeName: 'home' })
|
// composables/use-nav-target.js carries it out.
|
||||||
// → { kind: 'scroll', section } — scroll the homepage
|
|
||||||
// resolveNavTarget({ item: { section: 'Connectivity' }, routeName: 'tool' })
|
|
||||||
// → { kind: 'home-then-scroll', section } — go home, then scroll
|
|
||||||
// resolveNavTarget({ item: { tool: 'whois' }, routeName })
|
|
||||||
// → { kind: 'tool', slug: 'whois' } — open the tool's page
|
|
||||||
//
|
|
||||||
// Anything else resolves to null. composables/use-nav-target.js carries the
|
|
||||||
// result out.
|
|
||||||
//
|
|
||||||
// isPlainClick(event) — false for a click the browser should keep (modified
|
|
||||||
// or non-primary: new tab / window), so link handlers let those through.
|
|
||||||
|
|
||||||
export const resolveNavTarget = ({ item, routeName } = {}) => {
|
export const resolveNavTarget = ({ item, routeName } = {}) => {
|
||||||
if (typeof item?.tool === 'string' && item.tool) return { kind: 'tool', slug: item.tool };
|
if (typeof item?.tool === 'string' && item.tool) return { kind: 'tool', slug: item.tool };
|
||||||
@@ -23,6 +12,7 @@ export const resolveNavTarget = ({ item, routeName } = {}) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// False for a click the browser should keep (modified or non-primary: new tab / window).
|
||||||
export const isPlainClick = (event) => !(
|
export const isPlainClick = (event) => !(
|
||||||
event.defaultPrevented || event.button !== 0
|
event.defaultPrevented || event.button !== 0
|
||||||
|| event.metaKey || event.ctrlKey || event.shiftKey || event.altKey
|
|| event.metaKey || event.ctrlKey || event.shiftKey || event.altKey
|
||||||
|
|||||||
@@ -1,29 +1,17 @@
|
|||||||
// Page transitions: route changes between pages animate like native
|
// Page transitions: route changes animate through the same-document View
|
||||||
// navigation through the same-document View Transitions API. The browser
|
// Transitions API, keyed by the direction set on <html data-page-transition>
|
||||||
// snapshots the old page, the router swaps the DOM (scroll restoration
|
// (keyframes in style/style.css). The router swaps the DOM, scroll included,
|
||||||
// included) inside the snapshot window, then the two snapshots animate — so
|
// inside the snapshot window, so pages of different heights never overlap live.
|
||||||
// pages of different heights and scroll positions never overlap live.
|
|
||||||
//
|
|
||||||
// transitionDirection(from, to) → null | 'forward' | 'back' | 'fade'
|
|
||||||
// shouldAnimate({ direction, supported, reducedMotion, inFlight, uaTransition })
|
|
||||||
// installPageTransitions(router) — registered once in router/index.js
|
|
||||||
//
|
|
||||||
// The direction lands on <html data-page-transition>, which style/style.css
|
|
||||||
// keys its keyframes on; no direction, no support or reduced motion is the
|
|
||||||
// plain instant switch.
|
|
||||||
|
|
||||||
import { nextTick as vueNextTick } from 'vue';
|
import { nextTick as vueNextTick } from 'vue';
|
||||||
|
|
||||||
const HOME = 'home';
|
const HOME = 'home';
|
||||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||||
// Upper bound on how long the router may hold the DOM update once the old
|
// Cap on how long the router may hold the DOM update once the old page is snapshotted.
|
||||||
// page is snapshotted; past it the transition finishes whatever its state.
|
|
||||||
export const UPDATE_TIMEOUT_MS = 1000;
|
export const UPDATE_TIMEOUT_MS = 1000;
|
||||||
|
|
||||||
// Which animation a navigation gets. Nothing animates on the first navigation
|
// Nothing animates on the first navigation (the router's START_LOCATION) or a
|
||||||
// (the router's START_LOCATION: no name, no matched records) or when only the
|
// query / hash-only change (a tool writing back its `?q=`).
|
||||||
// query / hash changes (a tool writing back its `?q=`). Home → a page slides
|
|
||||||
// forward, a page → Home slides back, page → page cross-fades.
|
|
||||||
export const transitionDirection = (from, to) => {
|
export const transitionDirection = (from, to) => {
|
||||||
if (!from || !to) return null;
|
if (!from || !to) return null;
|
||||||
const firstNavigation = from.name === undefined
|
const firstNavigation = from.name === undefined
|
||||||
@@ -34,10 +22,8 @@ export const transitionDirection = (from, to) => {
|
|||||||
return 'fade';
|
return 'fade';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Whether a navigation starts a view transition. A running one is never
|
// A running transition is never stacked on, and a history step the browser
|
||||||
// stacked on (the caller skips it instead), and a history step the browser
|
// already animated (iOS swipe-back: `hasUAVisualTransition`) is not animated twice.
|
||||||
// already animated itself (iOS swipe-back: `hasUAVisualTransition`) is not
|
|
||||||
// animated twice.
|
|
||||||
export const shouldAnimate = ({ direction, supported, reducedMotion, inFlight, uaTransition }) =>
|
export const shouldAnimate = ({ direction, supported, reducedMotion, inFlight, uaTransition }) =>
|
||||||
Boolean(direction) && supported === true && !reducedMotion && !inFlight && !uaTransition;
|
Boolean(direction) && supported === true && !reducedMotion && !inFlight && !uaTransition;
|
||||||
|
|
||||||
@@ -57,8 +43,7 @@ export const installPageTransitions = (router, {
|
|||||||
let endUpdate = null; // resolves the active transition's update callback
|
let endUpdate = null; // resolves the active transition's update callback
|
||||||
let uaTransition = false; // the pending history step was animated by the browser
|
let uaTransition = false; // the pending history step was animated by the browser
|
||||||
|
|
||||||
// Capture phase on the target: runs before the router's own popstate
|
// Capture phase: runs before the router's own popstate listener.
|
||||||
// listener starts the navigation.
|
|
||||||
win?.addEventListener?.('popstate', (event) => {
|
win?.addEventListener?.('popstate', (event) => {
|
||||||
uaTransition = event?.hasUAVisualTransition === true;
|
uaTransition = event?.hasUAVisualTransition === true;
|
||||||
}, { capture: true });
|
}, { capture: true });
|
||||||
@@ -105,7 +90,6 @@ export const installPageTransitions = (router, {
|
|||||||
// `ready` rejects when the animation is skipped; nothing to report.
|
// `ready` rejects when the animation is skipped; nothing to report.
|
||||||
transition.ready?.catch?.(() => {});
|
transition.ready?.catch?.(() => {});
|
||||||
Promise.resolve(transition.finished).catch(() => {}).finally(() => {
|
Promise.resolve(transition.finished).catch(() => {}).finally(() => {
|
||||||
if (active !== transition) return;
|
|
||||||
active = null;
|
active = null;
|
||||||
delete root.dataset.pageTransition;
|
delete root.dataset.pageTransition;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,9 @@
|
|||||||
// Revives the page's running CSS animations after a history step.
|
// Revives the page's running CSS animations after a history step. On iOS
|
||||||
//
|
// (measured on 26), animations running before a swipe-back stay visually frozen
|
||||||
// On iOS, accelerated CSS animations that were running before a swipe-back
|
// after it until their layer is rebuilt: cancel + play does it, ancestor style
|
||||||
// gesture stay visually frozen once the gesture lands on the previous page:
|
// changes don't. Each is replayed at the time it was at, so nothing jumps.
|
||||||
// requestAnimationFrame keeps its rate and the animations' own time keeps
|
|
||||||
// advancing, yet the compositor paints no new frame for them until their
|
|
||||||
// layer is rebuilt (measured on iOS 26: cancel + play, a display:none round
|
|
||||||
// trip or a re-insert revive them; style changes on an ancestor don't).
|
|
||||||
// A navigation that commits a new animation (a push with its page
|
|
||||||
// transition) revives them as a side effect, so only history steps — where
|
|
||||||
// the browser's own gesture animation replaces ours — are affected.
|
|
||||||
//
|
|
||||||
// reviveAnimations(doc) → how many animations were restarted
|
|
||||||
// installAnimationRevival(router) — registered once in router/index.js
|
|
||||||
//
|
|
||||||
// Each running CSS animation is cancelled, replayed and set back to the time
|
|
||||||
// it was at, so the revival shows no jump; passes run a couple of frames
|
|
||||||
// after the navigation settles and again shortly after, in case the browser
|
|
||||||
// dismisses its gesture snapshot late.
|
|
||||||
|
|
||||||
|
// Late passes: the browser may dismiss its gesture snapshot late.
|
||||||
const REVIVE_DELAYS_MS = [0, 400, 1200];
|
const REVIVE_DELAYS_MS = [0, 400, 1200];
|
||||||
|
|
||||||
const isCssAnimation = (animation) => typeof animation?.animationName === 'string';
|
const isCssAnimation = (animation) => typeof animation?.animationName === 'string';
|
||||||
@@ -36,9 +22,7 @@ export const reviveAnimations = (doc = globalThis.document) => {
|
|||||||
return revived;
|
return revived;
|
||||||
};
|
};
|
||||||
|
|
||||||
// `win` / `doc` / `schedule` are injectable for tests; `schedule(fn, delayMs)`
|
// `schedule` defaults to two animation frames (the new DOM is on screen) plus the delay.
|
||||||
// defaults to two animation frames (the navigation's DOM is on screen) plus
|
|
||||||
// the delay.
|
|
||||||
export const installAnimationRevival = (router, {
|
export const installAnimationRevival = (router, {
|
||||||
win = globalThis.window,
|
win = globalThis.window,
|
||||||
doc = globalThis.document,
|
doc = globalThis.document,
|
||||||
@@ -50,8 +34,7 @@ export const installAnimationRevival = (router, {
|
|||||||
} = {}) => {
|
} = {}) => {
|
||||||
if (!win?.addEventListener || !router?.afterEach) return;
|
if (!win?.addEventListener || !router?.afterEach) return;
|
||||||
let historyStep = false;
|
let historyStep = false;
|
||||||
// Capture phase: runs before the router's own popstate listener starts
|
// Capture phase: runs before the router's own popstate listener.
|
||||||
// the navigation this flag belongs to.
|
|
||||||
win.addEventListener('popstate', () => { historyStep = true; }, { capture: true });
|
win.addEventListener('popstate', () => { historyStep = true; }, { capture: true });
|
||||||
router.afterEach(() => {
|
router.afterEach(() => {
|
||||||
if (!historyStep) return;
|
if (!historyStep) return;
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// Smooth scroll to specified element (supports passing element or id string).
|
// Smooth scroll to specified element (supports passing element or id string).
|
||||||
//
|
|
||||||
// SECTION_SCROLL_OFFSET is the one offset every homepage section scroll uses
|
// Every homepage section scroll (nav, breadcrumb, shortcuts): the fixed Nav (h-14) plus air.
|
||||||
// (nav links, breadcrumb, keyboard shortcuts): room for the fixed Nav (h-14)
|
|
||||||
// plus a little air, so all of them land a section at the same spot.
|
|
||||||
export const SECTION_SCROLL_OFFSET = 70;
|
export const SECTION_SCROLL_OFFSET = 70;
|
||||||
|
|
||||||
export function scrollToElement(el, offset = 0) {
|
export function scrollToElement(el, offset = 0) {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
// Deployment gates for the Advanced Tools registry (data/tools.js): decides
|
// Deployment gates for the Advanced Tools registry (data/tools.js): decides
|
||||||
// whether a tool is listed in the card grid and the nav. A
|
// whether a tool is listed in the card grid and the nav. A tool can require
|
||||||
// tool can require the original site (`requiresOriginalSite`) and/or one
|
// the original site (`requiresOriginalSite`) and/or one `/api/configs` flag
|
||||||
// `/api/configs` flag (`requiresConfig`). Configs arrive asynchronously (`{}` until the fetch
|
// (`requiresConfig`). Configs arrive asynchronously (`{}` until the fetch
|
||||||
// lands), so a gated tool reads as unavailable until then. A /tools/:slug
|
// lands), so a gated tool reads as unavailable until then. A /tools/:slug
|
||||||
// link is not gated.
|
// link is not gated.
|
||||||
//
|
|
||||||
// listedTools() applies the gates to a whole registry for a menu.
|
|
||||||
|
|
||||||
export const isToolAvailable = (tool, configs) => {
|
export const isToolAvailable = (tool, configs) => {
|
||||||
if (!tool) return false;
|
if (!tool) return false;
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
// Tool-page cache policy: ToolPage keeps every visited tool alive in a
|
// Tool-page cache policy: cached tools can hold one account's results, so
|
||||||
// KeepAlive, and a tool's state can hold one account's results (quota hints,
|
// ToolPage drops them on sign-out or an account switch; a first sign-in
|
||||||
// signed-in test runs). When the signed-in account goes away — sign-out or a
|
// keeps them, as nothing there belonged to anyone else.
|
||||||
// switch to another account — the cache is dropped so the next viewer never
|
|
||||||
// sees the previous account's results. A first sign-in keeps it: nothing in
|
|
||||||
// there belonged to anyone else.
|
|
||||||
|
|
||||||
export const shouldDropToolCache = (prevUid, nextUid) => Boolean(prevUid) && prevUid !== nextUid;
|
export const shouldDropToolCache = (prevUid, nextUid) => Boolean(prevUid) && prevUid !== nextUid;
|
||||||
+5
-9
@@ -11,10 +11,8 @@
|
|||||||
<meta name="author" content="Jason Ng" />
|
<meta name="author" content="Jason Ng" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<!-- No apple-mobile-web-app-status-bar-style: the default keeps the iOS
|
<!-- No apple-mobile-web-app-status-bar-style: the default keeps the iOS
|
||||||
status bar as opaque system chrome above the web view. A translucent
|
status bar opaque system chrome. A translucent one lets the page run
|
||||||
one lets the page run under the status bar, and since iOS 26 the system
|
under it, and since iOS 26 its scroll-edge blur then covers the Nav. -->
|
||||||
then draws its Liquid Glass scroll-edge blur over the top of the
|
|
||||||
installed app, across the Nav. -->
|
|
||||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||||
<meta name="theme-color" content="#0e0e0e" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#0e0e0e" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="background-color" content="#ffffff" />
|
<meta name="background-color" content="#ffffff" />
|
||||||
@@ -114,11 +112,9 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reserve space at the top of the body for: (a) the iOS safe-area
|
/* Room for the safe-area inset and the fixed Nav (3.5rem = h-14 in
|
||||||
inset (the Nav sits below it), and (b) the fixed Nav that App.vue
|
Nav.vue), which takes no flow height so it stays put while the body
|
||||||
renders above every route (3.5rem = h-14 in Nav.vue). Being `fixed`,
|
rubber-bands on iOS. */
|
||||||
the Nav stays put while the body rubber-bands on iOS, and takes no
|
|
||||||
flow height of its own. */
|
|
||||||
body {
|
body {
|
||||||
padding-top: calc(env(safe-area-inset-top) + 3.5rem);
|
padding-top: calc(env(safe-area-inset-top) + 3.5rem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
// Tests for the sign-in flow choice (frontend/utils/auth-flow.js): popup in a
|
|
||||||
// browser tab, redirect in the installed PWA and on a popup that can't open.
|
|
||||||
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { describe, it } from 'node:test';
|
|
||||||
import { resolveSignInFlow, shouldFallBackToRedirect } from '../frontend/utils/auth-flow.js';
|
|
||||||
|
|
||||||
describe('resolveSignInFlow', () => {
|
|
||||||
it('redirects inside the installed PWA', () => {
|
|
||||||
assert.equal(resolveSignInFlow({ runningAsPwa: true }), 'redirect');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the popup in a browser tab', () => {
|
|
||||||
assert.equal(resolveSignInFlow({ runningAsPwa: false }), 'popup');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('shouldFallBackToRedirect', () => {
|
|
||||||
it('falls back when the popup cannot open', () => {
|
|
||||||
assert.equal(shouldFallBackToRedirect('auth/popup-blocked'), true);
|
|
||||||
assert.equal(shouldFallBackToRedirect('auth/operation-not-supported-in-this-environment'), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not fall back when the visitor closed or superseded the popup', () => {
|
|
||||||
assert.equal(shouldFallBackToRedirect('auth/popup-closed-by-user'), false);
|
|
||||||
assert.equal(shouldFallBackToRedirect('auth/cancelled-popup-request'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not fall back on other or missing codes', () => {
|
|
||||||
assert.equal(shouldFallBackToRedirect('auth/account-exists-with-different-credential'), false);
|
|
||||||
assert.equal(shouldFallBackToRedirect('auth/network-request-failed'), false);
|
|
||||||
assert.equal(shouldFallBackToRedirect(undefined), false);
|
|
||||||
assert.equal(shouldFallBackToRedirect(''), false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
// Tests for the pending-redirect sign-in marker (frontend/utils/auth-redirect.js),
|
// Tests for frontend/utils/auth-redirect.js: the popup → redirect fallback
|
||||||
// driven through an injected fake sessionStorage.
|
// codes, and the pending-redirect marker driven through a fake sessionStorage.
|
||||||
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, it, beforeEach } from 'node:test';
|
import { describe, it, beforeEach } from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
shouldFallBackToRedirect,
|
||||||
markPendingRedirectSignIn,
|
markPendingRedirectSignIn,
|
||||||
hasPendingRedirectSignIn,
|
hasPendingRedirectSignIn,
|
||||||
takePendingRedirectSignIn,
|
takePendingRedirectSignIn,
|
||||||
@@ -22,17 +23,26 @@ beforeEach(() => {
|
|||||||
storage = createStorage();
|
storage = createStorage();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('shouldFallBackToRedirect', () => {
|
||||||
|
it('falls back when the popup cannot open', () => {
|
||||||
|
assert.equal(shouldFallBackToRedirect('auth/popup-blocked'), true);
|
||||||
|
assert.equal(shouldFallBackToRedirect('auth/operation-not-supported-in-this-environment'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fall back when the visitor closed the popup, or on other codes', () => {
|
||||||
|
assert.equal(shouldFallBackToRedirect('auth/popup-closed-by-user'), false);
|
||||||
|
assert.equal(shouldFallBackToRedirect('auth/cancelled-popup-request'), false);
|
||||||
|
assert.equal(shouldFallBackToRedirect('auth/network-request-failed'), false);
|
||||||
|
assert.equal(shouldFallBackToRedirect(undefined), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('auth-redirect marker', () => {
|
describe('auth-redirect marker', () => {
|
||||||
it('reports nothing pending on a fresh storage', () => {
|
it('reports nothing pending on a fresh storage', () => {
|
||||||
assert.equal(hasPendingRedirectSignIn(storage), false);
|
assert.equal(hasPendingRedirectSignIn(storage), false);
|
||||||
assert.equal(takePendingRedirectSignIn(storage), null);
|
assert.equal(takePendingRedirectSignIn(storage), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks a pending redirect with its provider', () => {
|
|
||||||
markPendingRedirectSignIn('github', storage);
|
|
||||||
assert.equal(hasPendingRedirectSignIn(storage), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has() reads without consuming', () => {
|
it('has() reads without consuming', () => {
|
||||||
markPendingRedirectSignIn('google', storage);
|
markPendingRedirectSignIn('google', storage);
|
||||||
hasPendingRedirectSignIn(storage);
|
hasPendingRedirectSignIn(storage);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
// resolveBackTarget() — where the page breadcrumb's "Home" crumb goes:
|
// resolveBackTarget() — where the page breadcrumb's "Home" crumb goes.
|
||||||
// a history step back when the previous in-app entry is the homepage, a push
|
|
||||||
// of `/` otherwise (direct visit, another page before this one).
|
|
||||||
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
// useNavTarget() — carries out a site-navigation click. With an injected
|
// useNavTarget() with an injected route and router: the section scroll must
|
||||||
// route and router: the order of going home and scrolling (the router's own
|
// land after the router's own scroll (next tick) and a frame.
|
||||||
// scroll to the top of a pushed page runs after the next tick, so the section
|
|
||||||
// scroll must come after that tick and a frame), the plain scroll on the
|
|
||||||
// homepage, tool pages, a refused navigation scrolling nothing, and the
|
|
||||||
// re-aiming while a freshly mounted homepage resizes.
|
|
||||||
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// use-route-active.js — the KeepAlive-awareness helpers. Activation itself
|
// use-route-active.js: activation itself needs a mounted tree; here the
|
||||||
// needs a mounted component tree (covered in the browser); here: the
|
// outside-a-component fallback and useActiveValue with an injected `active`.
|
||||||
// outside-a-component fallback and useActiveValue's hold-while-hidden rule
|
|
||||||
// with an injected `active` ref.
|
|
||||||
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { useActiveValue } from '../frontend/composables/use-route-active.js';
|
|||||||
const source = readFileSync(new URL('../frontend/components/advanced-tools/IpCalculator.vue', import.meta.url), 'utf8');
|
const source = readFileSync(new URL('../frontend/components/advanced-tools/IpCalculator.vue', import.meta.url), 'utf8');
|
||||||
const script = parse(source).descriptor.scriptSetup.content.replace(/^import[\s\S]*?;$/gm, '');
|
const script = parse(source).descriptor.scriptSetup.content.replace(/^import[\s\S]*?;$/gm, '');
|
||||||
// `active` stands in for the page's KeepAlive state (use-route-active.js).
|
// `active` stands in for the page's KeepAlive state (use-route-active.js).
|
||||||
const fixture = (q, drawer = false, active = ref(true)) => {
|
const fixture = (q, active = ref(true)) => {
|
||||||
const route = reactive({ path: '/tools/ipcalculator', query: { q, hl: 'en', ...(drawer ? { tool: 'ipcalculator' } : {}) } });
|
const route = reactive({ path: '/tools/ipcalculator', query: { q, hl: 'en' } });
|
||||||
const replacements = [];
|
const replacements = [];
|
||||||
const bindings = {
|
const bindings = {
|
||||||
computed, ref, watch, analyzeCidr, calculate,
|
computed, ref, watch, analyzeCidr, calculate,
|
||||||
@@ -35,24 +35,22 @@ for (const [input, prefix, expected] of [
|
|||||||
['2001:db8::1/64', 128, '2001:db8::1/128'],
|
['2001:db8::1/64', 128, '2001:db8::1/128'],
|
||||||
]) {
|
]) {
|
||||||
test(`prefix commit preserves the host in the share URL: ${input}`, () => {
|
test(`prefix commit preserves the host in the share URL: ${input}`, () => {
|
||||||
for (const drawer of [false, true]) {
|
const run = fixture(input);
|
||||||
const run = fixture(input, drawer);
|
const split = run.result.value.analysis.split;
|
||||||
const split = run.result.value.analysis.split;
|
run.picked.value = input;
|
||||||
run.picked.value = input;
|
run.onPrefix(prefix);
|
||||||
run.onPrefix(prefix);
|
assert.equal(run.query.value, input, 'dragging leaves the input and URL untouched');
|
||||||
assert.equal(run.query.value, input, 'dragging leaves the input and URL untouched');
|
assert.equal(run.replacements.length, 0);
|
||||||
assert.equal(run.replacements.length, 0);
|
assert.equal(run.result.value.analysis.split, split, 'dragging preserves the subnet list');
|
||||||
assert.equal(run.result.value.analysis.split, split, 'dragging preserves the subnet list');
|
run.onPrefixCommit(prefix);
|
||||||
run.onPrefixCommit(prefix);
|
assert.equal(run.query.value, expected);
|
||||||
assert.equal(run.query.value, expected);
|
assert.deepEqual(run.route.query, { q: expected, hl: 'en' });
|
||||||
assert.deepEqual(run.route.query, { q: expected, hl: 'en', ...(drawer ? { tool: 'ipcalculator' } : {}) });
|
assert.equal(run.replacements.length, 1);
|
||||||
assert.equal(run.replacements.length, 1);
|
assert.equal(run.picked.value, '');
|
||||||
assert.equal(run.picked.value, '');
|
assert.deepEqual(calculate(run.route.query.q).analysis.cidr, run.result.value.analysis.cidr);
|
||||||
assert.deepEqual(calculate(run.route.query.q).analysis.cidr, run.result.value.analysis.cidr);
|
assert.equal(run.result.value.analysis.split, run.result.value.analysis.cidr);
|
||||||
assert.equal(run.result.value.analysis.split, run.result.value.analysis.cidr);
|
run.onPrefixCommit(prefix);
|
||||||
run.onPrefixCommit(prefix);
|
assert.equal(run.replacements.length, 1, 'an unchanged prefix does not replace the URL again');
|
||||||
assert.equal(run.replacements.length, 1, 'an unchanged prefix does not replace the URL again');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,25 +65,22 @@ test('a prefix commit without an address result leaves the URL alone', () => {
|
|||||||
|
|
||||||
test('?q= runs on mount, follows the route while active, and is ignored while cached away', async () => {
|
test('?q= runs on mount, follows the route while active, and is ignored while cached away', async () => {
|
||||||
const active = ref(true);
|
const active = ref(true);
|
||||||
const run = fixture('10.0.0.0/22', false, active);
|
const run = fixture('10.0.0.0/22', active);
|
||||||
assert.equal(run.query.value, '10.0.0.0/22');
|
assert.equal(run.query.value, '10.0.0.0/22');
|
||||||
assert.equal(run.result.value.analysis.cidr.prefix, 22);
|
assert.equal(run.result.value.analysis.cidr.prefix, 22);
|
||||||
|
|
||||||
// Navigating to another tool changes the route before this one is
|
// The route changes before this page is deactivated; the path tells them apart.
|
||||||
// deactivated: the path already tells it the query isn't its own.
|
|
||||||
run.route.path = '/tools/asn';
|
run.route.path = '/tools/asn';
|
||||||
run.route.query = { q: 'AS13335' };
|
run.route.query = { q: 'AS13335' };
|
||||||
await nextTick();
|
await nextTick();
|
||||||
assert.equal(run.query.value, '10.0.0.0/22', 'another path\'s ?q= is ignored');
|
assert.equal(run.query.value, '10.0.0.0/22', 'another path\'s ?q= is ignored');
|
||||||
|
|
||||||
// Another page rewrites ?q= while the calculator is cached.
|
|
||||||
active.value = false;
|
active.value = false;
|
||||||
run.route.query = { q: 'AS15169' };
|
run.route.query = { q: 'AS15169' };
|
||||||
await nextTick();
|
await nextTick();
|
||||||
assert.equal(run.query.value, '10.0.0.0/22', 'a hidden tool ignores the route');
|
assert.equal(run.query.value, '10.0.0.0/22', 'a hidden tool ignores the route');
|
||||||
assert.equal(run.result.value.analysis.cidr.prefix, 22);
|
assert.equal(run.result.value.analysis.cidr.prefix, 22);
|
||||||
|
|
||||||
// Coming back to the same URL keeps an unsubmitted draft.
|
|
||||||
run.route.path = '/tools/ipcalculator';
|
run.route.path = '/tools/ipcalculator';
|
||||||
run.route.query = { q: '10.0.0.0/22' };
|
run.route.query = { q: '10.0.0.0/22' };
|
||||||
run.query.value = '192.0.2.';
|
run.query.value = '192.0.2.';
|
||||||
@@ -93,7 +88,6 @@ test('?q= runs on mount, follows the route while active, and is ignored while ca
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
assert.equal(run.query.value, '192.0.2.', 'an unchanged ?q= does not overwrite the input');
|
assert.equal(run.query.value, '192.0.2.', 'an unchanged ?q= does not overwrite the input');
|
||||||
|
|
||||||
// A different ?q= on arrival runs.
|
|
||||||
active.value = false;
|
active.value = false;
|
||||||
run.route.query = { q: '192.0.2.0/24' };
|
run.route.query = { q: '192.0.2.0/24' };
|
||||||
active.value = true;
|
active.value = true;
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
// resolveNavTarget() — what a site-navigation click does on the current route
|
// resolveNavTarget() and isPlainClick() (utils/nav-target.js).
|
||||||
// (Nav, mobile nav sheet, page breadcrumb) — and isPlainClick(), which lets a
|
|
||||||
// link's modified / non-primary clicks through to the browser.
|
|
||||||
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// Page transitions: the direction a navigation animates in, the decision to
|
// Spec for utils/page-transition.js, the router hook-up driven by a fake
|
||||||
// animate at all, and the router hook-up driven by a fake router and a fake
|
// router and View Transitions document.
|
||||||
// View Transitions document.
|
|
||||||
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
@@ -68,9 +67,7 @@ describe('shouldAnimate()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// A router stand-in recording the hooks, and a document whose
|
// startViewTransition runs the update on the next task, as a browser does after the snapshot.
|
||||||
// startViewTransition runs the update callback on the next task like a browser
|
|
||||||
// does after snapshotting the old page.
|
|
||||||
const setup = ({ supported = true, reducedMotion = false, timeoutMs = 1000 } = {}) => {
|
const setup = ({ supported = true, reducedMotion = false, timeoutMs = 1000 } = {}) => {
|
||||||
const hooks = { beforeResolve: null, afterEach: null, onError: null };
|
const hooks = { beforeResolve: null, afterEach: null, onError: null };
|
||||||
const router = {
|
const router = {
|
||||||
|
|||||||
@@ -62,5 +62,4 @@ describe('installAnimationRevival()', () => {
|
|||||||
hooks.afterEach();
|
hooks.afterEach();
|
||||||
assert.equal(scheduled.length, 3, 'the flag is consumed');
|
assert.equal(scheduled.length, 3, 'the flag is consumed');
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Tests for frontend/utils/tool-availability.js — the listing gate the
|
// Tests for frontend/utils/tool-availability.js — the listing gate the
|
||||||
// Advanced Tools card grid and nav filter through — plus the gate
|
// Advanced Tools card grid and nav filter through — plus the gate fields the
|
||||||
// fields the registry in frontend/data/tools.js declares.
|
// registry in frontend/data/tools.js declares.
|
||||||
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
@@ -46,14 +46,6 @@ describe('isToolAvailable', () => {
|
|||||||
assert.equal(isToolAvailable(null, { cloudFlare: true }), false);
|
assert.equal(isToolAvailable(null, { cloudFlare: true }), false);
|
||||||
assert.equal(isToolAvailable(undefined, {}), false);
|
assert.equal(isToolAvailable(undefined, {}), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters a list in registry order', () => {
|
|
||||||
const tools = [PUBLIC, ORIGINAL, RADAR, BOTH];
|
|
||||||
const listed = (configs) => tools.filter((tool) => isToolAvailable(tool, configs)).map((tool) => tool.slug);
|
|
||||||
assert.deepEqual(listed({}), ['public']);
|
|
||||||
assert.deepEqual(listed({ originalSite: false, cloudFlare: true }), ['public', 'radar']);
|
|
||||||
assert.deepEqual(listed({ originalSite: true, cloudFlare: true }), ['public', 'original', 'radar', 'both']);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('listedTools', () => {
|
describe('listedTools', () => {
|
||||||
@@ -63,13 +55,13 @@ describe('listedTools', () => {
|
|||||||
it('applies the gates in registry order', () => {
|
it('applies the gates in registry order', () => {
|
||||||
assert.deepEqual(slugs(listedTools(tools, {})), ['public']);
|
assert.deepEqual(slugs(listedTools(tools, {})), ['public']);
|
||||||
assert.deepEqual(slugs(listedTools(tools, { originalSite: true })), ['public', 'original']);
|
assert.deepEqual(slugs(listedTools(tools, { originalSite: true })), ['public', 'original']);
|
||||||
|
assert.deepEqual(slugs(listedTools(tools, { originalSite: false, cloudFlare: true })), ['public', 'radar']);
|
||||||
assert.deepEqual(slugs(listedTools(tools, { originalSite: true, cloudFlare: true })),
|
assert.deepEqual(slugs(listedTools(tools, { originalSite: true, cloudFlare: true })),
|
||||||
['public', 'original', 'radar', 'both']);
|
['public', 'original', 'radar', 'both']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists every registered tool, Persona Check included, once all gates pass', () => {
|
it('lists every registered tool once all gates pass', () => {
|
||||||
const configs = { originalSite: true, cloudFlare: true };
|
const configs = { originalSite: true, cloudFlare: true };
|
||||||
assert.ok(slugs(listedTools(ADVANCED_TOOLS, configs)).includes('personacheck'));
|
|
||||||
assert.equal(listedTools(ADVANCED_TOOLS, configs).length, ADVANCED_TOOLS.length);
|
assert.equal(listedTools(ADVANCED_TOOLS, configs).length, ADVANCED_TOOLS.length);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-3
@@ -268,10 +268,9 @@ export default defineConfig({
|
|||||||
port: frontEndPort,
|
port: frontEndPort,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': `http://localhost:${backEndPort}`,
|
'/api': `http://localhost:${backEndPort}`,
|
||||||
// Firebase Auth handler on the dev origin, as frontend-server.js does in
|
// Firebase Auth handler, as frontend-server.js serves it in production
|
||||||
// production; only with VITE_FIREBASE_PROJECT_ID set
|
|
||||||
...(firebaseAuthTarget && {
|
...(firebaseAuthTarget && {
|
||||||
[FIREBASE_AUTH_PROXY_PATH]: { target: firebaseAuthTarget, changeOrigin: true, secure: true },
|
[FIREBASE_AUTH_PROXY_PATH]: { target: firebaseAuthTarget, changeOrigin: true },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
allowedHosts: ['dev.ipcheck.ing', 'test.ipcheck.ing'],
|
allowedHosts: ['dev.ipcheck.ing', 'test.ipcheck.ing'],
|
||||||
|
|||||||
Reference in new issue
Block a user