wip bsky client for the web & android
0
fork

Configure Feed

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

feat: uhhhh cute animations and uhhhhhhh

vi ae7800f4 e23b360c

+526 -185
+15 -6
README.md
··· 5 5 work in progress client for bluesky & other atproto-based apps, written with vue 6 6 & typescript. 7 7 8 + [try it here](https://bbell.vt3e.cat) - note that this may be terribly out of date! 9 + i have not yet set up automated deployments. 10 + 8 11 ## todo 9 12 10 13 as stated previously, bluebell is _very_ work in progress and is far from feature 11 14 parity with the official bluesky app or other third party clients. 12 15 13 - you can see [TODO.md](./.tangled/TODO.md) for a more detailed list of planned features 14 - & progress, i will definitely forget to update it though. 16 + at the moment, what is implemented is: 17 + 18 + - oauth login 19 + - viewing feeds, interacting with posts/replies (likes, reposts, replying) 20 + - viewing profiles, following/unfollowing, viewing followers/following lists 21 + - posting incl. video & images, though i don't do any compression 22 + - smooth animations and a pretty ui!! 15 23 16 - general roadmap: feature parity with social-app, then later integrations with 17 - other atproto applications (e.g., leaflet, stream.place, teal.fm), and other small 18 - quality of life improvements to the core experience. 24 + it's not a crazy amount but it is basically the core experience that's done. 19 25 20 26 ## name 21 27 22 28 "blue" is NOT from bluesky i promise. i wanted a flower themed name, i also wanted 23 29 a pretty photo to go in the onboarding flow, and i only really had photos of bluebells, 24 - sooo bluebell it is. 30 + sooo bluebell it is. i was going to call it "scilla" at first but then realised 31 + i had no photos of scillas. 32 + 33 + i personally like to shorten the name down to just bell, or bbell. 25 34 26 35 also yes i realise the logo kinda looks like the wilted rose emoji. this ALSO was 27 36 not intentional oh my god.
+3 -1
src/App.vue
··· 40 40 return 41 41 } 42 42 43 - const wait = () => new Promise((resolve) => setTimeout(resolve, 2500)) 43 + const wait = () => new Promise((resolve) => setTimeout(resolve, 1500)) 44 44 45 45 // waiting for auth 46 46 // we then either determine the Next Phase - either the onboarding flow or to the shell ··· 79 79 currentPhase.value = 'shell' 80 80 81 81 const profile = auth.profile 82 + if (!profile) return 83 + 82 84 if (!profile?.pronouns) { 83 85 setTimeout(() => { 84 86 const dismissed = localStorage.getItem(KEYS.STATE.WOKE_DISMISSED)
+10
src/assets/main.css
··· 112 112 background: hsla(var(--overlay0) / 0.8); 113 113 } 114 114 115 + .feed-fade-enter-active, 116 + .feed-fade-leave-active { 117 + transition: opacity 0.3s ease; 118 + } 119 + 120 + .feed-fade-enter-from, 121 + .feed-fade-leave-to { 122 + opacity: 0; 123 + } 124 + 115 125 h1, 116 126 .text-h1 { 117 127 font-size: 2rem;
+6 -10
src/components/Dialogs/CreateAccount.vue src/components/Modals/Auth/CreateAccount.vue
··· 4 4 import { useAuthStore } from '@/stores/auth' 5 5 import { getProviderList, type HydratedProvider } from '@/utils/pds' 6 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' 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 11 12 12 const auth = useAuthStore() 13 - const isOpen = defineModel<boolean>('open', { required: true }) 14 - function closeModal() { 15 - isOpen.value = false 16 - } 17 13 18 14 const providers = ref<HydratedProvider[]>([]) 19 15 const selectedProvider = ref<HydratedProvider>() ··· 57 53 </script> 58 54 59 55 <template> 60 - <Modal title="Create an account" v-model:open="isOpen" width="600px"> 56 + <Modal title="Create an account" width="600px"> 61 57 <div class="modal-body"> 62 58 <p> 63 59 Choose a provider to host your account. You can migrate to a different provider later if ··· 100 96 </div> 101 97 102 98 <template #footer> 103 - <Button variant="ghost" type="button" @click="closeModal">Cancel</Button> 99 + <Button variant="ghost" type="button" @click="$emit('close')">Cancel</Button> 104 100 <Button 105 101 variant="primary" 106 102 :loading="auth.isLoading"
+56 -3
src/components/Feed/FeedItem.vue
··· 1 1 <script setup lang="ts"> 2 - import { computed } from 'vue' 2 + import { computed, ref, onMounted, onUnmounted } from 'vue' // Updated imports 3 3 import { AppBskyFeedDefs, AppBskyEmbedRecord } from '@atcute/bluesky' 4 4 import { 5 5 IconRefreshRounded, ··· 30 30 31 31 const postStore = usePostStore() 32 32 const navigationStore = useNavigationStore() 33 + const rootEl = ref<HTMLElement | null>(null) 34 + const isVisible = ref(false) 33 35 34 36 const displayPost = computed(() => { 35 37 if (props.item) return props.item.post ··· 137 139 window.open(url, '_blank') 138 140 } 139 141 } 142 + 143 + let observer: IntersectionObserver | null = null 144 + 145 + onMounted(() => { 146 + if (props.embedded || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { 147 + isVisible.value = true 148 + return 149 + } 150 + 151 + observer = new IntersectionObserver( 152 + (entries) => { 153 + if (entries[0]?.isIntersecting) { 154 + isVisible.value = true 155 + observer?.disconnect() 156 + observer = null 157 + } 158 + }, 159 + { 160 + threshold: 0.1, 161 + rootMargin: '50px', 162 + }, 163 + ) 164 + 165 + if (rootEl.value) observer.observe(rootEl.value) 166 + }) 167 + 168 + onUnmounted(() => { 169 + if (observer) observer.disconnect() 170 + }) 140 171 </script> 141 172 142 173 <template> 143 174 <article 144 175 v-if="displayPost" 176 + ref="rootEl" 145 177 :key="displayPost.uri" 146 178 :id="displayPost.uri" 147 179 class="feed-item" 148 - :class="{ 'is-embedded': embedded, 'is-root-post': rootPost }" 180 + :class="{ 181 + 'is-embedded': embedded, 182 + 'is-root-post': rootPost, 183 + 'is-visible': isVisible, 184 + }" 149 185 @click="handleClick" 150 186 @click.middle="handleMiddleClick" 151 187 > ··· 265 301 <style lang="scss" scoped> 266 302 .feed-item { 267 303 padding: 0.75rem 1rem; 268 - border-bottom: 1px solid hsla(var(--surface2) / 0.3); 269 304 display: flex; 270 305 flex-direction: column; 271 306 gap: 0.25rem; 272 307 308 + opacity: 0; 309 + filter: blur(4px); 310 + transform: translateY(12px); 311 + transition: 312 + filter 0.4s ease-out, 313 + opacity 0.4s ease-out, 314 + transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); 315 + 316 + &.is-visible { 317 + opacity: 1; 318 + transform: translateY(0); 319 + filter: blur(0); 320 + } 321 + 273 322 &:not(.is-root-post) { 274 323 &:hover { 275 324 background-color: hsla(var(--surface0) / 0.3); ··· 283 332 padding: 0.5rem; 284 333 margin-top: 0.5rem; 285 334 background-color: transparent; 335 + 336 + opacity: 1; 337 + transform: none; 338 + transition: none; 286 339 287 340 &:hover { 288 341 background-color: hsla(var(--surface0) / 0.5);
+95 -46
src/components/Feed/FeedList.vue
··· 207 207 208 208 <template> 209 209 <div class="feed-container"> 210 - <div v-if="!auth.isAuthenticated" class="state-message"> 211 - <p>Please sign in to view your timeline.</p> 212 - <Button variant="primary" @click="nav.push('login')">Sign In</Button> 213 - </div> 210 + <Transition name="layout-fade"> 211 + <div v-if="!auth.isAuthenticated" key="state-no-auth" class="state-message"> 212 + <p>Please sign in to view your timeline.</p> 213 + <Button variant="primary" @click="nav.push('login')">Sign In</Button> 214 + </div> 214 215 215 - <div v-else-if="error" class="state-message"> 216 - <p class="error-text">{{ error }}</p> 217 - <Button @click="fetchTimeline" variant="secondary"> <IconRefreshRounded /> Retry </Button> 218 - </div> 216 + <div v-else-if="error" key="state-error" class="state-message"> 217 + <p class="error-text">{{ error }}</p> 218 + <Button @click="fetchTimeline" variant="secondary"> <IconRefreshRounded /> Retry </Button> 219 + </div> 219 220 220 - <div v-else-if="loading && threadedFeed.length === 0" class="loading-stack"> 221 - <div v-for="(skel, i) in skeletonItems" :key="i" class="feed-item skeleton-item"> 222 - <SkeletonLoader circle width="2.75rem" height="2.75rem" /> 221 + <div 222 + v-else-if="loading && threadedFeed.length === 0" 223 + key="state-loading" 224 + class="loading-stack" 225 + > 226 + <div v-for="(skel, i) in skeletonItems" :key="i" class="feed-item skeleton-item"> 227 + <SkeletonLoader circle width="2.75rem" height="2.75rem" /> 223 228 224 - <div class="post-content"> 225 - <div class="post-header"> 226 - <div class="header-text"> 227 - <SkeletonLoader width="25%" height="1rem" /> 228 - <SkeletonLoader width="15%" height="1rem" /> 229 - <SkeletonLoader width="5%" height="1rem" /> 229 + <div class="post-content"> 230 + <div class="post-header"> 231 + <div class="header-text"> 232 + <SkeletonLoader width="25%" height="1rem" /> 233 + <SkeletonLoader width="15%" height="1rem" /> 234 + <SkeletonLoader width="5%" height="1rem" /> 235 + </div> 230 236 </div> 231 - </div> 232 - <div class="post-body"> 233 - <SkeletonLoader :width="skel.line1Width" height="1rem" /> 234 - <SkeletonLoader :width="skel.line2Width" height="1rem" /> 235 - <SkeletonLoader v-if="skel.line3Width" :width="skel.line3Width" height="1rem" /> 237 + <div class="post-body"> 238 + <SkeletonLoader :width="skel.line1Width" height="1rem" /> 239 + <SkeletonLoader :width="skel.line2Width" height="1rem" /> 240 + <SkeletonLoader v-if="skel.line3Width" :width="skel.line3Width" height="1rem" /> 236 241 237 - <SkeletonLoader 238 - v-if="skel.hasImage" 239 - width="100%" 240 - :height="skel.imageHeight" 241 - style="margin-top: 0.5rem; border-radius: 8px" 242 - /> 242 + <SkeletonLoader 243 + v-if="skel.hasImage" 244 + width="100%" 245 + :height="skel.imageHeight" 246 + style="margin-top: 0.5rem; border-radius: 8px" 247 + /> 248 + </div> 243 249 </div> 244 250 </div> 245 251 </div> 246 - </div> 247 252 248 - <div v-else class="feed-list"> 249 - <FeedThread 250 - v-for="node in threadedFeed" 251 - :key="'post' in node.data ? node.data.post.uri : node.data.uri" 252 - :node="node" 253 - /> 253 + <div v-else key="state-content" class="feed-list"> 254 + <TransitionGroup name="feed-fade" tag="div" class="feed-items-container"> 255 + <FeedThread 256 + v-for="node in threadedFeed" 257 + :key="'post' in node.data ? node.data.post.uri : node.data.uri" 258 + :node="node" 259 + /> 260 + </TransitionGroup> 254 261 255 - <div class="feed-end" ref="loadMoreTrigger"> 256 - <div v-if="loading" class="spinner-container"> 257 - <div class="spinner"></div> 262 + <div class="feed-end" ref="loadMoreTrigger"> 263 + <div v-if="loading" class="spinner-container"> 264 + <div class="spinner"></div> 265 + </div> 266 + <template v-else-if="cursor"> 267 + <Button variant="subtle-alt" @click="fetchTimeline"> Load More </Button> 268 + <p> 269 + You've scrolled past <span class="post-count">{{ totalPostCount }} posts</span> so 270 + far. 271 + </p> 272 + </template> 273 + <p v-else class="end-text">You've reached the end!</p> 258 274 </div> 259 - <template v-else-if="cursor"> 260 - <Button variant="subtle-alt" @click="fetchTimeline"> Load More </Button> 261 - <p> 262 - You've scrolled past <span class="post-count">{{ totalPostCount }} posts</span> so far. 263 - </p> 264 - </template> 265 - <p v-else class="end-text">You've reached the end!</p> 266 275 </div> 267 - </div> 276 + </Transition> 268 277 </div> 269 278 </template> 270 279 ··· 273 282 width: 100%; 274 283 min-height: 100%; 275 284 padding-bottom: 4rem; 285 + position: relative; 276 286 } 277 287 278 288 .state-message { ··· 325 335 flex-direction: column; 326 336 gap: 0.5rem; 327 337 } 338 + } 339 + } 340 + 341 + .layout-fade-enter-active { 342 + transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); 343 + } 344 + 345 + .layout-fade-leave-active { 346 + transition: all 0.4s ease; 347 + position: absolute; 348 + top: 0; 349 + left: 0; 350 + width: 100%; 351 + z-index: 5; 352 + pointer-events: none; 353 + } 354 + 355 + .layout-fade-enter-from, 356 + .layout-fade-leave-to { 357 + opacity: 0; 358 + transform: translateY(15px); 359 + filter: blur(4px); 360 + } 361 + 362 + .layout-fade-enter-to, 363 + .layout-fade-leave-from { 364 + opacity: 1; 365 + transform: translateY(0); 366 + } 367 + 368 + .state-message, 369 + .loading-stack, 370 + .feed-list { 371 + width: 100%; 372 + } 373 + 374 + :deep(.feed-list) { 375 + .feed-items-container > .thread-node > .thread-content > .feed-item { 376 + border-top: 1px solid hsla(var(--surface2) / 0.25); 328 377 } 329 378 } 330 379
+9 -8
src/components/Feed/FeedThread.vue
··· 31 31 32 32 <template> 33 33 <div class="thread-node" :class="{ 'is-child': currentDepth > 0 }"> 34 - <div class="thread-content"> 35 - <FeedItem :item="displayItem" :class="{ 'virtual-node': node.isVirtual }" /> 36 - <div v-if="sortedChildren.length > 0" class="thread-line"></div> 37 - </div> 34 + <Transition name="fade" mode="out-in"> 35 + <div class="thread-content" :key="'post' in node.data ? node.data.post.uri : node.data.uri"> 36 + <FeedItem :item="displayItem" :class="{ 'virtual-node': node.isVirtual }" /> 37 + <div v-if="sortedChildren.length > 0" class="thread-line"></div> 38 + </div> 39 + </Transition> 38 40 39 41 <div v-if="sortedChildren.length > 0" class="thread-children"> 40 42 <FeedThread ··· 52 54 display: flex; 53 55 flex-direction: column; 54 56 position: relative; 57 + &.is-root { 58 + border-bottom: 1px solid hsla(var(--surface2) / 0.3); 59 + } 55 60 } 56 61 57 62 .thread-content { ··· 72 77 background-color: hsl(var(--surface0)); 73 78 z-index: 5; 74 79 } 75 - } 76 - 77 - .thread-node:not(.is-child) { 78 - border-bottom: 1px solid hsla(var(--surface2) / 0.3); 79 80 } 80 81 </style>
+1 -1
src/components/Layout/ScreenLayout.vue
··· 28 28 .screen-layout { 29 29 position: fixed; 30 30 inset: 0; 31 - z-index: 9999; 31 + z-index: 1000; 32 32 display: flex; 33 33 flex-direction: column; 34 34 align-items: center;
+106 -21
src/components/Layout/SplashScreen.vue
··· 1 1 <script setup lang="ts"> 2 + import { onMounted, onUnmounted, ref } from 'vue' 3 + 2 4 import ScreenLayout from '@/components/Layout/ScreenLayout.vue' 3 5 import SVG from '@/components/UI/SVG.vue' 4 6 import BluebellLogo from '@/assets/icons/bluebell.svg?raw' 5 - import { ref } from 'vue' 7 + import { tap } from '@/utils/haptics' 8 + import { useAuthStore } from '@/stores/auth' 6 9 10 + const auth = useAuthStore() 7 11 const messages = [ 8 12 "they're calling it the coilest client", 9 13 'thank you for using bell >.<', ··· 40 44 } while (messages[newIndex] === message.value && messages.length > 1) 41 45 42 46 message.value = messages[newIndex] 47 + tap() 43 48 } 44 49 const message = ref() 45 50 setMessage() 51 + 52 + onMounted(() => { 53 + document.addEventListener('pointerdown', setMessage) 54 + }) 55 + onUnmounted(() => { 56 + document.removeEventListener('pointerdown', setMessage) 57 + }) 46 58 </script> 47 59 48 60 <template> ··· 53 65 <SVG :icon="BluebellLogo" class="logo" /> 54 66 </div> 55 67 <h1 class="title">Bluebell</h1> 56 - <p class="subtitle" role="status" aria-live="polite" @click="setMessage">{{ message }}</p> 68 + <p class="subtitle" role="status" aria-live="polite">{{ message }}</p> 57 69 </div> 58 70 59 - <div class="loader-track" aria-hidden="true"> 60 - <div class="loader-bar"></div> 61 - </div> 71 + <Teleport to="body"> 72 + <!-- me fr --> 73 + <div class="bottom"> 74 + <Transition name="pop-up"> 75 + <div v-if="auth.profile" class="hi-user-pill"> 76 + <img 77 + :src="auth.profile.avatar" 78 + :alt="`Avatar for ${auth.profile.handle}`" 79 + class="avatar" 80 + /> 81 + <p class="hello"> 82 + hi, <span class="name">{{ auth.profile.displayName || auth.profile.handle }}</span 83 + >! 84 + </p> 85 + </div> 86 + </Transition> 87 + 88 + <div class="loader-track" aria-hidden="true"> 89 + <div class="loader-bar"></div> 90 + </div> 91 + </div> 92 + </Teleport> 62 93 </div> 63 94 </ScreenLayout> 64 95 </template> 65 96 66 97 <style scoped lang="scss"> 98 + .pop-up-enter-active { 99 + animation-delay: 2s; 100 + animation: contentIn 0.5s cubic-bezier(0.2, 0.9, 0.2, 1) both; 101 + } 102 + 103 + @keyframes popUp { 104 + } 105 + 67 106 .splash-content { 68 107 position: relative; 69 108 z-index: 10; 70 109 display: flex; 71 110 flex-direction: column; 72 111 align-items: center; 112 + 73 113 gap: 1.5rem; 74 114 padding: 2rem; 75 115 width: 100%; ··· 80 120 animation: contentIn 0.7s cubic-bezier(0.2, 0.9, 0.2, 1) both; 81 121 } 82 122 123 + .bottom { 124 + position: fixed; 125 + bottom: calc(2rem + var(--inset-bottom, 0)); 126 + left: 50%; 127 + transform: translateX(-50%); 128 + width: 100%; 129 + 130 + z-index: 2000; 131 + display: flex; 132 + flex-direction: column; 133 + justify-content: center; 134 + align-items: center; 135 + } 136 + 137 + .hi-user-pill { 138 + display: flex; 139 + align-items: center; 140 + gap: 0.75em; 141 + padding: 0.5rem; 142 + padding-right: 1.25rem; 143 + background-color: hsla(var(--base) / 1); 144 + border: 1px solid hsla(var(--surface2) / 0.3); 145 + border-radius: 10rem; 146 + margin-bottom: 1.5rem; 147 + max-width: 40vw; 148 + 149 + .avatar { 150 + width: 2rem; 151 + height: 2rem; 152 + border-radius: 50%; 153 + object-fit: cover; 154 + border: 1.5px solid hsl(var(--surface2)); 155 + 156 + @media (min-width: 512px) { 157 + width: 2.5rem; 158 + height: 2.5rem; 159 + } 160 + } 161 + 162 + .hello { 163 + font-size: 0.875rem; 164 + color: hsl(var(--text)); 165 + 166 + .name { 167 + font-weight: 600; 168 + color: hsl(var(--accent)); 169 + } 170 + 171 + @media (min-width: 512px) { 172 + font-size: 1rem; 173 + } 174 + } 175 + } 176 + 83 177 .app-hero { 84 178 display: flex; 85 179 flex-direction: column; ··· 113 207 margin: 0; 114 208 width: 100%; 115 209 color: hsl(var(--subtext1)); 116 - 117 - &:hover { 118 - cursor: pointer; 119 - text-decoration: underline; 120 - } 210 + user-select: none; 121 211 } 122 212 } 123 213 124 214 .loader-track { 125 - width: 120px; 126 - height: 4px; 215 + width: 10rem; 216 + height: 0.5rem; 127 217 background-color: hsla(var(--surface2) / 0.3); 128 - border-radius: 99px; 218 + border-radius: 5rem; 129 219 overflow: hidden; 130 - position: relative; 131 220 132 221 .loader-bar { 133 - position: absolute; 222 + position: relative; 134 223 top: 0; 135 224 left: 0; 136 225 height: 100%; ··· 157 246 @keyframes contentIn { 158 247 from { 159 248 opacity: 0; 160 - transform: translateY(12px) scale(0.995); 161 - filter: blur(6px); 249 + transform: translateY(3rem) scale(0.75); 250 + filter: blur(8px); 162 251 } 163 252 to { 164 253 opacity: 1; ··· 170 259 @media (min-width: 512px) { 171 260 .title { 172 261 font-size: 3rem; 173 - } 174 - .loader-track { 175 - width: 220px; 176 - height: 8px; 177 262 } 178 263 } 179 264 </style>
+65
src/components/Modals/Auth/HandleInput.vue
··· 1 + <script lang="ts" setup> 2 + import { 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 Modal from '../../UI/BaseModal.vue' 9 + import TextInput from '@/components/UI/TextInput.vue' 10 + 11 + const auth = useAuthStore() 12 + 13 + const providers = ref<HydratedProvider[]>([]) 14 + const selectedProvider = ref<HydratedProvider>() 15 + 16 + const pdsError = ref('') 17 + const pdsInput = ref('') 18 + 19 + async function submitPds() { 20 + const val = 21 + pdsInput.value.trim() === '' ? selectedProvider.value?.url.toString() : pdsInput.value.trim() 22 + if (!val) { 23 + pdsError.value = 'Enter a PDS URL or handle' 24 + return 25 + } 26 + await auth.login(val) 27 + } 28 + 29 + onMounted(async () => { 30 + providers.value = await getProviderList() 31 + if (providers.value.length) { 32 + selectedProvider.value = providers.value[0] 33 + } else { 34 + pdsError.value = 'No providers available' 35 + } 36 + }) 37 + </script> 38 + 39 + <template> 40 + <Modal title="Enter your handle" @close="$emit('close')" width="480px"> 41 + <div class="modal-body"> 42 + <label for="pds-input" class="sr-only">PDS URL or handle</label> 43 + 44 + <TextInput 45 + id="pds-input" 46 + v-model="pdsInput" 47 + placeholder="https://pds.example or example.cat" 48 + :error="pdsError" 49 + inputmode="url" 50 + aria-describedby="pds-error" 51 + @keydown.enter.prevent="submitPds" 52 + style="margin-top: 1rem" 53 + /> 54 + 55 + <div id="pds-error" role="alert" v-if="pdsError">{{ pdsError }}</div> 56 + </div> 57 + 58 + <template #footer> 59 + <Button variant="ghost" type="button" @click="$emit('close')">Cancel</Button> 60 + <Button variant="primary" :loading="auth.isLoading" @click="submitPds" type="button"> 61 + Sign in 62 + </Button> 63 + </template> 64 + </Modal> 65 + </template>
+3 -3
src/components/UI/BaseButton.vue
··· 82 82 83 83 background-color: hsla(var(--bg-colour) / 0.9); 84 84 color: hsl(var(--text-colour)); 85 - border-color: hsla(var(--border-colour) / 0.2); 85 + /*border-color: hsla(var(--border-colour) / 0.2);*/ 86 86 87 87 &:not(.variant-text) { 88 88 &:hover:not(:disabled) { 89 89 background-color: hsla(var(--bg-colour) / 1); 90 - border-color: hsla(var(--border-colour) / 0.5); 90 + /*border-color: hsla(var(--border-colour) / 0.5);*/ 91 91 } 92 92 93 93 &:active:not(:disabled) { 94 94 background-color: hsla(var(--bg-colour) / 0.7); 95 - border-color: hsla(var(--border-colour) / 0.5); 95 + /*border-color: hsla(var(--border-colour) / 0.5);*/ 96 96 } 97 97 } 98 98 }
+1 -1
src/stores/auth.ts
··· 112 112 authUrl = await createAuthorizationUrl({ 113 113 target: { type: 'pds', serviceUrl: input }, 114 114 scope, 115 - // prompt: createAccount ? 'create' : undefined, 115 + prompt: createAccount ? 'none' : 'login', 116 116 }) 117 117 } else { 118 118 try {
+13
src/utils/pds.ts
··· 6 6 url: URL 7 7 location: string 8 8 subtitle?: string 9 + /** whether this provider should be prominently displayed as a login option */ 10 + default?: boolean 9 11 /** whether this provider should be pinned when displayed */ 10 12 pin?: boolean 11 13 /** if the PDS has a policy on who can use what handles, blacksky, for example. */ ··· 25 27 pin: true, 26 28 }, 27 29 { 30 + name: 'Blacksky', 31 + url: new URL('https://blacksky.app/'), 32 + subtitle: 'A PDS for the black community and allies.', 33 + location: 'US', 34 + handlePolicy: new URL( 35 + 'https://docs.blacksky.community/migrating-to-blacksky-pds-complete-guide#who-can-use-blacksky-services', 36 + ), 37 + default: true, 38 + }, 39 + { 28 40 name: 'Bluesky', 29 41 url: new URL('https://bsky.social/'), 30 42 location: 'US', 43 + default: true, 31 44 }, 32 45 { 33 46 name: 'Tophhie Social',
+71 -75
src/views/Auth/LoginPage.vue
··· 1 1 <script setup lang="ts"> 2 2 import { ref, computed, onMounted } from 'vue' 3 3 4 - import { useAuthStore } from '@/stores/auth' 4 + import CreateAccount from '@/components/Modals/Auth/CreateAccount.vue' 5 + import HandleInput from '@/components/Modals/Auth/HandleInput.vue' 5 6 import PageLayout from '@/components/Navigation/PageLayout.vue' 6 7 import Button from '@/components/UI/BaseButton.vue' 7 - import Modal from '@/components/UI/BaseModal.vue' 8 - import TextInput from '@/components/UI/TextInput.vue' 8 + 9 + import { useAuthStore } from '@/stores/auth' 10 + import { useModalStore } from '@/stores/modal' 11 + 9 12 import { getProviderList, type HydratedProvider } from '@/utils/pds' 10 13 11 - import CreateAccount from '@/components/Dialogs/CreateAccount.vue' 12 - 14 + const modals = useModalStore() 13 15 const auth = useAuthStore() 14 16 15 17 const isLoading = computed(() => auth.isLoading) 16 18 const isLoadingProviders = ref(true) 17 - const showPdsModal = ref(false) 18 - const pdsInput = ref('') 19 - const pdsError = ref('') 20 - 21 - const showCreateAccountModal = ref(false) 22 19 23 20 const providers = ref<HydratedProvider[]>([]) 24 21 ··· 27 24 await auth.login(pds, providerList) 28 25 } 29 26 30 - function openPdsModal() { 31 - pdsInput.value = '' 32 - pdsError.value = '' 33 - showPdsModal.value = true 34 - } 35 - 36 - function openCreateModal() { 37 - showCreateAccountModal.value = true 38 - } 39 - 40 - async function submitPds() { 41 - const val = pdsInput.value.trim() 42 - if (!val) { 43 - pdsError.value = 'Enter a PDS URL or handle (e.g. https://pds.example or awesome.cat)' 44 - return 45 - } 46 - await auth.login(val) 47 - } 48 - 49 27 onMounted(async () => { 50 28 providers.value = await getProviderList() 51 29 isLoadingProviders.value = false ··· 55 33 <template> 56 34 <PageLayout title="Sign in"> 57 35 <div class="login-view"> 36 + <div class="loading-overlay" v-if="auth.isLoading"> 37 + <div class="spinner"></div> 38 + </div> 39 + 58 40 <header class="header"> 59 41 <h1 class="title">Sign in</h1> 60 42 <p class="subtitle">Pick a provider to sign in with.</p> 61 43 </header> 62 44 63 45 <div class="button-row compact"> 64 - <Button 65 - variant="primary" 66 - size="lg" 67 - :block="true" 68 - :loading="isLoading && !showPdsModal" 69 - @click="pdsSubmit('https://bsky.social')" 70 - > 71 - <span class="btn-inner"> 72 - <span>Sign in with Bluesky</span> 73 - </span> 74 - </Button> 46 + <div class="primary-row"> 47 + <Button 48 + variant="primary" 49 + size="lg" 50 + :block="true" 51 + :disabled="isLoading" 52 + pill 53 + @click="pdsSubmit('https://bsky.social', false)" 54 + > 55 + <span class="btn-inner"> 56 + <span>Sign in with Bluesky</span> 57 + </span> 58 + </Button> 59 + <Button 60 + variant="primary" 61 + size="lg" 62 + :block="true" 63 + :disabled="isLoading" 64 + pill 65 + @click="pdsSubmit('https://blacksky.app', false)" 66 + > 67 + <span class="btn-inner"> 68 + <span>Sign in with Blacksky</span> 69 + </span> 70 + </Button> 71 + </div> 75 72 76 73 <div class="secondary-row"> 77 - <Button variant="subtle-alt" @click="openPdsModal" :disabled="isLoading && !showPdsModal"> 74 + <Button variant="subtle-alt" @click="modals.open(HandleInput)" :disabled="isLoading"> 78 75 Enter your handle 79 76 </Button> 80 - <Button 81 - variant="subtle-alt" 82 - @click="openCreateModal" 83 - :disabled="isLoading && !showPdsModal" 84 - > 77 + <Button variant="subtle-alt" @click="modals.open(CreateAccount)" :disabled="isLoading"> 85 78 Create an account 86 79 </Button> 87 80 </div> ··· 89 82 90 83 <p v-if="auth.error" class="error-text">{{ auth.error }}</p> 91 84 </div> 92 - 93 - <Modal v-model:open="showPdsModal" title="Enter your handle" @close="showPdsModal = false"> 94 - <div class="modal-body"> 95 - <label for="pds-input" class="sr-only">PDS URL or handle</label> 96 - 97 - <TextInput 98 - id="pds-input" 99 - v-model="pdsInput" 100 - placeholder="https://pds.example or example.cat" 101 - :error="pdsError" 102 - inputmode="url" 103 - aria-describedby="pds-error" 104 - @keydown.enter.prevent="submitPds" 105 - style="margin-top: 1rem" 106 - /> 107 - 108 - <div id="pds-error" role="alert" v-if="pdsError">{{ pdsError }}</div> 109 - </div> 110 - 111 - <template #footer> 112 - <Button variant="ghost" type="button" @click="showPdsModal = false">Cancel</Button> 113 - <Button variant="primary" :loading="auth.isLoading" @click="submitPds" type="button"> 114 - Sign in 115 - </Button> 116 - </template> 117 - </Modal> 118 - 119 - <CreateAccount v-model:open="showCreateAccountModal" @close="showCreateAccountModal = false" /> 120 85 </PageLayout> 121 86 </template> 122 87 ··· 129 94 margin: 0 auto; 130 95 } 131 96 97 + .loading-overlay { 98 + position: absolute; 99 + inset: 0; 100 + backdrop-filter: blur(4px) brightness(0.8); 101 + display: flex; 102 + align-items: center; 103 + justify-content: center; 104 + z-index: 10; 105 + 106 + .spinner { 107 + border: 4px solid hsla(var(--accent) / 0.2); 108 + border-top-color: hsl(var(--accent)); 109 + border-radius: 50%; 110 + width: 3rem; 111 + height: 3rem; 112 + animation: spin 1s linear infinite; 113 + } 114 + 115 + @keyframes spin { 116 + to { 117 + transform: rotate(360deg); 118 + } 119 + } 120 + } 121 + 132 122 .header { 133 123 display: flex; 134 124 flex-direction: column; ··· 157 147 align-items: center; 158 148 gap: 0.6rem; 159 149 } 150 + 151 + .primary-row, 160 152 .secondary-row { 161 153 display: flex; 162 - gap: 0.5rem; 154 + gap: 0.25rem; 163 155 width: 100%; 164 156 button { 165 157 flex: 1; 166 158 } 159 + } 160 + 161 + .primary-row { 162 + flex-direction: column; 167 163 } 168 164 169 165 .provider-list {
+72 -10
src/views/Root/HomeView.vue
··· 10 10 import { useDraggableScroll } from '@/composables/useDraggableScroll' 11 11 12 12 import KEYS from '@/utils/keys' 13 + import { tap } from '@/utils/haptics' 13 14 14 15 type PinnedFeedItem = 15 16 | { ··· 29 30 30 31 const auth = useAuthStore() 31 32 const pinnedFeeds = ref<PinnedFeedItem[]>([]) 33 + const loadingPinnedFeeds = ref(true) 34 + 32 35 const feedList = ref<InstanceType<typeof FeedList> | null>(null) 33 36 const pageLayout = ref<InstanceType<typeof PageLayout> | null>(null) 34 37 ··· 108 111 } 109 112 110 113 pinnedFeeds.value = pinnedItems 114 + loadingPinnedFeeds.value = false 111 115 112 116 const activeFeedKey = localStorage.getItem(KEYS.STATE.ACTIVE_FEED_URI) 113 117 if (activeFeedKey) { ··· 123 127 }) 124 128 125 129 const switchFeed = async (feed: PinnedFeedItem) => { 130 + tap() 126 131 if (activeFeed.value === feed) { 127 - pageLayout.value?.scrollToTop(true) 132 + pageLayout.value?.scrollToTop(false) 128 133 await feedList.value?.refresh() 129 134 return 130 135 } ··· 140 145 141 146 <template> 142 147 <PageLayout no-padding title="Home" ref="pageLayout"> 143 - <template #app-bar v-if="pinnedFeeds.length > 0"> 148 + <template #app-bar> 144 149 <div 145 150 class="feeds-bar" 146 151 ref="feedsBarRef" 147 152 v-on="dragEvents" 148 153 :class="{ 'is-dragging': isDragging }" 149 154 > 150 - <button 151 - v-for="feed in pinnedFeeds" 152 - :key="feed.type + ('uri' in feed ? feed.uri : '')" 153 - :class="['feed-button', { active: activeFeed === feed }]" 154 - @click="switchFeed(feed)" 155 - > 156 - {{ feed.displayName || 'Unnamed Feed' }} 157 - </button> 155 + <Transition name="slide-up" mode="out-in"> 156 + <div v-if="!loadingPinnedFeeds" class="feed-group" key="loaded"> 157 + <button 158 + v-for="feed in pinnedFeeds" 159 + :key="feed.type + ('uri' in feed ? feed.uri : '')" 160 + :class="['feed-button', { active: activeFeed === feed }]" 161 + @click="switchFeed(feed)" 162 + > 163 + {{ feed.displayName || 'Unnamed Feed' }} 164 + </button> 165 + </div> 166 + 167 + <div v-else class="feed-group" key="loading"> 168 + <button 169 + v-for="n in 5" 170 + :key="n" 171 + class="feed-button skeleton" 172 + :style="{ width: `${Math.random() * 10 + 6}ch` }" 173 + aria-hidden="true" 174 + disabled 175 + > 176 + meow :3 177 + </button> 178 + </div> 179 + </Transition> 158 180 </div> 159 181 </template> 160 182 <FeedList ··· 193 215 } 194 216 } 195 217 218 + .feed-group { 219 + display: flex; 220 + gap: 0.5rem; 221 + width: max-content; 222 + transform-style: preserve-3d; 223 + } 224 + 225 + .slide-up-enter-from { 226 + opacity: 0; 227 + transform: translateY(10px); 228 + } 229 + .slide-up-leave-to { 230 + opacity: 0; 231 + transform: translateY(-10px); 232 + } 233 + 196 234 .feed-button { 197 235 position: relative; 198 236 z-index: 0; ··· 235 273 } 236 274 &:active::before { 237 275 scale: 0.95; 276 + } 277 + 278 + &.skeleton { 279 + color: transparent; 280 + overflow: hidden; 281 + background: hsla(var(--surface1) / 0.5); 282 + 283 + &::before { 284 + background: linear-gradient( 285 + 90deg, 286 + hsla(var(--surface1) / 0), 287 + hsla(var(--surface1) / 0.5), 288 + transparent 289 + ); 290 + animation: shimmer 1.5s infinite; 291 + @keyframes shimmer { 292 + 0% { 293 + transform: translateX(-100%); 294 + } 295 + 100% { 296 + transform: translateX(100%); 297 + } 298 + } 299 + } 238 300 } 239 301 } 240 302 </style>