Files
jason5ng32andClaude Opus 5.5 45f920248f Fix(analytics): one NavClick label per tool, sent once
Tool opens labelled NavClick with the slug's first letter capitalized
('Pingtest'), while keyboard shortcuts sent a second, hand-written label
('PingTest', 'AsnProfile') on top of the one openTool already sent. GA
labels are case-sensitive, so one tool split into two rows and every
shortcut open counted twice. The label is now the slug as-is, the same
id as /tools/<slug>, and shortcuts rely on openTool's event alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:44:56 +08:00

386 lines
16 KiB
Vue

<template>
<!-- The iOS status bar is opaque (no apple-mobile-web-app-status-bar-style), so the
inset is non-zero only on landscape notch edges. jn-site-nav: see style.css. -->
<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="{ '-translate-y-full': isNavHidden,
'bg-background/80 supports-[backdrop-filter:blur(0px)]:bg-background/60 backdrop-blur': !isPwa || (isPwa && !isMobile),
'bg-page-bg': isPwa && isMobile }">
<nav id="navbar-top" ref="navEl"
class="relative mx-auto flex w-full max-w-[1600px] items-center gap-2 px-3 sm:px-4 h-14">
<!-- Left: Hamburger (collapsed nav only) + Brand. Expanded, every group
keeps its natural width so use-nav-overflow measures true sizes. -->
<div ref="brandEl" class="flex items-center gap-2" :class="{ 'shrink-0': !isNavCollapsed }">
<Button v-if="isNavCollapsed" variant="ghost" size="icon" class="size-8" :aria-expanded="isNavMenuOpen"
aria-label="Toggle navigation menu" @click="store.toggleSheet('navMenu')">
<Menu />
</Button>
<a :href="isHome ? '#' : homeHref" @click="handleLogoClick"
class="inline-flex items-center gap-1.5 rounded-md px-1 py-1 text-lg font-semibold text-foreground no-underline hover:opacity-80 transition-opacity">
<brandIcon />
<span class="tracking-tight truncate">
<span class="font-bold">IP</span><span class="font-extralight">Check.</span>
<span class="font-extralight"
:class="{ 'jn-shimmer-light': shimmer && !isDarkMode, 'jn-shimmer-dark': shimmer && isDarkMode }">ing</span>
</span>
</a>
</div>
<!-- Middle: nav links + GitHub star badge (left aligned, next to brand).
Always rendered so its width stays measurable; collapsed, it is
invisible, out of flow and out of the a11y tree / tab order. -->
<div ref="linksEl" class="flex w-max shrink-0 items-center gap-0.5"
:class="{ 'invisible absolute left-0 top-0 pointer-events-none': isNavCollapsed }"
:aria-hidden="isNavCollapsed ? 'true' : undefined" :inert="isNavCollapsed || undefined">
<template v-for="item in navItems" :key="item">
<!-- Advanced Tools: hover reveals the sub-tools, click goes to the
section like the other links (disable-click-trigger frees the
click from toggling the menu; viewport=false anchors the panel
under the trigger). -->
<NavigationMenu v-if="item === 'AdvancedTools'" as="div" :viewport="false" :disable-click-trigger="true"
class="flex-none">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger :class="['h-auto bg-transparent whitespace-nowrap', navLinkClass(item)]"
@click="goToSection(item)">
{{ t(`nav.${item}`) }}
</NavigationMenuTrigger>
<NavigationMenuContent class="z-50">
<!-- Two-column grid on PC -->
<ul class="relative grid grid-cols-2 gap-x-4 gap-y-0.5 min-w-[28rem]">
<span aria-hidden="true"
class="pointer-events-none absolute inset-y-1 left-1/2 w-px -translate-x-1/2 bg-border"></span>
<li v-for="tool in advancedTools" :key="tool.slug">
<NavigationMenuLink as-child class="cursor-pointer" :active="tool.slug === currentToolSlug">
<button type="button" class="w-full text-left leading-snug" @click="openTool(tool.slug)">
{{ t(tool.titleKey) }}
</button>
</NavigationMenuLink>
</li>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
<!-- All other sections -->
<a v-else href="#" :class="[navLinkClass(item), 'whitespace-nowrap']" @click.prevent="goToSection(item)">
{{ t(`nav.${item}`) }}
</a>
</template>
<!-- GitHub repo link + star count from our own /api/github-stars
(edge-cached). The count is hidden until it lands / on error, so the
link itself never depends on the fetch. -->
<Badge variant="outline" v-if="githubStarsLabel">
<a :href="t('page.footerLink')" target="_blank" rel="noopener" class="inline-flex items-center gap-1"
aria-label="Star on GitHub" title="Star on GitHub">
<Icon icon="ri:star-fill" class="size-3.5 text-yellow-400" />
<span class="tabular-nums">{{ githubStarsLabel }}</span>
<Icon icon="ri:github-line" class="size-3.5" />
</a>
</Badge>
</div>
<!-- Right: Action area (ml-auto push to the right) -->
<div ref="actionsEl" class="ml-auto flex items-center gap-2" :class="{ 'shrink-0': !isNavCollapsed }">
<!-- Earth Online entry (code name: pulse) -->
<Pulse />
<!-- Docs assistant entry point (ask box from xl up, icon below) -->
<DocsSearch />
<!-- Account menu: preferences cog (Firebase-less) or the user dropdown -->
<UserMenu />
</div>
</nav>
<!-- Navigation sheet of the collapsed nav (a phone, or a viewport where the
links don't fit). Flex column so the link list scrolls instead of
clipping on short screens when Advanced Tools is expanded. -->
<Sheet v-if="isNavCollapsed" :open="isNavMenuOpen" @update:open="onNavMenuChange">
<SheetContent side="left" class="w-80 p-0 flex flex-col gap-0" :title="t('nav.Navigation')">
<div class="flex shrink-0 items-center justify-between border-b px-4 py-3">
<h5 class="m-0 text-base font-semibold">{{ t('nav.Navigation') }}</h5>
<SheetClose />
</div>
<nav class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-3">
<template v-for="item in navItems" :key="item">
<!-- Advanced Tools expands inline into its sub-tools (open by default)
so they're discoverable, not hidden behind a bare label. -->
<Collapsible v-if="item === 'AdvancedTools'" v-model:open="mobileToolsOpen">
<CollapsibleTrigger as-child>
<button type="button"
:class="[navLinkClass(item, { block: true }), 'flex w-full items-center justify-between']">
<span>{{ t(`nav.${item}`) }}</span>
<ChevronDown class="size-4 shrink-0 opacity-60 transition-transform duration-200"
:class="{ 'rotate-180': mobileToolsOpen }" />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div class="my-0.5 ml-3 flex flex-col gap-0.5 border-l pl-3">
<button v-for="tool in advancedTools" :key="tool.slug" type="button"
class="block w-full rounded-md px-3 py-1.5 text-left text-sm leading-snug text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground aria-[current=page]:bg-accent/50 aria-[current=page]:text-foreground"
:aria-current="tool.slug === currentToolSlug ? 'page' : undefined" @click="openTool(tool.slug)">
{{ t(tool.titleKey) }}
</button>
</div>
</CollapsibleContent>
</Collapsible>
<!-- All other sections, as in the expanded nav. -->
<a v-else href="#" :class="navLinkClass(item, { block: true })" @click.prevent="goToSection(item)">
{{ t(`nav.${item}`) }}
</a>
</template>
<a :href="t('page.footerLink')" target="_blank" rel="noopener"
class="mt-3 flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium hover:bg-muted">
<Icon icon="ri:github-line" class="size-4" />
<span>Star on GitHub</span>
<!-- Same /api/github-stars count as the desktop badge (fetched on
mount); hidden until it lands / on error. -->
<span v-if="githubStarsLabel"
class="ml-auto tabular-nums text-muted-foreground inline-flex items-center gap-1">
<Icon icon="ri:star-fill" class="size-3.5 text-yellow-400" />
{{ githubStarsLabel }}
</span>
</a>
</nav>
</SheetContent>
</Sheet>
</header>
</template>
<script setup>
// The site navigation bar, rendered once by App.vue above every route; what a
// section or tool entry does on the current route is utils/nav-target.js.
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuTrigger,
NavigationMenuContent,
NavigationMenuLink,
} from '@/components/ui/navigation-menu';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ChevronDown, Menu } from '@lucide/vue';
import DocsSearch from '@/components/widgets/DocsSearch.vue';
import Pulse from '@/components/widgets/Pulse.vue';
import UserMenu from '@/components/UserMenu.vue';
import { Icon } from '@iconify/vue';
import brandIcon from './svgicons/Brand.vue';
import { SECTION_IDS } from '@/data/sections';
import { ADVANCED_TOOLS } from '@/data/tools.js';
import { listedTools } from '@/utils/tool-availability.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { formatStarCount } from '@/utils/format-star-count.js';
import { isRunningAsPwa } from '@/utils/pwa.js';
import { isPlainClick } from '@/utils/nav-target.js';
import { useNavTarget } from '@/composables/use-nav-target.js';
import { useNavOverflow } from '@/composables/use-nav-overflow.js';
const { t } = useI18n();
const store = useMainStore();
const route = useRoute();
const router = useRouter();
const { navigateTo } = useNavTarget();
const isDarkMode = computed(() => store.isDarkMode);
const isMobile = computed(() => store.isMobile);
// The link row collapses into the hamburger + Sheet on a phone, or wherever
// it doesn't fit beside the brand and actions. isMobile alone drives phone
// behaviour (scroll-hide, PWA background).
const NAV_GAP_PX = 8; // the nav's gap-2
const navEl = ref(null);
const brandEl = ref(null);
const linksEl = ref(null);
const actionsEl = ref(null);
const { overflow } = useNavOverflow({
nav: navEl, brand: brandEl, links: linksEl, actions: actionsEl, gap: NAV_GAP_PX,
});
const isNavCollapsed = computed(() => isMobile.value || overflow.value);
// store.currentSection keeps Home's last section, so it's ignored elsewhere.
const isHome = computed(() => route.name === 'home');
const currentSection = computed(() => (isHome.value ? store.currentSection : null));
const currentToolSlug = computed(() => (route.name === 'tool' ? String(route.params.slug) : ''));
const homeHref = router.resolve('/').href;
const loaded = computed(() => store.allHasLoaded);
// The brand shimmers while the homepage tests run.
const shimmer = computed(() => isHome.value && !loaded.value);
// Running as an installed PWA (chromeless window) — see utils/pwa.js.
const isPwa = isRunningAsPwa();
const navItems = SECTION_IDS;
// Gated tools stay hidden where the deployment lacks them, as in the card grid.
const configs = computed(() => store.configs);
const advancedTools = computed(() => listedTools(ADVANCED_TOOLS, configs.value));
// Sheet: Advanced Tools sub-list expanded by default for discoverability.
const mobileToolsOpen = ref(true);
// GitHub star count for the repo badge. Fetched from our own edge-cached
// endpoint; stays null (badge hides the count) if the request fails.
const githubStars = ref(null);
const githubStarsLabel = computed(() => formatStarCount(githubStars.value));
const fetchGithubStars = async () => {
try {
const res = await fetchWithTimeout('/api/github-stars');
if (!res.ok) return;
const data = await res.json();
if (typeof data.stars === 'number') githubStars.value = data.stars;
} catch {
/* leave the badge without a count */
}
};
// nav link style — current section highlight use bg-accent instead of only bold
const navLinkClass = (item, { block = false } = {}) => {
const base = 'rounded-md px-3 py-1.5 text-sm font-medium no-underline cursor-pointer transition-colors';
const state = item === currentSection.value
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground';
return [base, state, block ? 'block' : ''].filter(Boolean).join(' ');
};
const isNavMenuOpen = computed(() => store.openSheet === 'navMenu');
const onNavMenuChange = (val) => {
store.setOpenSheet(val ? 'navMenu' : null);
};
// Off the homepage → home (a real link there, so modified clicks open a new
// tab). On it: at top → full refresh; mid-page → smooth scroll up.
// preventDefault avoids the native instant-jump of <a href="#">.
const handleLogoClick = (e) => {
if (!isHome.value) {
if (!isPlainClick(e)) return;
e.preventDefault();
router.push('/');
} else if (window.scrollY === 0) {
store.setRefreshEveryThing(true);
} else {
e.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
trackEvent('Nav', 'NavClick', 'Logo');
};
// Section and tool entries close the nav Sheet first and navigate once
// its slide-out is over: WebKit records the back/forward snapshot of a page
// as it navigates away, and a swipe back would show the sheet still open.
const SHEET_CLOSE_MS = 300; // ui/sheet data-[state=closed]:duration-300
const afterSheetClosed = (fn) => {
if (!isNavMenuOpen.value) return fn();
store.setOpenSheet(null);
setTimeout(fn, SHEET_CLOSE_MS);
};
const goToSection = (section) => {
afterSheetClosed(() => navigateTo({ section }));
trackEvent('Nav', 'NavClick', section);
};
const openTool = (slug) => {
afterSheetClosed(() => navigateTo({ tool: slug }));
trackEvent('Nav', 'NavClick', slug);
};
// Mobile: hide nav on scroll-down, show on scroll-up.
// SCROLL_DELTA filters out micro-jitter; SHOW_AT_TOP forces the nav
// visible near the top of the page regardless of direction.
const isNavHidden = ref(false);
let lastScrollY = 0;
let scrollTicking = false;
const SCROLL_DELTA = 5;
const SHOW_AT_TOP = 48;
const onScroll = () => {
if (scrollTicking) return;
scrollTicking = true;
requestAnimationFrame(() => {
const y = window.scrollY;
const dy = y - lastScrollY;
if (y <= SHOW_AT_TOP) {
isNavHidden.value = false;
} else if (Math.abs(dy) > SCROLL_DELTA) {
// Keep nav visible while the menu sheet is open so its close
// affordance stays in place.
if (dy > 0 && !isNavMenuOpen.value) {
isNavHidden.value = true;
} else if (dy < 0) {
isNavHidden.value = false;
}
}
lastScrollY = y;
scrollTicking = false;
});
};
// Mobile only; attaching resyncs lastScrollY so the first scroll isn't a phantom jump.
const setScrollListener = (on) => {
if (on) {
lastScrollY = window.scrollY;
window.addEventListener('scroll', onScroll, { passive: true });
} else {
window.removeEventListener('scroll', onScroll);
}
};
watch(isMobile, (mobile) => {
if (!mobile) isNavHidden.value = false;
setScrollListener(mobile);
}, { immediate: true });
onMounted(fetchGithubStars);
onBeforeUnmount(() => {
setScrollListener(false);
});
</script>
<style scoped>
.jn-shimmer-light,
.jn-shimmer-dark {
position: relative;
overflow: hidden;
display: inline-flex;
}
.jn-shimmer-light::before,
.jn-shimmer-dark::before {
content: '';
position: absolute;
bottom: 0;
left: -100%;
width: 100%;
height: 10%;
animation: jn-shimmer-slide 1s linear infinite;
}
.jn-shimmer-light::before {
background-color: rgb(0, 0, 0);
}
.jn-shimmer-dark::before {
background-color: rgb(255, 255, 255);
}
@keyframes jn-shimmer-slide {
from {
left: -100%;
}
to {
left: 100%;
}
}
</style>