wip bsky client for the web & android bbell.vt3e.cat

feat: login page v3

vt3e.cat cbf5059a d5fa73ce

verified
+527 -96
+221
src/components/Dialogs/CreateAccount.vue
··· 1 + <script lang="ts" setup> 2 + import { computed, onMounted, ref } from 'vue' 3 + 4 + import { useAuthStore } from '@/stores/auth' 5 + import { getProviderList, type HydratedProvider } from '@/utils/pds' 6 + 7 + import Button from '../UI/BaseButton.vue' 8 + import Toggle from '../UI/BaseCheckbox.vue' 9 + import Modal from '../UI/BaseModal.vue' 10 + import PdsList from '../PdsSelector.vue' 11 + 12 + const auth = useAuthStore() 13 + const isOpen = defineModel<boolean>('open', { required: true }) 14 + function closeModal() { 15 + isOpen.value = false 16 + } 17 + 18 + const providers = ref<HydratedProvider[]>([]) 19 + const selectedProvider = ref<HydratedProvider>() 20 + 21 + const selectedHasHandlePolicy = computed(() => !!selectedProvider.value?.handlePolicy) 22 + const hasAgreedToHandlePolicy = ref(false) 23 + 24 + const canProgress = computed(() => { 25 + const selected = selectedProvider.value 26 + if (!selected) return false 27 + if (selectedHasHandlePolicy.value && hasAgreedToHandlePolicy.value) return true 28 + return !selected.handlePolicy 29 + }) 30 + 31 + const pdsError = ref('') 32 + const pdsInput = ref('') 33 + 34 + async function submitPds() { 35 + const val = 36 + pdsInput.value.trim() === '' ? selectedProvider.value?.url.toString() : pdsInput.value.trim() 37 + if (!val) { 38 + pdsError.value = 'Enter a PDS URL or handle' 39 + return 40 + } 41 + await auth.login(val) 42 + } 43 + 44 + onMounted(async () => { 45 + providers.value = await getProviderList() 46 + if (providers.value.length) { 47 + selectedProvider.value = providers.value[0] 48 + } else { 49 + pdsError.value = 'No providers available' 50 + } 51 + }) 52 + 53 + function selectProvider(provider: HydratedProvider) { 54 + hasAgreedToHandlePolicy.value = false 55 + selectedProvider.value = provider 56 + } 57 + </script> 58 + 59 + <template> 60 + <Modal title="Create an account" v-model:open="isOpen" width="600px"> 61 + <div class="modal-body"> 62 + <p> 63 + Choose a provider to host your account. You can migrate to a different provider later if 64 + needed. 65 + </p> 66 + 67 + <PdsList @select="selectProvider" /> 68 + 69 + <div v-if="selectedProvider" class="provider-profile"> 70 + <div class="profile-main"> 71 + <div class="pds-info"> 72 + <p class="pds-name">{{ selectedProvider.name }}</p> 73 + <p class="pds-location">{{ selectedProvider.location }}</p> 74 + </div> 75 + </div> 76 + 77 + <div 78 + class="policy-transition-wrapper" 79 + :class="{ open: selectedHasHandlePolicy }" 80 + :inert="!selectedHasHandlePolicy" 81 + > 82 + <div class="policy-inner"> 83 + <div class="policy-section"> 84 + <div class="policy-notice"> 85 + <p> 86 + This provider has restrictions on who can use what handles. 87 + <a :href="selectedProvider.handlePolicy?.toString() || '#'" class="policy-link"> 88 + View terms here 89 + </a> 90 + </p> 91 + </div> 92 + <label class="policy-checkbox" :class="{ checked: hasAgreedToHandlePolicy }"> 93 + <Toggle v-model="hasAgreedToHandlePolicy" id="policy-agree" /> 94 + <span>I've read and accept these terms</span> 95 + </label> 96 + </div> 97 + </div> 98 + </div> 99 + </div> 100 + </div> 101 + 102 + <template #footer> 103 + <Button variant="ghost" type="button" @click="closeModal">Cancel</Button> 104 + <Button 105 + variant="primary" 106 + :loading="auth.isLoading" 107 + @click="submitPds" 108 + type="button" 109 + :disabled="!canProgress" 110 + > 111 + Create account 112 + </Button> 113 + </template> 114 + </Modal> 115 + </template> 116 + 117 + <style lang="scss" scoped> 118 + .modal-body { 119 + display: flex; 120 + flex-direction: column; 121 + gap: 1.5rem; 122 + } 123 + 124 + .provider-list { 125 + margin-top: -0.75rem; 126 + } 127 + 128 + .provider-profile { 129 + background: hsla(var(--surface0) / 0.4); 130 + margin-left: -0.75rem; 131 + width: calc(100% + 1.5rem); 132 + border-radius: 1rem; 133 + padding: 1.25rem; 134 + display: flex; 135 + flex-direction: column; 136 + gap: 0; 137 + 138 + .profile-main { 139 + display: flex; 140 + align-items: center; 141 + gap: 1rem; 142 + 143 + .pds-info { 144 + display: flex; 145 + flex-direction: row; 146 + align-items: baseline; 147 + gap: 0.5rem; 148 + 149 + .pds-name { 150 + font-weight: 600; 151 + font-size: 1rem; 152 + color: hsla(var(--text)); 153 + display: block; 154 + } 155 + 156 + .pds-location { 157 + font-size: 0.8rem; 158 + color: hsla(var(--text) / 0.6); 159 + display: block; 160 + } 161 + } 162 + } 163 + 164 + .policy-transition-wrapper { 165 + display: grid; 166 + grid-template-rows: 0fr; 167 + opacity: 0; 168 + 169 + &.open { 170 + grid-template-rows: 1fr; 171 + opacity: 1; 172 + } 173 + } 174 + 175 + .policy-inner { 176 + overflow: hidden; 177 + min-height: 0; 178 + } 179 + 180 + .policy-section { 181 + border-top: 1px dashed hsla(var(--accent) / 0.5); 182 + margin-top: 0.5rem; 183 + padding-top: 0.5rem; 184 + 185 + display: flex; 186 + flex-direction: column; 187 + gap: 0.25rem; 188 + 189 + .policy-notice { 190 + font-size: 0.85rem; 191 + color: hsl(var(--subtext1)); 192 + 193 + .policy-link { 194 + color: hsla(var(--accent)); 195 + text-decoration: none; 196 + font-weight: 700; 197 + 198 + &:hover { 199 + text-decoration: underline; 200 + } 201 + } 202 + } 203 + 204 + .policy-checkbox { 205 + display: flex; 206 + align-items: center; 207 + gap: 0.75rem; 208 + 209 + span { 210 + font-size: 0.85rem; 211 + user-select: none; 212 + } 213 + } 214 + } 215 + } 216 + 217 + .fade-enter-from, 218 + .fade-leave-to { 219 + opacity: 0; 220 + } 221 + </style>
-1
src/components/Navigation/TabStack.vue
··· 20 20 }) 21 21 : comp 22 22 23 - console.log(acc) 24 23 return acc 25 24 }, 26 25 {} as Record<string, Component>,
+152
src/components/PdsSelector.vue
··· 1 + <script lang="ts" setup> 2 + import { onMounted, ref } from 'vue' 3 + import { IconCheckRounded } from '@iconify-prerendered/vue-material-symbols' 4 + 5 + import { getProviderList, type HydratedProvider } from '@/utils/pds' 6 + 7 + const loadedProviders = ref(false) 8 + const providers = ref<HydratedProvider[]>([]) 9 + 10 + const selectedProvider = ref<HydratedProvider>() 11 + 12 + const emit = defineEmits<{ 13 + (e: 'select', provider: HydratedProvider): void 14 + }>() 15 + 16 + onMounted(async () => { 17 + providers.value = await getProviderList() 18 + 19 + loadedProviders.value = true 20 + if (!providers.value.length) { 21 + return 22 + } 23 + 24 + selectedProvider.value = providers.value[0] 25 + if (!selectedProvider.value) return 26 + 27 + emit('select', selectedProvider.value) 28 + }) 29 + 30 + function selectProvider(provider: HydratedProvider) { 31 + selectedProvider.value = provider 32 + emit('select', provider) 33 + } 34 + </script> 35 + 36 + <template> 37 + <div class="provider-list"> 38 + <template v-if="loadedProviders"> 39 + <div 40 + v-for="(provider, i) in providers" 41 + :key="provider.url?.href || provider.name + i" 42 + class="provider" 43 + :class="{ selected: provider === selectedProvider, pinned: provider.pin }" 44 + role="option" 45 + :aria-selected="provider === selectedProvider" 46 + tabindex="0" 47 + @click="selectProvider(provider)" 48 + @keydown.enter.prevent="selectProvider(provider)" 49 + @keydown.space.prevent="selectProvider(provider)" 50 + > 51 + <div class="meta"> 52 + <h3>{{ provider.name }}</h3> 53 + <p>{{ provider.subtitle || provider.url.hostname }}</p> 54 + </div> 55 + <div class="hint"> 56 + <IconCheckRounded /> 57 + </div> 58 + </div> 59 + </template> 60 + <template v-else> 61 + <div class="provider skeleton" v-for="i in 4" :key="i"> 62 + <div class="meta"> 63 + <h3>...</h3> 64 + <p>...</p> 65 + </div> 66 + <div class="hint"> 67 + <IconCheckRounded /> 68 + </div> 69 + </div> 70 + </template> 71 + </div> 72 + </template> 73 + 74 + <style lang="scss" scoped> 75 + .provider-list { 76 + display: flex; 77 + flex-direction: column; 78 + gap: 0.25rem; 79 + 80 + --offset: 0.75rem; 81 + margin-left: calc(var(--offset) * -1); 82 + width: calc(100% + var(--offset) * 2); 83 + } 84 + 85 + .provider { 86 + display: flex; 87 + --colour: var(--surface0); 88 + gap: 0.5rem; 89 + align-items: center; 90 + padding: 1rem; 91 + background-color: hsla(var(--colour) / 0.5); 92 + /* border-radius: 0.25rem; */ 93 + border-radius: 0.5rem; 94 + cursor: pointer; 95 + 96 + &.skeleton { 97 + .skeleton-text { 98 + height: 1rem; 99 + width: 100%; 100 + border-radius: 0.25rem; 101 + background-color: hsla(var(--surface0) / 0.5); 102 + } 103 + } 104 + 105 + &:hover, 106 + &:focus-visible { 107 + background-color: hsla(var(--colour) / 0.7); 108 + } 109 + &:active { 110 + background-color: hsla(var(--colour) / 0.4); 111 + } 112 + 113 + .hint { 114 + opacity: 0; 115 + height: 100%; 116 + aspect-ratio: 1/1; 117 + width: 1.75rem; 118 + 119 + svg { 120 + width: 100%; 121 + height: 100%; 122 + color: hsla(var(--accent) / 1); 123 + } 124 + } 125 + 126 + .meta { 127 + flex: 1; 128 + 129 + h3 { 130 + font-weight: 600; 131 + font-size: 0.95rem; 132 + color: hsl(var(--text)); 133 + white-space: nowrap; 134 + } 135 + 136 + p { 137 + font-size: 0.8rem; 138 + color: hsl(var(--subtext0)); 139 + line-height: 1.4; 140 + } 141 + } 142 + 143 + &.selected { 144 + border-radius: 2.5rem; 145 + --colour: var(--surface1); 146 + 147 + .hint { 148 + opacity: 1; 149 + } 150 + } 151 + } 152 + </style>
+7 -7
src/components/UI/BaseButton.vue
··· 126 126 } 127 127 } 128 128 129 - .variant-primary { 129 + .th-btn.variant-primary { 130 130 --bg-colour: var(--accent); 131 131 --text-colour: var(--base); 132 132 --border-colour: var(--accent); 133 133 } 134 134 135 - .variant-secondary { 135 + .th-btn.variant-secondary { 136 136 --bg-colour: var(--surface0); 137 137 --text-colour: var(--text); 138 138 --border-colour: var(--surface2); 139 139 } 140 140 141 - .variant-subtle { 141 + .th-btn.variant-subtle { 142 142 --bg-colour: var(--accent); 143 143 --text-colour: var(--accent); 144 144 --border-colour: var(--accent); ··· 156 156 } 157 157 } 158 158 159 - .variant-subtle-alt { 159 + .th-btn.variant-subtle-alt { 160 160 border-color: transparent; 161 - background-color: hsla(var(--subtext0) / 0.04); 161 + background-color: hsla(var(--overlay0) / 0.1); 162 162 color: hsl(var(--subtext0)); 163 163 164 164 &:hover:not(:disabled) { 165 - background-color: hsla(var(--subtext0) / 0.06); 165 + background-color: hsla(var(--overlay0) / 0.15); 166 166 border-color: transparent; 167 167 } 168 168 &:active:not(:disabled) { 169 - background-color: hsla(var(--subtext0) / 0.02); 169 + background-color: hsla(var(--overlay0) / 0.075); 170 170 border-color: transparent; 171 171 } 172 172 }
+7 -3
src/components/UI/BaseCheckbox.vue
··· 45 45 width: 1.5rem; 46 46 height: 1.5rem; 47 47 border-radius: 0.5rem; 48 - border: 2px solid hsl(var(--subtext0)); 49 - background-color: hsl(var(--surface0)); 48 + border: 2px solid hsla(var(--subtext0) / 0.75); 49 + background-color: hsla(var(--surface0) / 0.5); 50 50 display: flex; 51 51 align-items: center; 52 52 justify-content: center; 53 - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 54 53 color: hsl(var(--base)); 54 + 55 + &:hover { 56 + background-color: hsla(var(--surface0) / 1); 57 + border-color: hsla(var(--subtext0) / 0.75); 58 + } 55 59 } 56 60 57 61 .checkbox-box.is-checked {
+2
src/components/UI/BaseModal.vue
··· 6 6 defineProps<{ 7 7 title?: string 8 8 width?: string 9 + id?: string 9 10 }>() 10 11 11 12 const emit = defineEmits<{ ··· 160 161 161 162 <Transition :name="isMobile ? 'slide-up' : 'zoom'"> 162 163 <div 164 + :id="id" 163 165 v-if="isOpen" 164 166 ref="modalContainerRef" 165 167 class="modal-container"
+1
src/components/UI/ListItemContent.vue
··· 1 1 <script lang="ts" setup> 2 2 import type { PageNames, RouteParams } from '@/router' 3 + import { IconChevronRightRounded } from '@iconify-prerendered/vue-material-symbols' 3 4 4 5 defineProps<{ 5 6 title?: string
+9 -4
src/stores/auth.ts
··· 111 111 authUrl = await createAuthorizationUrl({ 112 112 target: { type: 'pds', serviceUrl: input }, 113 113 scope, 114 - // @ts-expect-error: craete will be available soon:tm: 115 - prompt: createAccount ? 'create' : undefined, 114 + // prompt: createAccount ? 'create' : undefined, 116 115 }) 117 116 } else { 118 117 try { ··· 148 147 } 149 148 } 150 149 151 - async function attemptFallbackPdsLogin(input: string, scope: string): Promise<URL> { 150 + async function attemptFallbackPdsLogin( 151 + input: string, 152 + scope: string, 153 + isRetry = false, 154 + ): Promise<URL> { 152 155 const serviceUrl = `https://${input}` 153 156 154 157 const handler = simpleFetchHandler({ service: serviceUrl }) 155 158 const client = new Client({ handler }) 156 159 const { ok } = await client.get('com.atproto.server.describeServer') 157 160 158 - if (!ok) throw new Error('Failed to resolve handle and server is not a valid PDS') 161 + if (!ok) { 162 + throw new Error('Failed to resolve handle and server is not a valid PDS') 163 + } 159 164 160 165 return createAuthorizationUrl({ 161 166 target: { type: 'pds', serviceUrl },
+87
src/utils/pds.ts
··· 1 + import type { ComAtprotoServerDescribeServer } from '@atcute/atproto' 2 + import { Client, simpleFetchHandler } from '@atcute/client' 3 + 4 + export type BaseProvider = { 5 + name: string 6 + url: URL 7 + location: string 8 + subtitle?: string 9 + /** whether this provider should be pinned when displayed */ 10 + pin?: boolean 11 + /** if the PDS has a policy on who can use what handles, blacksky, for example. */ 12 + handlePolicy?: URL 13 + } 14 + 15 + export type HydratedProvider = BaseProvider & { 16 + server: ComAtprotoServerDescribeServer.$output 17 + } 18 + 19 + export const PROVIDERS: BaseProvider[] = [ 20 + { 21 + name: 'selfhosted.social', 22 + url: new URL('https://selfhosted.social/'), 23 + subtitle: 'A popular community-run PDS', 24 + location: 'US', 25 + pin: true, 26 + }, 27 + { 28 + name: 'Bluesky', 29 + url: new URL('https://bsky.social/'), 30 + location: 'US', 31 + }, 32 + { 33 + name: 'Blacksky', 34 + url: new URL('https://blacksky.app/'), 35 + subtitle: 'A PDS for the black community and allies.', 36 + location: 'US', 37 + handlePolicy: new URL( 38 + 'https://docs.blacksky.community/migrating-to-blacksky-pds-complete-guide#who-can-use-blacksky-services', 39 + ), 40 + }, 41 + { 42 + name: 'Tophhie Social', 43 + url: new URL('https://pds.tophhie.cloud'), 44 + location: 'GB', 45 + }, 46 + ] 47 + 48 + export async function getHydratedProviders(): Promise<HydratedProvider[]> { 49 + return ( 50 + await Promise.all( 51 + PROVIDERS.map(async (provider) => { 52 + const xrpc = new Client({ 53 + handler: simpleFetchHandler({ service: provider.url }), 54 + }) 55 + 56 + const { data, ok } = await xrpc.get('com.atproto.server.describeServer', {}) 57 + if (!ok) return undefined 58 + 59 + return { 60 + ...provider, 61 + server: data, 62 + } 63 + }), 64 + ) 65 + ).filter(Boolean) as HydratedProvider[] 66 + } 67 + 68 + function shuffle<T>(array: T[]): T[] { 69 + const shuffled = [...array] 70 + for (let i = shuffled.length - 1; i > 0; i--) { 71 + const j = Math.floor(Math.random() * (i + 1)) 72 + const temp = shuffled[i]! 73 + shuffled[i] = shuffled[j]! 74 + shuffled[j] = temp 75 + } 76 + 77 + return shuffled 78 + } 79 + 80 + export async function getProviderList(shuffled = true): Promise<HydratedProvider[]> { 81 + const hydratedProviders = await getHydratedProviders() 82 + const pinnedProviders = hydratedProviders.filter((provider) => provider.pin) 83 + const unpinnedProviders = hydratedProviders.filter((provider) => !provider.pin) 84 + 85 + if (shuffled) return [...pinnedProviders, ...shuffle(unpinnedProviders)] 86 + return [...pinnedProviders, ...unpinnedProviders] 87 + }
+39 -77
src/views/Auth/LoginPage.vue
··· 1 1 <script setup lang="ts"> 2 - import { ref, computed } from 'vue' 3 - import { IconOpenInNewRounded } from '@iconify-prerendered/vue-material-symbols' 2 + import { ref, computed, onMounted } from 'vue' 4 3 5 4 import { useAuthStore } from '@/stores/auth' 6 5 import PageLayout from '@/components/Navigation/PageLayout.vue' 7 6 import Button from '@/components/UI/BaseButton.vue' 8 7 import Modal from '@/components/UI/BaseModal.vue' 9 8 import TextInput from '@/components/UI/TextInput.vue' 9 + import { getProviderList, type HydratedProvider } from '@/utils/pds' 10 10 11 - const auth = useAuthStore() 11 + import CreateAccount from '@/components/Dialogs/CreateAccount.vue' 12 12 13 - type Provider = { 14 - name: string 15 - url: string 16 - subtitle?: string 17 - location: string 18 - } 19 - const providerList: Provider[] = [ 20 - { name: 'Bluesky PBC', subtitle: 'bsky.social', url: 'https://bsky.social/', location: 'US' }, 21 - { 22 - name: 'selfhosted.social', 23 - url: 'https://selfhosted.social/', 24 - subtitle: 25 - 'A collection of hackers, designers, developers, ATProto enthusiasts, scrobblers, tinkerers, friends, and curious minds. A shared space where everyone is welcome to be themselves.', 26 - location: 'US', 27 - }, 28 - { 29 - name: 'Tophhie Social', 30 - subtitle: 'pds.tophhie.cloud', 31 - url: 'https://pds.tophhie.cloud', 32 - location: 'GB', 33 - }, 34 - // { 35 - // name: 'Zio', 36 - // subtitle: 'zio.blue', 37 - // url: 'https://zio.blue', 38 - // location: 'Finland', 39 - // }, 40 - { 41 - name: 'peedee.es', 42 - url: 'https://peedee.es', 43 - location: 'Germany', 44 - }, 45 - { 46 - name: 'Blacksky', 47 - subtitle: 'A PDS for the black community.', 48 - url: 'https://blacksky.app/', 49 - location: 'US', 50 - }, 51 - { name: 'Spark', subtitle: 'pds.sprk.so', url: 'https://pds.sprk.so', location: 'US' }, 52 - ] 13 + const auth = useAuthStore() 53 14 54 15 const isLoading = computed(() => auth.isLoading) 16 + const isLoadingProviders = ref(true) 55 17 const showPdsModal = ref(false) 56 18 const pdsInput = ref('') 57 19 const pdsError = ref('') 58 20 21 + const showCreateAccountModal = ref(false) 22 + 23 + const providers = ref<HydratedProvider[]>([]) 24 + 59 25 async function pdsSubmit(pds: string, providerList = false) { 60 26 if (!pds || isLoading.value) return 61 27 await auth.login(pds, providerList) ··· 67 33 showPdsModal.value = true 68 34 } 69 35 36 + function openCreateModal() { 37 + showCreateAccountModal.value = true 38 + } 39 + 70 40 async function submitPds() { 71 41 const val = pdsInput.value.trim() 72 42 if (!val) { 73 43 pdsError.value = 'Enter a PDS URL or handle (e.g. https://pds.example or awesome.cat)' 74 44 return 75 45 } 76 - showPdsModal.value = false 77 46 await auth.login(val) 78 47 } 48 + 49 + onMounted(async () => { 50 + providers.value = await getProviderList() 51 + isLoadingProviders.value = false 52 + }) 79 53 </script> 80 54 81 55 <template> ··· 91 65 variant="primary" 92 66 size="lg" 93 67 :block="true" 94 - :loading="isLoading" 68 + :loading="isLoading && !showPdsModal" 95 69 @click="pdsSubmit('https://bsky.social')" 96 70 > 97 71 <span class="btn-inner"> ··· 100 74 </Button> 101 75 102 76 <div class="secondary-row"> 103 - <Button variant="text" @click="openPdsModal" :disabled="isLoading"> 104 - Sign in with AT Protocol 77 + <Button variant="subtle-alt" @click="openPdsModal" :disabled="isLoading && !showPdsModal"> 78 + Enter your handle 79 + </Button> 80 + <Button 81 + variant="subtle-alt" 82 + @click="openCreateModal" 83 + :disabled="isLoading && !showPdsModal" 84 + > 85 + Create an account 105 86 </Button> 106 87 </div> 107 88 </div> 108 89 109 - <div class="provider-list"> 110 - <button 111 - v-for="provider in providerList" 112 - :key="provider.name" 113 - class="list-item" 114 - :disabled="isLoading" 115 - @click="() => pdsSubmit(provider.url, true)" 116 - > 117 - <div class="content-grid"> 118 - <div class="top-row"> 119 - <span class="name">{{ provider.name }}</span> 120 - <span class="location" v-if="provider.location">{{ provider.location }}</span> 121 - </div> 122 - 123 - <p class="subtitle"> 124 - {{ provider.subtitle || provider.url.replace('https://', '') }} 125 - </p> 126 - </div> 127 - 128 - <div class="action"> 129 - <IconOpenInNewRounded /> 130 - </div> 131 - </button> 132 - </div> 133 - 134 90 <p v-if="auth.error" class="error-text">{{ auth.error }}</p> 135 91 </div> 136 92 137 - <Modal v-model:open="showPdsModal" title="Sign in with PDS"> 93 + <Modal v-model:open="showPdsModal" title="Enter your handle" @close="showPdsModal = false"> 138 94 <div class="modal-body"> 139 95 <label for="pds-input" class="sr-only">PDS URL or handle</label> 140 96 ··· 154 110 155 111 <template #footer> 156 112 <Button variant="ghost" type="button" @click="showPdsModal = false">Cancel</Button> 157 - <Button variant="primary" :loading="auth.isLoading" @click="submitPds" type="button" 158 - >Sign in</Button 159 - > 113 + <Button variant="primary" :loading="auth.isLoading" @click="submitPds" type="button"> 114 + Sign in 115 + </Button> 160 116 </template> 161 117 </Modal> 118 + 119 + <CreateAccount v-model:open="showCreateAccountModal" @close="showCreateAccountModal = false" /> 162 120 </PageLayout> 163 121 </template> 164 122 ··· 201 159 } 202 160 .secondary-row { 203 161 display: flex; 204 - justify-content: center; 162 + gap: 0.5rem; 163 + width: 100%; 164 + button { 165 + flex: 1; 166 + } 205 167 } 206 168 207 169 .provider-list {
+2 -1
src/views/Auth/OAuthCallback.vue
··· 37 37 38 38 onMounted(async () => { 39 39 try { 40 - if (!auth.session) return 41 40 const style = loadingBar.value?.style 42 41 43 42 loadingMessage.value = 'verifying... ' ··· 50 49 style?.setProperty('--scale', (2 / 3).toString()) 51 50 52 51 const rpc = auth.getRpc() 52 + if (!auth.session) return 53 + 53 54 const { data, ok } = await rpc.get('app.bsky.actor.getProfile', { 54 55 params: { actor: auth.session?.info.sub }, 55 56 })
-3
src/views/Profile/FollowsView.vue
··· 37 37 38 38 const fetcher = async (c?: string | null) => { 39 39 const rpc = auth.getRpc() 40 - console.log(mode.value) 41 40 const endpoint = 42 41 mode.value === 'followers' ? 'app.bsky.graph.getFollowers' : 'app.bsky.graph.getFollows' 43 42 ··· 46 45 headers: { 'atproto-proxy': BSKY_APPVIEW }, 47 46 }) 48 47 if (!ok) throw new Error((data && data.error) || 'Failed') 49 - 50 - console.log(props) 51 48 52 49 return { 53 50 // @ts-expect-error: its ok typescript. ..