Files
2026-10-29 09:46:17 +08:00

434 lines
20 KiB
Vue

<!-- Site footer on every page: brand / Project / Docs & Support / Clients
columns, plus a signature bar (with the copyright on self-hosted
deployments). About / Changelog / Acknowledgement open in its About
sheet, the CLI in its own dialog; nothing already on the page (tools,
sections) repeats here. -->
<template>
<footer class="border-t border-secondary text-sm text-muted-foreground">
<div class="mx-auto w-full px-4 jn-container">
<!-- Link columns: four tracks on md+, matching the Advanced Tools grid;
on phones the brand spans the top and the rest flow two per row. -->
<div class="grid grid-cols-2 gap-8 py-12 md:grid-cols-4">
<!-- Brand -->
<div class="col-span-2 md:col-span-1">
<div class="inline-flex items-center gap-1.5 text-xl font-semibold text-foreground">
<brandIcon />
<span class="tracking-tight">
<span class="font-bold">IP</span><span class="font-extralight">Check.ing</span>
</span>
</div>
<p class="mt-2">{{ t('footer.Tagline') }}</p>
<div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
<a :href="t('page.footerLink')" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'Github')">
<Icon icon="ri:github-line" class="size-4" />
<span>GitHub</span>
</a>
<a href="https://github.com/sponsors/jason5ng32" target="_blank" rel="noopener"
class="inline-flex items-center gap-1 text-sm text-[#d63384] no-underline transition-opacity hover:opacity-80">
<Icon icon="ri:heart-line" class="size-4" />
{{ t('about.Sponsor') }}
</a>
</div>
</div>
<!-- Project: the About sheet's tabs, privacy, license and contributing -->
<nav :aria-label="t('footer.Project')">
<h3 :class="headingClass">{{ t('footer.Project') }}</h3>
<ul class="space-y-2">
<li>
<button type="button" :class="linkClass" @click="openAbout">
{{ t('about.Title') }}
</button>
</li>
<li>
<button type="button" :class="linkClass" @click="openAboutTab('acknowledgement', 'Acknowledgement')">
{{ t('acknowledgement.Title') }}
</button>
</li>
<li>
<button type="button" :class="linkClass" @click="openAboutTab('changelog', 'Changelog')">
{{ t('changelog.Title') }}
</button>
</li>
<li>
<RouterLink to="/privacy" :class="linkClass" @click="trackEvent('Footer', 'FooterClick', 'Privacy')">
{{ t('about.Privacy') }}
</RouterLink>
</li>
<li>
<a :href="`${t('page.footerLink')}/blob/main/LICENSE`" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'License')">
{{ t('footer.License') }}
</a>
</li>
<li>
<a :href="`${t('page.footerLink')}/contribute`" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'Contribute')">
{{ t('footer.Contribute') }}
</a>
</li>
</ul>
</nav>
<!-- Docs & Support: locale-aware docs.ipcheck.ing links + issue tracker -->
<nav :aria-label="t('footer.Support')">
<h3 :class="headingClass">{{ t('footer.Support') }}</h3>
<ul class="space-y-2">
<li>
<a :href="docsUrl('knowledge-base', locale)" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'HelpCenter')">
{{ t('about.HelpCenter') }}
</a>
</li>
<li>
<a :href="docsUrl('developer', locale)" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'DeveloperDocs')">
{{ t('footer.DeveloperDocs') }}
</a>
</li>
<li>
<a :href="docsUrl('developer', locale, 'getting-started/')" target="_blank" rel="noopener"
:class="linkClass" @click="trackEvent('Footer', 'FooterClick', 'SelfHosting')">
{{ t('footer.SelfHosting') }}
</a>
</li>
<li>
<a :href="`${t('page.footerLink')}/issues`" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'ReportIssue')">
{{ t('footer.ReportIssue') }}
</a>
</li>
<li>
<a :href="`${t('page.footerLink')}/issues/new?template=feature_request.md`" target="_blank" rel="noopener"
:class="linkClass" @click="trackEvent('Footer', 'FooterClick', 'FeatureRequest')">
{{ t('footer.FeatureRequest') }}
</a>
</li>
</ul>
</nav>
<!-- Clients: Raycast / Lite always; the CLI entry needs the curl
domains configured. -->
<nav :aria-label="t('footer.Clients')">
<h3 :class="headingClass">{{ t('footer.Clients') }}</h3>
<ul class="space-y-2">
<li>
<a href="https://www.raycast.com/jason5ng32/ipcheck-ing" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'Raycast')">
<Icon icon="simple-icons:raycast" class="size-4 shrink-0" />
{{ t('footer.Raycast') }}
</a>
</li>
<li v-if="curlDomainsHadSet">
<button type="button" :class="linkClass" @click="openCurl">
<Terminal class="size-4 shrink-0" />
{{ t('curl.Title') }}
</button>
</li>
<li>
<a href="https://lite.ipcheck.ing" target="_blank" rel="noopener" :class="linkClass"
@click="trackEvent('Footer', 'FooterClick', 'Lite')">
<Feather class="size-4 shrink-0" />
{{ t('footer.Lite') }}
</a>
</li>
</ul>
</nav>
</div>
<!-- Signature bar: every deployment; the copyright only when self-hosted -->
<div class="flex flex-col items-center gap-y-1.5 border-t border-secondary py-6 text-center text-xs opacity-70">
<span>Created by Jason Ng with love</span>
<span v-if="!configs.originalSite">
{{ t('page.copyRightName') }}
<a :href="t('page.copyRightLink')" target="_blank" rel="noopener"
class="text-foreground/80 hover:text-foreground hover:underline">
{{ t('page.copyRightLinkName') }}
</a>
</span>
</div>
</div>
<!-- About Sheet -->
<Sheet :open="isOpen" @update:open="onOpenChange">
<SheetContent side="right" :title="t('about.Title')"
:class="['flex flex-col p-0 gap-0 w-full max-w-full md:w-125 md:max-w-125']">
<Tabs v-model="content" class="flex flex-col h-full">
<!-- Top: tabs + close -->
<div class="flex items-center justify-between gap-2 px-4 py-3 border-b shrink-0">
<TabsList>
<TabsTrigger v-for="tab in tabs" :key="tab" :value="tab">
{{ t(tab + '.Title') }}
</TabsTrigger>
</TabsList>
<SheetClose
class="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" />
</div>
<!-- Content area (independent scrolling) -->
<div class="flex-1 overflow-y-auto px-5 py-5" ref="sheetBody">
<!-- About: sections come from aboutSections, each one's paragraphs an array
in the locale file — adding or reordering copy is a locale-only edit. -->
<TabsContent value="about" class="space-y-6 mt-0">
<section v-for="section in aboutSections" :key="section.key">
<h3 v-if="section.titleKey" class="text-base font-semibold mb-2">{{ t(section.titleKey) }}</h3>
<div class="space-y-2" :class="section.links && 'mb-3'">
<p v-for="(paragraph, idx) in tm(`about.${section.key}`)" :key="idx"
class="text-sm leading-relaxed text-foreground/85">
{{ rt(paragraph) }}
</p>
</div>
<div v-if="section.links" class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Button v-for="link in section.links" :key="link.href" variant="outline" size="sm" as-child
class="justify-start">
<a :href="link.href" target="_blank" rel="noopener">
<Compass />
<span class="flex-1 text-left">{{ t(link.labelKey) }}</span>
<ExternalLink class="opacity-50" />
</a>
</Button>
</div>
</section>
</TabsContent>
<!-- Changelog -->
<!-- Data lives in frontend/data/changelog.json (shared version, ISO date, per-lang change
text). Dates localize at render via formatIsoDate; badge labels and the section title
stay in locale files as UI chrome. -->
<TabsContent value="changelog" class="space-y-6 mt-0">
<section v-for="(version, vi) in changelogReversed" :key="vi">
<header class="flex items-baseline justify-between mb-2">
<h3 class="text-lg font-semibold tracking-tight">{{ version.version }}</h3>
<span class="text-xs text-muted-foreground tabular-nums">{{ formatIsoDate(version.date, locale)
}}</span>
</header>
<Separator class="mb-3" />
<ul class="space-y-2">
<li v-for="(item, idx) in version.content" :key="idx" class="flex items-start gap-2 text-sm">
<Badge :class="changelogBadgeClass(item.type)"
class="shrink-0 shadow-none! rounded-full justify-center text-secondary p-1"
:title="t('changelog.' + item.type)">
<CircleFadingArrowUp v-if="item.type === 'improve'" class="size-3.5" />
<CirclePlus v-if="item.type === 'add'" class="size-3.5" />
<BugOff v-if="item.type === 'fix'" class="size-3.5" />
</Badge>
<span class="leading-relaxed">{{ changeText(item.change) }}</span>
</li>
</ul>
</section>
</TabsContent>
<!-- Acknowledgement -->
<TabsContent value="acknowledgement" class="space-y-4 mt-0">
<p class="text-sm text-muted-foreground leading-relaxed">{{ t('acknowledgement.Note1') }}</p>
<ul class="grid grid-cols-1 gap-2 list-none p-0">
<li v-for="(item, idx) in acknowledgementsList" :key="idx">
<Button v-if="item.link" variant="outline" size="sm" as-child class="w-full justify-start">
<a :href="item.link" target="_blank" rel="noopener">
<Smile />
<span class="flex-1 text-left truncate text-xs">{{ item.name }}</span>
<ExternalLink class="opacity-50" />
</a>
</Button>
<div v-else
class="flex items-center gap-2 h-8 px-3 text-sm rounded-md border bg-card text-foreground/80">
<Smile class="size-4 text-muted-foreground shrink-0" />
<span class="truncate text-xs">{{ item.name }}</span>
</div>
</li>
</ul>
</TabsContent>
</div>
</Tabs>
</SheetContent>
</Sheet>
<!-- Command Line API dialog: only opened from the Clients link, which
is listed only when the curl domains are configured. -->
<Dialog :open="isCurlOpen" @update:open="isCurlOpen = $event">
<DialogContent :title="t('curl.Title')">
<DialogHeader :icon="Terminal" :title="t('curl.Title')" />
<div class="space-y-3">
<!-- Description -->
<div class="space-y-1 text-xs font-mono">
<p class="jn-comment"><span class="text-muted-foreground">{{ t('curl.Note1') }}</span></p>
<p class="jn-comment">
<span class="text-muted-foreground">{{ t('curl.Note2_1') }}
<Badge variant="outline" class="text-success">curl</Badge> {{ t('curl.Note2_2') }}
</span>
</p>
<p class="jn-comment">
<span class="text-muted-foreground">
<Badge variant="outline" class="text-success">geo</Badge> {{ t('curl.Note3') }}
</span>
</p>
</div>
<!-- 3 curl command blocks -->
<div class="space-y-3">
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.getIPv4') }}</p>
<pre
class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ curl.ipv4Domain }}<span class="text-success">/geo</span></pre>
</div>
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.getIPv6') }}</p>
<pre
class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ curl.ipv6Domain }}<span class="text-success">/geo</span></pre>
</div>
<div>
<p class="jn-comment text-xs font-mono mb-1.5 text-muted-foreground">{{ t('curl.get6and4') }}</p>
<pre
class="jn-curl bg-black text-neutral-100 rounded-md p-3 text-xs font-mono overflow-x-auto">curl {{ curl.ipv64Domain }}<span class="text-success">/geo</span></pre>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</footer>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import changelogData from '@/data/changelog.json';
import { trackEvent } from '@/utils/analytics';
import { docsUrl } from '@/utils/docs-url.js';
import { formatIsoDate } from '@/utils/time-utils';
import { fallbackChain } from '@/utils/locale-registry.js';
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Compass, ExternalLink, CircleFadingArrowUp, CirclePlus, BugOff, Smile, Terminal, Feather } from '@lucide/vue';
import brandIcon from './svgicons/Brand.vue';
import { Icon } from '@iconify/vue';
import { useRouteActive } from '@/composables/use-route-active.js';
const { t, tm, rt, locale } = useI18n();
const store = useMainStore();
const configs = computed(() => store.configs);
const curl = computed(() => store.curl);
const curlDomainsHadSet = computed(() => store.curlDomainsHadSet);
const tabs = ['about', 'acknowledgement', 'changelog'];
const content = ref('about');
const headingClass = 'mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground';
const linkClass = 'inline-flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-foreground cursor-pointer';
// Static data from JSON — reverse once via computed so the template stays tidy.
const changelogReversed = computed(() => changelogData.slice().reverse());
// Entries carry one string per language; history is only guaranteed for full
// locales, so a beta one walks its fallback chain.
const changeText = (change) => fallbackChain(locale.value).map((code) => change[code]).find(Boolean) ?? '';
const sheetBody = ref(null);
const personalLinks = [
{ href: 'https://wujiaxian.com', labelKey: 'about.personal' },
{ href: 'https://kenengba.com', labelKey: 'about.blog' },
{ href: 'https://www.linkedin.com/in/jason5ng32/', labelKey: 'about.linkedIn' },
{ href: 'https://twitter.com/jason5ng32', labelKey: 'about.twitter' },
];
// About tab layout: one entry per section, rendered in order. `key` names the
// paragraph array under `about.` in the locale files; `titleKey` is omitted for
// the lead-in section, and `links` appends a link grid under the paragraphs.
const aboutSections = [
{ key: 'project' },
{ key: 'founder', titleKey: 'about.founderTitle', links: personalLinks },
{ key: 'contact', titleKey: 'about.contactTitle' },
];
const acknowledgementsList = [
{ name: 'Setilis Hu', link: '' },
{ name: 'Seven Yu', link: 'https://github.com/dofy' },
{ name: 'Nikolai Tschacher', link: 'https://incolumitas.com/pages/about/' },
{ name: 'Project Alexandria (Cloudflare)', link: 'https://www.cloudflare.com/lp/project-alexandria/' },
{ name: 'Cloudflare Speedtest', link: 'https://github.com/cloudflare/speedtest' },
{ name: 'V.PS', link: 'https://v.ps/?utm_source=ipcheck.ing&utm_medium=acknowledgement&utm_campaign=footer' },
{ name: 'SwiftProxy', link: 'https://www.swiftproxy.net/?ref=myip' },
{ name: 'Proxy-Seller', link: 'https://proxy-seller.com/?partner=4FEC1644D12240F4' },
{ name: 'Sentry', link: 'https://www.sentry.io/' },
{ name: '1Password', link: 'https://www.1password.com/' },
{ name: 'Greptile', link: 'https://www.greptile.com/' },
{ name: 'GitBook', link: 'https://www.gitbook.com/' },
{ name: 'Globalping by jsDelivr', link: 'https://globalping.io/' },
{ name: 'ProxyCheck.io', link: 'https://proxycheck.io/' },
{ name: 'Digital Defense', link: 'https://digital-defense.io/' },
{ name: 'RIPE NCC', link: 'https://stat.ripe.net/' },
{ name: 'OONI', link: 'https://ooni.org/' },
{ name: 'CAIDA', link: 'https://www.caida.org/' },
{ name: 'ChatGPT', link: 'https://chatgpt.com/' },
{ name: 'Claude', link: 'https://claude.ai/' },
];
// changelog type → semantic color token: add → success; improve → info; fix → destructive
const changelogBadgeClass = (type) => {
if (type === 'add') return 'bg-success ';
if (type === 'improve') return 'bg-info ';
if (type === 'fix') return 'bg-destructive ';
return '';
};
// Sheet toggle and store.openSheet bidirectional binding. Home and ToolPage are
// both cached with a Footer each; only the page on screen opens its sheet.
const routeActive = useRouteActive();
const isOpen = computed(() => routeActive.value && store.openSheet === 'about');
const onOpenChange = (val) => {
store.setOpenSheet(val ? 'about' : null);
};
const openAbout = () => {
content.value = 'about';
store.toggleSheet('about');
trackEvent('Footer', 'FooterClick', 'About');
};
// Direct-entry shortcuts: open the same About sheet but jump straight to the
// changelog / acknowledgement tab. Plain open (not toggle) so a second click
// from another tab just switches tabs instead of closing the sheet.
const openAboutTab = (tab, trackLabel) => {
content.value = tab;
store.setOpenSheet('about');
trackEvent('Footer', 'FooterClick', trackLabel);
};
const isCurlOpen = ref(false);
const openCurl = () => {
isCurlOpen.value = true;
trackEvent('Footer', 'FooterClick', 'Curl');
};
watch(content, () => {
nextTick(() => {
if (sheetBody.value) sheetBody.value.scrollTop = 0;
});
});
</script>
<style scoped>
.jn-curl::before {
content: '$ ';
color: var(--muted-foreground);
font-weight: 500;
margin-right: 0.25rem;
opacity: 0.7;
}
.jn-comment::before {
content: '// ';
color: var(--muted-foreground);
font-weight: 500;
opacity: 0.7;
}
</style>