Files
MyIP/frontend/components/Advanced.vue
T
jason5ng32andClaude Fable 5.1 c55634c8e0 Feat(asn): frontend groundwork for the ASN Profile tool
- Registry gate `requiresConfig`: a tool is listed in the Advanced Tools
  grid and the nav only when its backend config is present; the listing
  filter is one pure function. ASN Profile requires the Cloudflare key.
- `useAsnInfo` owns the Radar ASN fetch and its session cache, shared by
  the IP cards and the query dialog.
- ASN Info's traffic-share and connection-quality blocks become small
  components over pure helpers, reusable by the profile page.
- ASN Profile lands as a mock-data page for now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:47:15 +08:00

238 lines
10 KiB
Vue

<template>
<!-- Advanced Tools -->
<section class="advanced-tools-section mb-10">
<!-- Header -->
<header class="mb-2 flex flex-col items-start justify-between gap-4">
<div class="flex flex-row items-center justify-between gap-4 w-full">
<h2 id="AdvancedTools" class="m-0 flex min-w-0 flex-1 items-center gap-2 text-xl md:text-3xl font-semibold tracking-tight leading-tight">
🧰 {{ t('advancedtools.Title') }}
</h2>
</div>
<div class="text-base text-muted-foreground">
<p v-if="!isSimpleMode">{{ t('advancedtools.Note') }}</p>
</div>
</header>
<!-- Card grid. 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>
</Card>
</div>
<!-- Tool details Drawer -->
<Drawer :open="isOpen" @update:open="onOpenChange" :dismissible="true">
<DrawerContent :title="activeTool ? t(activeTool.titleKey) : t('advancedtools.Title')"
:safe-area-top="isMobile || isFullScreen"
:class="['jn-tools-drawer overflow-hidden', (isMobile || isFullScreen) ? 'h-full rounded-none' : 'h-[85vh]']">
<!-- Drawer internal header -->
<div class="flex items-center gap-2 px-4 pt-1 pb-3 jn-drawer-header shrink-0">
<button v-if="!isMobile" type="button"
class="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="fullScreen" :aria-label="isFullScreen ? 'Exit full screen' : 'Full screen'">
<Maximize v-if="!isFullScreen" class="size-4" />
<Minimize v-else class="size-4" />
</button>
<span v-if="activeTool" class="flex-1 text-base md:text-lg font-medium truncate"
:class="isMobile ? 'text-left' : 'text-center'">
<span class="mr-1">{{ activeTool.emoji }}</span>{{ t(activeTool.titleKey) }}
</span>
<span v-else class="flex-1" />
<!-- Open the current tool as a standalone page (hidden in a
PWA window, which has no new-tab affordance, and for
noStandalone tools, which have no such page) -->
<a v-if="activeTool && !isPwa && !activeTool.noStandalone" :href="`/tools/${activeTool.slug}`"
target="_blank" rel="noopener"
class="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
:title="t('advancedtools.OpenInNewTab')" :aria-label="t('advancedtools.OpenInNewTab')">
<SquareArrowOutUpRight class="size-4" />
</a>
<DrawerClose
class="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" />
</div>
<!-- Content area (scrollable) -->
<div class="flex-1 overflow-y-auto px-1 md:px-2 pb-6" ref="scrollContainer">
<div :class="isMobile ? 'w-full px-3' : 'jn-canvas-width px-6'">
<component :is="activeComponent" v-if="activeComponent" />
</div>
</div>
</DrawerContent>
</Drawer>
<!-- Section banner slot (data-driven; see InfoBanner.vue) -->
<InfoBanner section="advanced" />
</section>
</template>
<script setup>
import { ref, computed, onMounted, defineAsyncComponent } from 'vue';
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 { isToolAvailable } from '@/utils/tool-availability.js';
import { isRunningAsPwa } from '@/utils/pwa.js';
import { Drawer, DrawerContent, DrawerClose } from '@/components/ui/drawer';
import { Card, CardContent } from '@/components/ui/card';
import { ToolLoadingSkeleton } from '@/components/ui/tool-loading-skeleton';
import InfoBanner from '@/components/widgets/InfoBanner.vue';
import { Maximize, Minimize, PanelBottomOpen, SquareArrowOutUpRight } from '@lucide/vue';
const { t } = useI18n();
const store = useMainStore();
const isMobile = computed(() => store.isMobile);
// Running as an installed PWA → hide the "open in new tab" affordance (a PWA
// window has no tab strip; it would pop out to the browser). See utils/pwa.js.
const isPwa = isRunningAsPwa();
const configs = computed(() => store.configs);
const userPreferences = computed(() => store.userPreferences);
const isSimpleMode = computed(() => userPreferences.value.simpleMode);
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)));
// ── Drawer state, driven by the `?tool=<slug>` query on the home route ───────
// `?tool=whois` ⇒ the drawer is open showing Whois. Closing clears the query.
const activeTool = computed(() => {
const slug = route.query.tool;
return (typeof slug === 'string' && TOOL_BY_SLUG.get(slug)) || null;
});
const isOpen = computed(() => !!activeTool.value);
// Resolve each tool's lazy component once and cache it, so re-renders don't
// rebuild the async wrapper (which would remount the tool). The skeleton
// covers the chunk download; `delay` keeps fast loads flash-free.
const asyncToolCache = new Map();
const activeComponent = computed(() => {
const tool = activeTool.value;
if (!tool) return null;
if (!asyncToolCache.has(tool.slug)) {
asyncToolCache.set(tool.slug, defineAsyncComponent({
loader: tool.component,
loadingComponent: ToolLoadingSkeleton,
delay: 200,
}));
}
return asyncToolCache.get(tool.slug);
});
const isFullScreen = ref(false);
// Open a tool in the in-page drawer (just sets the query; isOpen reacts).
const openTool = (slug) => {
router.push({ path: '/', query: { tool: slug } });
const name = slug.charAt(0).toUpperCase() + slug.slice(1);
trackEvent('Nav', 'NavClick', name);
};
// Card left-click: open the drawer. Modifier / middle clicks fall through to
// the <a href> default so the browser opens the standalone page in a new tab.
const onCardClick = (e, slug) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return;
e.preventDefault();
openTool(slug);
};
const onOpenChange = (val) => {
// Drawer closed (drag / overlay / Esc / close button) → drop the ?tool query.
if (!val) {
if (route.query.tool) router.push({ path: '/', query: {} });
isFullScreen.value = false;
}
};
// Full screen toggle: height determined by DrawerContent's class
const fullScreen = () => {
isFullScreen.value = !isFullScreen.value;
};
onMounted(() => {
store.setMountingStatus('AdvancedTools', true);
});
defineExpose({
openTool, fullScreen,
});
</script>
<style scoped>
.jn-emoji {
position: absolute;
top: 0.5rem;
right: 0.75rem;
font-size: 1.6rem;
line-height: 1;
transition: transform 0.4s ease, text-shadow 0.4s ease;
pointer-events: none;
}
.jn-adv-card:hover .jn-emoji {
transform: translateY(-10pt) scale(1.8);
text-shadow: 0 0 10pt rgb(0 0 0 / 0.38);
}
:global(.dark) .jn-adv-card:hover .jn-emoji {
text-shadow: 0 0 10pt rgb(255 255 255 / 0.15);
}
/* Drawer content area width (desktop) */
.jn-canvas-width {
width: fit-content;
margin: auto;
max-width: 1400px;
}
.jn-drawer-header {
border-bottom: 1px solid var(--border);
}
/* Drawer root container needs flex-col, so that the header is fixed + content scrollable */
.jn-tools-drawer {
display: flex;
flex-direction: column;
}
/* Full screen toggle height transition */
:global(.jn-tools-drawer) {
transition:
transform 0.5s cubic-bezier(0.32, 0.72, 0, 1),
height 0.3s cubic-bezier(0.32, 0.72, 0, 1) !important;
}
</style>