mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
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
359 lines
15 KiB
Vue
359 lines
15 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" class="mx-auto flex w-full max-w-[1600px] items-center gap-2 px-3 sm:px-4 h-14">
|
|
|
|
<!-- Left: Hamburger (only mobile) + Brand -->
|
|
<div class="flex items-center gap-2">
|
|
<Button v-if="isMobile" 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: Desktop nav links + GitHub star badge (left aligned, next to brand) -->
|
|
<div v-if="!isMobile" class="flex items-center gap-0.5">
|
|
<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', 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)" @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 class="ml-auto flex items-center gap-2">
|
|
<!-- Earth Online entry (code name: pulse) -->
|
|
<Pulse />
|
|
|
|
<!-- Docs assistant entry point (ask box on desktop, icon on mobile) -->
|
|
<DocsSearch />
|
|
|
|
<!-- Account menu: preferences cog (Firebase-less) or the user dropdown -->
|
|
<UserMenu />
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Mobile navigation sheet. Flex column so the link list scrolls instead
|
|
of clipping on short screens when Advanced Tools is expanded. -->
|
|
<Sheet v-if="isMobile" :open="isNavMenuOpen" @update:open="onNavMenuChange">
|
|
<SheetContent side="left" class="w-80 p-0 flex flex-col gap-0" :title="t('nav.Navigation')">
|
|
<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 on desktop. -->
|
|
<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';
|
|
|
|
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);
|
|
// 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));
|
|
|
|
// Mobile: 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 mobile nav Sheet first (no-op on desktop).
|
|
const goToSection = (section) => {
|
|
store.setOpenSheet(null);
|
|
navigateTo({ section });
|
|
trackEvent('Nav', 'NavClick', section);
|
|
};
|
|
|
|
const openTool = (slug) => {
|
|
store.setOpenSheet(null);
|
|
navigateTo({ tool: slug });
|
|
const name = slug.charAt(0).toUpperCase() + slug.slice(1);
|
|
trackEvent('Nav', 'NavClick', name);
|
|
};
|
|
|
|
// 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>
|