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 { authRedirect } from '~/utils/atproto/helpers'
4import { isAtIdentifierString } from '@atproto/lex'
5
6const handleInput = shallowRef('')
7const errorMessage = shallowRef('')
8const route = useRoute()
9const { user, logout } = useAtproto()
10
11// https://atproto.com supports 4 locales as of 2026-02-07
12const { locale } = useI18n()
13const currentLang = locale.value.split('-')[0] ?? 'en'
14const localeSubPath = ['ko', 'pt', 'ja'].includes(currentLang) ? currentLang : ''
15const atprotoLink = `https://atproto.com/${localeSubPath}`
16
17async function handleBlueskySignIn() {
18 await authRedirect('https://bsky.social', { redirectTo: route.fullPath, locale: locale.value })
19}
20
21async function handleCreateAccount() {
22 await authRedirect('https://npmx.social', {
23 create: true,
24 redirectTo: route.fullPath,
25 locale: locale.value,
26 })
27}
28
29async function handleLogin() {
30 if (handleInput.value) {
31 // URLS to PDSs are valid for initiating oauth flows
32 if (handleInput.value.startsWith('https://') || isAtIdentifierString(handleInput.value)) {
33 await authRedirect(handleInput.value, {
34 redirectTo: route.fullPath,
35 locale: locale.value,
36 })
37 } else {
38 errorMessage.value = $t('auth.modal.default_input_error')
39 }
40 }
41}
42
43watch(handleInput, newHandleInput => {
44 errorMessage.value = ''
45 if (!newHandleInput) return
46
47 const normalized = newHandleInput.trim().toLowerCase().replace(/@/g, '')
48
49 if (normalized !== newHandleInput) {
50 handleInput.value = normalized
51 }
52})
53</script>
54
55<template>
56 <!-- Modal -->
57 <Modal :modalTitle="$t('auth.modal.title')" class="max-w-lg" id="auth-modal">
58 <div v-if="user?.handle" class="space-y-4">
59 <div class="flex items-center gap-3 p-4 bg-bg-subtle border border-border rounded-lg">
60 <span class="w-3 h-3 rounded-full bg-green-500" aria-hidden="true" />
61 <div>
62 <p class="font-mono text-xs text-fg-muted">
63 {{ $t('auth.modal.connected_as', { handle: user.handle }) }}
64 </p>
65 </div>
66 </div>
67 <button
68 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-accent/70"
69 @click="logout"
70 >
71 {{ $t('auth.modal.disconnect') }}
72 </button>
73 </div>
74
75 <!-- Disconnected state -->
76 <form v-else class="space-y-4" @submit.prevent="handleLogin">
77 <p class="text-sm text-fg-muted">{{ $t('auth.modal.connect_prompt') }}</p>
78
79 <div class="space-y-3">
80 <div>
81 <label
82 for="handle-input"
83 class="block text-xs text-fg-subtle uppercase tracking-wider mb-1.5"
84 >
85 {{ $t('auth.modal.handle_label') }}
86 </label>
87 <InputBase
88 id="handle-input"
89 v-model="handleInput"
90 type="text"
91 name="handle"
92 :placeholder="$t('auth.modal.handle_placeholder')"
93 no-correct
94 class="w-full"
95 size="medium"
96 />
97 <p v-if="errorMessage" class="text-red-500 text-xs mt-1" role="alert">
98 {{ errorMessage }}
99 </p>
100 </div>
101
102 <details class="text-sm">
103 <summary
104 class="text-fg-subtle hover:text-fg-muted transition-colors duration-200 focus-visible:(outline-2 outline-accent/70)"
105 >
106 {{ $t('auth.modal.what_is_atmosphere') }}
107 </summary>
108 <div class="mt-3">
109 <i18n-t keypath="auth.modal.atmosphere_explanation" tag="p" scope="global">
110 <template #npmx>
111 <span class="font-bold">npmx.dev</span>
112 </template>
113 <template #atproto>
114 <a :href="atprotoLink" target="_blank" class="text-blue-400 hover:underline">
115 AT Protocol
116 </a>
117 </template>
118 <template #bluesky>
119 <a href="https://bsky.app" target="_blank" class="text-blue-400 hover:underline">
120 Bluesky
121 </a>
122 </template>
123 <template #tangled>
124 <a href="https://tangled.org" target="_blank" class="text-blue-400 hover:underline">
125 Tangled
126 </a>
127 </template>
128 </i18n-t>
129 </div>
130 </details>
131 </div>
132
133 <ButtonBase type="submit" variant="primary" :disabled="!handleInput.trim()" class="w-full">
134 {{ $t('auth.modal.connect') }}
135 </ButtonBase>
136 <ButtonBase type="button" variant="primary" class="w-full" @click="handleCreateAccount">
137 {{ $t('auth.modal.create_account') }}
138 </ButtonBase>
139 <hr class="color-border" />
140 <ButtonBase type="button" variant="primary" class="w-full" @click="handleBlueskySignIn" block>
141 {{ $t('auth.modal.connect_bluesky') }}
142 <svg fill="none" viewBox="0 0 64 57" width="20" style="width: 20px">
143 <path
144 fill="#0F73FF"
145 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"
146 ></path>
147 </svg>
148 </ButtonBase>
149 </form>
150 </Modal>
151</template>