|
| 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> |
0 commit comments