Skip to content

Commit 5dce7b6

Browse files
committed
fix: code page visual fixes, add container toggle
1 parent 1cd901f commit 5dce7b6

File tree

9 files changed

+399
-204
lines changed

9 files changed

+399
-204
lines changed

app/components/Code/DirectoryListing.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const bytesFormatter = useBytesFormatter()
5858
<div class="directory-listing">
5959
<!-- Empty state -->
6060
<div v-if="currentContents.length === 0" class="py-20 text-center text-fg-muted">
61+
<span class="i-lucide:folder-open w-12 h-12 text-fg-subtle mx-auto mb-4"> </span>
6162
<p>{{ $t('code.no_files') }}</p>
6263
</div>
6364

app/components/Code/Header.vue

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
<script setup lang="ts">
2+
import type { PackageFileContentResponse } from '#shared/types/npm-registry'
3+
4+
interface BreadcrumbItem {
5+
name: string
6+
path: string
7+
}
8+
9+
const props = defineProps<{
10+
filePath?: string | null
11+
loading: boolean
12+
isViewingFile: boolean
13+
isBinaryFile: boolean
14+
fileContent: PackageFileContentResponse | null | undefined
15+
markdownViewMode: 'preview' | 'code'
16+
selectedLines: { start: number; end: number } | null
17+
getCodeUrlWithPath: (path?: string) => string
18+
packageName: string
19+
version: string
20+
}>()
21+
22+
const emit = defineEmits<{
23+
'update:markdownViewMode': [value: 'preview' | 'code']
24+
'mobile-tree-drawer-toggle': []
25+
}>()
26+
27+
const { codeContainerFull, toggleCodeContainer } = useCodeContainer()
28+
29+
const markdownViewModes = [
30+
{
31+
key: 'preview' as const,
32+
label: $t('code.markdown_view_mode.preview'),
33+
icon: 'i-lucide:eye',
34+
},
35+
{
36+
key: 'code' as const,
37+
label: $t('code.markdown_view_mode.code'),
38+
icon: 'i-lucide:code',
39+
},
40+
]
41+
42+
// Build breadcrumb path segments
43+
const breadcrumbs = computed<{
44+
items: BreadcrumbItem[]
45+
current: string
46+
}>(() => {
47+
const parts = props.filePath?.split('/').filter(Boolean) ?? []
48+
const result: {
49+
items: BreadcrumbItem[]
50+
current: string
51+
} = {
52+
items: [],
53+
current: parts.at(-1) ?? '',
54+
}
55+
56+
for (let i = 0; i < parts.length - 1; i++) {
57+
const part = parts[i]
58+
if (part) {
59+
result.items.push({
60+
name: part,
61+
path: parts.slice(0, i + 1).join('/'),
62+
})
63+
}
64+
}
65+
66+
return result
67+
})
68+
69+
const { copied: fileContentCopied, copy: copyFileContent } = useClipboard({
70+
source: () => props.fileContent?.content || '',
71+
copiedDuring: 2000,
72+
})
73+
74+
// Copy link to current line(s)
75+
const { copied: permalinkCopied, copy: copyPermalink } = useClipboard({ copiedDuring: 2000 })
76+
77+
function copyPermalinkUrl() {
78+
const url = new URL(window.location.href)
79+
copyPermalink(url.toString())
80+
}
81+
82+
// Path dropdown (mobile breadcrumb collapse)
83+
const isPathDropdownOpen = shallowRef(false)
84+
const pathDropdownButtonRef = useTemplateRef('pathDropdownButtonRef')
85+
const pathDropdownListRef = useTemplateRef<HTMLElement>('pathDropdownListRef')
86+
87+
function togglePathDropdown(force: boolean) {
88+
if (force !== undefined) {
89+
isPathDropdownOpen.value = force
90+
return
91+
}
92+
93+
isPathDropdownOpen.value = !isPathDropdownOpen.value
94+
}
95+
96+
onClickOutside(pathDropdownListRef, () => togglePathDropdown(false), {
97+
ignore: [pathDropdownButtonRef],
98+
})
99+
100+
useEventListener('keydown', (event: KeyboardEvent) => {
101+
if (event.key === 'Escape' && isPathDropdownOpen.value) {
102+
togglePathDropdown(false)
103+
}
104+
})
105+
</script>
106+
107+
<template>
108+
<div
109+
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"
110+
>
111+
<div class="flex items-center w-full h-full relative">
112+
<!-- Breadcrumb navigation -->
113+
<nav
114+
:aria-label="$t('code.file_path')"
115+
class="flex items-center gap-0.5 font-mono text-sm overflow-x-auto"
116+
dir="ltr"
117+
>
118+
<NuxtLink
119+
v-if="filePath"
120+
:to="getCodeUrlWithPath()"
121+
class="text-fg-muted hover:text-fg transition-colors shrink-0"
122+
>
123+
..
124+
</NuxtLink>
125+
<span class="max-md:hidden">
126+
<template v-for="crumb in breadcrumbs.items" :key="crumb.path">
127+
<span class="text-fg-subtle">/</span>
128+
<NuxtLink
129+
:to="getCodeUrlWithPath(crumb.path)"
130+
class="text-fg-muted hover:text-fg transition-colors"
131+
>
132+
{{ crumb.name }}
133+
</NuxtLink>
134+
</template>
135+
</span>
136+
<!-- Show dropdown with path elements on small screens -->
137+
<span v-if="breadcrumbs.items.length" class="md:hidden">
138+
<span class="text-fg-subtle">/</span>
139+
<span ref="pathDropdownButtonRef">
140+
<ButtonBase
141+
size="small"
142+
class="px-2 mx-1"
143+
:aria-label="$t('code.open_path_dropdown')"
144+
:aria-expanded="isPathDropdownOpen"
145+
aria-haspopup="true"
146+
@click="togglePathDropdown"
147+
>
148+
...
149+
</ButtonBase>
150+
</span>
151+
</span>
152+
<template v-if="breadcrumbs.current">
153+
<span class="text-fg-subtle">/</span>
154+
<span class="text-fg">{{ breadcrumbs.current }}</span>
155+
</template>
156+
</nav>
157+
<Transition
158+
enter-active-class="transition-all duration-150"
159+
leave-active-class="transition-all duration-100"
160+
enter-from-class="opacity-0 translate-y-1"
161+
leave-to-class="opacity-0 translate-y-1"
162+
>
163+
<div
164+
v-if="isPathDropdownOpen"
165+
ref="pathDropdownListRef"
166+
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"
167+
>
168+
<NuxtLink
169+
v-for="(crumb, index) in breadcrumbs.items"
170+
:key="crumb.path"
171+
:to="getCodeUrlWithPath(crumb.path)"
172+
class="flex items-start px-3 py-1 text-fg-muted hover:text-fg hover:bg-bg-muted transition-colors"
173+
@click="togglePathDropdown(false)"
174+
>
175+
<span
176+
v-for="level in index"
177+
:key="level"
178+
aria-hidden="true"
179+
class="relative h-5 w-4 shrink-0"
180+
>
181+
<template v-if="level === index">
182+
<!-- vertical: top of row down to vertical midpoint -->
183+
<span class="absolute top-0 bottom-1/2 left-2 w-px bg-fg-subtle/50" />
184+
<!-- horizontal: midpoint across to right edge of span -->
185+
<span class="absolute top-1/2 left-2 right-0 h-px bg-fg-subtle/50" />
186+
</template>
187+
<!-- intermediate levels: empty spacer (correct for └, not ├) -->
188+
</span>
189+
<span :class="{ 'pl-1': index > 0 }" class="min-w-0 break-all"
190+
>{{ crumb.name }}<span class="text-fg-subtle">/</span></span
191+
>
192+
</NuxtLink>
193+
</div>
194+
</Transition>
195+
</div>
196+
<div class="flex max-md:(w-full justify-between border-border border-t pt-1)">
197+
<!-- Toggle button (mobile only) -->
198+
<ButtonBase
199+
class="md:hidden px-2"
200+
:aria-label="$t('code.toggle_tree')"
201+
@click="emit('mobile-tree-drawer-toggle')"
202+
classicon="i-lucide:folder-code"
203+
/>
204+
<div class="flex items-center gap-2">
205+
<template v-if="isViewingFile && !isBinaryFile && fileContent">
206+
<div
207+
v-if="fileContent?.markdownHtml"
208+
class="flex items-center gap-1 p-0.5 bg-bg-subtle border border-border-subtle rounded-md overflow-x-auto"
209+
role="tablist"
210+
aria-label="Markdown view mode selector"
211+
>
212+
<button
213+
v-for="mode in markdownViewModes"
214+
:key="mode.key"
215+
role="tab"
216+
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"
217+
:class="
218+
markdownViewMode === mode.key
219+
? 'bg-bg-muted shadow text-fg'
220+
: 'text-fg-subtle hover:text-fg'
221+
"
222+
:aria-selected="markdownViewMode === mode.key"
223+
@click="emit('update:markdownViewMode', mode.key)"
224+
>
225+
{{ mode.label }}
226+
</button>
227+
</div>
228+
<ButtonBase
229+
v-if="selectedLines"
230+
class="py-1 px-3"
231+
:classicon="permalinkCopied ? 'i-lucide:check' : 'i-lucide:file-braces-corner'"
232+
:aria-label="$t('code.copy_selected')"
233+
@click="copyPermalinkUrl"
234+
/>
235+
<ButtonBase
236+
v-if="!!fileContent?.content"
237+
class="px-3"
238+
:classicon="fileContentCopied ? 'i-lucide:check' : 'i-lucide:copy'"
239+
:aria-label="$t('code.copy_content')"
240+
@click="copyFileContent()"
241+
/>
242+
<LinkBase
243+
variant="button-secondary"
244+
:to="`https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}`"
245+
class="px-3"
246+
/>
247+
</template>
248+
<ButtonBase
249+
class="px-3 max-xl:hidden"
250+
:disabled="loading"
251+
:classicon="codeContainerFull ? 'i-lucide:fold-horizontal' : 'i-lucide:unfold-horizontal'"
252+
:aria-label="$t('code.toggle_container')"
253+
@click="toggleCodeContainer()"
254+
/>
255+
</div>
256+
</div>
257+
</div>
258+
</template>

app/components/Code/MobileTreeDrawer.vue

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,17 @@ watch(
2222
const isLocked = useScrollLock(document)
2323
// Prevent body scroll when drawer is open
2424
watch(isOpen, open => (isLocked.value = open))
25+
26+
function toggle() {
27+
isOpen.value = !isOpen.value
28+
}
29+
30+
defineExpose({
31+
toggle,
32+
})
2533
</script>
2634

2735
<template>
28-
<!-- Toggle button (mobile only) -->
29-
<ButtonBase
30-
variant="primary"
31-
class="md:hidden fixed bottom-9 inset-ie-4 z-45"
32-
:aria-label="$t('code.toggle_tree')"
33-
@click="isOpen = !isOpen"
34-
:classicon="isOpen ? 'i-lucide:x' : 'i-lucide:folder'"
35-
/>
36-
3736
<!-- Backdrop -->
3837
<Transition
3938
enter-active-class="transition-opacity duration-200"
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<template>
2+
<div class="flex min-h-full" aria-busy="true" :aria-label="$t('common.loading')">
3+
<!-- Fake line numbers column -->
4+
<div class="shrink-0 bg-bg-subtle border-ie border-border w-14 py-0">
5+
<div v-for="n in 20" :key="n" class="px-3 h-6 flex items-center justify-end">
6+
<SkeletonInline class="w-4 h-3 rounded-sm" />
7+
</div>
8+
</div>
9+
<!-- Fake code content -->
10+
<div class="flex-1 p-4 space-y-1.5">
11+
<SkeletonBlock class="h-4 w-32 rounded-sm" />
12+
<SkeletonBlock class="h-4 w-48 rounded-sm" />
13+
<SkeletonBlock class="h-4 w-24 rounded-sm" />
14+
<div class="h-4" />
15+
<SkeletonBlock class="h-4 w-64 rounded-sm" />
16+
<SkeletonBlock class="h-4 w-56 rounded-sm" />
17+
<SkeletonBlock class="h-4 w-40 rounded-sm" />
18+
<SkeletonBlock class="h-4 w-72 rounded-sm" />
19+
<div class="h-4" />
20+
<SkeletonBlock class="h-4 w-36 rounded-sm" />
21+
<SkeletonBlock class="h-4 w-52 rounded-sm" />
22+
<SkeletonBlock class="h-4 w-44 rounded-sm" />
23+
<SkeletonBlock class="h-4 w-28 rounded-sm" />
24+
<div class="h-4" />
25+
<SkeletonBlock class="h-4 w-60 rounded-sm" />
26+
<SkeletonBlock class="h-4 w-48 rounded-sm" />
27+
<SkeletonBlock class="h-4 w-32 rounded-sm" />
28+
<SkeletonBlock class="h-4 w-56 rounded-sm" />
29+
<SkeletonBlock class="h-4 w-40 rounded-sm" />
30+
<SkeletonBlock class="h-4 w-24 rounded-sm" />
31+
</div>
32+
</div>
33+
</template>

app/components/Code/Viewer.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ watch(
8686
</script>
8787

8888
<template>
89-
<div class="code-viewer flex min-h-full max-w-full">
89+
<div class="code-viewer flex min-h-full max-w-full overflow-x-auto">
9090
<!-- Line numbers column -->
9191
<div
9292
class="line-numbers shrink-0 bg-bg-subtle border-ie border-solid border-border text-end select-none relative"

app/composables/useSettings.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { RemovableRef } from '@vueuse/core'
21
import { useLocalStorage } from '@vueuse/core'
32
import { ACCENT_COLORS, type AccentColorId } from '#shared/utils/constants'
43
import type { LocaleObject } from '@nuxtjs/i18n'
@@ -38,6 +37,7 @@ export interface AppSettings {
3837
/** Automatically open the web auth page in the browser */
3938
autoOpenURL: boolean
4039
}
40+
codeContainerFull: boolean
4141
sidebar: {
4242
collapsed: string[]
4343
}
@@ -63,6 +63,7 @@ const DEFAULT_SETTINGS: AppSettings = {
6363
connector: {
6464
autoOpenURL: false,
6565
},
66+
codeContainerFull: false,
6667
sidebar: {
6768
collapsed: [],
6869
},
@@ -213,3 +214,22 @@ export function useBackgroundTheme() {
213214
setBackgroundTheme,
214215
}
215216
}
217+
218+
export function useCodeContainer() {
219+
const { settings } = useSettings()
220+
const isMounted = useMounted()
221+
222+
// Gate behind isMounted so SSR and initial client render both produce `false`,
223+
// eliminating the hydration mismatch. After mount, isMounted flips to true which
224+
// IS a reactive change, so Vue patches the class correctly from localStorage.
225+
const codeContainerFull = computed(() => isMounted.value && settings.value.codeContainerFull)
226+
227+
function toggleCodeContainer() {
228+
settings.value.codeContainerFull = !settings.value.codeContainerFull
229+
}
230+
231+
return {
232+
codeContainerFull,
233+
toggleCodeContainer,
234+
}
235+
}

0 commit comments

Comments
 (0)