Skip to content

Commit 79ef365

Browse files
committed
feat(frontend): community version distribution
1 parent 0c035c8 commit 79ef365

File tree

13 files changed

+1002
-12
lines changed

13 files changed

+1002
-12
lines changed

app/components/Package/ChartModal.vue

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
1-
<script setup lang="ts"></script>
1+
<script setup lang="ts">
2+
const props = withDefaults(
3+
defineProps<{
4+
titleKey?: string
5+
}>(),
6+
{
7+
titleKey: 'package.downloads.modal_title',
8+
},
9+
)
10+
11+
const emit = defineEmits<{
12+
(e: 'transitioned'): void
13+
}>()
14+
</script>
215

316
<template>
417
<Modal
5-
:modalTitle="$t('package.downloads.modal_title')"
18+
:modalTitle="$t(titleKey)"
619
id="chart-modal"
720
class="h-full sm:h-min sm:border sm:border-border sm:rounded-lg shadow-xl sm:max-h-[90vh] sm:max-w-3xl"
21+
@transitioned="emit('transitioned')"
822
>
923
<div class="font-mono text-sm">
1024
<slot />
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
<script setup lang="ts">
2+
import { VueUiXy } from 'vue-data-ui/vue-ui-xy'
3+
import type { VueUiXyDatasetItem } from 'vue-data-ui'
4+
import { useElementSize } from '@vueuse/core'
5+
import { useCssVariables } from '~/composables/useColors'
6+
import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '~/utils/colors'
7+
8+
const props = defineProps<{
9+
packageName: string
10+
inModal?: boolean
11+
}>()
12+
13+
const { accentColors, selectedAccentColor } = useAccentColor()
14+
const colorMode = useColorMode()
15+
const resolvedMode = shallowRef<'light' | 'dark'>('light')
16+
const rootEl = shallowRef<HTMLElement | null>(null)
17+
18+
onMounted(async () => {
19+
rootEl.value = document.documentElement
20+
resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light'
21+
})
22+
23+
const { colors } = useCssVariables(
24+
['--bg', '--fg', '--bg-subtle', '--bg-elevated', '--fg-subtle', '--border', '--border-subtle'],
25+
{
26+
element: rootEl,
27+
watchHtmlAttributes: true,
28+
watchResize: false,
29+
},
30+
)
31+
32+
watch(
33+
() => colorMode.value,
34+
value => {
35+
resolvedMode.value = value === 'dark' ? 'dark' : 'light'
36+
},
37+
{ flush: 'sync' },
38+
)
39+
40+
const isDarkMode = computed(() => resolvedMode.value === 'dark')
41+
42+
const accentColorValueById = computed<Record<string, string>>(() => {
43+
const map: Record<string, string> = {}
44+
for (const item of accentColors.value) {
45+
map[item.id] = item.value
46+
}
47+
return map
48+
})
49+
50+
const accent = computed(() => {
51+
const id = selectedAccentColor.value
52+
return id
53+
? (accentColorValueById.value[id] ?? colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK)
54+
: (colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK)
55+
})
56+
57+
const { width } = useElementSize(rootEl)
58+
const mobileBreakpointWidth = 640
59+
const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth)
60+
61+
const { groupingMode, hideSmallVersions, pending, error, chartDataset, hasData } =
62+
useVersionDistribution(() => props.packageName)
63+
64+
const compactNumberFormatter = useCompactNumberFormatter()
65+
66+
const chartConfig = computed(() => {
67+
return {
68+
theme: isDarkMode.value ? 'dark' : 'default',
69+
chart: {
70+
height: isMobile.value ? 500 : 400,
71+
backgroundColor: colors.value.bg,
72+
padding: {
73+
top: 24,
74+
right: 24,
75+
bottom: xAxisLabels.value.length > 10 ? 100 : 72, // More space for rotated labels
76+
left: isMobile.value ? 60 : 80,
77+
},
78+
userOptions: {
79+
buttons: { pdf: false, labels: false, fullscreen: false, table: false, tooltip: false },
80+
},
81+
grid: {
82+
stroke: colors.value.border,
83+
labels: {
84+
fontSize: isMobile.value ? 24 : 16,
85+
color: pending.value ? colors.value.border : colors.value.fgSubtle,
86+
axis: {
87+
yLabel: 'Downloads',
88+
xLabel: '',
89+
yLabelOffsetX: 12,
90+
fontSize: isMobile.value ? 32 : 24,
91+
},
92+
yAxis: {
93+
formatter: ({ value }: { value: number }) => {
94+
return compactNumberFormatter.value.format(Number.isFinite(value) ? value : 0)
95+
},
96+
useNiceScale: true,
97+
},
98+
xAxisLabels: {
99+
show: true,
100+
values: xAxisLabels.value,
101+
fontSize: isMobile.value ? 14 : 12,
102+
color: colors.value.fgSubtle,
103+
rotation: xAxisLabels.value.length > 10 ? 45 : 0,
104+
},
105+
},
106+
},
107+
timeTag: {
108+
show: false,
109+
},
110+
highlighter: { useLine: false },
111+
legend: { show: false },
112+
bar: {
113+
periodGap: 16,
114+
innerGap: 8,
115+
borderRadius: 4,
116+
},
117+
tooltip: {
118+
teleportTo: props.inModal ? '#chart-modal' : undefined,
119+
borderColor: 'transparent',
120+
backdropFilter: false,
121+
backgroundColor: 'transparent',
122+
customFormat: (params: any) => {
123+
const { datapoint, absoluteIndex, bars } = params
124+
if (!datapoint) return ''
125+
126+
// Use absoluteIndex to get the correct version from chartDataset
127+
const index = Number(absoluteIndex ?? 0)
128+
const chartItem = chartDataset.value[index]
129+
130+
if (!chartItem) return ''
131+
132+
const barValue = bars?.[0]?.values?.[index]
133+
const raw = Number(barValue ?? chartItem.downloads ?? 0)
134+
const v = compactNumberFormatter.value.format(Number.isFinite(raw) ? raw : 0)
135+
136+
return `<div class="font-mono text-xs p-3 border border-border rounded-md bg-[var(--bg)]/10 backdrop-blur-md">
137+
<div class="flex flex-col gap-2">
138+
<div class="flex items-center justify-between gap-4">
139+
<span class="text-3xs uppercase tracking-wide text-[var(--fg)]/70">
140+
${chartItem.name}
141+
</span>
142+
<span class="text-base text-[var(--fg)] font-mono tabular-nums">
143+
${v}
144+
</span>
145+
</div>
146+
</div>
147+
</div>`
148+
},
149+
},
150+
zoom: {
151+
maxWidth: isMobile.value ? 350 : 500,
152+
highlightColor: colors.value.bgElevated,
153+
minimap: {
154+
show: true,
155+
lineColor: '#FAFAFA',
156+
selectedColor: accent.value,
157+
selectedColorOpacity: 0.06,
158+
frameColor: colors.value.border,
159+
},
160+
preview: {
161+
fill: transparentizeOklch(accent.value, isDarkMode.value ? 0.95 : 0.92),
162+
stroke: transparentizeOklch(accent.value, 0.5),
163+
strokeWidth: 1,
164+
strokeDasharray: 3,
165+
},
166+
},
167+
},
168+
userOptions: {
169+
show: false,
170+
},
171+
table: {
172+
show: false,
173+
},
174+
}
175+
})
176+
177+
// VueUiXy expects one series with multiple values for bar charts
178+
const xyDataset = computed<VueUiXyDatasetItem[]>(() => {
179+
if (!chartDataset.value.length) return []
180+
181+
return [
182+
{
183+
name: 'Downloads',
184+
series: chartDataset.value.map(item => item.downloads),
185+
type: 'bar' as const,
186+
color: accent.value,
187+
},
188+
]
189+
})
190+
191+
const xAxisLabels = computed(() => {
192+
return chartDataset.value.map(item => item.name)
193+
})
194+
</script>
195+
196+
<template>
197+
<div
198+
class="w-full relative"
199+
:class="isMobile ? 'min-h-[600px]' : 'min-h-[500px]'"
200+
id="version-distribution"
201+
:aria-busy="pending ? 'true' : 'false'"
202+
>
203+
<div class="w-full mb-4 flex flex-col gap-3">
204+
<div class="flex flex-col sm:flex-row gap-3 sm:gap-2 sm:items-end">
205+
<div class="flex flex-col gap-1 sm:shrink-0">
206+
<label class="text-3xs font-mono text-fg-subtle tracking-wide uppercase">
207+
{{ $t('package.versions.distribution_title') }}
208+
</label>
209+
<div
210+
class="inline-flex items-center bg-bg-subtle border border-border rounded-md overflow-hidden w-fit"
211+
role="group"
212+
:aria-label="$t('package.versions.distribution_title')"
213+
>
214+
<button
215+
type="button"
216+
:class="[
217+
'px-4 py-1.75 font-mono text-sm transition-colors',
218+
groupingMode === 'major'
219+
? 'bg-accent text-bg font-medium'
220+
: 'text-fg-subtle hover:text-fg hover:bg-bg-subtle/50',
221+
]"
222+
:aria-pressed="groupingMode === 'major'"
223+
:disabled="pending"
224+
@click="groupingMode = 'major'"
225+
>
226+
{{ $t('package.versions.grouping_major') }}
227+
</button>
228+
<button
229+
type="button"
230+
:class="[
231+
'px-4 py-1.75 font-mono text-sm transition-colors border-is border-border',
232+
groupingMode === 'minor'
233+
? 'bg-accent text-bg font-medium'
234+
: 'text-fg-subtle hover:text-fg hover:bg-bg-subtle/50',
235+
]"
236+
:aria-pressed="groupingMode === 'minor'"
237+
:disabled="pending"
238+
@click="groupingMode = 'minor'"
239+
>
240+
{{ $t('package.versions.grouping_minor') }}
241+
</button>
242+
</div>
243+
</div>
244+
</div>
245+
246+
<SettingsToggle
247+
v-model="hideSmallVersions"
248+
:label="$t('package.versions.hide_old_versions')"
249+
:tooltip="$t('package.versions.hide_old_versions_tooltip')"
250+
tooltip-position="top"
251+
:class="{ 'opacity-50 pointer-events-none': pending }"
252+
/>
253+
</div>
254+
255+
<h2 id="version-distribution-title" class="sr-only">
256+
{{ $t('package.versions.distribution_title') }}
257+
</h2>
258+
259+
<div
260+
role="region"
261+
aria-labelledby="version-distribution-title"
262+
class="relative flex items-center justify-center"
263+
:class="isMobile ? 'min-h-[500px]' : 'min-h-[400px]'"
264+
>
265+
<div
266+
v-if="pending"
267+
role="status"
268+
aria-live="polite"
269+
class="text-xs text-fg-subtle font-mono bg-bg/70 backdrop-blur px-3 py-2 rounded-md border border-border"
270+
>
271+
{{ $t('common.loading') }}
272+
</div>
273+
274+
<div
275+
v-else-if="error"
276+
class="text-sm text-fg-subtle font-mono text-center flex flex-col items-center gap-2"
277+
role="alert"
278+
>
279+
<span class="i-carbon:warning-hex w-8 h-8 text-red-400" />
280+
<p>{{ error.message }}</p>
281+
<p class="text-xs">Package: {{ packageName }}</p>
282+
</div>
283+
284+
<div
285+
v-else-if="!hasData"
286+
class="text-sm text-fg-subtle font-mono text-center flex flex-col items-center gap-2"
287+
>
288+
<span class="i-carbon:data-vis-4 w-8 h-8" />
289+
<p>{{ $t('package.trends.no_data') }}</p>
290+
</div>
291+
292+
<ClientOnly v-else-if="xyDataset.length > 0">
293+
<div
294+
class="chart-container w-full h-[400px] sm:h-[400px]"
295+
:class="{ 'h-[500px]': isMobile }"
296+
>
297+
<VueUiXy :dataset="xyDataset" :config="chartConfig" class="[direction:ltr]" />
298+
</div>
299+
</ClientOnly>
300+
</div>
301+
</div>
302+
</template>
303+
304+
<style scoped>
305+
/* Disable all transitions on SVG elements to prevent repositioning animation */
306+
:deep(.vue-ui-xy) svg rect,
307+
:deep(.serie_bar_0) rect,
308+
:deep(svg) rect {
309+
transition: none !important;
310+
}
311+
312+
@keyframes fadeInUp {
313+
from {
314+
opacity: 0;
315+
transform: translateY(8px);
316+
}
317+
to {
318+
opacity: 1;
319+
transform: translateY(0);
320+
}
321+
}
322+
323+
.chart-container {
324+
animation: fadeInUp 350ms cubic-bezier(0.4, 0, 0.2, 1);
325+
}
326+
</style>

0 commit comments

Comments
 (0)