Skip to content

Commit 0bf2f53

Browse files
committed
feat: add package timeline
1 parent 230b7c7 commit 0bf2f53

4 files changed

Lines changed: 187 additions & 2 deletions

File tree

app/components/Package/Header.vue

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const props = defineProps<{
1313
latestVersion?: SlimVersion | null
1414
provenanceData?: ProvenanceDetails | null
1515
provenanceStatus?: string | null
16-
page: 'main' | 'docs' | 'code' | 'diff'
16+
page: 'main' | 'docs' | 'code' | 'diff' | 'timeline'
1717
versionUrlPattern: string
1818
}>()
1919
@@ -124,6 +124,19 @@ const diffLink = computed((): RouteLocationRaw | null => {
124124
return diffRoute(props.pkg.name, props.resolvedVersion, props.latestVersion.version)
125125
})
126126
127+
const timelineLink = computed((): RouteLocationRaw | null => {
128+
if (props.pkg == null || props.resolvedVersion == null) return null
129+
const split = props.pkg.name.split('/')
130+
return {
131+
name: 'timeline',
132+
params: {
133+
org: split.length === 2 ? split[0] : undefined,
134+
packageName: split.length === 2 ? split[1]! : split[0]!,
135+
version: props.resolvedVersion,
136+
},
137+
}
138+
})
139+
127140
const keyboardShortcuts = useKeyboardShortcuts()
128141
129142
onKeyStroke(
@@ -178,6 +191,16 @@ onKeyStroke(
178191
{ dedupe: true },
179192
)
180193
194+
onKeyStroke(
195+
e => keyboardShortcuts.value && isKeyWithoutModifiers(e, 't') && !isEditableElement(e.target),
196+
e => {
197+
if (timelineLink.value === null) return
198+
e.preventDefault()
199+
navigateTo(timelineLink.value)
200+
},
201+
{ dedupe: true },
202+
)
203+
181204
//atproto
182205
// TODO: Maybe set this where it's not loaded here every load?
183206
const { user } = useAtproto()
@@ -426,6 +449,15 @@ const likeAction = async () => {
426449
>
427450
{{ $t('compare.compare_versions') }}
428451
</LinkBase>
452+
<LinkBase
453+
v-if="timelineLink"
454+
:to="timelineLink"
455+
aria-keyshortcuts="t"
456+
class="decoration-none border-b-2 p-1 hover:border-accent/50 focus-visible:[outline-offset:-2px]!"
457+
:class="page === 'timeline' ? 'border-accent text-accent!' : 'border-transparent'"
458+
>
459+
{{ $t('package.links.timeline') }}
460+
</LinkBase>
429461
</nav>
430462
</div>
431463
</div>
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<script setup lang="ts">
2+
import type { RouteLocationRaw } from 'vue-router'
3+
4+
definePageMeta({
5+
name: 'timeline',
6+
path: '/package-timeline/:org?/:packageName/v/:version',
7+
})
8+
9+
const route = useRoute('timeline')
10+
11+
const packageName = computed(() =>
12+
route.params.org ? `${route.params.org}/${route.params.packageName}` : route.params.packageName,
13+
)
14+
const version = computed(() => route.params.version)
15+
16+
const { data: pkg } = usePackage(packageName)
17+
18+
const latestVersion = computed(() => {
19+
if (!pkg.value) return null
20+
const latestTag = pkg.value['dist-tags']?.latest
21+
if (!latestTag) return null
22+
return pkg.value.versions[latestTag] ?? null
23+
})
24+
25+
const versionUrlPattern = computed(() => {
26+
const split = packageName.value.split('/')
27+
const org = split.length === 2 ? split[0] : undefined
28+
const name = split.length === 2 ? split[1]! : split[0]!
29+
return `/package-timeline/${org ? `${org}/` : ''}${name}/v/{version}`
30+
})
31+
32+
function timelineRoute(ver: string): RouteLocationRaw {
33+
return { name: 'timeline', params: { ...route.params, version: ver } }
34+
}
35+
36+
// Build chronological version list sorted newest-first
37+
const PAGE_SIZE = 25
38+
39+
const timelineEntries = computed(() => {
40+
if (!pkg.value) return []
41+
const time = pkg.value.time
42+
const versions = Object.keys(pkg.value.versions)
43+
44+
return versions
45+
.filter(v => time[v])
46+
.map(v => ({
47+
version: v,
48+
time: time[v]!,
49+
tags: Object.entries(pkg.value!['dist-tags'] ?? {})
50+
.filter(([, ver]) => ver === v)
51+
.map(([tag]) => tag),
52+
}))
53+
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
54+
})
55+
56+
const visibleCount = ref(PAGE_SIZE)
57+
58+
const visibleEntries = computed(() => timelineEntries.value.slice(0, visibleCount.value))
59+
const hasMore = computed(() => visibleCount.value < timelineEntries.value.length)
60+
61+
function loadMore() {
62+
visibleCount.value += PAGE_SIZE
63+
}
64+
65+
useSeoMeta({
66+
title: () => `Timeline - ${packageName.value} - npmx`,
67+
description: () => `Version timeline for ${packageName.value}`,
68+
})
69+
</script>
70+
71+
<template>
72+
<main class="flex-1 flex flex-col min-h-0">
73+
<PackageHeader
74+
:pkg="pkg"
75+
:resolved-version="version"
76+
:display-version="pkg?.requestedVersion"
77+
:latest-version="latestVersion"
78+
:version-url-pattern="versionUrlPattern"
79+
page="timeline"
80+
/>
81+
82+
<div class="container w-full py-8">
83+
<!-- Timeline -->
84+
<ol v-if="visibleEntries.length" class="relative border-s border-border ms-4">
85+
<li v-for="entry in visibleEntries" :key="entry.version" class="mb-6 ms-6">
86+
<!-- Dot -->
87+
<span
88+
class="absolute -start-2 flex items-center justify-center w-4 h-4 rounded-full border border-border"
89+
:class="entry.version === version ? 'bg-accent border-accent' : 'bg-bg-subtle'"
90+
/>
91+
<!-- Content -->
92+
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
93+
<LinkBase
94+
:to="timelineRoute(entry.version)"
95+
class="text-sm font-medium"
96+
:class="entry.version === version ? 'text-accent' : ''"
97+
dir="ltr"
98+
>
99+
{{ entry.version }}
100+
</LinkBase>
101+
<span
102+
v-for="tag in entry.tags"
103+
:key="tag"
104+
class="text-3xs font-semibold uppercase tracking-wide"
105+
:class="tag === 'latest' ? 'text-accent' : 'text-fg-subtle'"
106+
>
107+
{{ tag }}
108+
</span>
109+
<DateTime
110+
:datetime="entry.time"
111+
class="text-xs text-fg-subtle"
112+
year="numeric"
113+
month="short"
114+
day="numeric"
115+
/>
116+
</div>
117+
</li>
118+
</ol>
119+
120+
<!-- Load more -->
121+
<div v-if="hasMore" class="mt-4 ms-10">
122+
<button
123+
type="button"
124+
class="text-sm text-accent hover:text-accent/80 transition-colors"
125+
@click="loadMore"
126+
>
127+
{{ $t('package.timeline.load_more') }}
128+
</button>
129+
</div>
130+
131+
<!-- Empty state -->
132+
<div v-else-if="!pkg" class="py-20 text-center">
133+
<span class="i-svg-spinners:ring-resize w-5 h-5 text-fg-subtle" />
134+
</div>
135+
</div>
136+
</main>
137+
</template>

i18n/locales/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,8 @@
301301
"code": "code",
302302
"docs": "docs",
303303
"fund": "fund",
304-
"compare": "compare"
304+
"compare": "compare",
305+
"timeline": "timeline"
305306
},
306307
"likes": {
307308
"like": "Like this package",
@@ -420,6 +421,9 @@
420421
"version_filter_label": "Filter versions",
421422
"no_match_filter": "No versions match {filter}"
422423
},
424+
"timeline": {
425+
"load_more": "Load more"
426+
},
423427
"dependencies": {
424428
"title": "Dependency ({count}) | Dependencies ({count})",
425429
"list_label": "Package dependencies",

i18n/schema.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,9 @@
909909
},
910910
"compare": {
911911
"type": "string"
912+
},
913+
"timeline": {
914+
"type": "string"
912915
}
913916
},
914917
"additionalProperties": false
@@ -1264,6 +1267,15 @@
12641267
},
12651268
"additionalProperties": false
12661269
},
1270+
"timeline": {
1271+
"type": "object",
1272+
"properties": {
1273+
"load_more": {
1274+
"type": "string"
1275+
}
1276+
},
1277+
"additionalProperties": false
1278+
},
12671279
"dependencies": {
12681280
"type": "object",
12691281
"properties": {

0 commit comments

Comments
 (0)