Skip to content

Commit 98ad210

Browse files
committed
feat: use virtual rendering
1 parent 4b6228e commit 98ad210

1 file changed

Lines changed: 161 additions & 134 deletions

File tree

app/pages/package/[[org]]/[name]/versions.vue

Lines changed: 161 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script setup lang="ts">
2+
import { WindowVirtualizer } from 'virtua/vue'
23
import { getVersions } from 'fast-npm-meta'
34
import {
45
buildVersionToTagsMap,
@@ -54,13 +55,11 @@ const fullVersionMap = ref<Map<
5455
string,
5556
{ time?: string; deprecated?: string; hasProvenance: boolean }
5657
> | null>(null)
57-
const hasLoadedFull = ref(false)
5858
5959
async function ensureFullDataLoaded() {
60-
if (hasLoadedFull.value) return
60+
if (fullVersionMap.value) return
6161
const versions = await fetchAllPackageVersions(packageName.value)
6262
fullVersionMap.value = new Map(versions.map(v => [v.version, v]))
63-
hasLoadedFull.value = true
6463
}
6564
6665
// ─── Derived data ─────────────────────────────────────────────────────────────
@@ -79,7 +78,6 @@ function getVersionTime(version: string): string | undefined {
7978
// ─── Version groups ───────────────────────────────────────────────────────────
8079
8180
const expandedGroups = ref(new Set<string>())
82-
const renderedGroups = ref(new Set<string>())
8381
const loadingGroup = ref<string | null>(null)
8482
8583
const versionGroups = computed(() => {
@@ -105,22 +103,48 @@ const versionGroups = computed(() => {
105103
})
106104
107105
async function toggleGroup(groupKey: string) {
106+
console.log('toggleGroup', groupKey)
108107
if (expandedGroups.value.has(groupKey)) {
109108
expandedGroups.value.delete(groupKey)
110109
return
111110
}
112-
if (!hasLoadedFull.value) {
111+
expandedGroups.value.add(groupKey)
112+
console.log('toggleGroup expanded', fullVersionMap.value)
113+
console.log('toggleGroup expanded', !fullVersionMap.value)
114+
if (!fullVersionMap.value) {
113115
loadingGroup.value = groupKey
114116
try {
115117
await ensureFullDataLoaded()
116118
} finally {
117119
loadingGroup.value = null
118120
}
119121
}
120-
renderedGroups.value.add(groupKey)
121-
expandedGroups.value.add(groupKey)
122122
}
123123
124+
// ─── Flat list for virtual rendering ──────────────────────────────────────────
125+
126+
type FlatItem =
127+
| { type: 'header'; groupKey: string; label: string; versions: string[] }
128+
| { type: 'version'; version: string; groupKey: string }
129+
130+
const flatItems = computed<FlatItem[]>(() => {
131+
const items: FlatItem[] = []
132+
for (const group of versionGroups.value) {
133+
items.push({
134+
type: 'header',
135+
groupKey: group.groupKey,
136+
label: group.label,
137+
versions: group.versions,
138+
})
139+
if (expandedGroups.value.has(group.groupKey)) {
140+
for (const version of group.versions) {
141+
items.push({ type: 'version', version, groupKey: group.groupKey })
142+
}
143+
}
144+
}
145+
return items
146+
})
147+
124148
// ─── Changelog side panel ─────────────────────────────────────────────────────
125149
126150
const selectedChangelogVersion = ref<string | null>(null)
@@ -319,148 +343,151 @@ watch(jumpVersion, () => {
319343

320344
<!-- List + changelog side panel -->
321345
<div class="flex">
322-
<!-- Version list (grouped by major) -->
346+
<!-- Version list (grouped by major, virtualized) -->
323347
<div
324348
class="flex-1 min-w-0 border-y sm:border border-border sm:rounded-lg sm:overflow-hidden"
325349
>
326-
<div
327-
v-for="group in versionGroups"
328-
:key="group.groupKey"
329-
class="border-b border-border last:border-0"
330-
>
331-
<!-- Group header -->
332-
<button
333-
type="button"
334-
class="flex items-center gap-3 px-4 py-2.5 w-full text-start hover:bg-bg-subtle transition-colors"
335-
:aria-expanded="expandedGroups.has(group.groupKey)"
336-
:aria-label="`${expandedGroups.has(group.groupKey) ? 'Collapse' : 'Expand'} ${group.label}`"
337-
@click="toggleGroup(group.groupKey)"
338-
>
339-
<span class="w-4 h-4 flex items-center justify-center text-fg-subtle shrink-0">
340-
<span
341-
v-if="loadingGroup === group.groupKey"
342-
class="i-svg-spinners:ring-resize w-3 h-3"
343-
aria-hidden="true"
344-
/>
345-
<span
346-
v-else
347-
class="i-lucide:chevron-right w-3 h-3 transition-transform duration-200 rtl-flip"
348-
:class="expandedGroups.has(group.groupKey) ? 'rotate-90' : ''"
349-
aria-hidden="true"
350-
/>
351-
</span>
352-
<span class="font-mono text-sm font-medium">{{ group.label }}</span>
353-
<span class="text-xs text-fg-subtle">({{ group.versions.length }})</span>
354-
<span class="ms-auto flex items-center gap-3 shrink-0">
355-
<span class="font-mono text-xs text-fg-muted" dir="ltr">{{
356-
group.versions[0]
357-
}}</span>
358-
<DateTime
359-
v-if="getVersionTime(group.versions[0])"
360-
:datetime="getVersionTime(group.versions[0])!"
361-
class="text-xs text-fg-subtle hidden sm:block"
362-
year="numeric"
363-
month="short"
364-
day="numeric"
365-
/>
366-
</span>
367-
</button>
368-
369-
<!-- Expanded versions -->
370-
<div v-show="expandedGroups.has(group.groupKey)" class="border-t border-border">
371-
<template v-if="renderedGroups.has(group.groupKey)">
350+
<WindowVirtualizer :data="flatItems">
351+
<template #default="{ item, index }">
352+
<!-- ── Group header ── -->
353+
<button
354+
v-if="item.type === 'header'"
355+
type="button"
356+
class="flex items-center gap-3 px-4 py-2.5 w-full text-start hover:bg-bg-subtle transition-colors"
357+
:class="index < flatItems.length - 1 ? 'border-b border-border' : ''"
358+
:aria-expanded="expandedGroups.has(item.groupKey)"
359+
:aria-label="`${expandedGroups.has(item.groupKey) ? 'Collapse' : 'Expand'} ${item.label}`"
360+
@click="toggleGroup(item.groupKey)"
361+
>
362+
<span class="w-4 h-4 flex items-center justify-center text-fg-subtle shrink-0">
363+
<span
364+
v-if="loadingGroup === item.groupKey"
365+
class="i-svg-spinners:ring-resize w-3 h-3"
366+
aria-hidden="true"
367+
/>
368+
<span
369+
v-else
370+
class="i-lucide:chevron-right w-3 h-3 transition-transform duration-200 rtl-flip"
371+
:class="expandedGroups.has(item.groupKey) ? 'rotate-90' : ''"
372+
aria-hidden="true"
373+
/>
374+
</span>
375+
<span class="font-mono text-sm font-medium">{{ item.label }}</span>
376+
<span class="text-xs text-fg-subtle">({{ item.versions.length }})</span>
377+
<span class="ms-auto flex items-center gap-3 shrink-0">
378+
<span class="font-mono text-xs text-fg-muted" dir="ltr">{{
379+
item.versions[0]
380+
}}</span>
381+
<DateTime
382+
v-if="getVersionTime(item.versions[0])"
383+
:datetime="getVersionTime(item.versions[0])!"
384+
class="text-xs text-fg-subtle hidden sm:block"
385+
year="numeric"
386+
month="short"
387+
day="numeric"
388+
/>
389+
</span>
390+
</button>
391+
392+
<!-- ── Version row ── -->
393+
<div
394+
v-else
395+
class="transition-colors"
396+
:class="[
397+
index < flatItems.length - 1 ? 'border-b border-border' : '',
398+
selectedChangelogVersion === item.version ? 'bg-bg-subtle' : '',
399+
]"
400+
>
372401
<div
373-
v-for="v in group.versions"
374-
:key="v"
375-
class="border-b border-border last:border-0 transition-colors"
376-
:class="selectedChangelogVersion === v ? 'bg-bg-subtle' : ''"
402+
class="flex items-center gap-3 px-4 ps-11 py-2.5 group relative"
403+
:class="selectedChangelogVersion === item.version ? '' : 'hover:bg-bg-subtle'"
377404
>
378-
<div
379-
class="flex items-center gap-3 px-4 ps-11 py-2.5 group relative"
380-
:class="selectedChangelogVersion === v ? '' : 'hover:bg-bg-subtle'"
381-
>
382-
<!-- Version + badges -->
383-
<div class="flex-1 min-w-0 flex items-center gap-2 flex-wrap">
384-
<LinkBase
385-
:to="packageRoute(packageName, v)"
386-
class="font-mono text-sm after:absolute after:inset-0 after:content-['']"
387-
:class="
388-
fullVersionMap?.get(v)?.deprecated
389-
? 'text-red-700 dark:text-red-400'
390-
: ''
391-
"
392-
:classicon="
393-
fullVersionMap?.get(v)?.deprecated
394-
? 'i-lucide:octagon-alert'
395-
: undefined
396-
"
397-
dir="ltr"
398-
>
399-
{{ v }}
400-
</LinkBase>
401-
<div
402-
v-if="versionToTagsMap.get(v)?.length"
403-
class="flex items-center gap-1 flex-wrap relative z-10"
404-
>
405-
<span
406-
v-for="tag in versionToTagsMap.get(v)"
407-
:key="tag"
408-
class="text-4xs font-semibold uppercase tracking-wide"
409-
:class="tag === 'latest' ? 'text-accent' : 'text-fg-subtle'"
410-
>
411-
{{ tag }}
412-
</span>
413-
</div>
405+
<!-- Version + badges -->
406+
<div class="flex-1 min-w-0 flex items-center gap-2 flex-wrap">
407+
<LinkBase
408+
:to="packageRoute(packageName, item.version)"
409+
:prefetch="false"
410+
class="font-mono text-sm after:absolute after:inset-0 after:content-['']"
411+
:class="
412+
fullVersionMap?.get(item.version)?.deprecated
413+
? 'text-red-700 dark:text-red-400'
414+
: ''
415+
"
416+
:classicon="
417+
fullVersionMap?.get(item.version)?.deprecated
418+
? 'i-lucide:octagon-alert'
419+
: undefined
420+
"
421+
dir="ltr"
422+
>
423+
{{ item.version }}
424+
</LinkBase>
425+
<div
426+
v-if="versionToTagsMap.get(item.version)?.length"
427+
class="flex items-center gap-1 flex-wrap relative z-10"
428+
>
414429
<span
415-
v-if="fullVersionMap?.get(v)?.deprecated"
416-
class="text-3xs font-medium text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30 px-1.5 py-0.5 rounded relative z-10"
417-
:title="fullVersionMap.get(v)!.deprecated"
430+
v-for="tag in versionToTagsMap.get(item.version)"
431+
:key="tag"
432+
class="text-4xs font-semibold uppercase tracking-wide"
433+
:class="tag === 'latest' ? 'text-accent' : 'text-fg-subtle'"
418434
>
419-
deprecated
435+
{{ tag }}
420436
</span>
421437
</div>
438+
<span
439+
v-if="fullVersionMap?.get(item.version)?.deprecated"
440+
class="text-3xs font-medium text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30 px-1.5 py-0.5 rounded relative z-10"
441+
:title="fullVersionMap.get(item.version)!.deprecated"
442+
>
443+
deprecated
444+
</span>
445+
</div>
422446

423-
<!-- Right side -->
424-
<div class="flex items-center gap-2 shrink-0 relative z-10">
425-
<!-- TODO(atriiy): changelog would be implemented later -->
426-
427-
<!-- Metadata: date + provenance -->
428-
<DateTime
429-
v-if="getVersionTime(v)"
430-
:datetime="getVersionTime(v)!"
431-
class="text-xs text-fg-subtle hidden sm:block"
432-
year="numeric"
433-
month="short"
434-
day="numeric"
435-
/>
436-
<ProvenanceBadge
437-
v-if="fullVersionMap?.get(v)?.hasProvenance"
438-
:package-name="packageName"
439-
:version="v"
440-
compact
441-
:linked="false"
442-
/>
443-
</div>
447+
<!-- Right side -->
448+
<div class="flex items-center gap-2 shrink-0 relative z-10">
449+
<!-- TODO(atriiy): changelog would be implemented later -->
450+
451+
<!-- Metadata: date + provenance -->
452+
<DateTime
453+
v-if="getVersionTime(item.version)"
454+
:datetime="getVersionTime(item.version)!"
455+
class="text-xs text-fg-subtle hidden sm:block"
456+
year="numeric"
457+
month="short"
458+
day="numeric"
459+
/>
460+
<ProvenanceBadge
461+
v-if="fullVersionMap?.get(item.version)?.hasProvenance"
462+
:package-name="packageName"
463+
:version="item.version"
464+
compact
465+
:linked="false"
466+
/>
444467
</div>
468+
</div>
445469

446-
<!-- Mobile inline changelog (below the row, sm and up uses side panel) -->
447-
<div
448-
v-if="v in mockChangelogs"
449-
class="grid sm:hidden transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none"
450-
:class="
451-
selectedChangelogVersion === v ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
452-
"
453-
>
454-
<div class="overflow-hidden">
455-
<div class="changelog-body border-t border-border px-4 py-3 text-sm">
456-
{{ selectedChangelogVersion === v ? selectedChangelogContent : '' }}
457-
</div>
470+
<!-- Mobile inline changelog (below the row, sm and up uses side panel) -->
471+
<div
472+
v-if="item.version in mockChangelogs"
473+
class="grid sm:hidden transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none"
474+
:class="
475+
selectedChangelogVersion === item.version
476+
? 'grid-rows-[1fr]'
477+
: 'grid-rows-[0fr]'
478+
"
479+
>
480+
<div class="overflow-hidden">
481+
<div class="changelog-body border-t border-border px-4 py-3 text-sm">
482+
{{
483+
selectedChangelogVersion === item.version ? selectedChangelogContent : ''
484+
}}
458485
</div>
459486
</div>
460487
</div>
461-
</template>
462-
</div>
463-
</div>
488+
</div>
489+
</template>
490+
</WindowVirtualizer>
464491
</div>
465492

466493
<!-- Changelog side panel (desktop only) -->

0 commit comments

Comments
 (0)