mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
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>
386 lines
16 KiB
Vue
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>
|