From 8c02ab3d142d7b8ee048afae4ec9be8bf5819eb1 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 5 Oct 2026 13:24:32 +0800 Subject: [PATCH] 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 --- frontend/components/Advanced.vue | 73 +++++++++++++++++--------------- frontend/data/tools.js | 61 ++++++++++++++------------ frontend/locales/en.json | 5 +++ frontend/locales/fr.json | 5 +++ frontend/locales/pt-BR.json | 5 +++ frontend/locales/ru.json | 5 +++ frontend/locales/zh-TW.json | 5 +++ frontend/locales/zh.json | 5 +++ tests/tool-categories.test.js | 51 ++++++++++++++++++++++ 9 files changed, 156 insertions(+), 59 deletions(-) create mode 100644 tests/tool-categories.test.js diff --git a/frontend/components/Advanced.vue b/frontend/components/Advanced.vue index 6e542cdf..b0751000 100644 --- a/frontend/components/Advanced.vue +++ b/frontend/components/Advanced.vue @@ -13,36 +13,44 @@ - -
- - - -

- - {{ t(card.titleKey) }} -

- -

- {{ t(card.noteKey) }} -

- - -
-
+ @@ -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=` query on the home route ─────── // `?tool=whois` ⇒ the drawer is open showing Whois. Closing clears the query. diff --git a/frontend/data/tools.js b/frontend/data/tools.js index 527f7287..df2afbb2 100644 --- a/frontend/data/tools.js +++ b/frontend/data/tools.js @@ -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); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index b5e32982..cf8f9541 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 0eae9f4f..b5fc9861 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -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", diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json index 5fea0668..902e64f2 100644 --- a/frontend/locales/pt-BR.json +++ b/frontend/locales/pt-BR.json @@ -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", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 3b1a9fd9..e3c7a9c1 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -862,6 +862,11 @@ "BackToHome": "На главную", "Title": "Расширенные инструменты", "Note": "Те, кто требователен к качеству сети, обычно используют больше инструментов, чтобы оптимизировать своё подключение.", + "category": { + "network": "Диагностика сети", + "lookup": "Поиск и расчёты", + "deep": "Углублённые проверки" + }, "PingTestNote": "Пинг вашего IP из разных точек мира", "MTRTestNote": "Трассировка вашего сетевого маршрута из разных точек мира", "DNSResolverNote": "Многоканальное разрешение DNS в реальном времени", diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json index 21c378a5..ff19b1b6 100644 --- a/frontend/locales/zh-TW.json +++ b/frontend/locales/zh-TW.json @@ -862,6 +862,11 @@ "BackToHome": "回到首頁", "Title": "進階工具", "Note": "對網路品質追求極致的人,往往會使用更多工具來最佳化自己的網路連線。", + "category": { + "network": "網路診斷", + "lookup": "查詢與計算", + "deep": "深度檢測" + }, "PingTestNote": "從世界各地 ping 你的 IP", "MTRTestNote": "從世界各地追蹤你的網路路由", "DNSResolverNote": "即時多渠道 DNS 解析", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 3ea8b08f..f49dbb36 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -862,6 +862,11 @@ "BackToHome": "回到首页", "Title": "高级工具", "Note": "对网络质量追求极致的人,往往会使用更多工具来优化自己的网络连接。", + "category": { + "network": "网络诊断", + "lookup": "查询与计算", + "deep": "深度检测" + }, "PingTestNote": "从世界各地 ping 你的 IP", "MTRTestNote": "从世界各地追踪你的网络路由", "DNSResolverNote": "实时多渠道 DNS 解析", diff --git a/tests/tool-categories.test.js b/tests/tool-categories.test.js new file mode 100644 index 00000000..62e9ad5b --- /dev/null +++ b/tests/tool-categories.test.js @@ -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([]), []); + }); +});