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
528 lines
27 KiB
Vue
528 lines
27 KiB
Vue
<template>
|
|
<div class="security-checklist-section my-4 space-y-4">
|
|
<!-- Top note -->
|
|
<div class="text-sm text-muted-foreground space-y-1.5 leading-relaxed">
|
|
<p>{{ t('securitychecklist.Note') }}</p>
|
|
<p>{{ t('securitychecklist.Note2') }}</p>
|
|
</div>
|
|
|
|
<!-- Loading state (initial dataset fetch + locale switches); the
|
|
chunk phase before it shows ToolLoadingSkeleton instead. -->
|
|
<div v-if="!fullList" class="flex justify-center py-12">
|
|
<Spinner class="size-6 text-info" />
|
|
</div>
|
|
|
|
<template v-else>
|
|
<!-- Top progress panel -->
|
|
<Card>
|
|
<CardContent class="p-4 md:p-6 space-y-4">
|
|
<!-- Title bar -->
|
|
<header class="flex items-center justify-between gap-2">
|
|
<h3 class="flex items-center gap-2 text-base font-semibold m-0">
|
|
<ClipboardList class="size-4 text-muted-foreground" />
|
|
{{ t('securitychecklist.Progress') }}
|
|
</h3>
|
|
<JnTooltip :text="t('securitychecklist.Reset')" side="left">
|
|
<Button size="icon" variant="outline" @click="resetAllslugs()"
|
|
aria-label="Reset Security Checklist">
|
|
<RotateCw />
|
|
</Button>
|
|
</JnTooltip>
|
|
</header>
|
|
|
|
<!-- Statistical numbers -->
|
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
<div class="flex flex-col items-start p-3 rounded-md bg-muted/50">
|
|
<span class="text-xs text-muted-foreground">{{ t('securitychecklist.alert-total') }}</span>
|
|
<span class="text-xl font-semibold tabular-nums">{{ totalItems }}</span>
|
|
</div>
|
|
<div class="flex flex-col items-start p-3 rounded-md bg-success/10">
|
|
<span class="text-xs text-success">{{ t('securitychecklist.Checked') }}</span>
|
|
<span class="text-xl font-semibold tabular-nums text-success">{{ checkedItems }}</span>
|
|
</div>
|
|
<div class="flex flex-col items-start p-3 rounded-md bg-info/10">
|
|
<span class="text-xs text-info">{{ t('securitychecklist.Ignored') }}</span>
|
|
<span class="text-xl font-semibold tabular-nums text-info">{{ ignoredItems }}</span>
|
|
</div>
|
|
<div class="flex flex-col items-start p-3 rounded-md bg-muted/50">
|
|
<span class="text-xs text-muted-foreground">{{ t('securitychecklist.Unchecked') }}</span>
|
|
<span class="text-xl font-semibold tabular-nums">{{ uncheckedItems }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Left 2/3 category progress bar + right 1/3 priority circular chart -->
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<!-- Each category progress bar stacked (checked + ignored two segments) -->
|
|
<div class="md:col-span-2 space-y-2">
|
|
<div v-for="item in categories" :key="item" class="flex items-center gap-3 text-sm">
|
|
<span class="shrink-0 text-foreground/85 min-w-20 truncate">{{ fullList[item].title
|
|
}}</span>
|
|
<span class="flex h-2 flex-1 overflow-hidden rounded-full bg-muted" role="progressbar">
|
|
<div class="h-full bg-success transition-all"
|
|
:style="getProgressStyle(item)('checked')"
|
|
:title="t('securitychecklist.Checked')"></div>
|
|
<div class="h-full bg-info transition-all"
|
|
:style="getProgressStyle(item)('ignored')"
|
|
:title="t('securitychecklist.Ignored')"></div>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 4 circular progress by priority -->
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div v-for="item in priorities" :key="item" class="flex justify-center">
|
|
<CircleProgressBar v-if="priorityDenom(item) !== 0"
|
|
:value="countItems({ action: 'checked', category: 'all', priority: item })"
|
|
:max="priorityDenom(item)" :colorFilled="progressColors.filled"
|
|
:colorUnfilled="progressColors.filled" :colorBack="progressColors.back"
|
|
:size="'90pt'" :percentage="true" :strokeWidth="'10pt'">
|
|
<span class="text-xs">{{ t('securitychecklist.' + item) }}</span>
|
|
</CircleProgressBar>
|
|
<CircleProgressBar v-else :value="1" :max="1" :colorFilled="progressColors.info"
|
|
:colorUnfilled="progressColors.filled" :colorBack="progressColors.back"
|
|
:size="'90pt'" :strokeWidth="'10pt'">
|
|
<span class="text-xs">{{ t('securitychecklist.' + item) }}</span>
|
|
<br />
|
|
<span class="text-[10px] text-muted-foreground">{{ t('securitychecklist.Ignored')
|
|
}}</span>
|
|
</CircleProgressBar>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Main body: left category navigation + right details -->
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<!-- Left 1/3: category navigation -->
|
|
<nav class="space-y-1.5">
|
|
<button v-for="item in categories" :key="item" type="button" @click="changeList(item, true)"
|
|
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-md text-sm text-left transition-colors border cursor-pointer"
|
|
:class="item === currentList
|
|
? 'bg-success/10 border-success/40 text-success font-medium'
|
|
: 'bg-card border-border hover:bg-muted/50'">
|
|
<span class="flex items-center gap-2 min-w-0">
|
|
<component :is="getChecklistIcon(item)" class="size-4 shrink-0" />
|
|
<span class="truncate">{{ fullList[item].title }}</span>
|
|
</span>
|
|
<span class="shrink-0 flex items-center gap-1.5">
|
|
<!-- Right: completed 100% display ✓, otherwise display checked/total -->
|
|
<CircleCheck v-if="countItems({ action: 'percentage', category: item }) === 100"
|
|
class="size-4 text-success" />
|
|
<span v-else class="text-xs tabular-nums text-muted-foreground">
|
|
{{ countItems({ action: 'checked', category: item }) }}/{{
|
|
countItems({ action: 'total', category: item }) -
|
|
countItems({ action: 'ignored', category: item })
|
|
}}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</nav>
|
|
|
|
<!-- Right 2/3: current category details -->
|
|
<div id="checklist" class="md:col-span-2 scroll-mt-[calc(4rem+env(safe-area-inset-top))]">
|
|
<Card>
|
|
<CardContent class="p-4 md:p-6 space-y-4">
|
|
<!-- Category title + description + expandable intro -->
|
|
<header>
|
|
<h2 class="flex items-center gap-2 text-xl font-semibold tracking-tight m-0 mb-2">
|
|
<component :is="getChecklistIcon(currentList)"
|
|
class="size-5 text-muted-foreground" />
|
|
{{ fullList[currentList].title }}
|
|
</h2>
|
|
<Collapsible :open="isCategoryIntroOpen" @update:open="isCategoryIntroOpen = $event">
|
|
<span class="flex items-center">
|
|
<span class="text-sm text-muted-foreground leading-relaxed">
|
|
{{ fullList[currentList].description }}
|
|
</span>
|
|
<CollapsibleTrigger v-if="fullList[currentList].intro" as-child>
|
|
<button type="button"
|
|
class="inline-flex items-center ml-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
|
:aria-expanded="isCategoryIntroOpen"
|
|
:aria-label="'Display Info of ' + fullList[currentList].title">
|
|
<Info class="size-3.5" />
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
</span>
|
|
<CollapsibleContent>
|
|
<div
|
|
class="mt-2 p-3 rounded-md bg-muted/50 text-sm text-muted-foreground leading-relaxed">
|
|
<vue-markdown :source="fullList[currentList].intro" />
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</header>
|
|
|
|
<!-- Category progress bar + count -->
|
|
<div class="flex items-center gap-3">
|
|
<span class="flex h-2 flex-1 overflow-hidden rounded-full bg-muted" role="progressbar"
|
|
:aria-label="'Progress for ' + fullList[currentList].title">
|
|
<span class="h-full bg-success transition-all"
|
|
:style="'width: ' + countItems({ action: 'percentage', category: currentList }) + '%'"></span>
|
|
</span>
|
|
<span class="text-xs text-muted-foreground tabular-nums shrink-0">
|
|
{{ countItems({ action: 'checked', category: currentList }) }}/{{
|
|
countItems({ action: 'total', category: currentList }) -
|
|
countItems({ action: 'ignored', category: currentList })
|
|
}}
|
|
<span class="opacity-70">({{ t('securitychecklist.Ignored') }}: {{ countItems({
|
|
action: 'ignored', category:
|
|
currentList }) }})</span>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Filter Toggle -->
|
|
<div class="flex justify-start">
|
|
<ToggleGroup v-model="filterTag" type="single" variant="outline"
|
|
@update:model-value="(v) => v && filterChecklist(v)">
|
|
<ToggleGroupItem value="all" class="flex-1 gap-1.5"
|
|
:aria-label="t('securitychecklist.ShowAll')">
|
|
<ListChecks class="size-4" />
|
|
</ToggleGroupItem>
|
|
<ToggleGroupItem value="unchecked" class="flex-1 gap-1.5"
|
|
:aria-label="t('securitychecklist.ShowUnchecked')">
|
|
<Circle class="size-4" />
|
|
</ToggleGroupItem>
|
|
<ToggleGroupItem value="checked" class="flex-1 gap-1.5"
|
|
:aria-label="t('securitychecklist.ShowChecked')">
|
|
<CircleCheck class="size-4" />
|
|
</ToggleGroupItem>
|
|
<ToggleGroupItem value="ignored" class="flex-1 gap-1.5"
|
|
:aria-label="t('securitychecklist.ShowIgnored')">
|
|
<CirclePause class="size-4" />
|
|
</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
</div>
|
|
|
|
<!-- Item list -->
|
|
<ul class="rounded-lg border bg-card divide-y list-none p-0 m-0">
|
|
<li v-for="(item, index) in filterList" :key="item.slug" class="transition-colors"
|
|
:class="item.checked ? 'bg-success/10' : 'hover:bg-muted/30'">
|
|
<div class="flex items-start gap-3 px-3 py-2.5">
|
|
<!-- Status switch button -->
|
|
<button type="button" @click="checkItem(item)"
|
|
class="shrink-0 mt-0.5 text-muted-foreground cursor-pointer"
|
|
:class="{ 'text-success': item.checked, 'text-muted-foreground/60 cursor-not-allowed': item.ignored }"
|
|
:disabled="item.ignored" :aria-label="item.checked ? 'Uncheck' : 'Check'">
|
|
<component
|
|
:is="item.checked ? CircleCheck : item.ignored ? CirclePause : Circle"
|
|
class="size-5" />
|
|
</button>
|
|
|
|
<!-- Item name + Info expand -->
|
|
<div class="flex-1 min-w-0 text-sm">
|
|
<div class="flex items-start gap-1"
|
|
:class="{ 'line-through opacity-50': item.ignored }">
|
|
<span>{{ item.point }}</span>
|
|
<button type="button" @click="toggleChecklistInfo(index)"
|
|
class="mt-0.75 shrink-0 text-muted-foreground hover:text-foreground cursor-pointer"
|
|
:aria-expanded="!!checklistInfoOpen[index]"
|
|
:aria-label="'Display Info of ' + item.point">
|
|
<Info class="size-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Priority Badge -->
|
|
<Badge class="shrink-0 rounded-full font-normal shadow-none"
|
|
:class="priorityBadgeClass(item.priority)">
|
|
{{ t('securitychecklist.' + item.priority) }}
|
|
</Badge>
|
|
|
|
<!-- Ignore Switch -->
|
|
<Switch class="shrink-0" :model-value="item.ignored"
|
|
@update:model-value="() => ignoreItem(item)"
|
|
:aria-label="t('securitychecklist.Ignore')" />
|
|
</div>
|
|
|
|
<!-- Info details expand -->
|
|
<div v-show="checklistInfoOpen[index]" class="px-3 pb-3 pt-1">
|
|
<div class="p-3 rounded-md text-xs text-muted-foreground leading-relaxed">
|
|
<vue-markdown :source="item.details" />
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch, nextTick } from 'vue';
|
|
import { useMainStore } from '@/store';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { trackEvent } from '@/utils/analytics';
|
|
import { emitAppEvent } from '@/utils/app-events.js';
|
|
import { datasetLoaders, loadLocaleDataset } from '@/utils/locale-datasets.js';
|
|
import { CircleProgressBar } from 'circle-progress.vue';
|
|
import VueMarkdown from 'vue-markdown-render';
|
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { JnTooltip } from '@/components/ui/tooltip';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|
import { Spinner } from '@/components/ui/spinner';
|
|
import {
|
|
Banknote,
|
|
Circle,
|
|
CircleCheck,
|
|
CirclePause,
|
|
ClipboardList,
|
|
Compass,
|
|
FileWarning,
|
|
HousePlus,
|
|
Info,
|
|
KeyRound,
|
|
Laptop,
|
|
ListChecks,
|
|
Mail,
|
|
MessageCircle,
|
|
RotateCw,
|
|
ScanFace,
|
|
ShieldHalf,
|
|
Smartphone,
|
|
Users,
|
|
} from '@lucide/vue';
|
|
|
|
// Security checklist icon mapping: directly use slug as key (slug itself is a semantic category name)
|
|
const checklistIconMap = {
|
|
'authentication': KeyRound,
|
|
'web-browsing': Compass,
|
|
'email': Mail,
|
|
'messaging': MessageCircle,
|
|
'social-media': Users,
|
|
'networks': ShieldHalf,
|
|
'mobile-devices': Smartphone,
|
|
'personal-computers': Laptop,
|
|
'smart-home': HousePlus,
|
|
'personal-finance': Banknote,
|
|
'human-aspect': ScanFace,
|
|
'physical-security': FileWarning,
|
|
};
|
|
const getChecklistIcon = (slug) => checklistIconMap[slug] || Info;
|
|
|
|
const isCategoryIntroOpen = ref(false);
|
|
const checklistInfoOpen = ref({});
|
|
const toggleChecklistInfo = (index) => {
|
|
checklistInfoOpen.value[index] = !checklistInfoOpen.value[index];
|
|
};
|
|
|
|
const { t, locale } = useI18n();
|
|
|
|
// The checklist dataset is large (~30 KB gzipped per language) and only this tool
|
|
// reads it, so it's loaded on demand for the active locale instead of being baked
|
|
// into the initial i18n bundle (see frontend/locales/i18n.js). The shared loader
|
|
// walks the fallback chain, so a failed chunk degrades to the next language
|
|
// instead of a spinner that never stops.
|
|
const securityDataLoaders = datasetLoaders(import.meta.glob('../../locales/security-checklist/*.json'));
|
|
const securityDataCache = new Map();
|
|
|
|
const securityChecklist = ref(null);
|
|
|
|
// Fetch the active locale's dataset and (re)build the list. Nulling fullList first
|
|
// surfaces the template's existing loading state during the swap.
|
|
const loadSecurityChecklist = async () => {
|
|
fullList.value = null;
|
|
const loc = locale.value;
|
|
const pack = await loadLocaleDataset(securityDataLoaders, loc, securityDataCache);
|
|
if (loc !== locale.value) return; // stale load — the locale changed mid-flight
|
|
if (!pack) {
|
|
console.error('Security checklist dataset unavailable in every locale');
|
|
return;
|
|
}
|
|
securityChecklist.value = pack.data;
|
|
fullList.value = initSecurityList(pack.data);
|
|
};
|
|
|
|
const store = useMainStore();
|
|
const isDarkMode = computed(() => store.isDarkMode);
|
|
|
|
const categories = ref([]);
|
|
const currentList = ref('authentication');
|
|
const slugs = ref({});
|
|
|
|
const priorities = ['Basic', 'Optional', 'Essential', 'Advanced'];
|
|
|
|
// Priority Badge color: Basic → action (blue); Essential → success (green);
|
|
// Optional → info (sky); Advanced → secondary (neutral);
|
|
const priorityBadgeClass = (priority) => {
|
|
const map = {
|
|
Basic: 'bg-action text-action-foreground border-transparent',
|
|
Essential: 'bg-success text-success-foreground border-transparent',
|
|
Optional: 'bg-info text-info-foreground border-transparent',
|
|
Advanced: 'bg-secondary text-secondary-foreground border-transparent',
|
|
};
|
|
return map[priority] || '';
|
|
};
|
|
|
|
// CircleProgressBar color: third-party library takes hex string, keep semantic color intention consistent:
|
|
// filled use green-500, info (incomplete state) use sky-500, background has neutral value in light/dark mode
|
|
const progressColors = computed(() => ({
|
|
filled: '#22c55e', // green-500
|
|
info: '#0ea5e9', // sky-500
|
|
back: isDarkMode.value ? '#262626' : '#e5e5e5', // neutral-800 / neutral-200
|
|
}));
|
|
|
|
// Denominator by priority (total items - ignored)
|
|
const priorityDenom = (priority) =>
|
|
countItems({ action: 'total', category: 'all', priority }) -
|
|
countItems({ action: 'ignored', category: 'all', priority });
|
|
|
|
// Local persistence
|
|
const setLocalSlugs = () => {
|
|
localStorage.setItem('securityChecklistSlugs', JSON.stringify(slugs.value));
|
|
};
|
|
|
|
const loadLocalSlugs = () => {
|
|
const storedSlugs = localStorage.getItem('securityChecklistSlugs');
|
|
if (storedSlugs) {
|
|
const parsedSlugs = JSON.parse(storedSlugs);
|
|
Object.keys(parsedSlugs).forEach(key => { slugs.value[key] = parsedSlugs[key]; });
|
|
}
|
|
};
|
|
|
|
const updateSlugs = (slug, value) => {
|
|
slugs.value[slug] = value;
|
|
setLocalSlugs();
|
|
};
|
|
|
|
const initSecurityList = (securityChecklist) => {
|
|
loadLocalSlugs();
|
|
const transformedObject = {};
|
|
securityChecklist.forEach(item => {
|
|
if (!categories.value.includes(item.slug)) categories.value.push(item.slug);
|
|
transformedObject[item.slug] = {
|
|
title: item.title,
|
|
description: item.description,
|
|
intro: item.intro,
|
|
checklist: item.checklist.map(checkItem => {
|
|
const checkItemSlug = checkItem.slug;
|
|
if (slugs.value[checkItemSlug] === undefined) slugs.value[checkItemSlug] = '';
|
|
return {
|
|
...checkItem,
|
|
checked: slugs.value[checkItemSlug] === 'checked',
|
|
ignored: slugs.value[checkItemSlug] === 'ignored',
|
|
slug: checkItemSlug,
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
setLocalSlugs();
|
|
return transformedObject;
|
|
};
|
|
|
|
const fullList = ref(null);
|
|
|
|
const resetAllslugs = () => {
|
|
trackEvent('SecurityChecklist', 'SecurityChecklist', 'Reset');
|
|
for (const slug in slugs.value) slugs.value[slug] = '';
|
|
setLocalSlugs();
|
|
fullList.value = initSecurityList(securityChecklist.value);
|
|
listToShow.value = fullList.value[currentList.value];
|
|
filterList.value = listToShow.value.checklist;
|
|
filterTag.value = 'all';
|
|
};
|
|
|
|
const filterList = ref({});
|
|
const listToShow = ref([]);
|
|
const filterTag = ref('all');
|
|
|
|
const changeList = (listName, shouldScroll = true) => {
|
|
const previousList = currentList.value;
|
|
listToShow.value = fullList.value[listName];
|
|
filterList.value = listToShow.value.checklist;
|
|
currentList.value = listName;
|
|
filterTag.value = 'all';
|
|
if (shouldScroll) {
|
|
if (previousList !== currentList.value) {
|
|
trackEvent('SecurityChecklist', 'SecurityChecklist', 'ChangeList');
|
|
}
|
|
// On a narrow screen the details stack below the list: bring them into
|
|
// view, clear of the fixed Nav (scroll-mt-* on #checklist).
|
|
nextTick(() => {
|
|
const el = document.getElementById('checklist');
|
|
if (!el) return;
|
|
const { top } = el.getBoundingClientRect();
|
|
const headerBottom = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
|
|
if (top >= headerBottom && top < window.innerHeight * 0.75) return;
|
|
el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
|
});
|
|
}
|
|
};
|
|
|
|
const filterChecklist = (filter) => {
|
|
filterTag.value = filter;
|
|
if (filter === 'all') filterList.value = listToShow.value.checklist;
|
|
else if (filter === 'ignored') filterList.value = listToShow.value.checklist.filter(item => item.ignored);
|
|
else if (filter === 'checked') filterList.value = listToShow.value.checklist.filter(item => item.checked);
|
|
else if (filter === 'unchecked') filterList.value = listToShow.value.checklist.filter(item => !item.checked && !item.ignored);
|
|
};
|
|
|
|
const ignoreItem = (item) => {
|
|
item.ignored = !item.ignored;
|
|
item.checked = false;
|
|
updateSlugs(item.slug, item.ignored ? 'ignored' : '');
|
|
};
|
|
|
|
const checkItem = (item) => {
|
|
if (item.ignored) return;
|
|
item.checked = !item.checked;
|
|
updateSlugs(item.slug, item.checked ? 'checked' : '');
|
|
// Achievement rules (SurfaceCheck / HalfwayThere / FullySecured) live in
|
|
// data/achievement-rules.js.
|
|
emitAppEvent('securitychecklist:progress', {
|
|
checked: checkedItems.value,
|
|
total: totalItems.value,
|
|
});
|
|
};
|
|
|
|
const countItems = ({ action, category, priority }) => {
|
|
const categoriesArr = category === 'all' ? Object.keys(fullList.value) : [category];
|
|
const actionMap = {
|
|
total: (items) => items.length,
|
|
ignored: (items) => items.filter(item => item.ignored).length,
|
|
checked: (items) => items.filter(item => item.checked).length,
|
|
percentage: (items) => {
|
|
const total = items.length;
|
|
const ignored = items.filter(item => item.ignored).length;
|
|
const checked = items.filter(item => item.checked).length;
|
|
const denominator = total - ignored;
|
|
return denominator === 0 ? 100 : Math.round((checked / denominator) * 100);
|
|
},
|
|
};
|
|
return categoriesArr.reduce((sum, cat) => {
|
|
const checklist = fullList.value[cat]?.checklist;
|
|
if (!checklist) return sum;
|
|
const filteredItems = priority ? checklist.filter(item => item.priority === priority) : checklist;
|
|
return sum + (actionMap[action] || (() => 0))(filteredItems);
|
|
}, 0);
|
|
};
|
|
|
|
const totalItems = computed(() => countItems({ action: 'total', category: 'all' }));
|
|
const checkedItems = computed(() => countItems({ action: 'checked', category: 'all' }));
|
|
const ignoredItems = computed(() => countItems({ action: 'ignored', category: 'all' }));
|
|
const uncheckedItems = computed(() => totalItems.value - checkedItems.value - ignoredItems.value);
|
|
|
|
const getProgressStyle = (category) => {
|
|
return (action) =>
|
|
`width: ${countItems({ action, category }) / countItems({ action: 'total', category }) * 100}%`;
|
|
};
|
|
|
|
onMounted(async () => {
|
|
await loadSecurityChecklist();
|
|
setTimeout(() => { changeList('authentication', false); }, 300);
|
|
});
|
|
|
|
// Switching language while the tool is open swaps to that locale's dataset.
|
|
watch(locale, async () => {
|
|
await loadSecurityChecklist();
|
|
changeList(currentList.value, false);
|
|
});
|
|
</script>
|