Skip to content

Commit f5c594b

Browse files
committed
feat: Add download button component and install size types
1 parent 45790b1 commit f5c594b

File tree

11 files changed

+307
-38
lines changed

11 files changed

+307
-38
lines changed

app/components/Button/Base.vue

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const props = withDefaults(
55
defineProps<{
66
disabled?: boolean
77
type?: 'button' | 'submit'
8-
variant?: 'primary' | 'secondary'
8+
variant?: 'primary' | 'secondary' | 'subtle'
99
size?: 'small' | 'medium'
1010
ariaKeyshortcuts?: string
1111
block?: boolean
@@ -30,16 +30,21 @@ defineExpose({
3030
<template>
3131
<button
3232
ref="el"
33-
class="group gap-x-1 items-center justify-center font-mono border border-border rounded-md transition-all duration-200 disabled:(opacity-40 cursor-not-allowed border-transparent)"
33+
class="group gap-x-1 items-center justify-center font-mono border rounded-md transition-all duration-200 disabled:(opacity-40 cursor-not-allowed border-transparent)"
3434
:class="{
3535
'inline-flex': !block,
3636
'flex': block,
3737
'text-sm px-4 py-2': size === 'medium',
38-
'text-xs px-2 py-0.5': size === 'small',
38+
'text-xs px-2 py-0.5': size === 'small' && variant !== 'subtle',
39+
'text-xs px-2 py-2': size === 'small' && variant === 'subtle',
40+
'border-border': variant !== 'subtle',
41+
'border-border-subtle': variant === 'subtle',
3942
'bg-transparent text-fg hover:enabled:(bg-fg/10) focus-visible:enabled:(bg-fg/10) aria-pressed:(bg-fg/10 border-fg/20 hover:enabled:(bg-fg/20 text-fg/50))':
4043
variant === 'secondary',
4144
'text-bg bg-fg hover:enabled:(bg-fg/50) focus-visible:enabled:(bg-fg/50) aria-pressed:(bg-fg text-bg border-fg hover:enabled:(text-bg/50))':
4245
variant === 'primary',
46+
'bg-bg-subtle text-fg-muted hover:enabled:(text-fg border-border-hover) focus-visible:enabled:(text-fg border-border-hover) active:enabled:scale-95':
47+
variant === 'subtle',
4348
}"
4449
:type="props.type"
4550
:disabled="
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
<script setup lang="ts">
2+
import type { SlimPackumentVersion, InstallSizeResult } from '#shared/types'
3+
import { onClickOutside, useEventListener } from '@vueuse/core'
4+
5+
const props = withDefaults(
6+
defineProps<{
7+
packageName: string
8+
version: SlimPackumentVersion
9+
installSize: InstallSizeResult | null
10+
size?: 'small' | 'medium'
11+
}>(),
12+
{
13+
size: 'medium',
14+
},
15+
)
16+
17+
const triggerRef = useTemplateRef('triggerRef')
18+
const listRef = useTemplateRef('listRef')
19+
const isOpen = shallowRef(false)
20+
const highlightedIndex = shallowRef(-1)
21+
const dropdownPosition = shallowRef<{ top: number; right: number } | null>(null)
22+
23+
const menuId = useId()
24+
const menuItems = computed(() => [
25+
{ id: 'package', label: $t('package.download.package'), icon: 'i-lucide:package' },
26+
{ id: 'dependencies', label: $t('package.download.dependencies'), icon: 'i-lucide:list-tree' },
27+
])
28+
29+
function getDropdownStyle(): Record<string, string> {
30+
if (!dropdownPosition.value) return {}
31+
return {
32+
top: `${dropdownPosition.value.top}px`,
33+
right: `${document.documentElement.clientWidth - dropdownPosition.value.right}px`,
34+
}
35+
}
36+
37+
function toggle() {
38+
if (isOpen.value) {
39+
close()
40+
} else {
41+
const rect = triggerRef.value?.$el?.getBoundingClientRect()
42+
if (rect) {
43+
dropdownPosition.value = {
44+
top: rect.bottom + 4,
45+
right: rect.right,
46+
}
47+
}
48+
isOpen.value = true
49+
highlightedIndex.value = 0
50+
}
51+
}
52+
53+
function close() {
54+
isOpen.value = false
55+
highlightedIndex.value = -1
56+
}
57+
58+
onClickOutside(listRef, close, { ignore: [triggerRef] })
59+
60+
function handleKeydown(event: KeyboardEvent) {
61+
if (!isOpen.value) {
62+
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
63+
event.preventDefault()
64+
toggle()
65+
}
66+
return
67+
}
68+
69+
switch (event.key) {
70+
case 'ArrowDown':
71+
event.preventDefault()
72+
highlightedIndex.value = (highlightedIndex.value + 1) % menuItems.value.length
73+
break
74+
case 'ArrowUp':
75+
event.preventDefault()
76+
highlightedIndex.value =
77+
highlightedIndex.value <= 0 ? menuItems.value.length - 1 : highlightedIndex.value - 1
78+
break
79+
case 'Enter':
80+
case ' ':
81+
event.preventDefault()
82+
handleAction(menuItems.value[highlightedIndex.value]?.id)
83+
break
84+
case 'Escape':
85+
event.preventDefault()
86+
close()
87+
triggerRef.value?.$el?.focus()
88+
break
89+
case 'Tab':
90+
close()
91+
break
92+
}
93+
}
94+
95+
function handleAction(id: string | undefined) {
96+
if (id === 'package') {
97+
downloadPackage()
98+
} else if (id === 'dependencies') {
99+
downloadDependenciesScript()
100+
}
101+
close()
102+
}
103+
104+
function downloadPackage() {
105+
const tarballUrl = props.version.dist.tarball
106+
if (!tarballUrl) return
107+
108+
const link = document.createElement('a')
109+
link.href = tarballUrl
110+
link.download = `${props.packageName}-${props.version.version}.tgz`
111+
document.body.appendChild(link)
112+
link.click()
113+
document.body.removeChild(link)
114+
}
115+
116+
function downloadDependenciesScript() {
117+
if (!props.installSize) return
118+
119+
const lines = [
120+
'#!/bin/bash',
121+
`# Download dependencies for ${props.packageName}@${props.version.version}`,
122+
'mkdir -p node_modules',
123+
'',
124+
]
125+
126+
// Add root package
127+
const rootTarball = (props.version.dist as any).tarball
128+
if (rootTarball) {
129+
lines.push(`# ${props.packageName}@${props.version.version}`)
130+
lines.push(
131+
`curl -L ${rootTarball} -o ${props.packageName.replace(/\//g, '__')}-${props.version.version}.tgz`,
132+
)
133+
}
134+
135+
// Add dependencies
136+
props.installSize.dependencies.forEach((dep: any) => {
137+
lines.push(`# ${dep.name}@${dep.version}`)
138+
lines.push(`curl -L ${dep.tarballUrl} -o ${dep.name.replace(/\//g, '__')}-${dep.version}.tgz`)
139+
})
140+
141+
const blob = new Blob([lines.join('\n')], { type: 'text/x-shellscript' })
142+
const url = URL.createObjectURL(blob)
143+
const link = document.createElement('a')
144+
link.href = url
145+
link.download = `download-${props.packageName.replace(/\//g, '__')}-deps.sh`
146+
document.body.appendChild(link)
147+
link.click()
148+
document.body.removeChild(link)
149+
URL.revokeObjectURL(url)
150+
}
151+
152+
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
153+
154+
useEventListener('scroll', () => isOpen.value && close(), { passive: true })
155+
156+
defineOptions({
157+
inheritAttrs: false,
158+
})
159+
</script>
160+
161+
<template>
162+
<ButtonBase
163+
ref="triggerRef"
164+
v-bind="$attrs"
165+
type="button"
166+
:variant="size === 'small' ? 'subtle' : 'secondary'"
167+
:size="size"
168+
classicon="i-lucide:download"
169+
:aria-expanded="isOpen"
170+
aria-haspopup="menu"
171+
:aria-controls="menuId"
172+
@click="toggle"
173+
@keydown="handleKeydown"
174+
>
175+
{{ $t('package.download.button') }}
176+
<span
177+
class="i-lucide:chevron-down ms-1"
178+
:class="[
179+
size === 'small' ? 'w-3 h-3' : 'w-3.5 h-3.5',
180+
{ 'rotate-180': isOpen },
181+
prefersReducedMotion ? '' : 'transition-transform duration-200',
182+
]"
183+
aria-hidden="true"
184+
/>
185+
</ButtonBase>
186+
187+
<Teleport to="body">
188+
<Transition
189+
:enter-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-150'"
190+
:enter-from-class="prefersReducedMotion ? '' : 'opacity-0'"
191+
enter-to-class="opacity-100"
192+
:leave-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-100'"
193+
leave-from-class="opacity-100"
194+
:leave-to-class="prefersReducedMotion ? '' : 'opacity-0'"
195+
>
196+
<div
197+
v-if="isOpen"
198+
:id="menuId"
199+
ref="listRef"
200+
role="menu"
201+
:style="getDropdownStyle()"
202+
class="fixed bg-bg-subtle border border-border rounded-md shadow-lg z-50 py-1 w-64 overscroll-contain"
203+
@keydown="handleKeydown"
204+
>
205+
<button
206+
v-for="(item, index) in menuItems"
207+
:key="item.id"
208+
role="menuitem"
209+
type="button"
210+
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-fg-muted transition-colors duration-150"
211+
:class="[
212+
highlightedIndex === index
213+
? 'bg-bg-elevated text-fg'
214+
: 'hover:bg-bg-elevated hover:text-fg',
215+
]"
216+
@click="handleAction(item.id)"
217+
@mouseenter="highlightedIndex = index"
218+
>
219+
<span :class="item.icon" class="w-4 h-4" aria-hidden="true" />
220+
{{ item.label }}
221+
</button>
222+
</div>
223+
</Transition>
224+
</Teleport>
225+
</template>

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

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
ReadmeResponse,
88
ReadmeMarkdownResponse,
99
SkillsListResponse,
10+
InstallSizeResult,
1011
} from '#shared/types'
1112
import type { JsrPackageInfo } from '#shared/types/jsr'
1213
import type { IconClass } from '~/types'
@@ -159,13 +160,6 @@ const { data: jsrInfo } = useLazyFetch<JsrPackageInfo>(() => `/api/jsr/${package
159160
})
160161
161162
// Fetch total install size (lazy, can be slow for large dependency trees)
162-
interface InstallSizeResult {
163-
package: string
164-
version: string
165-
selfSize: number
166-
totalSize: number
167-
dependencyCount: number
168-
}
169163
const {
170164
data: installSize,
171165
status: installSizeStatus,
@@ -1147,8 +1141,17 @@ const showSkeleton = shallowRef(false)
11471141
{{ $t('package.get_started.title') }}
11481142
</LinkBase>
11491143
</h2>
1150-
<!-- Package manager dropdown -->
1151-
<PackageManagerSelect />
1144+
<!-- Package manager dropdown + Download button -->
1145+
<div class="flex items-center gap-2">
1146+
<PackageDownloadButton
1147+
v-if="displayVersion && installSize"
1148+
:package-name="pkg.name"
1149+
:version="displayVersion"
1150+
:install-size="installSize"
1151+
size="small"
1152+
/>
1153+
<PackageManagerSelect />
1154+
</div>
11521155
</div>
11531156
<div>
11541157
<div

i18n/locales/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,11 @@
462462
"b": "{size} B",
463463
"kb": "{size} kB",
464464
"mb": "{size} MB"
465+
},
466+
"download": {
467+
"button": "Download",
468+
"package": "Download Package (.tgz)",
469+
"dependencies": "Download Dependencies (.sh)"
465470
}
466471
},
467472
"connector": {

i18n/schema.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1392,6 +1392,21 @@
13921392
}
13931393
},
13941394
"additionalProperties": false
1395+
},
1396+
"download": {
1397+
"type": "object",
1398+
"properties": {
1399+
"button": {
1400+
"type": "string"
1401+
},
1402+
"package": {
1403+
"type": "string"
1404+
},
1405+
"dependencies": {
1406+
"type": "string"
1407+
}
1408+
},
1409+
"additionalProperties": false
13951410
}
13961411
},
13971412
"additionalProperties": false

lunaria/files/en-GB.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,11 @@
461461
"b": "{size} B",
462462
"kb": "{size} kB",
463463
"mb": "{size} MB"
464+
},
465+
"download": {
466+
"button": "Download",
467+
"package": "Download Package (.tgz)",
468+
"dependencies": "Download Dependencies (.sh)"
464469
}
465470
},
466471
"connector": {

lunaria/files/en-US.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,11 @@
461461
"b": "{size} B",
462462
"kb": "{size} kB",
463463
"mb": "{size} MB"
464+
},
465+
"download": {
466+
"button": "Download",
467+
"package": "Download Package (.tgz)",
468+
"dependencies": "Download Dependencies (.sh)"
464469
}
465470
},
466471
"connector": {

server/utils/dependency-resolver.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export interface ResolvedPackage {
107107
name: string
108108
version: string
109109
size: number
110+
tarballUrl: string
110111
optional: boolean
111112
/** Depth level (only when trackDepth is enabled) */
112113
depth?: DependencyDepth
@@ -161,13 +162,14 @@ export async function resolveDependencyTree(
161162
if (!matchesPlatform(versionData)) return
162163

163164
const size = (versionData.dist as { unpackedSize?: number })?.unpackedSize ?? 0
165+
const tarballUrl = versionData.dist.tarball
164166
const key = `${name}@${version}`
165167

166168
// Build path for this package (path to parent + this package with version)
167169
const currentPath = [...path, `${name}@${version}`]
168170

169171
if (!resolved.has(key)) {
170-
const pkg: ResolvedPackage = { name, version, size, optional }
172+
const pkg: ResolvedPackage = { name, version, size, tarballUrl, optional }
171173
if (options.trackDepth) {
172174
pkg.depth = level === 0 ? 'root' : level === 1 ? 'direct' : 'transitive'
173175
pkg.path = currentPath

0 commit comments

Comments
 (0)