forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2import { useAtproto } from '~/composables/atproto/useAtproto'
3import { useModal } from '~/composables/useModal'
4
5const {
6 isConnected: isNpmConnected,
7 isConnecting: isNpmConnecting,
8 npmUser,
9 avatar: npmAvatar,
10 activeOperations,
11 hasPendingOperations,
12} = useConnector()
13
14const { user: atprotoUser } = useAtproto()
15
16const isOpen = shallowRef(false)
17
18/** Check if connected to at least one service */
19const hasAnyConnection = computed(() => isNpmConnected.value || !!atprotoUser.value)
20
21/** Check if connected to both services */
22const hasBothConnections = computed(() => isNpmConnected.value && !!atprotoUser.value)
23
24/** Only show count of active (pending/approved/running) operations */
25const operationCount = computed(() => activeOperations.value.length)
26
27const accountMenuRef = useTemplateRef('accountMenuRef')
28
29onClickOutside(accountMenuRef, () => {
30 isOpen.value = false
31})
32
33useEventListener('keydown', event => {
34 if (event.key === 'Escape' && isOpen.value) {
35 isOpen.value = false
36 }
37})
38
39const connectorModal = useModal('connector-modal')
40
41function openConnectorModal() {
42 if (connectorModal) {
43 isOpen.value = false
44 connectorModal.open()
45 }
46}
47
48const authModal = useModal('auth-modal')
49
50function openAuthModal() {
51 if (authModal) {
52 isOpen.value = false
53 authModal.open()
54 }
55}
56</script>
57
58<template>
59 <div ref="accountMenuRef" class="relative flex min-w-28 justify-end">
60 <ButtonBase
61 type="button"
62 :aria-expanded="isOpen"
63 aria-haspopup="true"
64 @click="isOpen = !isOpen"
65 class="border-none"
66 >
67 <!-- Stacked avatars when connected -->
68 <span
69 v-if="hasAnyConnection"
70 class="flex items-center"
71 :class="hasBothConnections ? '-space-x-2' : ''"
72 >
73 <!-- npm avatar (first/back) -->
74 <img
75 v-if="isNpmConnected && npmAvatar"
76 :src="npmAvatar"
77 :alt="npmUser || $t('account_menu.npm_cli')"
78 width="24"
79 height="24"
80 class="w-6 h-6 rounded-full ring-2 ring-bg object-cover"
81 />
82 <span
83 v-else-if="isNpmConnected"
84 class="w-6 h-6 rounded-full bg-bg-muted ring-2 ring-bg flex items-center justify-center"
85 >
86 <span class="i-lucide:terminal w-3 h-3 text-fg-muted" aria-hidden="true" />
87 </span>
88
89 <!-- Atmosphere avatar (second/front, overlapping) -->
90 <img
91 v-if="atprotoUser?.avatar"
92 :src="atprotoUser.avatar"
93 :alt="atprotoUser.handle"
94 width="24"
95 height="24"
96 class="w-6 h-6 rounded-full ring-2 ring-bg object-cover"
97 :class="hasBothConnections ? 'relative z-10' : ''"
98 />
99 <span
100 v-else-if="atprotoUser"
101 class="w-6 h-6 rounded-full bg-bg-muted ring-2 ring-bg flex items-center justify-center"
102 :class="hasBothConnections ? 'relative z-10' : ''"
103 >
104 <span class="i-lucide:at-sign w-3 h-3 text-fg-muted" aria-hidden="true" />
105 </span>
106 </span>
107
108 <!-- "connect" text when not connected -->
109 <span v-if="!hasAnyConnection" class="font-mono text-sm">
110 {{ $t('account_menu.connect') }}
111 </span>
112
113 <!-- Chevron -->
114 <span
115 class="i-lucide:chevron-down w-3 h-3 transition-transform duration-200"
116 :class="{ 'rotate-180': isOpen }"
117 aria-hidden="true"
118 />
119
120 <!-- Operation count badge (when npm connected with pending ops) -->
121 <span
122 v-if="isNpmConnected && operationCount > 0"
123 class="absolute -top-1 -inset-ie-1 min-w-[1rem] h-4 px-1 flex items-center justify-center font-mono text-3xs rounded-full"
124 :class="hasPendingOperations ? 'bg-yellow-500 text-black' : 'bg-blue-500 text-white'"
125 aria-hidden="true"
126 >
127 {{ operationCount }}
128 </span>
129 </ButtonBase>
130
131 <!-- Dropdown menu -->
132 <Transition
133 enter-active-class="transition-all duration-150"
134 leave-active-class="transition-all duration-100"
135 enter-from-class="opacity-0 translate-y-1"
136 leave-to-class="opacity-0 translate-y-1"
137 >
138 <div v-if="isOpen" class="absolute inset-ie-0 top-full pt-2 w-72 z-50" role="menu">
139 <div
140 class="bg-bg-subtle/80 backdrop-blur-sm border border-border-subtle rounded-lg shadow-lg shadow-bg-elevated/50 overflow-hidden px-1"
141 >
142 <!-- Connected accounts section -->
143 <div v-if="hasAnyConnection" class="py-1">
144 <!-- npm CLI connection -->
145 <ButtonBase
146 v-if="isNpmConnected && npmUser"
147 role="menuitem"
148 class="w-full text-start gap-x-3 border-none"
149 @click="openConnectorModal"
150 out
151 >
152 <img
153 v-if="npmAvatar"
154 :src="npmAvatar"
155 :alt="npmUser"
156 width="32"
157 height="32"
158 class="w-8 h-8 rounded-full object-cover"
159 />
160 <span
161 v-else
162 class="w-8 h-8 rounded-full bg-bg-muted flex items-center justify-center"
163 >
164 <span class="i-lucide:terminal w-4 h-4 text-fg-muted" aria-hidden="true" />
165 </span>
166 <span class="flex-1 min-w-0">
167 <span class="font-mono text-sm text-fg truncate block">~{{ npmUser }}</span>
168 <span class="text-xs text-fg-subtle">{{ $t('account_menu.npm_cli') }}</span>
169 </span>
170 <span
171 v-if="operationCount > 0"
172 class="px-1.5 py-0.5 font-mono text-xs rounded"
173 :class="
174 hasPendingOperations
175 ? 'bg-yellow-500/20 text-yellow-600'
176 : 'bg-blue-500/20 text-blue-500'
177 "
178 >
179 {{
180 $t('account_menu.ops', {
181 count: operationCount,
182 })
183 }}
184 </span>
185 </ButtonBase>
186
187 <!-- Atmosphere connection -->
188 <ButtonBase
189 v-if="atprotoUser"
190 role="menuitem"
191 class="w-full text-start gap-x-3 border-none"
192 @click="openAuthModal"
193 >
194 <img
195 v-if="atprotoUser.avatar"
196 :src="atprotoUser.avatar"
197 :alt="atprotoUser.handle"
198 width="32"
199 height="32"
200 class="w-8 h-8 rounded-full object-cover"
201 />
202 <span
203 v-else
204 class="w-8 h-8 rounded-full bg-bg-muted flex items-center justify-center"
205 >
206 <span class="i-lucide:at-sign w-4 h-4 text-fg-muted" aria-hidden="true" />
207 </span>
208 <span class="flex-1 min-w-0">
209 <span class="font-mono text-sm text-fg truncate block"
210 >@{{ atprotoUser.handle }}</span
211 >
212 <span class="text-xs text-fg-subtle">{{ $t('account_menu.atmosphere') }}</span>
213 </span>
214 </ButtonBase>
215 </div>
216
217 <!-- Divider (only if we have connections AND options to connect) -->
218 <div
219 v-if="hasAnyConnection && (!isNpmConnected || !atprotoUser)"
220 class="border-t border-border"
221 />
222
223 <!-- Connect options -->
224 <div v-if="!isNpmConnected || !atprotoUser" class="py-1">
225 <ButtonBase
226 v-if="!isNpmConnected"
227 role="menuitem"
228 class="w-full text-start gap-x-3 border-none"
229 @click="openConnectorModal"
230 >
231 <span class="w-8 h-8 rounded-full bg-bg-muted flex items-center justify-center">
232 <span
233 v-if="isNpmConnecting"
234 class="i-svg-spinners:ring-resize w-4 h-4 text-yellow-500 animate-spin"
235 aria-hidden="true"
236 />
237 <span v-else class="i-lucide:terminal w-4 h-4 text-fg-muted" aria-hidden="true" />
238 </span>
239 <span class="flex-1 min-w-0">
240 <span class="font-mono text-sm text-fg block">
241 {{
242 isNpmConnecting
243 ? $t('account_menu.connecting')
244 : $t('account_menu.connect_npm_cli')
245 }}
246 </span>
247 <span class="text-xs text-fg-subtle">{{ $t('account_menu.npm_cli_desc') }}</span>
248 </span>
249 </ButtonBase>
250
251 <ButtonBase
252 v-if="!atprotoUser"
253 role="menuitem"
254 class="w-full text-start gap-x-3 border-none"
255 @click="openAuthModal"
256 >
257 <span class="w-8 h-8 rounded-full bg-bg-muted flex items-center justify-center">
258 <span class="i-lucide:at-sign w-4 h-4 text-fg-muted" aria-hidden="true" />
259 </span>
260 <span class="flex-1 min-w-0">
261 <span class="font-mono text-sm text-fg block">
262 {{ $t('account_menu.connect_atmosphere') }}
263 </span>
264 <span class="text-xs text-fg-subtle">{{ $t('account_menu.atmosphere_desc') }}</span>
265 </span>
266 </ButtonBase>
267 </div>
268 </div>
269 </div>
270 </Transition>
271 </div>
272 <HeaderConnectorModal />
273 <HeaderAuthModal />
274</template>