mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
Feat(tools): group the Advanced Tools cards by category
Fifteen cards in one flat grid mixed network diagnostics, lookups and the in-depth checks that only run on IPCheck.ing. Each registry entry now carries a category, and Advanced.vue renders the cards under three headed groups: Network Diagnostics, Lookup & Calculation and In-depth Checks. A group whose tools all fail their gates drops out with its heading, so a self-hosted deployment simply shows no In-depth Checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c29e48687b
commit
8c02ab3d14
9 files changed
+156
-59
No files matched your search
@@ -13,36 +13,44 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Card grid. Each card is a real <a> to the standalone /tools/:slug
|
||||
<!-- Card groups, one per category (data/tools.js TOOL_CATEGORIES). Each
|
||||
card is a real <a> to the standalone /tools/:slug
|
||||
page, so ⌘/Ctrl-click, middle-click and "open in new tab" all work.
|
||||
A plain left-click (or Enter / Space) is intercepted to open the
|
||||
in-page drawer instead. A dedicated ↗ corner button always opens the
|
||||
standalone page in a new tab. -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<Card v-for="card in enabledCards" :key="card.slug"
|
||||
:data-adv-slug="card.slug"
|
||||
class="keyboard-shortcut-card jn-card jn-adv-card group relative overflow-visible transition-transform duration-300 ease-out hover:-translate-y-1.5 data-[keyboard-hover=true]:ring-2 data-[keyboard-hover=true]:ring-green-500/50">
|
||||
<a :href="card.noStandalone ? `/?tool=${card.slug}` : `/tools/${card.slug}`"
|
||||
class="block cursor-pointer no-underline text-inherit"
|
||||
@click="onCardClick($event, card.slug)"
|
||||
@keydown.enter.prevent="openTool(card.slug)"
|
||||
@keydown.space.prevent="openTool(card.slug)">
|
||||
<CardContent class="p-4">
|
||||
<h3 class="text-xl font-medium text-primary mb-2 pr-10">
|
||||
<PanelBottomOpen
|
||||
class="inline size-[1em] align-[-0.15em] mr-1.5 transition-colors duration-300" />
|
||||
{{ t(card.titleKey) }}
|
||||
</h3>
|
||||
<!-- Description -->
|
||||
<p class="text-base text-muted-foreground line-clamp-2 min-h-10">
|
||||
{{ t(card.noteKey) }}
|
||||
</p>
|
||||
<!-- Top right emoji -->
|
||||
<span class="jn-emoji" aria-hidden="true">{{ card.icon }}</span>
|
||||
</CardContent>
|
||||
</a>
|
||||
<div class="mt-4 space-y-6">
|
||||
<div v-for="group in cardGroups" :key="group.id">
|
||||
<h3 class="mb-3 text-sm font-medium tracking-wide text-muted-foreground">
|
||||
{{ t(group.titleKey) }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<Card v-for="card in group.tools" :key="card.slug"
|
||||
:data-adv-slug="card.slug"
|
||||
class="keyboard-shortcut-card jn-card jn-adv-card group relative overflow-visible transition-transform duration-300 ease-out hover:-translate-y-1.5 data-[keyboard-hover=true]:ring-2 data-[keyboard-hover=true]:ring-green-500/50">
|
||||
<a :href="card.noStandalone ? `/?tool=${card.slug}` : `/tools/${card.slug}`"
|
||||
class="block cursor-pointer no-underline text-inherit"
|
||||
@click="onCardClick($event, card.slug)"
|
||||
@keydown.enter.prevent="openTool(card.slug)"
|
||||
@keydown.space.prevent="openTool(card.slug)">
|
||||
<CardContent class="p-4">
|
||||
<h4 class="text-xl font-medium text-primary mb-2 pr-10">
|
||||
<PanelBottomOpen
|
||||
class="inline size-[1em] align-[-0.15em] mr-1.5 transition-colors duration-300" />
|
||||
{{ t(card.titleKey) }}
|
||||
</h4>
|
||||
<!-- Description -->
|
||||
<p class="text-base text-muted-foreground line-clamp-2 min-h-10">
|
||||
{{ t(card.noteKey) }}
|
||||
</p>
|
||||
<!-- Top right emoji -->
|
||||
<span class="jn-emoji" aria-hidden="true">{{ card.emoji }}</span>
|
||||
</CardContent>
|
||||
</a>
|
||||
|
||||
</Card>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tool details Drawer -->
|
||||
@@ -95,7 +103,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMainStore } from '@/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { trackEvent } from '@/utils/analytics';
|
||||
import { ADVANCED_TOOLS, TOOL_BY_SLUG } from '@/data/tools.js';
|
||||
import { ADVANCED_TOOLS, TOOL_BY_SLUG, groupToolsByCategory } from '@/data/tools.js';
|
||||
import { isToolAvailable } from '@/utils/tool-availability.js';
|
||||
import { isRunningAsPwa } from '@/utils/pwa.js';
|
||||
import { Drawer, DrawerContent, DrawerClose } from '@/components/ui/drawer';
|
||||
@@ -118,13 +126,12 @@ const scrollContainer = ref(null);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Cards derive from the shared tool registry (frontend/data/tools.js). `icon`
|
||||
// is mapped from the registry's `emoji` so the template key stays stable.
|
||||
const cards = ADVANCED_TOOLS.map((tool) => ({ ...tool, icon: tool.emoji }));
|
||||
|
||||
// Deployment gates (original site / configs flag, see utils/tool-availability.js).
|
||||
// Reactive on configs, so gated cards appear the moment configs land.
|
||||
const enabledCards = computed(() => cards.filter((card) => isToolAvailable(card, configs.value)));
|
||||
// Card groups behind the deployment gates (original site / configs flag, see
|
||||
// utils/tool-availability.js). Reactive on configs, so gated cards appear the
|
||||
// moment configs land.
|
||||
const cardGroups = computed(() => groupToolsByCategory(
|
||||
ADVANCED_TOOLS.filter((tool) => isToolAvailable(tool, configs.value)),
|
||||
));
|
||||
|
||||
// ── Drawer state, driven by the `?tool=<slug>` query on the home route ───────
|
||||
// `?tool=whois` ⇒ the drawer is open showing Whois. Closing clears the query.
|
||||
|
||||
+35
-26
@@ -1,20 +1,15 @@
|
||||
// Single source of truth for the Advanced Tools.
|
||||
//
|
||||
// This one registry drives every place a tool is referenced:
|
||||
// - the vue-router routes (router/index.js)
|
||||
// - the Advanced.vue card grid + bottom drawer
|
||||
// - the standalone /tools/:slug pages (added in a later stage)
|
||||
// - the keyboard shortcuts (use-shortcuts.js)
|
||||
//
|
||||
// Previously the router's routes[] and Advanced.vue's cards[] were two parallel
|
||||
// hand-maintained arrays that drifted out of order (the Advanced.vue comment
|
||||
// still warns about it). Keying everything off one slug-ordered list removes
|
||||
// that failure mode.
|
||||
// This one registry drives every place a tool is referenced: the vue-router
|
||||
// routes (router/index.js), the Advanced.vue card groups + bottom drawer, the
|
||||
// standalone /tools/:slug pages, the nav menu and the keyboard shortcuts
|
||||
// (use-shortcuts.js).
|
||||
//
|
||||
// Entry shape:
|
||||
// slug — stable URL/identifier (drawer query + /tools/:slug)
|
||||
// emoji — card glyph + drawer header glyph
|
||||
// titleKey / noteKey — i18n keys for the tool title and one-line description
|
||||
// category — TOOL_CATEGORIES id: the card group it renders in
|
||||
// component — lazy import of the tool's .vue (drawer + standalone)
|
||||
// requiresOriginalSite — gate: only shown on the original site (private API +
|
||||
// sign-in required); omitted == public tool
|
||||
@@ -25,24 +20,38 @@
|
||||
// utils/tool-availability.js; deep links (?tool= / /tools/:slug) ignore them.
|
||||
|
||||
export const ADVANCED_TOOLS = [
|
||||
{ slug: 'pingtest', emoji: '⏱️', titleKey: 'pingtest.Title', noteKey: 'advancedtools.PingTestNote', component: () => import('@/components/advanced-tools/GlobalLatencyTest.vue') },
|
||||
{ slug: 'mtrtest', emoji: '🚉', titleKey: 'mtrtest.Title', noteKey: 'advancedtools.MTRTestNote', component: () => import('@/components/advanced-tools/MtrTest.vue') },
|
||||
{ slug: 'ruletest', emoji: '🚏', titleKey: 'ruletest.Title', noteKey: 'advancedtools.RuleTestNote', component: () => import('@/components/advanced-tools/RuleTest.vue') },
|
||||
{ slug: 'dnsresolver', emoji: '📟', titleKey: 'dnsresolver.Title', noteKey: 'advancedtools.DNSResolverNote', component: () => import('@/components/advanced-tools/DnsResolver.vue') },
|
||||
{ slug: 'censorshipcheck', emoji: '🚧', titleKey: 'censorshipcheck.Title', noteKey: 'advancedtools.CensorshipCheck', component: () => import('@/components/advanced-tools/CensorshipCheck.vue') },
|
||||
{ slug: 'whois', emoji: '📓', titleKey: 'whois.Title', noteKey: 'advancedtools.Whois', component: () => import('@/components/advanced-tools/Whois.vue') },
|
||||
{ slug: 'asn', emoji: '🛂', titleKey: 'asnprofile.Title', noteKey: 'advancedtools.AsnProfile', component: () => import('@/components/advanced-tools/AsnProfile.vue'), requiresConfig: 'cloudFlare' },
|
||||
{ slug: 'macchecker', emoji: '🗄️', titleKey: 'macchecker.Title', noteKey: 'advancedtools.MacChecker', component: () => import('@/components/advanced-tools/MacChecker.vue') },
|
||||
{ slug: 'ipcalculator', emoji: '🔢', titleKey: 'ipcalculator.Title', noteKey: 'advancedtools.IpCalculator', component: () => import('@/components/advanced-tools/IpCalculator.vue') },
|
||||
{ slug: 'browserinfo', emoji: '🖥️', titleKey: 'browserinfo.Title', noteKey: 'advancedtools.BrowserInfo', component: () => import('@/components/advanced-tools/BrowserInfo.vue') },
|
||||
{ slug: 'securitychecklist', emoji: '📋', titleKey: 'securitychecklist.Title', noteKey: 'advancedtools.SecurityChecklist', component: () => import('@/components/advanced-tools/SecurityChecklist.vue') },
|
||||
{ slug: 'servicestatus', emoji: '📡', titleKey: 'serviceStatus.Title', noteKey: 'advancedtools.ServiceStatus', component: () => import('@/components/advanced-tools/ServiceStatus.vue') },
|
||||
{ slug: 'invisibilitytest', emoji: '🫣', titleKey: 'invisibilitytest.Title', noteKey: 'advancedtools.InvisibilityTest', component: () => import('@/components/advanced-tools/InvisibilityTest.vue'), requiresOriginalSite: true },
|
||||
{ slug: 'enhanceddnsleaktest', emoji: '🌀', titleKey: 'enhanceddnsleaktest.Title', noteKey: 'advancedtools.EnhancedDnsLeakTest', component: () => import('@/components/advanced-tools/EnhancedDnsLeakTest.vue'), requiresOriginalSite: true },
|
||||
// noStandalone: the check requires the homepage tests' results, and running
|
||||
{ slug: 'pingtest', emoji: '⏱️', titleKey: 'pingtest.Title', noteKey: 'advancedtools.PingTestNote', category: 'network', component: () => import('@/components/advanced-tools/GlobalLatencyTest.vue') },
|
||||
{ slug: 'mtrtest', emoji: '🚉', titleKey: 'mtrtest.Title', noteKey: 'advancedtools.MTRTestNote', category: 'network', component: () => import('@/components/advanced-tools/MtrTest.vue') },
|
||||
{ slug: 'ruletest', emoji: '🚏', titleKey: 'ruletest.Title', noteKey: 'advancedtools.RuleTestNote', category: 'network', component: () => import('@/components/advanced-tools/RuleTest.vue') },
|
||||
{ slug: 'dnsresolver', emoji: '📟', titleKey: 'dnsresolver.Title', noteKey: 'advancedtools.DNSResolverNote', category: 'network', component: () => import('@/components/advanced-tools/DnsResolver.vue') },
|
||||
{ slug: 'censorshipcheck', emoji: '🚧', titleKey: 'censorshipcheck.Title', noteKey: 'advancedtools.CensorshipCheck', category: 'network', component: () => import('@/components/advanced-tools/CensorshipCheck.vue') },
|
||||
{ slug: 'servicestatus', emoji: '📡', titleKey: 'serviceStatus.Title', noteKey: 'advancedtools.ServiceStatus', category: 'network', component: () => import('@/components/advanced-tools/ServiceStatus.vue') },
|
||||
{ slug: 'browserinfo', emoji: '🖥️', titleKey: 'browserinfo.Title', noteKey: 'advancedtools.BrowserInfo', category: 'network', component: () => import('@/components/advanced-tools/BrowserInfo.vue') },
|
||||
{ slug: 'securitychecklist', emoji: '📋', titleKey: 'securitychecklist.Title', noteKey: 'advancedtools.SecurityChecklist', category: 'network', component: () => import('@/components/advanced-tools/SecurityChecklist.vue') },
|
||||
{ slug: 'whois', emoji: '📓', titleKey: 'whois.Title', noteKey: 'advancedtools.Whois', category: 'lookup', component: () => import('@/components/advanced-tools/Whois.vue') },
|
||||
{ slug: 'asn', emoji: '🛂', titleKey: 'asnprofile.Title', noteKey: 'advancedtools.AsnProfile', category: 'lookup', component: () => import('@/components/advanced-tools/AsnProfile.vue'), requiresConfig: 'cloudFlare' },
|
||||
{ slug: 'macchecker', emoji: '🗄️', titleKey: 'macchecker.Title', noteKey: 'advancedtools.MacChecker', category: 'lookup', component: () => import('@/components/advanced-tools/MacChecker.vue') },
|
||||
{ slug: 'ipcalculator', emoji: '🔢', titleKey: 'ipcalculator.Title', noteKey: 'advancedtools.IpCalculator', category: 'lookup', component: () => import('@/components/advanced-tools/IpCalculator.vue') },
|
||||
{ slug: 'invisibilitytest', emoji: '🫣', titleKey: 'invisibilitytest.Title', noteKey: 'advancedtools.InvisibilityTest', category: 'deep', component: () => import('@/components/advanced-tools/InvisibilityTest.vue'), requiresOriginalSite: true },
|
||||
{ slug: 'enhanceddnsleaktest', emoji: '🌀', titleKey: 'enhanceddnsleaktest.Title', noteKey: 'advancedtools.EnhancedDnsLeakTest', category: 'deep', component: () => import('@/components/advanced-tools/EnhancedDnsLeakTest.vue'), requiresOriginalSite: true },
|
||||
// noStandalone: the check requires the homepage tests' results, and running
|
||||
// them navigates home — a /tools/ page for it would immediately bounce away.
|
||||
{ slug: 'personacheck', emoji: '🎭', titleKey: 'personacheck.Title', noteKey: 'advancedtools.PersonaCheck', component: () => import('@/components/advanced-tools/PersonaCheck.vue'), requiresOriginalSite: true, noStandalone: true },
|
||||
{ slug: 'personacheck', emoji: '🎭', titleKey: 'personacheck.Title', noteKey: 'advancedtools.PersonaCheck', category: 'deep', component: () => import('@/components/advanced-tools/PersonaCheck.vue'), requiresOriginalSite: true, noStandalone: true },
|
||||
];
|
||||
|
||||
// Fast slug → entry lookup (drawer + standalone page resolve a tool by slug).
|
||||
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
|
||||
// (IPCheck.ing's private API) all fail their gates, and the group disappears.
|
||||
export const TOOL_CATEGORIES = [
|
||||
{ id: 'network', titleKey: 'advancedtools.category.network' },
|
||||
{ id: 'lookup', titleKey: 'advancedtools.category.lookup' },
|
||||
{ id: 'deep', titleKey: 'advancedtools.category.deep' },
|
||||
];
|
||||
|
||||
// Tools grouped by category in TOOL_CATEGORIES order (each keeping registry
|
||||
// order); groups left empty — every tool filtered out — are dropped.
|
||||
export const groupToolsByCategory = (tools) => TOOL_CATEGORIES
|
||||
.map((c) => ({ ...c, tools: tools.filter((tool) => tool.category === c.id) }))
|
||||
.filter((group) => group.tools.length > 0);
|
||||
@@ -862,6 +862,11 @@
|
||||
"BackToHome": "Back to Home",
|
||||
"Title": "Advanced Tools",
|
||||
"Note": "People who want the very best from their network usually reach for extra tools to fine-tune their connection.",
|
||||
"category": {
|
||||
"network": "Network Diagnostics",
|
||||
"lookup": "Lookup & Calculation",
|
||||
"deep": "In-depth Checks"
|
||||
},
|
||||
"PingTestNote": "Ping your IP from around the world",
|
||||
"MTRTestNote": "Trace your network route from around the world",
|
||||
"DNSResolverNote": "Real-time multi-channel DNS resolution",
|
||||
|
||||
@@ -862,6 +862,11 @@
|
||||
"BackToHome": "Retour à l'accueil",
|
||||
"Title": "Outils avancés",
|
||||
"Note": "Ceux qui exigent le meilleur de leur réseau utilisent souvent davantage d'outils pour optimiser leur connexion.",
|
||||
"category": {
|
||||
"network": "Diagnostic réseau",
|
||||
"lookup": "Recherche et calcul",
|
||||
"deep": "Contrôles approfondis"
|
||||
},
|
||||
"PingTestNote": "Pinguez votre IP depuis le monde entier",
|
||||
"MTRTestNote": "Tracez votre route réseau depuis le monde entier",
|
||||
"DNSResolverNote": "Résolution DNS multi-canal en temps réel",
|
||||
|
||||
@@ -862,6 +862,11 @@
|
||||
"BackToHome": "Voltar para o início",
|
||||
"Title": "Ferramentas avançadas",
|
||||
"Note": "Quem exige o máximo da qualidade da rede costuma usar mais ferramentas para otimizar a própria conexão.",
|
||||
"category": {
|
||||
"network": "Diagnóstico de rede",
|
||||
"lookup": "Consulta e cálculo",
|
||||
"deep": "Verificações aprofundadas"
|
||||
},
|
||||
"PingTestNote": "Faça ping no seu IP a partir de vários pontos do mundo",
|
||||
"MTRTestNote": "Trace sua rota de rede a partir de vários pontos do mundo",
|
||||
"DNSResolverNote": "Resolução DNS multicanal em tempo real",
|
||||
|
||||
@@ -862,6 +862,11 @@
|
||||
"BackToHome": "На главную",
|
||||
"Title": "Расширенные инструменты",
|
||||
"Note": "Те, кто требователен к качеству сети, обычно используют больше инструментов, чтобы оптимизировать своё подключение.",
|
||||
"category": {
|
||||
"network": "Диагностика сети",
|
||||
"lookup": "Поиск и расчёты",
|
||||
"deep": "Углублённые проверки"
|
||||
},
|
||||
"PingTestNote": "Пинг вашего IP из разных точек мира",
|
||||
"MTRTestNote": "Трассировка вашего сетевого маршрута из разных точек мира",
|
||||
"DNSResolverNote": "Многоканальное разрешение DNS в реальном времени",
|
||||
|
||||
@@ -862,6 +862,11 @@
|
||||
"BackToHome": "回到首頁",
|
||||
"Title": "進階工具",
|
||||
"Note": "對網路品質追求極致的人,往往會使用更多工具來最佳化自己的網路連線。",
|
||||
"category": {
|
||||
"network": "網路診斷",
|
||||
"lookup": "查詢與計算",
|
||||
"deep": "深度檢測"
|
||||
},
|
||||
"PingTestNote": "從世界各地 ping 你的 IP",
|
||||
"MTRTestNote": "從世界各地追蹤你的網路路由",
|
||||
"DNSResolverNote": "即時多渠道 DNS 解析",
|
||||
|
||||
@@ -862,6 +862,11 @@
|
||||
"BackToHome": "回到首页",
|
||||
"Title": "高级工具",
|
||||
"Note": "对网络质量追求极致的人,往往会使用更多工具来优化自己的网络连接。",
|
||||
"category": {
|
||||
"network": "网络诊断",
|
||||
"lookup": "查询与计算",
|
||||
"deep": "深度检测"
|
||||
},
|
||||
"PingTestNote": "从世界各地 ping 你的 IP",
|
||||
"MTRTestNote": "从世界各地追踪你的网络路由",
|
||||
"DNSResolverNote": "实时多渠道 DNS 解析",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Tests for the category layer of the Advanced Tools registry
|
||||
// (frontend/data/tools.js): every tool sits in a known category, the 'deep'
|
||||
// tools are exactly the original-site-only ones, and groupToolsByCategory
|
||||
// builds the card groups Advanced.vue renders.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { ADVANCED_TOOLS, TOOL_CATEGORIES, groupToolsByCategory } from '../frontend/data/tools.js';
|
||||
import { isToolAvailable } from '../frontend/utils/tool-availability.js';
|
||||
|
||||
const CATEGORY_IDS = TOOL_CATEGORIES.map((c) => c.id);
|
||||
const slugsByGroup = (groups) => Object.fromEntries(groups.map((g) => [g.id, g.tools.map((t) => t.slug)]));
|
||||
|
||||
describe('tool categories', () => {
|
||||
it('every tool has a known category', () => {
|
||||
for (const tool of ADVANCED_TOOLS) {
|
||||
assert.ok(CATEGORY_IDS.includes(tool.category), `${tool.slug}: ${tool.category}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('the deep tools are exactly the original-site-only ones', () => {
|
||||
const deep = ADVANCED_TOOLS.filter((t) => t.category === 'deep').map((t) => t.slug);
|
||||
const original = ADVANCED_TOOLS.filter((t) => t.requiresOriginalSite).map((t) => t.slug);
|
||||
assert.deepEqual(deep, original);
|
||||
});
|
||||
|
||||
it('the registry lists tools grouped by category, in TOOL_CATEGORIES order', () => {
|
||||
const order = ADVANCED_TOOLS.map((t) => CATEGORY_IDS.indexOf(t.category));
|
||||
assert.deepEqual(order, [...order].sort((a, b) => a - b));
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupToolsByCategory', () => {
|
||||
it('groups every tool once, in category order', () => {
|
||||
const groups = groupToolsByCategory(ADVANCED_TOOLS);
|
||||
assert.deepEqual(groups.map((g) => g.id), CATEGORY_IDS);
|
||||
assert.ok(groups.every((g) => g.titleKey));
|
||||
assert.equal(groups.flatMap((g) => g.tools).length, ADVANCED_TOOLS.length);
|
||||
});
|
||||
|
||||
it('drops a group whose tools were all filtered out', () => {
|
||||
const fork = ADVANCED_TOOLS.filter((t) => isToolAvailable(t, { originalSite: false, cloudFlare: false }));
|
||||
const groups = slugsByGroup(groupToolsByCategory(fork));
|
||||
assert.deepEqual(Object.keys(groups), ['network', 'lookup']);
|
||||
assert.ok(!groups.lookup.includes('asn'));
|
||||
});
|
||||
|
||||
it('no tools, no groups', () => {
|
||||
assert.deepEqual(groupToolsByCategory([]), []);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user