[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(a11y): use `<dialog>` tag for modal components (#522)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Dane Miller
Daniel Roe
and committed by
GitHub
8583ba87 4f08f0ae

+786 -868
+11
app/app.vue
··· 68 68 showKbdHints.value = false 69 69 } 70 70 71 + /* A hack to get light dismiss to work in safari because it does not support closedby="any" yet */ 72 + // https://codepen.io/paramagicdev/pen/gbYompq 73 + // see: https://github.com/npmx-dev/npmx.dev/pull/522#discussion_r2749978022 74 + function handleModalLightDismiss(e: MouseEvent) { 75 + const target = e.target as HTMLElement 76 + if (target.tagName === 'DIALOG' && target.hasAttribute('open')) { 77 + ;(target as HTMLDialogElement).close() 78 + } 79 + } 80 + 71 81 if (import.meta.client) { 72 82 useEventListener(document, 'keydown', handleGlobalKeydown) 73 83 useEventListener(document, 'keyup', handleGlobalKeyup) 84 + useEventListener(document, 'click', handleModalLightDismiss) 74 85 } 75 86 </script> 76 87
+6
app/assets/main.css
··· 233 233 animation-duration: 0.3s; 234 234 animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); 235 235 } 236 + 237 + /* Locking the scroll whenever any of the modals are open */ 238 + html:has(dialog:modal) { 239 + overflow: hidden; 240 + scrollbar-gutter: stable; 241 + }
+144
app/components/AuthModal.client.vue
··· 1 + <script setup lang="ts"> 2 + const handleInput = shallowRef('') 3 + 4 + const { user, logout } = useAtproto() 5 + 6 + async function handleBlueskySignIn() { 7 + await navigateTo( 8 + { 9 + path: '/api/auth/atproto', 10 + query: { handle: 'https://bsky.social' }, 11 + }, 12 + { external: true }, 13 + ) 14 + } 15 + 16 + async function handleCreateAccount() { 17 + await navigateTo( 18 + { 19 + path: '/api/auth/atproto', 20 + query: { handle: 'https://npmx.social', create: 'true' }, 21 + }, 22 + { external: true }, 23 + ) 24 + } 25 + 26 + async function handleLogin() { 27 + if (handleInput.value) { 28 + await navigateTo( 29 + { 30 + path: '/api/auth/atproto', 31 + query: { handle: handleInput.value }, 32 + }, 33 + { external: true }, 34 + ) 35 + } 36 + } 37 + </script> 38 + 39 + <template> 40 + <!-- Modal --> 41 + <Modal :modalTitle="$t('auth.modal.title')" class="max-w-lg" id="auth-modal"> 42 + <div v-if="user?.handle" class="space-y-4"> 43 + <div class="flex items-center gap-3 p-4 bg-bg-subtle border border-border rounded-lg"> 44 + <span class="w-3 h-3 rounded-full bg-green-500" aria-hidden="true" /> 45 + <div> 46 + <p class="font-mono text-xs text-fg-muted"> 47 + {{ $t('auth.modal.connected_as', { handle: user.handle }) }} 48 + </p> 49 + </div> 50 + </div> 51 + <button 52 + class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 53 + @click="logout" 54 + > 55 + {{ $t('auth.modal.disconnect') }} 56 + </button> 57 + </div> 58 + 59 + <!-- Disconnected state --> 60 + <form v-else class="space-y-4" @submit.prevent="handleLogin"> 61 + <p class="text-sm text-fg-muted">{{ $t('auth.modal.connect_prompt') }}</p> 62 + 63 + <div class="space-y-3"> 64 + <div> 65 + <label 66 + for="handle-input" 67 + class="block text-xs text-fg-subtle uppercase tracking-wider mb-1.5" 68 + > 69 + {{ $t('auth.modal.handle_label') }} 70 + </label> 71 + <input 72 + id="handle-input" 73 + v-model="handleInput" 74 + type="text" 75 + name="handle" 76 + :placeholder="$t('auth.modal.handle_placeholder')" 77 + autocomplete="off" 78 + spellcheck="false" 79 + class="w-full px-3 py-2 font-mono text-sm bg-bg-subtle border border-border rounded-md text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 80 + /> 81 + </div> 82 + 83 + <details class="text-sm"> 84 + <summary 85 + class="text-fg-subtle cursor-pointer hover:text-fg-muted transition-colors duration-200" 86 + > 87 + {{ $t('auth.modal.what_is_atmosphere') }} 88 + </summary> 89 + <div class="mt-3"> 90 + <i18n-t keypath="auth.modal.atmosphere_explanation" tag="p"> 91 + <template #npmx> 92 + <span class="font-bold">npmx.dev</span> 93 + </template> 94 + <template #atproto> 95 + <a href="https://atproto.com" target="_blank" class="text-blue-400 hover:underline"> 96 + AT Protocol 97 + </a> 98 + </template> 99 + <template #bluesky> 100 + <a href="https://bsky.app" target="_blank" class="text-blue-400 hover:underline"> 101 + Bluesky 102 + </a> 103 + </template> 104 + <template #tangled> 105 + <a href="https://tangled.org" target="_blank" class="text-blue-400 hover:underline"> 106 + Tangled 107 + </a> 108 + </template> 109 + </i18n-t> 110 + </div> 111 + </details> 112 + </div> 113 + 114 + <button 115 + type="submit" 116 + :disabled="!handleInput.trim()" 117 + class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 focus-visible:ring-offset-2 focus-visible:ring-offset-bg" 118 + > 119 + {{ $t('auth.modal.connect') }} 120 + </button> 121 + <button 122 + type="button" 123 + class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 focus-visible:ring-offset-2 focus-visible:ring-offset-bg" 124 + @click="handleCreateAccount" 125 + > 126 + {{ $t('auth.modal.create_account') }} 127 + </button> 128 + <hr /> 129 + <button 130 + type="button" 131 + class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 focus-visible:ring-offset-2 focus-visible:ring-offset-bg flex items-center justify-center gap-2" 132 + @click="handleBlueskySignIn" 133 + > 134 + {{ $t('auth.modal.connect_bluesky') }} 135 + <svg fill="none" viewBox="0 0 64 57" width="20" style="width: 20px"> 136 + <path 137 + fill="#0F73FF" 138 + d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z" 139 + ></path> 140 + </svg> 141 + </button> 142 + </form> 143 + </Modal> 144 + </template>
-198
app/components/AuthModal.vue
··· 1 - <script setup lang="ts"> 2 - const open = defineModel<boolean>('open', { default: false }) 3 - 4 - const handleInput = shallowRef('') 5 - 6 - const { user, logout } = useAtproto() 7 - 8 - async function handleBlueskySignIn() { 9 - await navigateTo( 10 - { 11 - path: '/api/auth/atproto', 12 - query: { handle: 'https://bsky.social' }, 13 - }, 14 - { external: true }, 15 - ) 16 - } 17 - 18 - async function handleCreateAccount() { 19 - await navigateTo( 20 - { 21 - path: '/api/auth/atproto', 22 - query: { handle: 'https://npmx.social', create: 'true' }, 23 - }, 24 - { external: true }, 25 - ) 26 - } 27 - 28 - async function handleLogin() { 29 - if (handleInput.value) { 30 - await navigateTo( 31 - { 32 - path: '/api/auth/atproto', 33 - query: { handle: handleInput.value }, 34 - }, 35 - { external: true }, 36 - ) 37 - } 38 - } 39 - </script> 40 - 41 - <template> 42 - <Teleport to="body"> 43 - <Transition 44 - enter-active-class="transition-opacity duration-200" 45 - leave-active-class="transition-opacity duration-200" 46 - enter-from-class="opacity-0" 47 - leave-to-class="opacity-0" 48 - > 49 - <div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center p-4"> 50 - <!-- Backdrop --> 51 - <button 52 - type="button" 53 - class="absolute inset-0 bg-black/60 cursor-default" 54 - :aria-label="$t('common.close')" 55 - @click="open = false" 56 - /> 57 - 58 - <!-- Modal --> 59 - <div 60 - class="relative w-full max-w-lg bg-bg border border-border rounded-lg shadow-xl max-h-[90vh] overflow-y-auto overscroll-contain" 61 - role="dialog" 62 - aria-modal="true" 63 - aria-labelledby="auth-modal-title" 64 - > 65 - <div class="p-6"> 66 - <div class="flex items-center justify-between mb-6"> 67 - <h2 id="auth-modal-title" class="font-mono text-lg font-medium"> 68 - {{ $t('auth.modal.title') }} 69 - </h2> 70 - <button 71 - type="button" 72 - class="text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 73 - :aria-label="$t('common.close')" 74 - @click="open = false" 75 - > 76 - <span class="i-carbon-close block w-5 h-5" aria-hidden="true" /> 77 - </button> 78 - </div> 79 - 80 - <div v-if="user?.handle" class="space-y-4"> 81 - <div class="flex items-center gap-3 p-4 bg-bg-subtle border border-border rounded-lg"> 82 - <span class="w-3 h-3 rounded-full bg-green-500" aria-hidden="true" /> 83 - <div> 84 - <p class="font-mono text-xs text-fg-muted"> 85 - {{ $t('auth.modal.connected_as', { handle: user.handle }) }} 86 - </p> 87 - </div> 88 - </div> 89 - <button 90 - class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 91 - @click="logout" 92 - > 93 - {{ $t('auth.modal.disconnect') }} 94 - </button> 95 - </div> 96 - 97 - <!-- Disconnected state --> 98 - <form v-else class="space-y-4" @submit.prevent="handleLogin"> 99 - <p class="text-sm text-fg-muted">{{ $t('auth.modal.connect_prompt') }}</p> 100 - 101 - <div class="space-y-3"> 102 - <div> 103 - <label 104 - for="handle-input" 105 - class="block text-xs text-fg-subtle uppercase tracking-wider mb-1.5" 106 - > 107 - {{ $t('auth.modal.handle_label') }} 108 - </label> 109 - <input 110 - id="handle-input" 111 - v-model="handleInput" 112 - type="text" 113 - name="handle" 114 - :placeholder="$t('auth.modal.handle_placeholder')" 115 - autocomplete="off" 116 - spellcheck="false" 117 - class="w-full px-3 py-2 font-mono text-sm bg-bg-subtle border border-border rounded-md text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 118 - /> 119 - </div> 120 - 121 - <details class="text-sm"> 122 - <summary 123 - class="text-fg-subtle cursor-pointer hover:text-fg-muted transition-colors duration-200" 124 - > 125 - {{ $t('auth.modal.what_is_atmosphere') }} 126 - </summary> 127 - <div class="mt-3"> 128 - <i18n-t keypath="auth.modal.atmosphere_explanation" tag="p"> 129 - <template #npmx> 130 - <span class="font-bold">npmx.dev</span> 131 - </template> 132 - <template #atproto> 133 - <a 134 - href="https://atproto.com" 135 - target="_blank" 136 - class="text-blue-400 hover:underline" 137 - > 138 - AT Protocol 139 - </a> 140 - </template> 141 - <template #bluesky> 142 - <a 143 - href="https://bsky.app" 144 - target="_blank" 145 - class="text-blue-400 hover:underline" 146 - > 147 - Bluesky 148 - </a> 149 - </template> 150 - <template #tangled> 151 - <a 152 - href="https://tangled.org" 153 - target="_blank" 154 - class="text-blue-400 hover:underline" 155 - > 156 - Tangled 157 - </a> 158 - </template> 159 - </i18n-t> 160 - </div> 161 - </details> 162 - </div> 163 - 164 - <button 165 - type="submit" 166 - :disabled="!handleInput.trim()" 167 - class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 focus-visible:ring-offset-2 focus-visible:ring-offset-bg" 168 - > 169 - {{ $t('auth.modal.connect') }} 170 - </button> 171 - <button 172 - type="button" 173 - class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 focus-visible:ring-offset-2 focus-visible:ring-offset-bg" 174 - @click="handleCreateAccount" 175 - > 176 - {{ $t('auth.modal.create_account') }} 177 - </button> 178 - <hr /> 179 - <button 180 - type="button" 181 - class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 focus-visible:ring-offset-2 focus-visible:ring-offset-bg flex items-center justify-center gap-2" 182 - @click="handleBlueskySignIn" 183 - > 184 - {{ $t('auth.modal.connect_bluesky') }} 185 - <svg fill="none" viewBox="0 0 64 57" width="20" style="width: 20px"> 186 - <path 187 - fill="#0F73FF" 188 - d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z" 189 - ></path> 190 - </svg> 191 - </button> 192 - </form> 193 - </div> 194 - </div> 195 - </div> 196 - </Transition> 197 - </Teleport> 198 - </template>
+11 -63
app/components/ChartModal.vue
··· 1 - <script setup lang="ts"> 2 - const open = defineModel<boolean>('open', { default: false }) 3 - 4 - function close() { 5 - open.value = false 6 - } 7 - 8 - function handleKeydown(event: KeyboardEvent) { 9 - if (event.key === 'Escape') { 10 - close() 11 - } 12 - } 13 - </script> 1 + <script setup lang="ts"></script> 14 2 15 3 <template> 16 - <Teleport to="body"> 17 - <Transition 18 - enter-active-class="transition-opacity duration-200" 19 - leave-active-class="transition-opacity duration-200" 20 - enter-from-class="opacity-0" 21 - leave-to-class="opacity-0" 22 - > 23 - <div 24 - v-if="open" 25 - class="fixed inset-0 z-50 flex items-center justify-center p-0 sm:p-4" 26 - @keydown="handleKeydown" 27 - > 28 - <!-- Backdrop --> 29 - <button 30 - type="button" 31 - class="absolute inset-0 bg-black/60 cursor-default" 32 - :aria-label="$t('common.close_modal')" 33 - @click="open = false" 34 - /> 35 - 36 - <div 37 - class="relative w-full h-full sm:h-auto bg-bg sm:border sm:border-border sm:rounded-lg shadow-xl sm:max-h-[90vh] overflow-y-auto overscroll-contain sm:max-w-3xl" 38 - role="dialog" 39 - aria-modal="true" 40 - aria-labelledby="chart-modal-title" 41 - > 42 - <div class="p-4 sm:p-6"> 43 - <div class="flex items-center justify-between mb-4 sm:mb-6"> 44 - <h2 id="chart-modal-title" class="font-mono text-lg font-medium"> 45 - <slot name="title" /> 46 - </h2> 47 - <button 48 - type="button" 49 - class="text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 50 - :aria-label="$t('common.close')" 51 - @click="open = false" 52 - > 53 - <span class="i-carbon-close block w-5 h-5" aria-hidden="true" /> 54 - </button> 55 - </div> 56 - <div class="font-mono text-sm"> 57 - <slot /> 58 - </div> 59 - </div> 60 - 61 - <slot name="after" v-bind="{ close }" /> 62 - </div> 63 - </div> 64 - </Transition> 65 - </Teleport> 4 + <Modal 5 + :modalTitle="$t('package.downloads.modal_title')" 6 + id="chart-modal" 7 + class="h-full sm:h-min sm:border sm:border-border sm:rounded-lg shadow-xl sm:max-h-[90vh] sm:max-w-3xl" 8 + > 9 + <div class="font-mono text-sm"> 10 + <slot /> 11 + </div> 12 + <slot name="after" /> 13 + </Modal> 66 14 </template>
+253 -291
app/components/ClaimPackageModal.vue
··· 6 6 packageName: string 7 7 }>() 8 8 9 - const open = defineModel<boolean>('open', { default: false }) 10 - 11 9 const { 12 10 isConnected, 13 11 state, ··· 37 35 isChecking.value = false 38 36 } 39 37 } 38 + 39 + const connectorModal = useModal('connector-modal') 40 40 41 41 async function handleClaim() { 42 42 if (!checkResult.value?.available || !isConnected.value) return ··· 71 71 } else if (completedOp?.status === 'failed') { 72 72 if (completedOp.result?.requiresOtp) { 73 73 // OTP is needed - open connector panel to handle it 74 - open.value = false 75 - connectorModalOpen.value = true 74 + close() 75 + connectorModal.open() 76 76 } else { 77 77 publishError.value = completedOp.result?.stderr || 'Failed to publish package' 78 78 } 79 79 } else { 80 80 // Still pending/approved/running - open connector panel to show progress 81 - open.value = false 82 - connectorModalOpen.value = true 81 + close() 82 + connectorModal.open() 83 83 } 84 84 } catch (err) { 85 85 publishError.value = err instanceof Error ? err.message : $t('claim.modal.failed_to_claim') ··· 88 88 } 89 89 } 90 90 91 - // Check availability when modal opens 92 - watch(open, isOpen => { 93 - if (isOpen) { 94 - checkResult.value = null 95 - publishError.value = null 96 - publishSuccess.value = false 97 - checkAvailability() 98 - } 99 - }) 91 + const dialogRef = ref<HTMLDialogElement>() 92 + 93 + function open() { 94 + // Reset state and check availability each time modal is opened 95 + checkResult.value = null 96 + publishError.value = null 97 + publishSuccess.value = false 98 + checkAvailability() 99 + dialogRef.value?.showModal() 100 + } 101 + 102 + function close() { 103 + dialogRef.value?.close() 104 + } 105 + 106 + defineExpose({ open, close }) 100 107 101 108 // Computed for similar packages with warnings 102 109 const hasDangerousSimilarPackages = computed(() => { ··· 124 131 ...(access && { publishConfig: { access } }), 125 132 } 126 133 }) 127 - 128 - const connectorModalOpen = shallowRef(false) 129 134 </script> 130 135 131 136 <template> 132 - <Teleport to="body"> 133 - <Transition 134 - enter-active-class="transition-opacity duration-200" 135 - leave-active-class="transition-opacity duration-200" 136 - enter-from-class="opacity-0" 137 - leave-to-class="opacity-0" 138 - > 139 - <div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center p-4"> 140 - <!-- Backdrop --> 141 - <button 142 - type="button" 143 - class="absolute inset-0 bg-black/60 cursor-default" 144 - :aria-label="$t('common.close_modal')" 145 - @click="open = false" 146 - /> 147 - 148 - <!-- Modal --> 149 - <div 150 - class="relative w-full max-w-lg bg-bg border border-border rounded-lg shadow-xl max-h-[90vh] overflow-y-auto overscroll-contain" 151 - role="dialog" 152 - aria-modal="true" 153 - aria-labelledby="claim-modal-title" 154 - > 155 - <div class="p-6"> 156 - <div class="flex items-center justify-between mb-6"> 157 - <h2 id="claim-modal-title" class="font-mono text-lg font-medium"> 158 - {{ $t('claim.modal.title') }} 159 - </h2> 160 - <button 161 - type="button" 162 - class="text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 163 - :aria-label="$t('common.close')" 164 - @click="open = false" 165 - > 166 - <span class="i-carbon-close block w-5 h-5" aria-hidden="true" /> 167 - </button> 168 - </div> 169 - 170 - <!-- Loading state --> 171 - <div v-if="isChecking" class="py-8 text-center"> 172 - <LoadingSpinner :text="$t('claim.modal.checking')" /> 173 - </div> 137 + <!-- Modal --> 138 + <Modal 139 + ref="dialogRef" 140 + :modalTitle="$t('claim.modal.title')" 141 + id="claim-package-modal" 142 + class="max-w-md" 143 + > 144 + <!-- Loading state --> 145 + <div v-if="isChecking" class="py-8 text-center"> 146 + <LoadingSpinner :text="$t('claim.modal.checking')" /> 147 + </div> 174 148 175 - <!-- Success state --> 176 - <div v-else-if="publishSuccess" class="space-y-4"> 177 - <div 178 - class="flex items-center gap-3 p-4 bg-green-500/10 border border-green-500/20 rounded-lg" 179 - > 180 - <span class="i-carbon-checkmark-filled text-green-500 w-6 h-6" aria-hidden="true" /> 181 - <div> 182 - <p class="font-mono text-sm text-fg">{{ $t('claim.modal.success') }}</p> 183 - <p class="text-xs text-fg-muted"> 184 - {{ $t('claim.modal.success_detail', { name: packageName }) }} 185 - </p> 186 - </div> 187 - </div> 149 + <!-- Success state --> 150 + <div v-else-if="publishSuccess" class="space-y-4"> 151 + <div 152 + class="flex items-center gap-3 p-4 bg-green-500/10 border border-green-500/20 rounded-lg" 153 + > 154 + <span class="i-carbon-checkmark-filled text-green-500 w-6 h-6" aria-hidden="true" /> 155 + <div> 156 + <p class="font-mono text-sm text-fg">{{ $t('claim.modal.success') }}</p> 157 + <p class="text-xs text-fg-muted"> 158 + {{ $t('claim.modal.success_detail', { name: packageName }) }} 159 + </p> 160 + </div> 161 + </div> 188 162 189 - <p class="text-sm text-fg-muted"> 190 - {{ $t('claim.modal.success_hint') }} 191 - </p> 163 + <p class="text-sm text-fg-muted"> 164 + {{ $t('claim.modal.success_hint') }} 165 + </p> 192 166 193 - <div class="flex gap-3"> 194 - <NuxtLink 195 - :to="`/package/${packageName}`" 196 - class="flex-1 px-4 py-2 font-mono text-sm text-center text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 197 - @click="open = false" 198 - > 199 - {{ $t('claim.modal.view_package') }} 200 - </NuxtLink> 201 - <button 202 - type="button" 203 - class="flex-1 px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 204 - @click="open = false" 205 - > 206 - {{ $t('common.close') }} 207 - </button> 208 - </div> 209 - </div> 167 + <div class="flex gap-3"> 168 + <NuxtLink 169 + :to="`/package/${packageName}`" 170 + class="flex-1 px-4 py-2 font-mono text-sm text-center text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 171 + @click="close" 172 + > 173 + {{ $t('claim.modal.view_package') }} 174 + </NuxtLink> 175 + <button 176 + type="button" 177 + class="flex-1 px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 178 + @click="close" 179 + > 180 + {{ $t('common.close') }} 181 + </button> 182 + </div> 183 + </div> 210 184 211 - <!-- Check result --> 212 - <div v-else-if="checkResult" class="space-y-4"> 213 - <!-- Package name display --> 214 - <div class="p-4 bg-bg-subtle border border-border rounded-lg"> 215 - <p class="font-mono text-lg text-fg">{{ checkResult.name }}</p> 216 - </div> 185 + <!-- Check result --> 186 + <div v-else-if="checkResult" class="space-y-4"> 187 + <!-- Package name display --> 188 + <div class="p-4 bg-bg-subtle border border-border rounded-lg"> 189 + <p class="font-mono text-lg text-fg">{{ checkResult.name }}</p> 190 + </div> 217 191 218 - <!-- Validation errors --> 219 - <div 220 - v-if="checkResult.validationErrors?.length" 221 - class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 222 - role="alert" 223 - > 224 - <p class="font-medium mb-1">{{ $t('claim.modal.invalid_name') }}</p> 225 - <ul class="list-disc list-inside space-y-1"> 226 - <li v-for="err in checkResult.validationErrors" :key="err">{{ err }}</li> 227 - </ul> 228 - </div> 192 + <!-- Validation errors --> 193 + <div 194 + v-if="checkResult.validationErrors?.length" 195 + class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 196 + role="alert" 197 + > 198 + <p class="font-medium mb-1">{{ $t('claim.modal.invalid_name') }}</p> 199 + <ul class="list-disc list-inside space-y-1"> 200 + <li v-for="err in checkResult.validationErrors" :key="err">{{ err }}</li> 201 + </ul> 202 + </div> 229 203 230 - <!-- Validation warnings --> 231 - <div 232 - v-if="checkResult.validationWarnings?.length" 233 - class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md" 234 - role="alert" 235 - > 236 - <p class="font-medium mb-1">{{ $t('common.warnings') }}</p> 237 - <ul class="list-disc list-inside space-y-1"> 238 - <li v-for="warn in checkResult.validationWarnings" :key="warn">{{ warn }}</li> 239 - </ul> 240 - </div> 204 + <!-- Validation warnings --> 205 + <div 206 + v-if="checkResult.validationWarnings?.length" 207 + class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md" 208 + role="alert" 209 + > 210 + <p class="font-medium mb-1">{{ $t('common.warnings') }}</p> 211 + <ul class="list-disc list-inside space-y-1"> 212 + <li v-for="warn in checkResult.validationWarnings" :key="warn">{{ warn }}</li> 213 + </ul> 214 + </div> 241 215 242 - <!-- Availability status --> 243 - <div v-if="checkResult.valid"> 244 - <div 245 - v-if="checkResult.available" 246 - class="flex items-center gap-3 p-4 bg-green-500/10 border border-green-500/20 rounded-lg" 247 - > 248 - <span 249 - class="i-carbon-checkmark-filled text-green-500 w-5 h-5" 250 - aria-hidden="true" 251 - /> 252 - <p class="font-mono text-sm text-fg">{{ $t('claim.modal.available') }}</p> 253 - </div> 216 + <!-- Availability status --> 217 + <div v-if="checkResult.valid"> 218 + <div 219 + v-if="checkResult.available" 220 + class="flex items-center gap-3 p-4 bg-green-500/10 border border-green-500/20 rounded-lg" 221 + > 222 + <span class="i-carbon-checkmark-filled text-green-500 w-5 h-5" aria-hidden="true" /> 223 + <p class="font-mono text-sm text-fg">{{ $t('claim.modal.available') }}</p> 224 + </div> 254 225 255 - <div 256 - v-else 257 - class="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/20 rounded-lg" 258 - > 259 - <span class="i-carbon-close-filled text-red-500 w-5 h-5" aria-hidden="true" /> 260 - <p class="font-mono text-sm text-fg">{{ $t('claim.modal.taken') }}</p> 261 - </div> 262 - </div> 226 + <div 227 + v-else 228 + class="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/20 rounded-lg" 229 + > 230 + <span class="i-carbon-close-filled text-red-500 w-5 h-5" aria-hidden="true" /> 231 + <p class="font-mono text-sm text-fg">{{ $t('claim.modal.taken') }}</p> 232 + </div> 233 + </div> 263 234 264 - <!-- Similar packages warning --> 265 - <div v-if="checkResult.similarPackages?.length && checkResult.available"> 266 - <div 267 - :class=" 268 - hasDangerousSimilarPackages 269 - ? 'bg-yellow-500/10 border-yellow-500/20' 270 - : 'bg-bg-subtle border-border' 271 - " 272 - class="p-4 border rounded-lg" 235 + <!-- Similar packages warning --> 236 + <div v-if="checkResult.similarPackages?.length && checkResult.available"> 237 + <div 238 + :class=" 239 + hasDangerousSimilarPackages 240 + ? 'bg-yellow-500/10 border-yellow-500/20' 241 + : 'bg-bg-subtle border-border' 242 + " 243 + class="p-4 border rounded-lg" 244 + > 245 + <p 246 + :class="hasDangerousSimilarPackages ? 'text-yellow-400' : 'text-fg-muted'" 247 + class="text-sm font-medium mb-3" 248 + > 249 + <span v-if="hasDangerousSimilarPackages"> 250 + {{ $t('claim.modal.similar_warning') }} 251 + </span> 252 + <span v-else>{{ $t('claim.modal.related') }}</span> 253 + </p> 254 + <ul class="space-y-2"> 255 + <li 256 + v-for="pkg in checkResult.similarPackages.slice(0, 5)" 257 + :key="pkg.name" 258 + class="flex items-start gap-2" 259 + > 260 + <span 261 + v-if="pkg.similarity === 'exact-match'" 262 + class="i-carbon-warning-filled text-red-500 w-4 h-4 mt-0.5 shrink-0" 263 + aria-hidden="true" 264 + /> 265 + <span 266 + v-else-if="pkg.similarity === 'very-similar'" 267 + class="i-carbon-warning text-yellow-500 w-4 h-4 mt-0.5 shrink-0" 268 + aria-hidden="true" 269 + /> 270 + <span v-else class="w-4 h-4 shrink-0" /> 271 + <div class="min-w-0"> 272 + <NuxtLink 273 + :to="`/package/${pkg.name}`" 274 + class="font-mono text-sm text-fg hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 275 + target="_blank" 273 276 > 274 - <p 275 - :class="hasDangerousSimilarPackages ? 'text-yellow-400' : 'text-fg-muted'" 276 - class="text-sm font-medium mb-3" 277 - > 278 - <span v-if="hasDangerousSimilarPackages"> 279 - {{ $t('claim.modal.similar_warning') }} 280 - </span> 281 - <span v-else>{{ $t('claim.modal.related') }}</span> 282 - </p> 283 - <ul class="space-y-2"> 284 - <li 285 - v-for="pkg in checkResult.similarPackages.slice(0, 5)" 286 - :key="pkg.name" 287 - class="flex items-start gap-2" 288 - > 289 - <span 290 - v-if="pkg.similarity === 'exact-match'" 291 - class="i-carbon-warning-filled text-red-500 w-4 h-4 mt-0.5 shrink-0" 292 - aria-hidden="true" 293 - /> 294 - <span 295 - v-else-if="pkg.similarity === 'very-similar'" 296 - class="i-carbon-warning text-yellow-500 w-4 h-4 mt-0.5 shrink-0" 297 - aria-hidden="true" 298 - /> 299 - <span v-else class="w-4 h-4 shrink-0" /> 300 - <div class="min-w-0"> 301 - <NuxtLink 302 - :to="`/package/${pkg.name}`" 303 - class="font-mono text-sm text-fg hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 304 - target="_blank" 305 - > 306 - {{ pkg.name }} 307 - </NuxtLink> 308 - <p v-if="pkg.description" class="text-xs text-fg-subtle truncate"> 309 - {{ pkg.description }} 310 - </p> 311 - </div> 312 - </li> 313 - </ul> 314 - </div> 277 + {{ pkg.name }} 278 + </NuxtLink> 279 + <p v-if="pkg.description" class="text-xs text-fg-subtle truncate"> 280 + {{ pkg.description }} 281 + </p> 315 282 </div> 316 - 317 - <!-- Error message --> 318 - <div 319 - v-if="publishError" 320 - role="alert" 321 - class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 322 - > 323 - {{ publishError }} 324 - </div> 325 - 326 - <!-- Actions --> 327 - <div v-if="checkResult.available && checkResult.valid" class="space-y-3"> 328 - <!-- Warning for unscoped packages --> 329 - <div 330 - v-if="!isScoped" 331 - class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md" 332 - > 333 - <p class="font-medium mb-1">{{ $t('claim.modal.scope_warning_title') }}</p> 334 - <p class="text-xs text-yellow-400/80"> 335 - {{ 336 - $t('claim.modal.scope_warning_text', { 337 - username: npmUser || 'username', 338 - name: packageName, 339 - }) 340 - }} 341 - </p> 342 - </div> 283 + </li> 284 + </ul> 285 + </div> 286 + </div> 343 287 344 - <!-- Not connected warning --> 345 - <div v-if="!isConnected" class="space-y-3"> 346 - <div 347 - class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md" 348 - > 349 - <p>{{ $t('claim.modal.connect_required') }}</p> 350 - </div> 351 - <button 352 - type="button" 353 - class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 354 - @click="connectorModalOpen = true" 355 - > 356 - {{ $t('claim.modal.connect_button') }} 357 - </button> 358 - </div> 288 + <!-- Error message --> 289 + <div 290 + v-if="publishError" 291 + role="alert" 292 + class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 293 + > 294 + {{ publishError }} 295 + </div> 359 296 360 - <!-- Claim button --> 361 - <div v-else class="space-y-3"> 362 - <p class="text-sm text-fg-muted"> 363 - {{ $t('claim.modal.publish_hint') }} 364 - </p> 297 + <!-- Actions --> 298 + <div v-if="checkResult.available && checkResult.valid" class="space-y-3"> 299 + <!-- Warning for unscoped packages --> 300 + <div 301 + v-if="!isScoped" 302 + class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md" 303 + > 304 + <p class="font-medium mb-1">{{ $t('claim.modal.scope_warning_title') }}</p> 305 + <p class="text-xs text-yellow-400/80"> 306 + {{ 307 + $t('claim.modal.scope_warning_text', { 308 + username: npmUser || 'username', 309 + name: packageName, 310 + }) 311 + }} 312 + </p> 313 + </div> 365 314 366 - <!-- Expandable package.json preview --> 367 - <details class="border border-border rounded-md overflow-hidden"> 368 - <summary 369 - class="px-3 py-2 text-sm text-fg-muted bg-bg-subtle cursor-pointer hover:text-fg transition-colors select-none" 370 - > 371 - {{ $t('claim.modal.preview_json') }} 372 - </summary> 373 - <pre class="p-3 text-xs font-mono text-fg-muted bg-bg-muted overflow-x-auto">{{ 374 - JSON.stringify(previewPackageJson, null, 2) 375 - }}</pre> 376 - </details> 315 + <!-- Not connected warning --> 316 + <div v-if="!isConnected" class="space-y-3"> 317 + <div 318 + class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md" 319 + > 320 + <p>{{ $t('claim.modal.connect_required') }}</p> 321 + </div> 322 + <button 323 + type="button" 324 + class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 325 + @click="connectorModal.open" 326 + > 327 + {{ $t('claim.modal.connect_button') }} 328 + </button> 329 + </div> 377 330 378 - <button 379 - type="button" 380 - :disabled="isPublishing" 381 - class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 382 - @click="handleClaim" 383 - > 384 - {{ 385 - isPublishing ? $t('claim.modal.publishing') : $t('claim.modal.claim_button') 386 - }} 387 - </button> 388 - </div> 389 - </div> 331 + <!-- Claim button --> 332 + <div v-else class="space-y-3"> 333 + <p class="text-sm text-fg-muted"> 334 + {{ $t('claim.modal.publish_hint') }} 335 + </p> 390 336 391 - <!-- Close button for unavailable/invalid --> 392 - <button 393 - v-if="!checkResult.available || !checkResult.valid" 394 - type="button" 395 - class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 396 - @click="open = false" 397 - > 398 - {{ $t('common.close') }} 399 - </button> 400 - </div> 337 + <!-- Expandable package.json preview --> 338 + <details class="border border-border rounded-md overflow-hidden"> 339 + <summary 340 + class="px-3 py-2 text-sm text-fg-muted bg-bg-subtle cursor-pointer hover:text-fg transition-colors select-none" 341 + > 342 + {{ $t('claim.modal.preview_json') }} 343 + </summary> 344 + <pre class="p-3 text-xs font-mono text-fg-muted bg-bg-muted overflow-x-auto">{{ 345 + JSON.stringify(previewPackageJson, null, 2) 346 + }}</pre> 347 + </details> 401 348 402 - <!-- Error state --> 403 - <div v-else-if="publishError" class="space-y-4"> 404 - <div 405 - role="alert" 406 - class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 407 - > 408 - {{ publishError }} 409 - </div> 410 - <button 411 - type="button" 412 - class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 413 - @click="checkAvailability" 414 - > 415 - {{ $t('common.retry') }} 416 - </button> 417 - </div> 418 - </div> 349 + <button 350 + type="button" 351 + :disabled="isPublishing" 352 + class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 353 + @click="handleClaim" 354 + > 355 + {{ isPublishing ? $t('claim.modal.publishing') : $t('claim.modal.claim_button') }} 356 + </button> 419 357 </div> 420 358 </div> 421 - </Transition> 422 - </Teleport> 359 + 360 + <!-- Close button for unavailable/invalid --> 361 + <button 362 + v-if="!checkResult.available || !checkResult.valid" 363 + type="button" 364 + class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 365 + @click="close" 366 + > 367 + {{ $t('common.close') }} 368 + </button> 369 + </div> 423 370 424 - <!-- Connector modal --> 425 - <ConnectorModal v-model:open="connectorModalOpen" /> 371 + <!-- Error state --> 372 + <div v-else-if="publishError" class="space-y-4"> 373 + <div 374 + role="alert" 375 + class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 376 + > 377 + {{ publishError }} 378 + </div> 379 + <button 380 + type="button" 381 + class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 382 + @click="checkAvailability" 383 + > 384 + {{ $t('common.retry') }} 385 + </button> 386 + </div> 387 + </Modal> 426 388 </template>
+180 -233
app/components/ConnectorModal.vue
··· 1 1 <script setup lang="ts"> 2 - const open = defineModel<boolean>('open', { default: false }) 3 - 4 2 const { isConnected, isConnecting, npmUser, error, hasOperations, connect, disconnect } = 5 3 useConnector() 6 4 ··· 10 8 11 9 const hasAttemptedConnect = shallowRef(false) 12 10 11 + watch(isConnected, connected => { 12 + if (!connected) { 13 + tokenInput.value = '' 14 + hasAttemptedConnect.value = false 15 + } 16 + }) 17 + 13 18 async function handleConnect() { 14 19 hasAttemptedConnect.value = true 15 20 const port = Number.parseInt(portInput.value, 10) || 31415 16 - const success = await connect(tokenInput.value.trim(), port) 17 - if (success) { 18 - tokenInput.value = '' 19 - open.value = false 20 - } 21 + await connect(tokenInput.value.trim(), port) 21 22 } 22 23 23 24 function handleDisconnect() { ··· 40 41 // packageManager: selectedPM.value, 41 42 // }) 42 43 // }) 43 - 44 - // Reset form when modal opens 45 - watch(open, isOpen => { 46 - if (isOpen) { 47 - tokenInput.value = '' 48 - hasAttemptedConnect.value = false 49 - } 50 - }) 51 44 </script> 52 45 53 46 <template> 54 - <Teleport to="body"> 55 - <Transition 56 - enter-active-class="transition-opacity duration-200" 57 - leave-active-class="transition-opacity duration-200" 58 - enter-from-class="opacity-0" 59 - leave-to-class="opacity-0" 60 - > 61 - <div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center p-4"> 62 - <!-- Backdrop --> 63 - <button 64 - type="button" 65 - class="absolute inset-0 bg-black/60 cursor-default" 66 - :aria-label="$t('common.close_modal')" 67 - @click="open = false" 68 - /> 47 + <Modal 48 + :modalTitle="$t('connector.modal.title')" 49 + :class="isConnected && hasOperations ? 'max-w-2xl' : 'max-w-md'" 50 + id="connector-modal" 51 + > 52 + <!-- Connected state --> 53 + <div v-if="isConnected" class="space-y-4"> 54 + <div class="flex items-center gap-3 p-4 bg-bg-subtle border border-border rounded-lg"> 55 + <span class="w-3 h-3 rounded-full bg-green-500" aria-hidden="true" /> 56 + <div> 57 + <p class="font-mono text-sm text-fg">{{ $t('connector.modal.connected') }}</p> 58 + <p v-if="npmUser" class="font-mono text-xs text-fg-muted"> 59 + {{ $t('connector.modal.connected_as_user', { user: npmUser }) }} 60 + </p> 61 + </div> 62 + </div> 69 63 70 - <!-- Modal --> 71 - <div 72 - class="relative w-full bg-bg border border-border rounded-lg shadow-xl max-h-[90vh] overflow-y-auto overscroll-contain" 73 - :class="isConnected && hasOperations ? 'max-w-2xl' : 'max-w-md'" 74 - role="dialog" 75 - aria-modal="true" 76 - aria-labelledby="connector-modal-title" 77 - > 78 - <div class="p-6"> 79 - <div class="flex items-center justify-between mb-6"> 80 - <h2 id="connector-modal-title" class="font-mono text-lg font-medium"> 81 - {{ $t('connector.modal.title') }} 82 - </h2> 83 - <button 84 - type="button" 85 - class="text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 86 - :aria-label="$t('common.close')" 87 - @click="open = false" 88 - > 89 - <span class="i-carbon:close block w-5 h-5" aria-hidden="true" /> 90 - </button> 91 - </div> 64 + <!-- Operations Queue --> 65 + <OperationsQueue /> 92 66 93 - <!-- Connected state --> 94 - <div v-if="isConnected" class="space-y-4"> 95 - <div class="flex items-center gap-3 p-4 bg-bg-subtle border border-border rounded-lg"> 96 - <span class="w-3 h-3 rounded-full bg-green-500" aria-hidden="true" /> 97 - <div> 98 - <p class="font-mono text-sm text-fg">{{ $t('connector.modal.connected') }}</p> 99 - <p v-if="npmUser" class="font-mono text-xs text-fg-muted"> 100 - {{ $t('connector.modal.connected_as_user', { user: npmUser }) }} 101 - </p> 102 - </div> 103 - </div> 67 + <div v-if="!hasOperations" class="text-sm text-fg-muted"> 68 + {{ $t('connector.modal.connected_hint') }} 69 + </div> 104 70 105 - <!-- Operations Queue --> 106 - <OperationsQueue /> 71 + <button 72 + type="button" 73 + class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 74 + @click="handleDisconnect" 75 + > 76 + {{ $t('connector.modal.disconnect') }} 77 + </button> 78 + </div> 107 79 108 - <div v-if="!hasOperations" class="text-sm text-fg-muted"> 109 - {{ $t('connector.modal.connected_hint') }} 110 - </div> 80 + <!-- Disconnected state --> 81 + <form v-else class="space-y-4" @submit.prevent="handleConnect"> 82 + <!-- Contributor-only notice --> 83 + <div class="p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg"> 84 + <div class="space-y-2"> 85 + <span 86 + class="inline-block px-2 py-0.5 text-xs font-bold uppercase tracking-wider bg-amber-500/20 text-amber-400 rounded" 87 + > 88 + {{ $t('connector.modal.contributor_badge') }} 89 + </span> 90 + <p class="text-sm text-fg-muted"> 91 + <i18n-t keypath="connector.modal.contributor_notice"> 92 + <template #link> 93 + <a 94 + href="https://github.com/npmx-dev/npmx.dev/blob/main/CONTRIBUTING.md#local-connector-cli" 95 + target="_blank" 96 + rel="noopener noreferrer" 97 + class="text-amber-400 hover:underline" 98 + > 99 + {{ $t('connector.modal.contributor_link') }} 100 + </a> 101 + </template> 102 + </i18n-t> 103 + </p> 104 + </div> 105 + </div> 111 106 112 - <button 113 - type="button" 114 - class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 115 - @click="handleDisconnect" 116 - > 117 - {{ $t('connector.modal.disconnect') }} 118 - </button> 119 - </div> 107 + <p class="text-sm text-fg-muted"> 108 + {{ $t('connector.modal.run_hint') }} 109 + </p> 120 110 121 - <!-- Disconnected state --> 122 - <form v-else class="space-y-4" @submit.prevent="handleConnect"> 123 - <!-- Contributor-only notice --> 124 - <div class="p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg"> 125 - <div class="space-y-2"> 126 - <span 127 - class="inline-block px-2 py-0.5 text-xs font-bold uppercase tracking-wider bg-amber-500/20 text-amber-400 rounded" 128 - > 129 - {{ $t('connector.modal.contributor_badge') }} 130 - </span> 131 - <p class="text-sm text-fg-muted"> 132 - <i18n-t keypath="connector.modal.contributor_notice"> 133 - <template #link> 134 - <a 135 - href="https://github.com/npmx-dev/npmx.dev/blob/main/CONTRIBUTING.md#local-connector-cli" 136 - target="_blank" 137 - rel="noopener noreferrer" 138 - class="text-amber-400 hover:underline" 139 - > 140 - {{ $t('connector.modal.contributor_link') }} 141 - </a> 142 - </template> 143 - </i18n-t> 144 - </p> 145 - </div> 146 - </div> 111 + <div 112 + class="flex items-center p-3 bg-bg-muted border border-border rounded-lg font-mono text-sm" 113 + > 114 + <span class="text-fg-subtle">$</span> 115 + <span class="text-fg-subtle ms-2">pnpm npmx-connector</span> 116 + <button 117 + type="button" 118 + :aria-label="copied ? $t('connector.modal.copied') : $t('connector.modal.copy_command')" 119 + class="ms-auto text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 120 + @click="copy('pnpm npmx-connector')" 121 + > 122 + <span v-if="!copied" class="i-carbon:copy block w-5 h-5" aria-hidden="true" /> 123 + <span v-else class="i-carbon:checkmark block w-5 h-5 text-green-500" aria-hidden="true" /> 124 + </button> 125 + </div> 147 126 148 - <p class="text-sm text-fg-muted"> 149 - {{ $t('connector.modal.run_hint') }} 150 - </p> 127 + <!-- TODO: Uncomment when npmx-connector is published to npm 128 + <div 129 + class="flex items-center p-3 bg-bg-muted border border-border rounded-lg font-mono text-sm" 130 + > 131 + <span class="text-fg-subtle">$</span> 132 + <span class="text-fg-subtle ms-2">{{ executeNpmxConnectorCommand }}</span> 133 + <div class="ms-auto flex items-center gap-2"> 134 + <PackageManagerSelect /> 151 135 152 - <div 153 - class="flex items-center p-3 bg-bg-muted border border-border rounded-lg font-mono text-sm" 154 - > 155 - <span class="text-fg-subtle">$</span> 156 - <span class="text-fg-subtle ms-2">pnpm npmx-connector</span> 157 - <button 158 - type="button" 159 - :aria-label=" 160 - copied ? $t('connector.modal.copied') : $t('connector.modal.copy_command') 161 - " 162 - class="ms-auto text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 163 - @click="copy('pnpm npmx-connector')" 164 - > 165 - <span v-if="!copied" class="i-carbon:copy block w-5 h-5" aria-hidden="true" /> 166 - <span 167 - v-else 168 - class="i-carbon:checkmark block w-5 h-5 text-green-500" 169 - aria-hidden="true" 170 - /> 171 - </button> 172 - </div> 136 + <button 137 + type="button" 138 + :aria-label=" 139 + copied ? $t('connector.modal.copied') : $t('connector.modal.copy_command') 140 + " 141 + class="ms-auto text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 142 + @click="copyCommand" 143 + > 144 + <span v-if="!copied" class="i-carbon:copy block w-5 h-5" aria-hidden="true" /> 145 + <span 146 + v-else 147 + class="i-carbon:checkmark block w-5 h-5 text-green-500" 148 + aria-hidden="true" 149 + /> 150 + </button> 151 + </div> 152 + </div> 153 + --> 173 154 174 - <!-- TODO: Uncomment when npmx-connector is published to npm 175 - <div 176 - class="flex items-center p-3 bg-bg-muted border border-border rounded-lg font-mono text-sm" 177 - > 178 - <span class="text-fg-subtle">$</span> 179 - <span class="text-fg-subtle ms-2">{{ executeNpmxConnectorCommand }}</span> 180 - <div class="ms-auto flex items-center gap-2"> 181 - <PackageManagerSelect /> 155 + <p class="text-sm text-fg-muted">{{ $t('connector.modal.paste_token') }}</p> 182 156 183 - <button 184 - type="button" 185 - :aria-label=" 186 - copied ? $t('connector.modal.copied') : $t('connector.modal.copy_command') 187 - " 188 - class="ms-auto text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 189 - @click="copyCommand" 190 - > 191 - <span v-if="!copied" class="i-carbon:copy block w-5 h-5" aria-hidden="true" /> 192 - <span 193 - v-else 194 - class="i-carbon:checkmark block w-5 h-5 text-green-500" 195 - aria-hidden="true" 196 - /> 197 - </button> 198 - </div> 199 - </div> 200 - --> 201 - 202 - <p class="text-sm text-fg-muted">{{ $t('connector.modal.paste_token') }}</p> 203 - 204 - <div class="space-y-3"> 205 - <div> 206 - <label 207 - for="connector-token" 208 - class="block text-xs text-fg-subtle uppercase tracking-wider mb-1.5" 209 - > 210 - {{ $t('connector.modal.token_label') }} 211 - </label> 212 - <input 213 - id="connector-token" 214 - v-model="tokenInput" 215 - type="password" 216 - name="connector-token" 217 - :placeholder="$t('connector.modal.token_placeholder')" 218 - v-bind="noCorrect" 219 - class="w-full px-3 py-2 font-mono text-sm bg-bg-subtle border border-border rounded-md text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" 220 - /> 221 - </div> 222 - 223 - <details class="text-sm"> 224 - <summary 225 - class="text-fg-subtle cursor-pointer hover:text-fg-muted transition-colors duration-200" 226 - > 227 - {{ $t('connector.modal.advanced') }} 228 - </summary> 229 - <div class="mt-3"> 230 - <label 231 - for="connector-port" 232 - class="block text-xs text-fg-subtle uppercase tracking-wider mb-1.5" 233 - > 234 - {{ $t('connector.modal.port_label') }} 235 - </label> 236 - <input 237 - id="connector-port" 238 - v-model="portInput" 239 - type="text" 240 - name="connector-port" 241 - inputmode="numeric" 242 - autocomplete="off" 243 - class="w-full px-3 py-2 font-mono text-sm bg-bg-subtle border border-border rounded-md text-fg transition-colors duration-200 focus:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" 244 - /> 245 - </div> 246 - </details> 247 - </div> 157 + <div class="space-y-3"> 158 + <div> 159 + <label 160 + for="connector-token" 161 + class="block text-xs text-fg-subtle uppercase tracking-wider mb-1.5" 162 + > 163 + {{ $t('connector.modal.token_label') }} 164 + </label> 165 + <input 166 + id="connector-token" 167 + v-model="tokenInput" 168 + type="password" 169 + name="connector-token" 170 + :placeholder="$t('connector.modal.token_placeholder')" 171 + v-bind="noCorrect" 172 + class="w-full px-3 py-2 font-mono text-sm bg-bg-subtle border border-border rounded-md text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" 173 + /> 174 + </div> 248 175 249 - <!-- Error message (only show after user explicitly clicks Connect) --> 250 - <div 251 - v-if="error && hasAttemptedConnect" 252 - role="alert" 253 - class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 254 - > 255 - {{ error }} 256 - </div> 176 + <details class="text-sm"> 177 + <summary 178 + class="text-fg-subtle cursor-pointer hover:text-fg-muted transition-colors duration-200" 179 + > 180 + {{ $t('connector.modal.advanced') }} 181 + </summary> 182 + <div class="mt-3"> 183 + <label 184 + for="connector-port" 185 + class="block text-xs text-fg-subtle uppercase tracking-wider mb-1.5" 186 + > 187 + {{ $t('connector.modal.port_label') }} 188 + </label> 189 + <input 190 + id="connector-port" 191 + v-model="portInput" 192 + type="text" 193 + name="connector-port" 194 + inputmode="numeric" 195 + autocomplete="off" 196 + class="w-full px-3 py-2 font-mono text-sm bg-bg-subtle border border-border rounded-md text-fg transition-colors duration-200 focus:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" 197 + /> 198 + </div> 199 + </details> 200 + </div> 257 201 258 - <!-- Warning message --> 259 - <div 260 - role="alert" 261 - class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 262 - > 263 - <p class="font-mono text-sm text-fg font-bold"> 264 - {{ $t('connector.modal.warning') }} 265 - </p> 266 - <p class="text-sm text-fg-muted"> 267 - {{ $t('connector.modal.warning_text') }} 268 - </p> 269 - </div> 202 + <!-- Error message (only show after user explicitly clicks Connect) --> 203 + <div 204 + v-if="error && hasAttemptedConnect" 205 + role="alert" 206 + class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 207 + > 208 + {{ error }} 209 + </div> 270 210 271 - <button 272 - type="submit" 273 - :disabled="!tokenInput.trim() || isConnecting" 274 - class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 focus-visible:ring-offset-2 focus-visible:ring-offset-bg" 275 - > 276 - {{ 277 - isConnecting ? $t('connector.modal.connecting') : $t('connector.modal.connect') 278 - }} 279 - </button> 280 - </form> 281 - </div> 282 - </div> 211 + <!-- Warning message --> 212 + <div 213 + role="alert" 214 + class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 215 + > 216 + <p class="font-mono text-sm text-fg font-bold"> 217 + {{ $t('connector.modal.warning') }} 218 + </p> 219 + <p class="text-sm text-fg-muted"> 220 + {{ $t('connector.modal.warning_text') }} 221 + </p> 283 222 </div> 284 - </Transition> 285 - </Teleport> 223 + 224 + <button 225 + type="submit" 226 + :disabled="!tokenInput.trim() || isConnecting" 227 + class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 focus-visible:ring-offset-2 focus-visible:ring-offset-bg" 228 + > 229 + {{ isConnecting ? $t('connector.modal.connecting') : $t('connector.modal.connect') }} 230 + </button> 231 + </form> 232 + </Modal> 286 233 </template>
+19 -13
app/components/HeaderAccountMenu.client.vue
··· 1 1 <script setup lang="ts"> 2 + import { useModal } from '~/composables/useModal' 3 + 2 4 const { 3 5 isConnected: isNpmConnected, 4 6 isConnecting: isNpmConnecting, ··· 11 13 const { user: atprotoUser } = useAtproto() 12 14 13 15 const isOpen = shallowRef(false) 14 - const showConnectorModal = shallowRef(false) 15 - const showAuthModal = shallowRef(false) 16 16 17 17 /** Check if connected to at least one service */ 18 18 const hasAnyConnection = computed(() => isNpmConnected.value || !!atprotoUser.value) ··· 35 35 } 36 36 }) 37 37 38 + const connectorModal = useModal('connector-modal') 39 + 38 40 function openConnectorModal() { 39 - isOpen.value = false 40 - showConnectorModal.value = true 41 + if (connectorModal) { 42 + isOpen.value = false 43 + connectorModal.open() 44 + } 41 45 } 42 46 47 + const authModal = useModal('auth-modal') 48 + 43 49 function openAuthModal() { 44 - isOpen.value = false 45 - showAuthModal.value = true 50 + if (authModal) { 51 + isOpen.value = false 52 + authModal.open() 53 + } 46 54 } 47 55 </script> 48 56 ··· 118 126 leave-to-class="opacity-0 translate-y-1" 119 127 > 120 128 <div v-if="isOpen" class="absolute inset-ie-0 top-full pt-2 w-72 z-50" role="menu"> 121 - <div class="bg-bg-elevated border border-border rounded-lg shadow-lg overflow-hidden"> 129 + <div class="bg-bg-elevated border border-border rounded-lg shadow-lg overflow-hidden px-1"> 122 130 <!-- Connected accounts section --> 123 131 <div v-if="hasAnyConnection" class="py-1"> 124 132 <!-- npm CLI connection --> ··· 194 202 v-if="!isNpmConnected" 195 203 type="button" 196 204 role="menuitem" 197 - class="w-full px-3 py-2.5 flex items-center gap-3 hover:bg-bg-subtle transition-colors text-start" 205 + class="w-full px-3 py-2.5 flex items-center gap-3 hover:bg-bg-subtle transition-colors text-start rounded-md" 198 206 @click="openConnectorModal" 199 207 > 200 208 <span class="w-8 h-8 rounded-full bg-bg-muted flex items-center justify-center"> ··· 221 229 v-if="!atprotoUser" 222 230 type="button" 223 231 role="menuitem" 224 - class="w-full px-3 py-2.5 flex items-center gap-3 hover:bg-bg-subtle transition-colors text-start" 232 + class="w-full px-3 py-2.5 flex items-center gap-3 hover:bg-bg-subtle transition-colors text-start rounded-md" 225 233 @click="openAuthModal" 226 234 > 227 235 <span class="w-8 h-8 rounded-full bg-bg-muted flex items-center justify-center"> ··· 238 246 </div> 239 247 </div> 240 248 </Transition> 241 - 242 - <!-- Modals --> 243 - <ConnectorModal v-model:open="showConnectorModal" /> 244 - <AuthModal v-model:open="showAuthModal" /> 245 249 </div> 250 + <ConnectorModal /> 251 + <AuthModal /> 246 252 </template>
+10 -11
app/components/MobileMenu.vue
··· 4 4 const { isConnected, npmUser, avatar: npmAvatar } = useConnector() 5 5 const { user: atprotoUser } = useAtproto() 6 6 7 - const showConnectorModal = shallowRef(false) 8 - const showAuthModal = shallowRef(false) 9 - 10 7 function closeMenu() { 11 8 isOpen.value = false 12 9 } 13 10 14 11 function handleShowConnector() { 15 - showConnectorModal.value = true 16 - closeMenu() 12 + const connectorModal = document.querySelector<HTMLDialogElement>('#connector-modal') 13 + if (connectorModal) { 14 + closeMenu() 15 + connectorModal.showModal() 16 + } 17 17 } 18 18 19 19 function handleShowAuth() { 20 - showAuthModal.value = true 21 - closeMenu() 20 + const authModal = document.querySelector<HTMLDialogElement>('#auth-modal') 21 + if (authModal) { 22 + closeMenu() 23 + authModal.showModal() 24 + } 22 25 } 23 26 24 27 // Close menu on route change ··· 279 282 </Transition> 280 283 </div> 281 284 </Transition> 282 - 283 - <!-- Modals --> 284 - <ConnectorModal v-model:open="showConnectorModal" /> 285 - <AuthModal v-model:open="showAuthModal" /> 286 285 </Teleport> 287 286 </template>
+80
app/components/Modal.client.vue
··· 1 + <script setup lang="ts"> 2 + const props = defineProps<{ 3 + modalTitle: string 4 + }>() 5 + 6 + const dialogRef = ref<HTMLDialogElement>() 7 + 8 + const modalTitleId = computed(() => { 9 + const id = getCurrentInstance()?.attrs.id 10 + return id ? `${id}-title` : undefined 11 + }) 12 + 13 + function handleModalClose() { 14 + dialogRef.value?.close() 15 + } 16 + 17 + defineExpose({ 18 + showModal: () => dialogRef.value?.showModal(), 19 + close: () => dialogRef.value?.close(), 20 + }) 21 + </script> 22 + 23 + <template> 24 + <Teleport to="body"> 25 + <dialog 26 + ref="dialogRef" 27 + closedby="any" 28 + class="w-full bg-bg border border-border rounded-lg shadow-xl max-h-[90vh] overflow-y-auto overscroll-contain m-0 m-auto p-6 text-white" 29 + :aria-labelledby="modalTitleId" 30 + v-bind="$attrs" 31 + > 32 + <!-- Modal top header section --> 33 + <div class="flex items-center justify-between mb-6"> 34 + <h2 :id="modalTitleId" class="font-mono text-lg font-medium"> 35 + {{ modalTitle }} 36 + </h2> 37 + <button 38 + type="button" 39 + class="text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 40 + :aria-label="$t('common.close')" 41 + @click="handleModalClose" 42 + > 43 + <span class="i-carbon-close block w-5 h-5" aria-hidden="true" /> 44 + </button> 45 + </div> 46 + <!-- Modal body content --> 47 + <slot /> 48 + </dialog> 49 + </Teleport> 50 + </template> 51 + 52 + <style scoped> 53 + /* Backdrop styling when any of the modals are open */ 54 + dialog:modal::backdrop { 55 + @apply bg-black/60; 56 + } 57 + 58 + dialog::backdrop { 59 + pointer-events: none; 60 + } 61 + 62 + /* Modal transition styles */ 63 + dialog { 64 + opacity: 0; 65 + transition: opacity 200ms ease; 66 + transition-behavior: allow-discrete; 67 + } 68 + 69 + dialog:modal { 70 + opacity: 1; 71 + transition: opacity 200ms ease; 72 + transition-behavior: allow-discrete; 73 + } 74 + 75 + @starting-style { 76 + dialog:modal { 77 + opacity: 0; 78 + } 79 + } 80 + </style>
+25 -30
app/components/PackageDownloadAnalytics.vue
··· 5 5 import { useCssVariables } from '../composables/useColors' 6 6 import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '../utils/colors' 7 7 8 - const { 9 - weeklyDownloads, 10 - inModal = false, 11 - packageName, 12 - createdIso, 13 - } = defineProps<{ 8 + const props = defineProps<{ 14 9 weeklyDownloads: WeeklyDownloadPoint[] 15 10 inModal?: boolean 16 11 packageName: string ··· 131 126 return { 132 127 dataset: [ 133 128 { 134 - name: packageName, 129 + name: props.packageName, 135 130 type: 'line', 136 131 series: dataset.map(d => d.downloads), 137 132 color: accent.value, ··· 149 144 return { 150 145 dataset: [ 151 146 { 152 - name: packageName, 147 + name: props.packageName, 153 148 type: 'line', 154 149 series: dataset.map(d => d.downloads), 155 150 color: accent.value, ··· 162 157 return { 163 158 dataset: [ 164 159 { 165 - name: packageName, 160 + name: props.packageName, 166 161 type: 'line', 167 162 series: dataset.map(d => d.downloads), 168 163 color: accent.value, ··· 175 170 return { 176 171 dataset: [ 177 172 { 178 - name: packageName, 173 + name: props.packageName, 179 174 type: 'line', 180 175 series: dataset.map(d => d.downloads), 181 176 color: accent.value, ··· 235 230 236 231 function initDateRangeFromWeekly() { 237 232 if (hasUserEditedDates.value) return 238 - if (!weeklyDownloads?.length) return 233 + if (!props.weeklyDownloads?.length) return 239 234 240 - const first = weeklyDownloads[0] 241 - const last = weeklyDownloads[weeklyDownloads.length - 1] 235 + const first = props.weeklyDownloads[0] 236 + const last = props.weeklyDownloads[props.weeklyDownloads.length - 1] 242 237 const start = first?.weekStart ? toIsoDateOnly(first.weekStart) : '' 243 238 const end = last?.weekEnd ? toIsoDateOnly(last.weekEnd) : '' 244 239 if (isValidIsoDateOnly(start)) startDate.value = start ··· 265 260 } 266 261 267 262 watch( 268 - () => weeklyDownloads?.length, 263 + () => props.weeklyDownloads?.length, 269 264 () => { 270 265 initDateRangeFromWeekly() 271 266 initDateRangeFallbackClient() ··· 342 337 343 338 const { fetchPackageDownloadEvolution } = useCharts() 344 339 345 - const evolution = shallowRef<EvolutionData>(weeklyDownloads) 340 + const evolution = shallowRef<EvolutionData>(props.weeklyDownloads) 346 341 const pending = shallowRef(false) 347 342 348 343 let lastRequestKey = '' ··· 354 349 355 350 async function load() { 356 351 if (!import.meta.client) return 357 - if (!inModal) return 352 + if (!props.inModal) return 358 353 359 354 const o = options.value 360 355 const extraBase = ··· 366 361 367 362 const startKey = (o as any).startDate ?? '' 368 363 const endKey = (o as any).endDate ?? '' 369 - const requestKey = `${packageName}|${createdIso ?? ''}|${o.granularity}|${extraBase}|${startKey}|${endKey}` 364 + const requestKey = `${props.packageName}|${props.createdIso ?? ''}|${o.granularity}|${extraBase}|${startKey}|${endKey}` 370 365 371 366 if (requestKey === lastRequestKey) return 372 367 lastRequestKey = requestKey 373 368 374 369 const hasExplicitRange = Boolean((o as any).startDate || (o as any).endDate) 375 - if (o.granularity === 'week' && weeklyDownloads?.length && !hasExplicitRange) { 376 - evolution.value = weeklyDownloads 370 + if (o.granularity === 'week' && props.weeklyDownloads?.length && !hasExplicitRange) { 371 + evolution.value = props.weeklyDownloads 377 372 pending.value = false 378 373 displayedGranularity.value = 'weekly' 379 374 return ··· 384 379 385 380 try { 386 381 const result = await fetchPackageDownloadEvolution( 387 - () => packageName, 388 - () => createdIso, 382 + () => props.packageName, 383 + () => props.createdIso, 389 384 () => o as any, // FIXME: any 390 385 ) 391 386 ··· 404 399 } 405 400 406 401 watch( 407 - () => inModal, 402 + () => props.inModal, 408 403 () => { 409 404 // modal open/close should be immediate 410 405 load() ··· 414 409 415 410 watch( 416 411 () => [ 417 - packageName, 418 - createdIso, 412 + props.packageName, 413 + props.createdIso, 419 414 options.value.granularity, 420 415 (options.value as any).weeks, 421 416 (options.value as any).months, ··· 437 432 ) 438 433 439 434 const effectiveData = computed<EvolutionData>(() => { 440 - if (displayedGranularity.value === 'weekly' && weeklyDownloads?.length) { 435 + if (displayedGranularity.value === 'weekly' && props.weeklyDownloads?.length) { 441 436 if (isWeeklyDataset(evolution.value) && evolution.value.length) return evolution.value 442 - return weeklyDownloads 437 + return props.weeklyDownloads 443 438 } 444 439 return evolution.value 445 440 }) ··· 481 476 img: ({ imageUri }: { imageUri: string }) => { 482 477 loadFile( 483 478 imageUri, 484 - `${packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.png`, 479 + `${props.packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.png`, 485 480 ) 486 481 }, 487 482 csv: (csvStr: string) => { ··· 502 497 const url = URL.createObjectURL(blob) 503 498 loadFile( 504 499 url, 505 - `${packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.csv`, 500 + `${props.packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.csv`, 506 501 ) 507 502 URL.revokeObjectURL(url) 508 503 }, ··· 510 505 const url = URL.createObjectURL(blob) 511 506 loadFile( 512 507 url, 513 - `${packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.svg`, 508 + `${props.packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.svg`, 514 509 ) 515 510 URL.revokeObjectURL(url) 516 511 }, ··· 525 520 yLabel: $t('package.downloads.y_axis_label', { 526 521 granularity: $t(`package.downloads.granularity_${selectedGranularity.value}`), 527 522 }), 528 - xLabel: packageName, 523 + xLabel: props.packageName, 529 524 yLabelOffsetX: 12, 530 525 fontSize: isMobile.value ? 32 : 24, 531 526 },
+15 -23
app/components/PackageWeeklyDownloadStats.vue
··· 3 3 import { useCssVariables } from '../composables/useColors' 4 4 import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '../utils/colors' 5 5 6 - const { packageName } = defineProps<{ 6 + const props = defineProps<{ 7 7 packageName: string 8 8 }>() 9 9 10 - const showModal = shallowRef(false) 10 + const chartModal = useModal('chart-modal') 11 + 12 + const isChartModalOpen = shallowRef(false) 13 + function openChartModal() { 14 + isChartModalOpen.value = true 15 + // ensure the component renders before opening the dialog 16 + nextTick(() => chartModal.open()) 17 + } 11 18 12 - const { data: packument } = usePackage(() => packageName) 19 + const { data: packument } = usePackage(() => props.packageName) 13 20 const createdIso = computed(() => packument.value?.time?.created ?? null) 14 21 15 22 const { fetchPackageDownloadEvolution } = useCharts() ··· 84 91 85 92 try { 86 93 const result = await fetchPackageDownloadEvolution( 87 - () => packageName, 94 + () => props.packageName, 88 95 () => createdIso.value, 89 96 () => ({ granularity: 'week' as const, weeks: 52 }), 90 97 ) ··· 99 106 }) 100 107 101 108 watch( 102 - () => packageName, 109 + () => props.packageName, 103 110 () => loadWeeklyDownloads(), 104 111 ) 105 112 ··· 194 201 <template #actions> 195 202 <button 196 203 type="button" 197 - @click="showModal = true" 204 + @click="openChartModal" 198 205 class="link-subtle font-mono text-sm inline-flex items-center gap-1.5 ms-auto shrink-0 self-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 199 206 :title="$t('package.downloads.analyze')" 200 207 > ··· 241 248 </CollapsibleSection> 242 249 </div> 243 250 244 - <ChartModal v-model:open="showModal"> 245 - <template #title>{{ $t('package.downloads.modal_title') }}</template> 246 - 251 + <ChartModal v-if="isChartModalOpen" @close="isChartModalOpen = false"> 247 252 <PackageDownloadAnalytics 248 253 :weeklyDownloads="weeklyDownloads" 249 254 :inModal="true" 250 - :packageName="packageName" 255 + :packageName="props.packageName" 251 256 :createdIso="createdIso" 252 257 /> 253 - 254 - <template #after="{ close }"> 255 - <div class="sm:hidden flex justify-center"> 256 - <button 257 - type="button" 258 - @click="close" 259 - class="w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg flex items-center justify-center text-fg-muted hover:text-fg transition-colors" 260 - :aria-label="$t('common.close')" 261 - > 262 - <span class="w-5 h-5 i-carbon:close" aria-hidden="true" /> 263 - </button> 264 - </div> 265 - </template> 266 258 </ChartModal> 267 259 </template> 268 260
+24
app/composables/useModal.ts
··· 1 + export function useModal(modalId: string) { 2 + const getModal = () => document.querySelector<HTMLDialogElement>(`#${modalId}`) 3 + 4 + function open() { 5 + const modal = getModal() 6 + if (modal) { 7 + setTimeout(() => { 8 + modal.showModal() 9 + }) 10 + } 11 + } 12 + 13 + function close() { 14 + const modal = getModal() 15 + if (modal) { 16 + modal.close() 17 + } 18 + } 19 + 20 + return { 21 + open, 22 + close, 23 + } 24 + }
+4 -5
app/pages/search.vue
··· 287 287 ) 288 288 }) 289 289 290 - // Modal state for claiming a package 291 - const claimModalOpen = shallowRef(false) 290 + const claimPackageModalRef = useTemplateRef('claimPackageModalRef') 292 291 293 292 /** 294 293 * Check if a string is a valid npm username/org name ··· 626 625 <button 627 626 type="button" 628 627 class="shrink-0 px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md motion-safe:transition-colors motion-safe:duration-200 hover:bg-fg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 629 - @click="claimModalOpen = true" 628 + @click="claimPackageModalRef?.open()" 630 629 > 631 630 {{ $t('search.claim_button', { name: query }) }} 632 631 </button> ··· 717 716 <button 718 717 type="button" 719 718 class="px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 720 - @click="claimModalOpen = true" 719 + @click="claimPackageModalRef?.open()" 721 720 > 722 721 {{ $t('search.claim_button', { name: query }) }} 723 722 </button> ··· 763 762 </div> 764 763 765 764 <!-- Claim package modal --> 766 - <ClaimPackageModal v-model:open="claimModalOpen" :package-name="query" /> 765 + <ClaimPackageModal ref="claimPackageModalRef" :package-name="query" /> 767 766 </main> 768 767 </template>
+4 -1
app/pages/settings.vue
··· 10 10 'Escape', 11 11 e => { 12 12 const target = e.target as HTMLElement 13 - if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(target?.tagName)) { 13 + if ( 14 + !['INPUT', 'SELECT', 'TEXTAREA'].includes(target?.tagName) && 15 + !document.documentElement.matches('html:has(:modal)') 16 + ) { 14 17 e.preventDefault() 15 18 router.back() 16 19 }