Skip to content

Commit 205556c

Browse files
authored
feat: add user, orgs, and packages to nav (#98)
1 parent f95f1bb commit 205556c

File tree

11 files changed

+760
-37
lines changed

11 files changed

+760
-37
lines changed

app/components/AppHeader.vue

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,29 @@ withDefaults(
99
showConnector: true,
1010
},
1111
)
12+
13+
const { isConnected, npmUser } = useConnector()
1214
</script>
1315

1416
<template>
1517
<header class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border">
16-
<nav aria-label="Main navigation" class="container h-14 flex items-center justify-between">
17-
<NuxtLink
18-
v-if="showLogo"
19-
to="/"
20-
aria-label="npmx home"
21-
class="header-logo font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring rounded"
22-
>
23-
<span class="text-fg-subtle"><span style="letter-spacing: -0.2em">.</span>/</span>npmx
24-
</NuxtLink>
25-
<!-- Spacer when logo is hidden -->
26-
<span v-else class="w-1" />
18+
<nav aria-label="Main navigation" class="container h-14 flex items-center">
19+
<!-- Left: Logo -->
20+
<div class="flex-shrink-0">
21+
<NuxtLink
22+
v-if="showLogo"
23+
to="/"
24+
aria-label="npmx home"
25+
class="header-logo font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring rounded"
26+
>
27+
<span class="text-fg-subtle"><span style="letter-spacing: -0.2em">.</span>/</span>npmx
28+
</NuxtLink>
29+
<!-- Spacer when logo is hidden -->
30+
<span v-else class="w-1" />
31+
</div>
2732

28-
<ul class="flex items-center gap-4 sm:gap-6 list-none m-0 p-0">
33+
<!-- Center: Main nav items -->
34+
<ul class="flex-1 flex items-center justify-center gap-4 sm:gap-6 list-none m-0 p-0">
2935
<li class="flex items-center">
3036
<NuxtLink
3137
to="/search"
@@ -41,26 +47,38 @@ withDefaults(
4147
</kbd>
4248
</NuxtLink>
4349
</li>
44-
<li class="flex items-center">
45-
<ClientOnly>
46-
<SettingsMenu />
47-
</ClientOnly>
48-
</li>
49-
<li v-if="showConnector" class="flex items-center">
50-
<ConnectorStatus />
50+
51+
<!-- Packages dropdown (when connected) -->
52+
<li v-if="isConnected && npmUser" class="flex items-center">
53+
<HeaderPackagesDropdown :username="npmUser" />
5154
</li>
52-
<li v-else class="flex items-center">
53-
<a
54-
href="https://github.com/npmx-dev/npmx.dev"
55-
rel="noopener noreferrer"
56-
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
57-
aria-label="GitHub"
58-
>
59-
<span class="i-carbon-logo-github w-4 h-4" aria-hidden="true" />
60-
<span class="hidden sm:inline" aria-hidden="true">github</span>
61-
</a>
55+
56+
<!-- Orgs dropdown (when connected) -->
57+
<li v-if="isConnected && npmUser" class="flex items-center">
58+
<HeaderOrgsDropdown :username="npmUser" />
6259
</li>
6360
</ul>
61+
62+
<!-- Right: User status + GitHub -->
63+
<div class="flex-shrink-0 flex items-center gap-6">
64+
<ClientOnly>
65+
<SettingsMenu />
66+
</ClientOnly>
67+
68+
<div v-if="showConnector">
69+
<ConnectorStatus />
70+
</div>
71+
72+
<a
73+
href="https://github.com/npmx-dev/npmx.dev"
74+
target="_blank"
75+
rel="noopener noreferrer"
76+
class="link-subtle"
77+
aria-label="GitHub repository"
78+
>
79+
<span class="i-carbon-logo-github w-5 h-5" aria-hidden="true" />
80+
</a>
81+
</div>
6482
</nav>
6583
</header>
6684
</template>

app/components/ConnectorStatus.client.vue

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@ const {
1212
const showModal = shallowRef(false)
1313
const showTooltip = shallowRef(false)
1414
15-
const statusText = computed(() => {
15+
const tooltipText = computed(() => {
1616
if (isConnecting.value) return 'connecting…'
17-
if (isConnected.value && npmUser.value) return `connected as @${npmUser.value}`
1817
if (isConnected.value) return 'connected'
1918
return 'connect local CLI'
2019
})
@@ -37,7 +36,16 @@ const ariaLabel = computed(() => {
3736
</script>
3837

3938
<template>
40-
<div class="relative">
39+
<div class="relative flex items-center gap-2">
40+
<!-- Username link (when connected) -->
41+
<NuxtLink
42+
v-if="isConnected && npmUser"
43+
:to="`/~${npmUser}`"
44+
class="link-subtle font-mono text-sm hidden sm:inline"
45+
>
46+
@{{ npmUser }}
47+
</NuxtLink>
48+
4149
<button
4250
type="button"
4351
class="relative flex items-center justify-center w-8 h-8 rounded-md transition-colors duration-200 hover:bg-bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
@@ -53,6 +61,8 @@ const ariaLabel = computed(() => {
5361
v-if="isConnected && avatar"
5462
:src="avatar"
5563
:alt="`${npmUser}'s avatar`"
64+
width="24"
65+
height="24"
5666
class="w-6 h-6 rounded-full"
5767
/>
5868
<!-- Status dot (when not connected or no avatar) -->
@@ -85,7 +95,7 @@ const ariaLabel = computed(() => {
8595
role="tooltip"
8696
class="absolute right-0 top-full mt-2 px-2 py-1 font-mono text-xs text-fg bg-bg-elevated border border-border rounded shadow-lg whitespace-nowrap z-50"
8797
>
88-
{{ statusText }}
98+
{{ tooltipText }}
8999
</div>
90100
</Transition>
91101

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
username: string
4+
}>()
5+
6+
const { listUserOrgs } = useConnector()
7+
8+
const isOpen = ref(false)
9+
const isLoading = ref(false)
10+
const orgs = ref<string[]>([])
11+
const hasLoaded = ref(false)
12+
const error = ref<string | null>(null)
13+
14+
async function loadOrgs() {
15+
if (hasLoaded.value || isLoading.value) return
16+
17+
isLoading.value = true
18+
error.value = null
19+
try {
20+
const orgList = await listUserOrgs()
21+
if (orgList) {
22+
// Already sorted alphabetically by server, take top 10
23+
orgs.value = orgList.slice(0, 10)
24+
} else {
25+
error.value = 'Failed to load organizations'
26+
}
27+
hasLoaded.value = true
28+
} catch {
29+
error.value = 'Failed to load organizations'
30+
} finally {
31+
isLoading.value = false
32+
}
33+
}
34+
35+
function handleMouseEnter() {
36+
isOpen.value = true
37+
if (!hasLoaded.value) {
38+
loadOrgs()
39+
}
40+
}
41+
42+
function handleMouseLeave() {
43+
isOpen.value = false
44+
}
45+
46+
function handleKeydown(event: KeyboardEvent) {
47+
if (event.key === 'Escape' && isOpen.value) {
48+
isOpen.value = false
49+
}
50+
}
51+
</script>
52+
53+
<template>
54+
<div
55+
class="relative"
56+
@mouseenter="handleMouseEnter"
57+
@mouseleave="handleMouseLeave"
58+
@keydown="handleKeydown"
59+
>
60+
<NuxtLink
61+
:to="`/~${username}/orgs`"
62+
class="link-subtle font-mono text-sm inline-flex items-center gap-1"
63+
>
64+
orgs
65+
<span
66+
class="i-carbon-chevron-down w-3 h-3 transition-transform duration-200"
67+
:class="{ 'rotate-180': isOpen }"
68+
aria-hidden="true"
69+
/>
70+
</NuxtLink>
71+
72+
<Transition
73+
enter-active-class="transition-all duration-150"
74+
leave-active-class="transition-all duration-100"
75+
enter-from-class="opacity-0 translate-y-1"
76+
leave-to-class="opacity-0 translate-y-1"
77+
>
78+
<div v-if="isOpen" class="absolute right-0 top-full pt-2 w-56 z-50">
79+
<div class="bg-bg-elevated border border-border rounded-lg shadow-lg overflow-hidden">
80+
<div class="px-3 py-2 border-b border-border">
81+
<span class="font-mono text-xs text-fg-subtle">Your Organizations</span>
82+
</div>
83+
84+
<div v-if="isLoading" class="px-3 py-4 text-center">
85+
<span class="text-fg-muted text-sm">Loading…</span>
86+
</div>
87+
88+
<div v-else-if="error" class="px-3 py-4 text-center">
89+
<span class="text-fg-muted text-sm">{{ error }}</span>
90+
</div>
91+
92+
<ul v-else-if="orgs.length > 0" class="py-1 max-h-80 overflow-y-auto">
93+
<li v-for="org in orgs" :key="org">
94+
<NuxtLink
95+
:to="`/@${org}`"
96+
class="block px-3 py-2 font-mono text-sm text-fg hover:bg-bg-subtle transition-colors"
97+
>
98+
@{{ org }}
99+
</NuxtLink>
100+
</li>
101+
</ul>
102+
103+
<div v-else class="px-3 py-4 text-center">
104+
<span class="text-fg-muted text-sm">No organizations found</span>
105+
</div>
106+
107+
<div class="px-3 py-2 border-t border-border">
108+
<NuxtLink
109+
:to="`/~${username}/orgs`"
110+
class="link-subtle font-mono text-xs inline-flex items-center gap-1"
111+
>
112+
View all
113+
<span class="i-carbon-arrow-right w-3 h-3" aria-hidden="true" />
114+
</NuxtLink>
115+
</div>
116+
</div>
117+
</div>
118+
</Transition>
119+
</div>
120+
</template>
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
username: string
4+
}>()
5+
6+
const { listUserPackages } = useConnector()
7+
8+
const isOpen = ref(false)
9+
const isLoading = ref(false)
10+
const packages = ref<string[]>([])
11+
const hasLoaded = ref(false)
12+
const error = ref<string | null>(null)
13+
14+
async function loadPackages() {
15+
if (hasLoaded.value || isLoading.value) return
16+
17+
isLoading.value = true
18+
error.value = null
19+
try {
20+
const pkgMap = await listUserPackages()
21+
if (pkgMap) {
22+
// Sort alphabetically and take top 10
23+
packages.value = Object.keys(pkgMap).sort().slice(0, 10)
24+
} else {
25+
error.value = 'Failed to load packages'
26+
}
27+
hasLoaded.value = true
28+
} catch {
29+
error.value = 'Failed to load packages'
30+
} finally {
31+
isLoading.value = false
32+
}
33+
}
34+
35+
function handleMouseEnter() {
36+
isOpen.value = true
37+
if (!hasLoaded.value) {
38+
loadPackages()
39+
}
40+
}
41+
42+
function handleMouseLeave() {
43+
isOpen.value = false
44+
}
45+
46+
function handleKeydown(event: KeyboardEvent) {
47+
if (event.key === 'Escape' && isOpen.value) {
48+
isOpen.value = false
49+
}
50+
}
51+
</script>
52+
53+
<template>
54+
<div
55+
class="relative"
56+
@mouseenter="handleMouseEnter"
57+
@mouseleave="handleMouseLeave"
58+
@keydown="handleKeydown"
59+
>
60+
<NuxtLink
61+
:to="`/~${username}`"
62+
class="link-subtle font-mono text-sm inline-flex items-center gap-1"
63+
>
64+
packages
65+
<span
66+
class="i-carbon-chevron-down w-3 h-3 transition-transform duration-200"
67+
:class="{ 'rotate-180': isOpen }"
68+
aria-hidden="true"
69+
/>
70+
</NuxtLink>
71+
72+
<Transition
73+
enter-active-class="transition-all duration-150"
74+
leave-active-class="transition-all duration-100"
75+
enter-from-class="opacity-0 translate-y-1"
76+
leave-to-class="opacity-0 translate-y-1"
77+
>
78+
<div v-if="isOpen" class="absolute right-0 top-full pt-2 w-64 z-50">
79+
<div class="bg-bg-elevated border border-border rounded-lg shadow-lg overflow-hidden">
80+
<div class="px-3 py-2 border-b border-border">
81+
<span class="font-mono text-xs text-fg-subtle">Your Packages</span>
82+
</div>
83+
84+
<div v-if="isLoading" class="px-3 py-4 text-center">
85+
<span class="text-fg-muted text-sm">Loading…</span>
86+
</div>
87+
88+
<div v-else-if="error" class="px-3 py-4 text-center">
89+
<span class="text-fg-muted text-sm">{{ error }}</span>
90+
</div>
91+
92+
<ul v-else-if="packages.length > 0" class="py-1 max-h-80 overflow-y-auto">
93+
<li v-for="pkg in packages" :key="pkg">
94+
<NuxtLink
95+
:to="`/${pkg}`"
96+
class="block px-3 py-2 font-mono text-sm text-fg hover:bg-bg-subtle transition-colors truncate"
97+
>
98+
{{ pkg }}
99+
</NuxtLink>
100+
</li>
101+
</ul>
102+
103+
<div v-else class="px-3 py-4 text-center">
104+
<span class="text-fg-muted text-sm">No packages found</span>
105+
</div>
106+
107+
<div class="px-3 py-2 border-t border-border">
108+
<NuxtLink
109+
:to="`/~${username}`"
110+
class="link-subtle font-mono text-xs inline-flex items-center gap-1"
111+
>
112+
View all
113+
<span class="i-carbon-arrow-right w-3 h-3" aria-hidden="true" />
114+
</NuxtLink>
115+
</div>
116+
</div>
117+
</div>
118+
</Transition>
119+
</div>
120+
</template>

0 commit comments

Comments
 (0)