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
1 change: 1 addition & 0 deletions app/components/Code/DirectoryListing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const bytesFormatter = useBytesFormatter()
<div class="directory-listing">
<!-- Empty state -->
<div v-if="currentContents.length === 0" class="py-20 text-center text-fg-muted">
<span class="i-lucide:folder-open w-12 h-12 text-fg-subtle mx-auto mb-4"> </span>
<p>{{ $t('code.no_files') }}</p>
</div>

Expand Down
257 changes: 257 additions & 0 deletions app/components/Code/Header.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
<script setup lang="ts">
import type { PackageFileContentResponse } from '#shared/types/npm-registry'

interface BreadcrumbItem {
name: string
path: string
}

const props = defineProps<{
filePath?: string | null
loading: boolean
isViewingFile: boolean
isBinaryFile: boolean
fileContent: PackageFileContentResponse | null | undefined
markdownViewMode: 'preview' | 'code'
selectedLines: { start: number; end: number } | null
getCodeUrlWithPath: (path?: string) => string
packageName: string
version: string
}>()

const emit = defineEmits<{
'update:markdownViewMode': [value: 'preview' | 'code']
'mobile-tree-drawer-toggle': []
}>()

const { codeContainerFull, toggleCodeContainer } = useCodeContainer()

const markdownViewModes = [
{
key: 'preview' as const,
label: $t('code.markdown_view_mode.preview'),
icon: 'i-lucide:eye',
},
{
key: 'code' as const,
label: $t('code.markdown_view_mode.code'),
icon: 'i-lucide:code',
},
]

// Build breadcrumb path segments
const breadcrumbs = computed<{
items: BreadcrumbItem[]
current: string
}>(() => {
const parts = props.filePath?.split('/').filter(Boolean) ?? []
const result: {
items: BreadcrumbItem[]
current: string
} = {
items: [],
current: parts.at(-1) ?? '',
}

for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]
if (part) {
result.items.push({
name: part,
path: parts.slice(0, i + 1).join('/'),
})
}
}

return result
})

const { copied: fileContentCopied, copy: copyFileContent } = useClipboard({
source: () => props.fileContent?.content || '',
copiedDuring: 2000,
})

// Copy link to current line(s)
const { copied: permalinkCopied, copy: copyPermalink } = useClipboard({ copiedDuring: 2000 })

function copyPermalinkUrl() {
const url = new URL(window.location.href)
copyPermalink(url.toString())
}

// Path dropdown (mobile breadcrumb collapse)
const isPathDropdownOpen = shallowRef(false)
const pathDropdownButtonRef = useTemplateRef('pathDropdownButtonRef')
const pathDropdownListRef = useTemplateRef<HTMLElement>('pathDropdownListRef')

function togglePathDropdown(forceClose?: boolean) {
if (forceClose) {
isPathDropdownOpen.value = false
return
}

isPathDropdownOpen.value = !isPathDropdownOpen.value
}

onClickOutside(pathDropdownListRef, () => togglePathDropdown(true), {
ignore: [pathDropdownButtonRef],
})

useEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Escape' && isPathDropdownOpen.value) {
togglePathDropdown(true)
}
})
</script>

<template>
<div
class="sticky flex-split h-11 max-md:(h-20 top-32 flex-col items-start) z-5 top-25 gap-0 bg-bg-subtle border-b border-border px-2 py-1 text-nowrap max-w-full"
>
<div class="flex items-center w-full h-full relative">
<!-- Breadcrumb navigation -->
<nav
:aria-label="$t('code.file_path')"
class="flex items-center gap-0.5 font-mono text-sm overflow-x-auto"
dir="ltr"
>
<NuxtLink
v-if="filePath"
:to="getCodeUrlWithPath()"
class="text-fg-muted hover:text-fg transition-colors shrink-0"
>
..
</NuxtLink>
<span class="max-md:hidden">
<template v-for="crumb in breadcrumbs.items" :key="crumb.path">
<span class="text-fg-subtle">/</span>
<NuxtLink
:to="getCodeUrlWithPath(crumb.path)"
class="text-fg-muted hover:text-fg transition-colors"
>
{{ crumb.name }}
</NuxtLink>
</template>
</span>
<!-- Show dropdown with path elements on small screens -->
<span v-if="breadcrumbs.items.length" class="md:hidden">
<span class="text-fg-subtle">/</span>
<span ref="pathDropdownButtonRef">
<ButtonBase
size="sm"
class="px-2 mx-1"
:aria-label="$t('code.open_path_dropdown')"
:aria-expanded="isPathDropdownOpen"
aria-haspopup="true"
@click="togglePathDropdown"
>
...
</ButtonBase>
</span>
</span>
<template v-if="breadcrumbs.current">
<span class="text-fg-subtle">/</span>
<span class="text-fg">{{ breadcrumbs.current }}</span>
</template>
</nav>
<Transition
enter-active-class="transition-all duration-150"
leave-active-class="transition-all duration-100"
enter-from-class="opacity-0 translate-y-1"
leave-to-class="opacity-0 translate-y-1"
>
<div
v-if="isPathDropdownOpen"
ref="pathDropdownListRef"
class="absolute top-8 z-50 bg-bg-subtle border border-border rounded-lg shadow-lg py-1 min-w-65 max-w-full font-mono text-sm"
>
<NuxtLink
v-for="(crumb, index) in breadcrumbs.items"
:key="crumb.path"
:to="getCodeUrlWithPath(crumb.path)"
class="flex items-start px-3 py-1 text-fg-muted hover:text-fg hover:bg-bg-muted transition-colors"
@click="togglePathDropdown(false)"
>
<span
v-for="level in index"
:key="level"
aria-hidden="true"
class="relative h-5 w-4 shrink-0"
>
<!-- add └ mark to better visualize nested folders) -->
<template v-if="level === index">
<span class="absolute top-0 bottom-1/2 inset-is-2 w-px bg-fg-subtle/50" />
<span class="absolute top-1/2 inset-is-2 inset-ie-0 h-px bg-fg-subtle/50" />
</template>
</span>
<span :class="{ 'ps-1': index > 0 }" class="min-w-0 break-all"
>{{ crumb.name }}<span class="text-fg-subtle">/</span></span
>
</NuxtLink>
</div>
</Transition>
</div>
<div class="flex max-md:(w-full justify-between border-border border-t pt-1)">
<!-- Toggle button (mobile only) -->
<ButtonBase
class="md:hidden px-2"
:aria-label="$t('code.toggle_tree')"
@click="emit('mobile-tree-drawer-toggle')"
classicon="i-lucide:folder-code"
/>
<div class="flex items-center gap-2">
<template v-if="isViewingFile && !isBinaryFile && fileContent">
<div
v-if="fileContent?.markdownHtml"
class="flex items-center gap-1 p-0.5 bg-bg-subtle border border-border-subtle rounded-md overflow-x-auto"
role="tablist"
aria-label="Markdown view mode selector"
>
<button
v-for="mode in markdownViewModes"
:key="mode.key"
role="tab"
class="px-2 py-1.5 font-mono text-xs rounded transition-colors duration-150 focus-visible:outline-accent/70 inline-flex items-center gap-1.5"
:class="
markdownViewMode === mode.key
? 'bg-bg-muted shadow text-fg'
: 'text-fg-subtle hover:text-fg'
"
:aria-selected="markdownViewMode === mode.key"
@click="emit('update:markdownViewMode', mode.key)"
>
{{ mode.label }}
</button>
</div>
<ButtonBase
v-if="selectedLines"
class="py-1 px-3"
:classicon="permalinkCopied ? 'i-lucide:check' : 'i-lucide:file-braces-corner'"
:aria-label="$t('code.copy_link')"
@click="copyPermalinkUrl"
/>
<ButtonBase
v-if="!!fileContent?.content"
class="px-3"
:classicon="fileContentCopied ? 'i-lucide:check' : 'i-lucide:copy'"
:aria-label="$t('code.copy_content')"
@click="copyFileContent()"
/>
<LinkBase
variant="button-secondary"
:to="`https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}`"
class="px-3"
:aria-label="$t('code.open_raw_file')"
/>
</template>
<ButtonBase
class="px-3 max-xl:hidden"
:disabled="loading"
:classicon="codeContainerFull ? 'i-lucide:fold-horizontal' : 'i-lucide:unfold-horizontal'"
:aria-label="$t('code.toggle_container')"
@click="toggleCodeContainer()"
/>
</div>
</div>
</div>
</template>
17 changes: 8 additions & 9 deletions app/components/Code/MobileTreeDrawer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,17 @@ watch(
const isLocked = useScrollLock(document)
// Prevent body scroll when drawer is open
watch(isOpen, open => (isLocked.value = open))

function toggle() {
isOpen.value = !isOpen.value
}

defineExpose({
toggle,
})
</script>

<template>
<!-- Toggle button (mobile only) -->
<ButtonBase
variant="primary"
class="md:hidden fixed bottom-9 inset-ie-4 z-45"
:aria-label="$t('code.toggle_tree')"
@click="isOpen = !isOpen"
:classicon="isOpen ? 'i-lucide:x' : 'i-lucide:folder'"
/>

<!-- Backdrop -->
<Transition
enter-active-class="transition-opacity duration-200"
Expand Down
33 changes: 33 additions & 0 deletions app/components/Code/SkeletonLoader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<template>
<div class="flex min-h-full" role="status" aria-busy="true" :aria-label="$t('common.loading')">
<!-- Fake line numbers column -->
<div class="shrink-0 bg-bg-subtle border-ie border-border w-14 py-0">
<div v-for="n in 20" :key="n" class="px-3 h-6 flex items-center justify-end">
<SkeletonInline class="w-4 h-3 rounded-sm" />
</div>
</div>
<!-- Fake code content -->
<div class="flex-1 p-4 space-y-1.5">
<SkeletonBlock class="h-4 w-32 rounded-sm" />
<SkeletonBlock class="h-4 w-48 rounded-sm" />
<SkeletonBlock class="h-4 w-24 rounded-sm" />
<div class="h-4" />
<SkeletonBlock class="h-4 w-64 rounded-sm" />
<SkeletonBlock class="h-4 w-56 rounded-sm" />
<SkeletonBlock class="h-4 w-40 rounded-sm" />
<SkeletonBlock class="h-4 w-72 rounded-sm" />
<div class="h-4" />
<SkeletonBlock class="h-4 w-36 rounded-sm" />
<SkeletonBlock class="h-4 w-52 rounded-sm" />
<SkeletonBlock class="h-4 w-44 rounded-sm" />
<SkeletonBlock class="h-4 w-28 rounded-sm" />
<div class="h-4" />
<SkeletonBlock class="h-4 w-60 rounded-sm" />
<SkeletonBlock class="h-4 w-48 rounded-sm" />
<SkeletonBlock class="h-4 w-32 rounded-sm" />
<SkeletonBlock class="h-4 w-56 rounded-sm" />
<SkeletonBlock class="h-4 w-40 rounded-sm" />
<SkeletonBlock class="h-4 w-24 rounded-sm" />
</div>
</div>
</template>
2 changes: 1 addition & 1 deletion app/components/Code/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ watch(
</script>

<template>
<div class="code-viewer flex min-h-full max-w-full">
<div class="code-viewer flex min-h-full max-w-full overflow-x-auto">
<!-- Line numbers column -->
<div
class="line-numbers shrink-0 bg-bg-subtle border-ie border-solid border-border text-end select-none relative"
Expand Down
18 changes: 18 additions & 0 deletions app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface AppSettings {
/** Automatically open the web auth page in the browser */
autoOpenURL: boolean
}
codeContainerFull: boolean
sidebar: {
collapsed: string[]
}
Expand All @@ -63,6 +64,7 @@ const DEFAULT_SETTINGS: AppSettings = {
connector: {
autoOpenURL: false,
},
codeContainerFull: false,
sidebar: {
collapsed: [],
},
Expand Down Expand Up @@ -236,3 +238,19 @@ export function useBackgroundTheme() {
setBackgroundTheme,
}
}

export function useCodeContainer() {
const { settings } = useSettings()
const isMounted = useMounted()

const codeContainerFull = computed(() => isMounted.value && settings.value.codeContainerFull)

function toggleCodeContainer() {
settings.value.codeContainerFull = !settings.value.codeContainerFull
}

return {
codeContainerFull,
toggleCodeContainer,
}
}
Loading
Loading