Skip to content

Commit 706f81c

Browse files
committed
feat: add about, user, orgs, and packages to nav
1. Add an "about" nav item (and a new about page, content tbd) 2. Always show "github" item instead of hiding it when logged in 3. Add logged in username next to 🟢, clicking brings up `/~user` 4. when logged in, shows new 'packages' + 'orgs' nav items - clicking packages goes to user's packages - clicking orgs goes to user's orgs (new page, lists user's orgs, with org card showing # of pkgs + user's teams) - hovering on packages pops down user's 10 first packages alphabetically (this is a placeholder, eventually user prefs screen will allow pinning packages and such) - hovering on orgs pops down user's 10 first orgs alphabetically (this is a placeholder, eventually user prefs screen will allow pinning orgs and such) Closes #95
1 parent 5c606dc commit 706f81c

12 files changed

Lines changed: 791 additions & 37 deletions

File tree

app/components/AppHeader.vue

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,28 @@ 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+
<span v-else class="w-1" />
30+
</div>
2731

28-
<ul class="flex items-center gap-4 sm:gap-6 list-none m-0 p-0">
32+
<!-- Center: Main nav items -->
33+
<ul class="flex-1 flex items-center justify-center gap-4 sm:gap-6 list-none m-0 p-0">
2934
<li class="flex items-center">
3035
<NuxtLink
3136
to="/search"
@@ -41,26 +46,38 @@ withDefaults(
4146
</kbd>
4247
</NuxtLink>
4348
</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 />
49+
50+
<!-- Packages dropdown (when connected) -->
51+
<li v-if="isConnected && npmUser" class="flex items-center">
52+
<HeaderPackagesDropdown :username="npmUser" />
5153
</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>
54+
55+
<!-- Orgs dropdown (when connected) -->
56+
<li v-if="isConnected && npmUser" class="flex items-center">
57+
<HeaderOrgsDropdown :username="npmUser" />
6258
</li>
6359
</ul>
60+
61+
<!-- Right: User status + GitHub -->
62+
<div class="flex-shrink-0 flex items-center gap-6">
63+
<ClientOnly>
64+
<SettingsMenu />
65+
</ClientOnly>
66+
67+
<div v-if="showConnector">
68+
<ConnectorStatus />
69+
</div>
70+
71+
<a
72+
href="https://github.com/npmx-dev/npmx.dev"
73+
target="_blank"
74+
rel="noopener noreferrer"
75+
class="link-subtle"
76+
aria-label="GitHub repository"
77+
>
78+
<span class="i-carbon-logo-github w-5 h-5" aria-hidden="true" />
79+
</a>
80+
</div>
6481
</nav>
6582
</header>
6683
</template>

app/components/ConnectorStatus.client.vue

Lines changed: 12 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"
@@ -85,7 +93,7 @@ const ariaLabel = computed(() => {
8593
role="tooltip"
8694
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"
8795
>
88-
{{ statusText }}
96+
{{ tooltipText }}
8997
</div>
9098
</Transition>
9199

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

app/composables/useConnector.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,19 @@ export const useConnector = createSharedComposable(function useConnector() {
350350
return response?.success ? (response.data ?? null) : null
351351
}
352352

353+
async function listUserPackages(): Promise<Record<string, 'read-write' | 'read-only'> | null> {
354+
const response =
355+
await connectorFetch<ApiResponse<Record<string, 'read-write' | 'read-only'>>>(
356+
'/user/packages',
357+
)
358+
return response?.success ? (response.data ?? null) : null
359+
}
360+
361+
async function listUserOrgs(): Promise<string[] | null> {
362+
const response = await connectorFetch<ApiResponse<string[]>>('/user/orgs')
363+
return response?.success ? (response.data ?? null) : null
364+
}
365+
353366
// Computed helpers for operations
354367
const pendingOperations = computed(() =>
355368
state.value.operations.filter(op => op.status === 'pending'),
@@ -427,6 +440,8 @@ export const useConnector = createSharedComposable(function useConnector() {
427440
listOrgTeams,
428441
listTeamUsers,
429442
listPackageCollaborators,
443+
listUserPackages,
444+
listUserOrgs,
430445
}
431446
})
432447

0 commit comments

Comments
 (0)