Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions app/components/Button/Base.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const props = withDefaults(
defineProps<{
disabled?: boolean
type?: 'button' | 'submit'
variant?: 'primary' | 'secondary'
variant?: 'primary' | 'secondary' | 'subtle'
size?: 'small' | 'medium'
ariaKeyshortcuts?: string
block?: boolean
Expand All @@ -30,16 +30,21 @@ defineExpose({
<template>
<button
ref="el"
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)"
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)"
:class="{
'inline-flex': !block,
'flex': block,
'text-sm px-4 py-2': size === 'medium',
'text-xs px-2 py-0.5': size === 'small',
'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))':
'text-xs px-2 py-0.5': size === 'small' && variant !== 'subtle',
'text-xs px-2 py-2': size === 'small' && variant === 'subtle',
'border-border': variant !== 'subtle',
'border-border-subtle': variant === 'subtle',
'bg-transparent text-fg hover:enabled:(bg-fg/10) aria-pressed:(bg-fg/10 border-fg/20 hover:enabled:(bg-fg/20 text-fg/50))':
variant === 'secondary',
'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))':
'text-bg bg-fg hover:enabled:(bg-fg/50) aria-pressed:(bg-fg text-bg border-fg hover:enabled:(text-bg/50))':
variant === 'primary',
'bg-bg-subtle text-fg-muted hover:enabled:(text-fg border-border-hover) active:enabled:scale-95':
variant === 'subtle',
}"
:type="props.type"
:disabled="
Expand Down
244 changes: 244 additions & 0 deletions app/components/Package/DownloadButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
<script setup lang="ts">
import type { SlimPackumentVersion, InstallSizeResult } from '#shared/types'
import { onClickOutside, useEventListener } from '@vueuse/core'

const props = withDefaults(
defineProps<{
packageName: string
version: SlimPackumentVersion
installSize: InstallSizeResult | null
size?: 'small' | 'medium'
}>(),
{
size: 'medium',
},
)

const triggerRef = useTemplateRef('triggerRef')
const listRef = useTemplateRef('listRef')
const isOpen = shallowRef(false)
const highlightedIndex = shallowRef(-1)
const dropdownPosition = shallowRef<{ top: number; right: number } | null>(null)

const { t } = useI18n()
const menuId = useId()
const menuItems = computed(() => [
{ id: 'package', label: t('package.download.package'), icon: 'i-lucide:package' },
{ id: 'dependencies', label: t('package.download.dependencies'), icon: 'i-lucide:list-tree' },
])

function getDropdownStyle(): Record<string, string> {
if (!dropdownPosition.value) return {}
return {
top: `${dropdownPosition.value.top}px`,
right: `${document.documentElement.clientWidth - dropdownPosition.value.right}px`,
}
}

function toggle() {
if (isOpen.value) {
close()
} else {
const rect = triggerRef.value?.$el?.getBoundingClientRect()
if (rect) {
dropdownPosition.value = {
top: rect.bottom + 4,
right: rect.right,
}
}
isOpen.value = true
highlightedIndex.value = 0
}
}

function close() {
isOpen.value = false
highlightedIndex.value = -1
}

onClickOutside(listRef, close, { ignore: [triggerRef] })

function handleKeydown(event: KeyboardEvent) {
if (!isOpen.value) {
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
toggle()
}
return
}

switch (event.key) {
case 'ArrowDown':
event.preventDefault()
highlightedIndex.value = (highlightedIndex.value + 1) % menuItems.value.length
break
case 'ArrowUp':
event.preventDefault()
highlightedIndex.value =
highlightedIndex.value <= 0 ? menuItems.value.length - 1 : highlightedIndex.value - 1
break
case 'Enter':
case ' ':
event.preventDefault()
handleAction(menuItems.value[highlightedIndex.value]?.id)
break
case 'Escape':
event.preventDefault()
close()
triggerRef.value?.$el?.focus()
break
case 'Tab':
close()
break
}
}

function handleAction(id: string | undefined) {
if (id === 'package') {
downloadPackage()
} else if (id === 'dependencies') {
downloadDependenciesScript()
}
close()
}

async function downloadPackage() {
const tarballUrl = props.version.dist.tarball
if (!tarballUrl) return

try {
const response = await fetch(tarballUrl)
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${props.packageName.replace(/\//g, '__')}-${props.version.version}.tgz`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (error) {
console.error('Failed to download package:', error)
// Fallback to direct link for non-CORS or other issues, though download attribute may be ignored
const link = document.createElement('a')
link.href = tarballUrl
link.download = `${props.packageName.replace(/\//g, '__')}-${props.version.version}.tgz`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}

function downloadDependenciesScript() {
if (!props.installSize) return

const lines = [
'#!/bin/bash',
`# Download dependencies for ${props.packageName}@${props.version.version}`,
'mkdir -p node_modules',
'',
]

// Add root package
const rootTarball = props.version.dist.tarball
if (rootTarball) {
lines.push(`# ${props.packageName}@${props.version.version}`)
lines.push(
`curl -L "${rootTarball}" -o "${props.packageName.replace(/\//g, '__')}-${props.version.version}.tgz"`,
)
}

// Add dependencies
props.installSize.dependencies.forEach(dep => {
if (!dep.tarballUrl) return
lines.push(`# ${dep.name}@${dep.version}`)
lines.push(
`curl -L "${dep.tarballUrl}" -o "${dep.name.replace(/\//g, '__')}-${dep.version}.tgz"`,
)
})

const blob = new Blob([lines.join('\n')], { type: 'text/x-shellscript' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `download-${props.packageName.replace(/\//g, '__')}-deps.sh`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}

const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')

useEventListener('scroll', () => isOpen.value && close(), { passive: true })

defineOptions({
inheritAttrs: false,
})
</script>

<template>
<ButtonBase
ref="triggerRef"
v-bind="$attrs"
type="button"
:variant="size === 'small' ? 'subtle' : 'secondary'"
:size="size"
classicon="i-lucide:download"
:aria-expanded="isOpen"
aria-haspopup="menu"
:aria-controls="menuId"
@click="toggle"
@keydown="handleKeydown"
>
{{ $t('package.download.button') }}
<span
class="i-lucide:chevron-down ms-1"
:class="[
size === 'small' ? 'w-3 h-3' : 'w-3.5 h-3.5',
{ 'rotate-180': isOpen },
prefersReducedMotion ? '' : 'transition-transform duration-200',
]"
aria-hidden="true"
/>
</ButtonBase>

<Teleport to="body">
<Transition
:enter-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-150'"
:enter-from-class="prefersReducedMotion ? '' : 'opacity-0'"
enter-to-class="opacity-100"
:leave-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-100'"
leave-from-class="opacity-100"
:leave-to-class="prefersReducedMotion ? '' : 'opacity-0'"
>
<div
v-if="isOpen"
:id="menuId"
ref="listRef"
role="menu"
:style="getDropdownStyle()"
class="fixed bg-bg-subtle border border-border rounded-md shadow-lg z-50 py-1 w-64 overscroll-contain"
@keydown="handleKeydown"
>
<button
v-for="(item, index) in menuItems"
:key="item.id"
role="menuitem"
type="button"
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-fg-muted transition-colors duration-150"
:class="[
highlightedIndex === index
? 'bg-bg-elevated text-fg'
: 'hover:bg-bg-elevated hover:text-fg',
]"
@click="handleAction(item.id)"
@mouseenter="highlightedIndex = index"
>
<span :class="item.icon" class="w-4 h-4" aria-hidden="true" />
{{ item.label }}
</button>
</div>
</Transition>
</Teleport>
</template>
21 changes: 12 additions & 9 deletions app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ReadmeResponse,
ReadmeMarkdownResponse,
SkillsListResponse,
InstallSizeResult,
} from '#shared/types'
import type { JsrPackageInfo } from '#shared/types/jsr'
import type { IconClass } from '~/types'
Expand Down Expand Up @@ -159,13 +160,6 @@ const { data: jsrInfo } = useLazyFetch<JsrPackageInfo>(() => `/api/jsr/${package
})

// Fetch total install size (lazy, can be slow for large dependency trees)
interface InstallSizeResult {
package: string
version: string
selfSize: number
totalSize: number
dependencyCount: number
}
const {
data: installSize,
status: installSizeStatus,
Expand Down Expand Up @@ -1147,8 +1141,17 @@ const showSkeleton = shallowRef(false)
{{ $t('package.get_started.title') }}
</LinkBase>
</h2>
<!-- Package manager dropdown -->
<PackageManagerSelect />
<!-- Package manager dropdown + Download button -->
<div class="flex items-center gap-2">
<PackageDownloadButton
v-if="displayVersion && installSize"
:package-name="pkg.name"
:version="displayVersion"
:install-size="installSize"
size="small"
/>
<PackageManagerSelect />
</div>
</div>
<div>
<div
Expand Down
5 changes: 5 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,11 @@
"b": "{size} B",
"kb": "{size} kB",
"mb": "{size} MB"
},
"download": {
"button": "Download",
"package": "Download Package (.tgz)",
"dependencies": "Download Dependencies (.sh)"
}
},
"connector": {
Expand Down
15 changes: 15 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1392,6 +1392,21 @@
}
},
"additionalProperties": false
},
"download": {
"type": "object",
"properties": {
"button": {
"type": "string"
},
"package": {
"type": "string"
},
"dependencies": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
Expand Down
5 changes: 5 additions & 0 deletions lunaria/files/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,11 @@
"b": "{size} B",
"kb": "{size} kB",
"mb": "{size} MB"
},
"download": {
"button": "Download",
"package": "Download Package (.tgz)",
"dependencies": "Download Dependencies (.sh)"
}
},
"connector": {
Expand Down
5 changes: 5 additions & 0 deletions lunaria/files/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,11 @@
"b": "{size} B",
"kb": "{size} kB",
"mb": "{size} MB"
},
"download": {
"button": "Download",
"package": "Download Package (.tgz)",
"dependencies": "Download Dependencies (.sh)"
}
},
"connector": {
Expand Down
Loading
Loading