1+ <script setup lang="ts">
2+ interface HealthScoreDimension {
3+ score: number
4+ weight: number
5+ }
6+
7+ interface HealthScoreResponse {
8+ package: string
9+ version: string
10+ score: number
11+ grade: ' A' | ' B' | ' C' | ' D' | ' F'
12+ dimensions: {
13+ maintenance: HealthScoreDimension
14+ quality: HealthScoreDimension
15+ security: HealthScoreDimension
16+ popularity: HealthScoreDimension
17+ }
18+ analyzedAt: string
19+ }
20+
21+ const props = defineProps <{
22+ packageName: string
23+ version? : string
24+ }>()
25+
26+ const { data, status } = useFetch <HealthScoreResponse >(
27+ () => {
28+ const base = ` https://npm-pulse.vercel.app/api/v1/score/${props .packageName } `
29+ return props .version ? ` ${base }?version=${props .version } ` : base
30+ },
31+ {
32+ key : () => ` health-score-${props .packageName }-${props .version ?? ' latest' } ` ,
33+ server: false ,
34+ lazy: true ,
35+ },
36+ )
37+
38+ const isLoading = computed (() => status .value === ' pending' || status .value === ' idle' )
39+ const isError = computed (() => status .value === ' error' )
40+
41+ function gradeColor(grade : string | undefined ): string {
42+ switch (grade ) {
43+ case ' A' : return ' text-emerald-500'
44+ case ' B' : return ' text-lime-500'
45+ case ' C' : return ' text-amber-500'
46+ case ' D' : return ' text-orange-500'
47+ case ' F' : return ' text-red-500'
48+ default : return ' text-fg-subtle'
49+ }
50+ }
51+
52+ function scoreBarColor(score : number ): string {
53+ if (score >= 80 ) return ' bg-emerald-500'
54+ if (score >= 60 ) return ' bg-lime-500'
55+ if (score >= 40 ) return ' bg-amber-500'
56+ if (score >= 20 ) return ' bg-orange-500'
57+ return ' bg-red-500'
58+ }
59+
60+ const dimensions = computed (() => {
61+ if (! data .value ) return []
62+ const d = data .value .dimensions
63+ return [
64+ { key: ' maintenance' , label: $t (' package.health_score.dimension_maintenance' ), score: d .maintenance .score , weight: d .maintenance .weight },
65+ { key: ' quality' , label: $t (' package.health_score.dimension_quality' ), score: d .quality .score , weight: d .quality .weight },
66+ { key: ' security' , label: $t (' package.health_score.dimension_security' ), score: d .security .score , weight: d .security .weight },
67+ { key: ' popularity' , label: $t (' package.health_score.dimension_popularity' ), score: d .popularity .score , weight: d .popularity .weight },
68+ ]
69+ })
70+ </script >
71+
72+ <template >
73+ <section aria-labelledby =" health-score-heading" >
74+ <h2
75+ id =" health-score-heading"
76+ class =" text-xs text-fg-subtle uppercase tracking-wider mb-3 flex items-center gap-1.5"
77+ >
78+ <span class =" i-lucide:activity w-3.5 h-3.5" aria-hidden =" true" />
79+ {{ $t('package.health_score.title') }}
80+ </h2 >
81+
82+ <!-- Loading state -->
83+ <div v-if =" isLoading" class =" flex items-center gap-2 text-fg-subtle text-sm" >
84+ <span class =" i-svg-spinners:ring-resize w-4 h-4" aria-hidden =" true" />
85+ <span >{{ $t('package.health_score.loading') }}</span >
86+ </div >
87+
88+ <!-- Error state -->
89+ <div v-else-if =" isError" class =" flex items-center gap-2 text-fg-subtle text-sm" >
90+ <span class =" i-lucide:circle-alert w-4 h-4" aria-hidden =" true" />
91+ <span >{{ $t('package.health_score.error') }}</span >
92+ </div >
93+
94+ <!-- Score display -->
95+ <div v-else-if =" data" class =" space-y-3" >
96+ <!-- Score header: large score + grade badge -->
97+ <div class =" flex items-center gap-3" >
98+ <TooltipApp :text =" $t('package.health_score.score_tooltip')" strategy =" fixed" >
99+ <div class =" flex items-baseline gap-1 cursor-default" tabindex =" 0" >
100+ <span class =" font-mono text-2xl font-bold text-fg leading-none" >{{ data.score }}</span >
101+ <span class =" text-xs text-fg-subtle" >/100</span >
102+ </div >
103+ </TooltipApp >
104+
105+ <TooltipApp
106+ :text =" $t('package.health_score.grade_tooltip', { grade: data.grade })"
107+ strategy =" fixed"
108+ >
109+ <TagStatic
110+ tabindex =" 0"
111+ :class =" gradeColor(data.grade)"
112+ class =" font-mono font-bold text-sm! min-w-8 justify-center"
113+ variant =" ghost"
114+ >
115+ {{ data.grade }}
116+ </TagStatic >
117+ </TooltipApp >
118+ </div >
119+
120+ <!-- Dimension bars -->
121+ <ul
122+ class =" space-y-2 list-none m-0 p-0"
123+ :aria-label =" $t('package.health_score.dimensions_label')"
124+ >
125+ <li v-for =" dim in dimensions" :key =" dim.key" >
126+ <div class =" flex items-center justify-between mb-0.5" >
127+ <span class =" text-xs text-fg-subtle" >{{ dim.label }}</span >
128+ <span class =" font-mono text-xs text-fg-muted" >{{ dim.score }}</span >
129+ </div >
130+ <div
131+ class =" h-1.5 w-full rounded-full overflow-hidden"
132+ style =" background-color : var (--border )"
133+ role =" progressbar"
134+ :aria-valuenow =" dim.score"
135+ aria-valuemin =" 0"
136+ aria-valuemax =" 100"
137+ :aria-label =" `${dim.label}: ${dim.score}/100`"
138+ >
139+ <div
140+ class =" h-full rounded-full transition-all duration-500"
141+ :class =" scoreBarColor(dim.score)"
142+ :style =" { width: `${dim.score}%` }"
143+ />
144+ </div >
145+ </li >
146+ </ul >
147+
148+ <!-- Footer link -->
149+ <a
150+ :href =" `https://npm-pulse.vercel.app/api/v1/score/${packageName}`"
151+ target =" _blank"
152+ rel =" noopener noreferrer"
153+ class =" inline-flex items-center gap-1 text-xs text-fg-subtle hover:text-fg transition-colors duration-150 underline underline-offset-2 decoration-fg-subtle/40"
154+ >
155+ {{ $t('package.health_score.powered_by') }}
156+ <span class =" i-lucide:external-link w-3 h-3" aria-hidden =" true" />
157+ </a >
158+ </div >
159+ </section >
160+ </template >
0 commit comments