Skip to content

Commit 7d697a2

Browse files
alexdlnshuuji3
andauthored
refactor: move package-header to new component (#2030)
Co-authored-by: TAKAHASHI Shuuji <id@shuuji3.xyz>
1 parent 41b84d5 commit 7d697a2

File tree

4 files changed

+404
-340
lines changed

4 files changed

+404
-340
lines changed

app/components/Package/Header.vue

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
<script setup lang="ts">
2+
import type { PackumentVersion, ProvenanceDetails, SlimVersion } from '#shared/types'
3+
import type { RouteLocationRaw } from 'vue-router'
4+
import { SCROLL_TO_TOP_THRESHOLD } from '~/composables/useScrollToTop'
5+
import { useModal } from '~/composables/useModal'
6+
import { useAtproto } from '~/composables/atproto/useAtproto'
7+
import { togglePackageLike } from '~/utils/atproto/likes'
8+
9+
const props = defineProps<{
10+
pkg: { name: string } | null
11+
resolvedVersion?: string | null
12+
displayVersion: PackumentVersion | null
13+
latestVersion: SlimVersion | null
14+
provenanceData: ProvenanceDetails | null
15+
provenanceStatus: string
16+
docsLink: RouteLocationRaw | null
17+
codeLink: RouteLocationRaw | null
18+
isBinaryOnly: boolean
19+
}>()
20+
21+
const { requestedVersion, orgName } = usePackageRoute()
22+
const { scrollToTop, isTouchDeviceClient } = useScrollToTop()
23+
const packageHeaderHeight = usePackageHeaderHeight()
24+
25+
const header = useTemplateRef('header')
26+
const isHeaderPinned = shallowRef(false)
27+
const { height: headerHeight } = useElementBounding(header)
28+
29+
function isStickyPinned(el: HTMLElement | null): boolean {
30+
if (!el) return false
31+
32+
const style = getComputedStyle(el)
33+
const top = parseFloat(style.top) || 0
34+
const rect = el.getBoundingClientRect()
35+
36+
return Math.abs(rect.top - top) < 1
37+
}
38+
39+
function checkHeaderPosition() {
40+
isHeaderPinned.value = isStickyPinned(header.value)
41+
}
42+
43+
useEventListener('scroll', checkHeaderPosition, { passive: true })
44+
useEventListener('resize', checkHeaderPosition)
45+
46+
onMounted(() => {
47+
checkHeaderPosition()
48+
})
49+
50+
watch(
51+
headerHeight,
52+
value => {
53+
packageHeaderHeight.value = Math.max(0, value)
54+
},
55+
{ immediate: true },
56+
)
57+
58+
onBeforeUnmount(() => {
59+
packageHeaderHeight.value = 0
60+
})
61+
62+
const navExtraOffsetStyle = { '--package-nav-extra': '0px' }
63+
64+
const { y: scrollY } = useScroll(window)
65+
const showScrollToTop = computed(
66+
() => isTouchDeviceClient.value && scrollY.value > SCROLL_TO_TOP_THRESHOLD,
67+
)
68+
69+
const packageName = computed(() => props.pkg?.name ?? '')
70+
const compactNumberFormatter = useCompactNumberFormatter()
71+
72+
const { copied: copiedPkgName, copy: copyPkgName } = useClipboard({
73+
source: packageName,
74+
copiedDuring: 2000,
75+
})
76+
77+
const { copied: copiedVersion, copy: copyVersion } = useClipboard({
78+
source: () => props.resolvedVersion ?? '',
79+
copiedDuring: 2000,
80+
})
81+
82+
function hasProvenance(version: PackumentVersion | null): boolean {
83+
if (!version?.dist) return false
84+
return !!(version.dist as { attestations?: unknown }).attestations
85+
}
86+
87+
//atproto
88+
// TODO: Maybe set this where it's not loaded here every load?
89+
const { user } = useAtproto()
90+
91+
const authModal = useModal('auth-modal')
92+
93+
const { data: likesData, status: likeStatus } = useFetch(
94+
() => `/api/social/likes/${packageName.value}`,
95+
{
96+
default: () => ({ totalLikes: 0, userHasLiked: false }),
97+
server: false,
98+
},
99+
)
100+
101+
const isLoadingLikeData = computed(
102+
() => likeStatus.value === 'pending' || likeStatus.value === 'idle',
103+
)
104+
105+
const isLikeActionPending = shallowRef(false)
106+
107+
const likeAction = async () => {
108+
if (user.value?.handle == null) {
109+
authModal.open()
110+
return
111+
}
112+
113+
if (isLikeActionPending.value) return
114+
115+
const currentlyLiked = likesData.value?.userHasLiked ?? false
116+
const currentLikes = likesData.value?.totalLikes ?? 0
117+
118+
// Optimistic update
119+
likesData.value = {
120+
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
121+
userHasLiked: !currentlyLiked,
122+
}
123+
124+
isLikeActionPending.value = true
125+
126+
try {
127+
const result = await togglePackageLike(packageName.value, currentlyLiked, user.value?.handle)
128+
129+
isLikeActionPending.value = false
130+
131+
if (result.success) {
132+
// Update with server response
133+
likesData.value = result.data
134+
} else {
135+
// Revert on error
136+
likesData.value = {
137+
totalLikes: currentLikes,
138+
userHasLiked: currentlyLiked,
139+
}
140+
}
141+
} catch {
142+
// Revert on error
143+
likesData.value = {
144+
totalLikes: currentLikes,
145+
userHasLiked: currentlyLiked,
146+
}
147+
isLikeActionPending.value = false
148+
}
149+
}
150+
</script>
151+
152+
<template>
153+
<!-- Package header -->
154+
<header
155+
class="sticky top-14 z-1 bg-bg py-2 border-border"
156+
ref="header"
157+
:class="[$style.packageHeader, { 'border-b': isHeaderPinned }]"
158+
>
159+
<!-- Package name and version -->
160+
<div class="flex items-baseline gap-x-2 gap-y-1 sm:gap-x-3 flex-wrap min-w-0">
161+
<CopyToClipboardButton
162+
:copied="copiedPkgName"
163+
:copy-text="$t('package.copy_name')"
164+
class="flex flex-col items-start min-w-0"
165+
@click="copyPkgName()"
166+
>
167+
<h1
168+
class="font-mono text-2xl sm:text-3xl font-medium min-w-0 break-words"
169+
:title="pkg?.name"
170+
dir="ltr"
171+
>
172+
<LinkBase v-if="orgName" :to="{ name: 'org', params: { org: orgName } }">
173+
@{{ orgName }}
174+
</LinkBase>
175+
<span v-if="orgName">/</span>
176+
<span :class="{ 'text-fg-muted': orgName }">
177+
{{ orgName ? pkg?.name.replace(`@${orgName}/`, '') : pkg?.name }}
178+
</span>
179+
</h1>
180+
</CopyToClipboardButton>
181+
182+
<CopyToClipboardButton
183+
v-if="resolvedVersion"
184+
:copied="copiedVersion"
185+
:copy-text="$t('package.copy_version')"
186+
class="inline-flex items-baseline gap-1.5 font-mono text-base sm:text-lg text-fg-muted shrink-0"
187+
@click="copyVersion()"
188+
>
189+
<!-- Version resolution indicator (e.g., "latest → 4.2.0") -->
190+
<template v-if="requestedVersion && resolvedVersion !== requestedVersion">
191+
<span class="font-mono text-fg-muted text-sm" dir="ltr">{{ requestedVersion }}</span>
192+
<span class="i-lucide:arrow-right rtl-flip w-3 h-3" aria-hidden="true" />
193+
</template>
194+
195+
<LinkBase
196+
v-if="requestedVersion && resolvedVersion !== requestedVersion"
197+
:to="packageRoute(packageName, resolvedVersion)"
198+
:title="$t('package.view_permalink')"
199+
dir="ltr"
200+
>{{ resolvedVersion }}</LinkBase
201+
>
202+
<span dir="ltr" v-else>v{{ resolvedVersion }}</span>
203+
204+
<template v-if="hasProvenance(displayVersion)">
205+
<TooltipApp
206+
:text="
207+
provenanceData && provenanceStatus !== 'pending'
208+
? $t('package.provenance_section.built_and_signed_on', {
209+
provider: provenanceData.providerLabel,
210+
})
211+
: $t('package.verified_provenance')
212+
"
213+
position="bottom"
214+
strategy="fixed"
215+
>
216+
<LinkBase
217+
variant="button-secondary"
218+
size="small"
219+
to="#provenance"
220+
:aria-label="$t('package.provenance_section.view_more_details')"
221+
classicon="i-lucide:shield-check"
222+
/>
223+
</TooltipApp>
224+
</template>
225+
<span
226+
v-if="requestedVersion && latestVersion && resolvedVersion !== latestVersion.version"
227+
class="text-fg-subtle text-sm shrink-0"
228+
>{{ $t('package.not_latest') }}</span
229+
>
230+
</CopyToClipboardButton>
231+
232+
<!-- Docs + Code + Compare — inline on desktop, floating bottom bar on mobile -->
233+
<ButtonGroup
234+
v-if="resolvedVersion"
235+
as="nav"
236+
:aria-label="$t('package.navigation')"
237+
class="hidden sm:flex max-sm:flex max-sm:fixed max-sm:z-40 max-sm:inset-is-1/2 max-sm:-translate-x-1/2 max-sm:rtl:translate-x-1/2 max-sm:bg-[--bg]/90 max-sm:backdrop-blur-md max-sm:border max-sm:border-border max-sm:rounded-md max-sm:shadow-md ms-auto"
238+
:style="navExtraOffsetStyle"
239+
:class="$style.packageNav"
240+
>
241+
<LinkBase
242+
variant="button-secondary"
243+
v-if="docsLink"
244+
:to="docsLink"
245+
aria-keyshortcuts="d"
246+
classicon="i-lucide:file-text"
247+
>
248+
<span class="max-sm:sr-only">{{ $t('package.links.docs') }}</span>
249+
</LinkBase>
250+
<LinkBase
251+
v-if="codeLink"
252+
variant="button-secondary"
253+
:to="codeLink"
254+
aria-keyshortcuts="."
255+
classicon="i-lucide:code"
256+
>
257+
<span class="max-sm:sr-only">{{ $t('package.links.code') }}</span>
258+
</LinkBase>
259+
<LinkBase
260+
variant="button-secondary"
261+
:to="{ name: 'compare', query: { packages: packageName } }"
262+
aria-keyshortcuts="c"
263+
classicon="i-lucide:git-compare"
264+
>
265+
<span class="max-sm:sr-only">{{ $t('package.links.compare') }}</span>
266+
</LinkBase>
267+
<LinkBase
268+
v-if="displayVersion && latestVersion && displayVersion.version !== latestVersion.version"
269+
variant="button-secondary"
270+
:to="diffRoute(packageName, displayVersion.version, latestVersion.version)"
271+
classicon="i-lucide:diff"
272+
:title="$t('compare.compare_versions_title')"
273+
>
274+
<span class="max-sm:sr-only">{{ $t('compare.compare_versions') }}</span>
275+
</LinkBase>
276+
<ButtonBase
277+
v-if="showScrollToTop"
278+
variant="secondary"
279+
:aria-label="$t('common.scroll_to_top')"
280+
@click="scrollToTop"
281+
classicon="i-lucide:arrow-up"
282+
class="sm:p-2.75"
283+
/>
284+
</ButtonGroup>
285+
286+
<!-- Package metrics -->
287+
<div class="basis-full flex gap-2 sm:gap-3 flex-wrap items-stretch">
288+
<PackageMetricsBadges
289+
v-if="resolvedVersion"
290+
:package-name="packageName"
291+
:version="resolvedVersion"
292+
:is-binary="isBinaryOnly"
293+
class="self-baseline"
294+
/>
295+
296+
<!-- Package likes -->
297+
<TooltipApp
298+
:text="
299+
isLoadingLikeData
300+
? $t('common.loading')
301+
: likesData?.userHasLiked
302+
? $t('package.likes.unlike')
303+
: $t('package.likes.like')
304+
"
305+
position="bottom"
306+
class="items-center"
307+
strategy="fixed"
308+
>
309+
<ButtonBase
310+
@click="likeAction"
311+
size="small"
312+
:aria-label="
313+
likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like')
314+
"
315+
:aria-pressed="likesData?.userHasLiked"
316+
:classicon="
317+
likesData?.userHasLiked ? 'i-lucide:heart-minus text-red-500' : 'i-lucide:heart-plus'
318+
"
319+
>
320+
<span
321+
v-if="isLoadingLikeData"
322+
class="i-svg-spinners:ring-resize w-3 h-3 my-0.5"
323+
aria-hidden="true"
324+
/>
325+
<span v-else>
326+
{{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}
327+
</span>
328+
</ButtonBase>
329+
</TooltipApp>
330+
</div>
331+
</div>
332+
</header>
333+
</template>
334+
335+
<style module>
336+
.packageHeader h1 {
337+
overflow-wrap: anywhere;
338+
}
339+
340+
.packageHeader p {
341+
word-wrap: break-word;
342+
overflow-wrap: break-word;
343+
word-break: break-word;
344+
}
345+
346+
@media (max-width: 639.9px) {
347+
.packageNav {
348+
bottom: calc(1.25rem + var(--package-nav-extra, 0px) + env(safe-area-inset-bottom, 0px));
349+
}
350+
351+
.packageNav > :global(a kbd) {
352+
display: none;
353+
}
354+
}
355+
</style>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function usePackageHeaderHeight() {
2+
return useState<number>('package-header-height', () => 0)
3+
}

0 commit comments

Comments
 (0)