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

feat: uhh following/followers pages; new auth page; i forgot

vt3e.cat ab334541 8c9868d0

verified
+1543 -389
+1 -1
.zed/settings.json
··· 1 1 { 2 - "hard_tabs": false 2 + "hard_tabs": true, 3 3 }
+1 -1
android/app/capacitor.build.gradle
··· 10 10 apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" 11 11 dependencies { 12 12 implementation project(':capacitor-app') 13 - 13 + implementation project(':capacitor-haptics') 14 14 } 15 15 16 16
+3
android/capacitor.settings.gradle
··· 4 4 5 5 include ':capacitor-app' 6 6 project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') 7 + 8 + include ':capacitor-haptics' 9 + project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
+3
bun.lock
··· 13 13 "@capacitor/android": "^8.0.0", 14 14 "@capacitor/app": "^8.0.0", 15 15 "@capacitor/core": "^8.0.0", 16 + "@capacitor/haptics": "^8.0.0", 16 17 "@iconify-prerendered/vue-material-symbols": "^0.28.1755063979", 17 18 "add": "^2.0.6", 18 19 "android": "^0.0.8", ··· 139 140 "@capacitor/cli": ["@capacitor/cli@8.0.0", "", { "dependencies": { "@ionic/cli-framework-output": "^2.2.8", "@ionic/utils-subprocess": "^3.0.1", "@ionic/utils-terminal": "^2.3.5", "commander": "^12.1.0", "debug": "^4.4.0", "env-paths": "^2.2.0", "fs-extra": "^11.2.0", "kleur": "^4.1.5", "native-run": "^2.0.1", "open": "^8.4.0", "plist": "^3.1.0", "prompts": "^2.4.2", "rimraf": "^6.0.1", "semver": "^7.6.3", "tar": "^6.1.11", "tslib": "^2.8.1", "xml2js": "^0.6.2" }, "bin": { "cap": "bin/capacitor", "capacitor": "bin/capacitor" } }, "sha512-v9hEBi69xGxuuZhg55N031bMEenKaPSv71Il8C22VOOH6surDyv/MPeImN0oVfFc7eiklaW3rDFYVz6cmXfJWQ=="], 140 141 141 142 "@capacitor/core": ["@capacitor/core@8.0.0", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-250HTVd/W/KdMygoqaedisvNbHbpbQTN2Hy/8ZYGm1nAqE0Fx7sGss4l0nDg33STxEdDhtVRoL2fIaaiukKseA=="], 143 + 144 + "@capacitor/haptics": ["@capacitor/haptics@8.0.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-DY1IUOjke1T4ITl7mFHQIKCaJJyHYAYRYHG9bVApU7PDOZiMVGMp48Yjzdqjya+wv/AHS5mDabSTUmhJ5uDvBA=="], 142 145 143 146 "@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], 144 147
+1
package.json
··· 27 27 "@capacitor/android": "^8.0.0", 28 28 "@capacitor/app": "^8.0.0", 29 29 "@capacitor/core": "^8.0.0", 30 + "@capacitor/haptics": "^8.0.0", 30 31 "@iconify-prerendered/vue-material-symbols": "^0.28.1755063979", 31 32 "add": "^2.0.6", 32 33 "android": "^0.0.8",
+6
src/assets/main.css
··· 180 180 .mb-4 { 181 181 margin-bottom: var(--space-4); 182 182 } 183 + 184 + .dot { 185 + user-select: none; 186 + color: hsl(var(--surface2)); 187 + font-weight: 900; 188 + }
+16 -8
src/components/Feed/FeedItem.vue
··· 17 17 import EmbedRecord from './Embeds/EmbedRecord.vue' 18 18 import ExternalEmbed from './Embeds/ExternalEmbed.vue' 19 19 import VideoEmbed from './Embeds/VideoEmbed.vue' 20 + import { tap } from '@/utils/haptics' 20 21 21 22 type PostInput = AppBskyFeedDefs.PostView | AppBskyEmbedRecord.ViewRecord 22 23 ··· 78 79 } 79 80 80 81 const handleLike = () => { 81 - if (displayPost.value && !props.embedded) postStore.toggleLike(displayPost.value) 82 + if (displayPost.value && !props.embedded) { 83 + postStore.toggleLike(displayPost.value) 84 + tap() 85 + } 82 86 } 83 87 84 88 const handleRepost = () => { 85 - if (displayPost.value && !props.embedded) postStore.toggleRepost(displayPost.value) 89 + if (displayPost.value && !props.embedded) { 90 + postStore.toggleRepost(displayPost.value) 91 + tap() 92 + } 86 93 } 87 94 88 95 const handleClick = (e: MouseEvent) => { ··· 145 152 <div v-if="item?.reason?.$type === 'app.bsky.feed.defs#reasonRepost'" class="repost-indicator"> 146 153 <IconRefreshRounded class="repost-icon" /> 147 154 <span>Reposted by {{ item.reason.by.displayName || item.reason.by.handle }}</span> 155 + <span class="repost-indicator__time"> · {{ formatTime(item.reason.indexedAt) }} </span> 148 156 </div> 149 157 150 158 <div class="post-layout"> ··· 164 172 displayPost.author.displayName || displayPost.author.handle 165 173 }}</span> 166 174 <span class="handle">@{{ displayPost.author.handle }}</span> 175 + <template v-if="displayPost.author.pronouns"> 176 + <span class="dot" aria-hidden="true">·</span> 177 + <span class="pronouns">{{ displayPost.author.pronouns }}</span> 178 + </template> 167 179 <span class="dot" aria-hidden="true">·</span> 168 180 <span class="time">{{ formatTime(displayPost.indexedAt) }}</span> 169 181 </div> ··· 363 375 font-weight: 400; 364 376 } 365 377 366 - .dot { 367 - user-select: none; 368 - color: hsl(var(--surface2)); 369 - } 370 - 371 - .time { 378 + .time, 379 + .pronouns { 372 380 color: hsl(var(--subtext0)); 373 381 font-size: 0.85rem; 374 382 flex-shrink: 0;
+2 -2
src/components/Navigation/BackButton.vue
··· 77 77 padding: 0.5rem; 78 78 79 79 &:hover { 80 - background: hsla(var(--surface1) / 1); 80 + background: hsla(var(--surface0) / 1); 81 81 } 82 82 &:active { 83 - background: hsla(var(--surface2) / 1); 83 + background: hsla(var(--surface0) / 0.5); 84 84 } 85 85 86 86 .icon {
+2
src/components/Navigation/NavItem.vue
··· 1 1 <script lang="ts" setup> 2 2 import { useNavigationStore } from '@/stores/navigation' 3 3 import type { Page, PageNames } from '@/router' 4 + import { tap } from '@/utils/haptics' 4 5 5 6 const nav = useNavigationStore() 6 7 defineProps<{ item: Page }>() ··· 9 10 const currentTab = nav.activeTab 10 11 if (currentTab === tab) nav.resetTab(tab) 11 12 else nav.switchTab(tab as PageNames) 13 + tap() 12 14 } 13 15 14 16 const handleKeydown = (event: KeyboardEvent, tab: string) => {
+1 -1
src/components/Navigation/PageLayout.vue
··· 104 104 -webkit-overflow-scrolling: touch; 105 105 height: 100%; 106 106 overflow-y: scroll; 107 - padding-top: calc(var(--inset-top, 0) + 3.5rem); 107 + padding-top: calc(var(--inset-top, 0) + 4.5rem); 108 108 109 109 display: flex; 110 110 flex-direction: column;
+57 -68
src/components/Navigation/TabStack.vue
··· 1 1 <script lang="ts" setup> 2 - import { computed, ref, watch, defineAsyncComponent } from "vue"; 3 - import { useNavigationStore } from "@/stores/navigation"; 4 - import { pages, type StackRootNames } from "@/router"; 5 - import { useEnvironmentStore } from "@/stores/environment"; 2 + import { computed, ref, watch, defineAsyncComponent } from 'vue' 3 + import { useNavigationStore } from '@/stores/navigation' 4 + import { pages, type StackRootNames } from '@/router' 5 + import { useEnvironmentStore } from '@/stores/environment' 6 6 7 - const props = defineProps<{ tab: StackRootNames }>(); 8 - const nav = useNavigationStore(); 9 - const env = useEnvironmentStore(); 7 + const props = defineProps<{ tab: StackRootNames }>() 8 + const nav = useNavigationStore() 9 + const env = useEnvironmentStore() 10 10 11 - const stack = computed(() => nav.stacks[props.tab]); 11 + const stack = computed(() => nav.stacks[props.tab]) 12 12 13 + // TODO)) rm the `any`s 13 14 const registry: Record<string, any> = pages.reduce( 14 15 (acc, page) => { 15 - const comp = page.component; 16 + const comp = page.component 16 17 acc[page.name] = 17 - typeof comp === "function" 18 + typeof comp === 'function' 18 19 ? defineAsyncComponent({ 19 20 loader: comp as unknown as () => Promise<any>, 20 21 }) 21 - : comp; 22 - return acc; 22 + : comp 23 + return acc 23 24 }, 24 25 {} as Record<string, any>, 25 - ); 26 + ) 26 27 27 - const isAnimating = ref(false); 28 - const animationType = ref<"push" | "pop" | null>(null); 29 - const previousStackLength = ref(stack.value?.length || 0); 28 + const isAnimating = ref(false) 29 + const animationType = ref<'push' | 'pop' | null>(null) 30 + const previousStackLength = ref(stack.value?.length || 0) 30 31 31 32 const visualTopIndex = computed(() => { 32 - if (nav.pendingPop?.tab === props.tab) 33 - return stack.value?.length ? stack.value.length - 2 : 0; 34 - return stack.value?.length ? stack.value.length - 1 : 0; 35 - }); 33 + if (nav.pendingPop?.tab === props.tab) return stack.value?.length ? stack.value.length - 2 : 0 34 + return stack.value?.length ? stack.value.length - 1 : 0 35 + }) 36 36 37 37 const shouldAnimate = computed(() => { 38 - return env.isMobile && !env.prefersReducedMotion; 39 - }); 38 + return env.isMobile && !env.prefersReducedMotion 39 + }) 40 40 41 41 watch( 42 42 () => stack.value?.length, 43 43 (newLength, oldLength) => { 44 - if (!newLength || !oldLength) return; 44 + if (!newLength || !oldLength) return 45 45 46 46 if (nav.activeTab !== props.tab) { 47 - previousStackLength.value = newLength; 48 - return; 47 + previousStackLength.value = newLength 48 + return 49 49 } 50 50 51 51 if (newLength > oldLength && shouldAnimate.value) { 52 - animationType.value = "push"; 53 - isAnimating.value = true; 52 + animationType.value = 'push' 53 + isAnimating.value = true 54 54 55 55 setTimeout(() => { 56 - isAnimating.value = false; 57 - animationType.value = null; 58 - }, 300); 56 + isAnimating.value = false 57 + animationType.value = null 58 + }, 300) 59 59 } 60 60 61 - previousStackLength.value = newLength; 61 + previousStackLength.value = newLength 62 62 }, 63 - ); 63 + ) 64 64 65 65 watch( 66 66 () => nav.pendingPop, 67 67 (pendingPop) => { 68 - if ( 69 - !pendingPop || 70 - pendingPop.tab !== props.tab || 71 - nav.activeTab !== props.tab 72 - ) { 73 - return; 68 + if (!pendingPop || pendingPop.tab !== props.tab || nav.activeTab !== props.tab) { 69 + return 74 70 } 75 71 76 72 if (!shouldAnimate.value) { 77 - nav.completePop(); 78 - return; 73 + nav.completePop() 74 + return 79 75 } 80 76 81 - animationType.value = "pop"; 82 - isAnimating.value = true; 77 + animationType.value = 'pop' 78 + isAnimating.value = true 83 79 84 80 setTimeout(() => { 85 - isAnimating.value = false; 86 - animationType.value = null; 87 - nav.completePop(); 88 - }, 300); 81 + isAnimating.value = false 82 + animationType.value = null 83 + nav.completePop() 84 + }, 300) 89 85 }, 90 86 { immediate: true }, 91 - ); 87 + ) 92 88 </script> 93 89 94 90 <template> ··· 105 101 v-for="(entry, index) in stack" 106 102 :key="entry.id" 107 103 :class="[ 108 - 'stack-page', 109 - { 110 - 'is-visible': 111 - index === stack.length - 1 || 112 - index === visualTopIndex || 113 - index === visualTopIndex - 1, 104 + 'stack-page', 105 + { 106 + 'is-visible': 107 + index === stack.length - 1 || 108 + index === visualTopIndex || 109 + index === visualTopIndex - 1, 114 110 115 - 'is-visual-top': index === visualTopIndex, 116 - 'is-below-visual-top': index === visualTopIndex - 1, 117 - 'is-animating': isAnimating, 118 - 'push-enter': 119 - isAnimating && 120 - animationType === 'push' && 121 - index === stack.length - 1, 122 - 'pop-exit': 123 - isAnimating && 124 - animationType === 'pop' && 125 - index === stack.length - 1, 126 - }, 127 - 128 - ]" 111 + 'is-visual-top': index === visualTopIndex, 112 + 'is-below-visual-top': index === visualTopIndex - 1, 113 + 'is-animating': isAnimating, 114 + 'push-enter': isAnimating && animationType === 'push' && index === stack.length - 1, 115 + 'pop-exit': isAnimating && animationType === 'pop' && index === stack.length - 1, 116 + }, 117 + ]" 129 118 :data-entry-id="entry.id" 130 119 :data-index="index" 131 120 :aria-hidden="index !== visualTopIndex" ··· 133 122 > 134 123 <Suspense> 135 124 <template #default> 136 - <component :is="registry[entry.page]" v-bind="entry.props"/> 125 + <component :is="registry[entry.page]" v-bind="entry.props" :routeName="entry.page" /> 137 126 </template> 138 127 <template #fallback> 139 128 <div class="page-loading" aria-hidden="true"></div>
+205
src/components/Profile/ProfileRow.vue
··· 1 + <script setup lang="ts"> 2 + import { computed } from 'vue' 3 + import type { AppBskyActorDefs } from '@atcute/bluesky' 4 + import { useFollowToggle } from '@/composables/useFollowToggle' 5 + 6 + const props = defineProps<{ profile: AppBskyActorDefs.ProfileView }>() 7 + const emit = defineEmits(['toggled']) 8 + const { toggle } = useFollowToggle() 9 + 10 + import AppLink from '../Navigation/AppLink.vue' 11 + 12 + const isFollowing = computed(() => !!props.profile.viewer?.following) 13 + const isMutual = computed( 14 + () => !!(props.profile.viewer?.following && props.profile.viewer?.followedBy), 15 + ) 16 + const label = computed(() => { 17 + if (isMutual.value) return 'Mutuals' 18 + return isFollowing.value ? 'Following' : 'Follow' 19 + }) 20 + 21 + async function onClick(e: Event) { 22 + e.stopPropagation() 23 + try { 24 + await toggle(props.profile) 25 + emit('toggled', props.profile) 26 + } catch (err) { 27 + console.error(err) 28 + } 29 + } 30 + </script> 31 + 32 + <template> 33 + <AppLink 34 + :key="profile.did" 35 + :class="{ 36 + follow: true, 37 + blocked: profile.viewer?.blockedBy, 38 + blocking: profile.viewer?.blocking, 39 + }" 40 + name="user-profile" 41 + :params="{ id: profile.did }" 42 + role="listitem" 43 + > 44 + <img 45 + class="avatar" 46 + :src="profile.avatar" 47 + :alt="`${profile.displayName || profile.handle}'s avatar`" 48 + loading="lazy" 49 + /> 50 + <div class="info"> 51 + <div class="top"> 52 + <div class="display-name">{{ profile.displayName || profile.handle }}</div> 53 + <span class="handle">@{{ profile.handle }}</span> 54 + <span v-if="profile.pronouns" class="dot" aria-hidden="true">·</span> 55 + <span v-if="profile.pronouns" class="pronouns">{{ profile.pronouns }}</span> 56 + </div> 57 + <div class="meta"> 58 + <span v-if="profile.description" class="bio">{{ profile.description }}</span> 59 + </div> 60 + </div> 61 + 62 + <button 63 + class="follow-btn" 64 + :class="{ mutual: isMutual, following: isFollowing }" 65 + @click.stop.prevent="onClick" 66 + :aria-pressed="!!profile.viewer?.following" 67 + :title=" 68 + isMutual ? 'Mutuals - click to unfollow' : profile.viewer?.following ? 'Unfollow' : 'Follow' 69 + " 70 + > 71 + {{ label }} 72 + </button> 73 + </AppLink> 74 + </template> 75 + 76 + <style lang="scss" scoped> 77 + .follow { 78 + display: flex; 79 + align-items: center; 80 + gap: 0.75rem; 81 + padding: 0.75rem; 82 + /* border-radius: 0.75rem; */ 83 + /* background: hsla(var(--base) / 0.6); */ 84 + border-bottom: 1px solid hsla(var(--surface2) / 0.35); 85 + text-decoration: none; 86 + color: inherit; 87 + 88 + &:hover { 89 + background: hsla(var(--surface0) / 0.2); 90 + } 91 + 92 + &.blocked { 93 + filter: brightness(0.75); 94 + } 95 + &.blocking { 96 + filter: brightness(0.75); 97 + } 98 + 99 + .avatar { 100 + width: 3rem; 101 + height: 3rem; 102 + border-radius: 50%; 103 + object-fit: cover; 104 + flex: 0 0 auto; 105 + } 106 + 107 + .info { 108 + flex: 1; 109 + display: flex; 110 + flex-direction: column; 111 + overflow: hidden; 112 + 113 + .top { 114 + display: flex; 115 + align-items: center; 116 + gap: 0.5rem; 117 + min-width: 0; 118 + font-size: 0.9rem; 119 + 120 + .display-name { 121 + font-weight: 700; 122 + white-space: nowrap; 123 + text-overflow: ellipsis; 124 + overflow: hidden; 125 + } 126 + 127 + .handle { 128 + color: hsl(var(--subtext1)); 129 + font-size: 0.9rem; 130 + } 131 + 132 + .pronouns { 133 + color: hsl(var(--subtext0)); 134 + white-space: nowrap; 135 + } 136 + 137 + .badge { 138 + margin-left: auto; 139 + font-size: 0.72rem; 140 + padding: 0.15rem 0.4rem; 141 + border-radius: 999px; 142 + background: hsla(var(--surface2) / 0.45); 143 + color: hsl(var(--subtext1)); 144 + font-weight: 600; 145 + } 146 + 147 + .badge.mutual { 148 + background: hsla(var(--accent) / 0.12); 149 + color: hsl(var(--accent)); 150 + } 151 + } 152 + 153 + .meta { 154 + color: hsl(var(--subtext0)); 155 + font-size: 0.88rem; 156 + margin-top: 0.25rem; 157 + overflow: hidden; 158 + text-overflow: ellipsis; 159 + white-space: nowrap; 160 + } 161 + } 162 + 163 + .follow-btn { 164 + flex: 0 0 auto; 165 + padding: 0.35rem 0.75rem; 166 + border-radius: 999px; 167 + border: none; 168 + cursor: pointer; 169 + color: hsl(var(--crust)); 170 + font-weight: 700; 171 + 172 + background: hsla(var(--accent) / 0.85); 173 + 174 + &:hover { 175 + background: hsla(var(--accent) / 1); 176 + } 177 + &:active { 178 + background: hsla(var(--accent) / 0.75); 179 + } 180 + 181 + &.following { 182 + background: transparent; 183 + border: 1px solid hsla(var(--surface2) / 0.75); 184 + color: hsl(var(--text)); 185 + &:hover { 186 + background: hsla(var(--surface0) / 0.9); 187 + } 188 + &:active { 189 + background: hsla(var(--surface0) / 0.5); 190 + } 191 + } 192 + /* &.mutual { 193 + background: hsla(var(--accent) / 0.85); 194 + color: hsl(var(--base)); 195 + border: 1px solid hsla(var(--surface2) / 0.45); 196 + &:hover { 197 + background: hsla(var(--accent) / 1); 198 + } 199 + &:active { 200 + background: hsla(var(--accent) / 0.6); 201 + } 202 + } */ 203 + } 204 + } 205 + </style>
+23 -8
src/components/UI/BaseButton.vue
··· 1 1 <script setup lang="ts"> 2 2 withDefaults( 3 3 defineProps<{ 4 - variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'subtle' | 'subtle-alt' 4 + variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'subtle' | 'subtle-alt' | 'text' 5 5 size?: 'sm' | 'md' | 'lg' 6 6 icon?: boolean 7 7 block?: boolean ··· 74 74 color: hsl(var(--text-colour)); 75 75 border-color: hsla(var(--border-colour) / 0.2); 76 76 77 - &:hover:not(:disabled) { 78 - background-color: hsla(var(--bg-colour) / 1); 79 - border-color: hsla(var(--border-colour) / 0.5); 80 - } 77 + &:not(.variant-text) { 78 + &:hover:not(:disabled) { 79 + background-color: hsla(var(--bg-colour) / 1); 80 + border-color: hsla(var(--border-colour) / 0.5); 81 + } 81 82 82 - &:active:not(:disabled) { 83 - background-color: hsla(var(--bg-colour) / 0.7); 84 - border-color: hsla(var(--border-colour) / 0.5); 83 + &:active:not(:disabled) { 84 + background-color: hsla(var(--bg-colour) / 0.7); 85 + border-color: hsla(var(--border-colour) / 0.5); 86 + } 85 87 } 86 88 } 87 89 ··· 191 193 &:active:not(:disabled) { 192 194 background-color: hsla(var(--red) / 0.3); 193 195 border-color: hsla(var(--red) / 0.3); 196 + } 197 + } 198 + 199 + .variant-text { 200 + background-color: none; 201 + color: hsl(var(--text-colour)); 202 + border-color: transparent; 203 + 204 + &:hover { 205 + color: hsla(var(--text) / 0.9); 206 + } 207 + &:active { 208 + color: hsla(var(--text) / 0.75); 194 209 } 195 210 } 196 211
+114 -114
src/components/UI/ListItem.vue
··· 4 4 import { IconChevronRightRounded } from '@iconify-prerendered/vue-material-symbols' 5 5 6 6 const props = defineProps<{ 7 - title?: string 8 - subtitle?: string 9 - chevron?: boolean 10 - clickable?: boolean 11 - danger?: boolean 7 + title?: string 8 + subtitle?: string 9 + chevron?: boolean 10 + clickable?: boolean 11 + danger?: boolean 12 12 13 - href?: string 14 - to?: string | object 15 - target?: string 13 + href?: string 14 + to?: string | object 15 + target?: string 16 + disabled?: boolean 16 17 }>() 17 18 18 19 const emit = defineEmits<{ 19 - (e: 'click', event: MouseEvent): void 20 + (e: 'click', event: MouseEvent): void 20 21 }>() 21 22 22 23 const isLink = computed(() => !!props.href || !!props.to) 23 24 const isInteractive = computed(() => props.clickable || isLink.value) 24 25 25 26 const componentType = computed(() => { 26 - if (props.to) return AppLink 27 - if (props.href) return 'a' 28 - return 'div' 27 + if (props.to) return AppLink 28 + if (props.href) return 'a' 29 + return 'div' 29 30 }) 30 31 31 32 function handleClick(e: MouseEvent) { 32 - if (isInteractive.value) emit('click', e) 33 + if (isInteractive.value) emit('click', e) 33 34 } 34 35 35 36 function handleKeydown(e: KeyboardEvent) { 36 - if (!isInteractive.value) return 37 - if (!isLink.value && (e.key === 'Enter' || e.key === ' ')) { 38 - e.preventDefault() 39 - emit('click', e as unknown as MouseEvent) 40 - } 37 + if (!isInteractive.value) return 38 + if (!isLink.value && (e.key === 'Enter' || e.key === ' ')) { 39 + e.preventDefault() 40 + emit('click', e as unknown as MouseEvent) 41 + } 41 42 } 42 43 </script> 43 44 44 45 <template> 45 - <component 46 - :is="componentType" 47 - class="list-item" 48 - :class="{ 'is-clickable': isInteractive, 'is-danger': danger }" 49 - :href="href" 50 - :to="to" 51 - :target="target" 52 - :tabindex="isInteractive ? 0 : -1" 53 - :role="!isLink && isInteractive ? 'button' : undefined" 54 - @click="handleClick" 55 - @keydown="handleKeydown" 56 - > 57 - <div v-if="$slots.start" class="item-start"> 58 - <slot name="start" /> 59 - </div> 46 + <component 47 + :is="componentType" 48 + class="list-item" 49 + :class="{ 'is-clickable': isInteractive, 'is-danger': danger }" 50 + :href="href" 51 + :to="to" 52 + :target="target" 53 + :tabindex="isInteractive ? 0 : -1" 54 + :role="!isLink && isInteractive ? 'button' : undefined" 55 + @click="handleClick" 56 + @keydown="handleKeydown" 57 + > 58 + <div v-if="$slots.start" class="item-start"> 59 + <slot name="start" /> 60 + </div> 60 61 61 - <div class="item-content"> 62 - <div v-if="title" class="item-title">{{ title }}</div> 63 - <div v-if="subtitle" class="item-subtitle">{{ subtitle }}</div> 64 - <slot /> 65 - </div> 62 + <div class="item-content"> 63 + <div v-if="title" class="item-title">{{ title }}</div> 64 + <div v-if="subtitle" class="item-subtitle">{{ subtitle }}</div> 65 + <slot /> 66 + </div> 66 67 67 - <div v-if="$slots.end || chevron" class="item-end"> 68 - <slot name="end" /> 69 - <IconChevronRightRounded v-if="chevron" class="chevron" /> 70 - </div> 71 - </component> 68 + <div v-if="$slots.end || chevron" class="item-end"> 69 + <slot name="end" /> 70 + <IconChevronRightRounded v-if="chevron" class="chevron" /> 71 + </div> 72 + </component> 72 73 </template> 73 74 74 75 <style scoped lang="scss"> 75 76 .list-item { 76 - position: relative; 77 - display: flex; 78 - align-items: center; 79 - gap: var(--space-3); 80 - padding: var(--space-4); 81 - background: hsl(var(--surface0)); 82 - transition: background-color 0.2s ease; 83 - text-decoration: none; 84 - color: inherit; 85 - min-height: 3.5rem; 86 - outline: none; 77 + position: relative; 78 + display: flex; 79 + align-items: center; 80 + gap: var(--space-3); 81 + padding: var(--space-4); 82 + background: hsl(var(--surface0)); 83 + text-decoration: none; 84 + color: inherit; 85 + min-height: 3.5rem; 86 + outline: none; 87 87 88 - &::after { 89 - content: ''; 90 - position: absolute; 91 - bottom: 0; 92 - right: 0; 93 - left: 1rem; 94 - height: 1px; 95 - background-color: hsla(var(--surface2) / 0.5); 96 - } 88 + &::after { 89 + content: ''; 90 + position: absolute; 91 + bottom: 0; 92 + right: 0; 93 + left: 1rem; 94 + height: 1px; 95 + background-color: hsla(var(--surface2) / 0.5); 96 + } 97 97 98 - &:last-child::after { 99 - display: none; 100 - } 98 + &:last-child::after { 99 + display: none; 100 + } 101 101 } 102 102 103 103 .is-clickable { 104 - cursor: pointer; 105 - &:hover { 106 - background: hsl(var(--surface1)); 107 - } 108 - &:active { 109 - background: hsl(var(--surface2)); 110 - } 111 - &:focus-visible { 112 - z-index: 1; 113 - background: hsl(var(--surface1)); 114 - box-shadow: inset 0 0 0 2px hsl(var(--accent)); 115 - } 104 + cursor: pointer; 105 + &:hover { 106 + background: hsla(var(--surface1) / 1); 107 + } 108 + &:active { 109 + background: hsla(var(--surface1) / 0.6); 110 + } 111 + &:focus-visible { 112 + z-index: 1; 113 + background: hsl(var(--surface1)); 114 + box-shadow: inset 0 0 0 2px hsl(var(--accent)); 115 + } 116 116 } 117 117 118 118 .is-danger { 119 - .item-title, 120 - .item-start, 121 - .chevron { 122 - color: hsl(var(--red)); 123 - } 119 + .item-title, 120 + .item-start, 121 + .chevron { 122 + color: hsl(var(--red)); 123 + } 124 124 } 125 125 126 126 .item-start { 127 - display: flex; 128 - align-items: center; 129 - justify-content: center; 130 - font-size: 1.5rem; 131 - color: hsl(var(--accent)); 127 + display: flex; 128 + align-items: center; 129 + justify-content: center; 130 + font-size: 1.5rem; 131 + color: hsl(var(--accent)); 132 132 133 - svg { 134 - display: block; 135 - } 133 + svg { 134 + display: block; 135 + } 136 136 } 137 137 138 138 .item-content { 139 - flex: 1; 140 - display: flex; 141 - flex-direction: column; 142 - justify-content: center; 143 - min-width: 0; 144 - gap: 2px; 139 + flex: 1; 140 + display: flex; 141 + flex-direction: column; 142 + justify-content: center; 143 + min-width: 0; 144 + gap: 2px; 145 145 } 146 146 147 147 .item-title { 148 - font-weight: 600; 149 - color: hsl(var(--text)); 150 - font-size: 1rem; 151 - white-space: nowrap; 152 - overflow: hidden; 153 - text-overflow: ellipsis; 148 + font-weight: 600; 149 + color: hsl(var(--text)); 150 + font-size: 1rem; 151 + white-space: nowrap; 152 + overflow: hidden; 153 + text-overflow: ellipsis; 154 154 } 155 155 156 156 .item-subtitle { 157 - font-size: 0.8rem; 158 - color: hsl(var(--subtext0)); 159 - line-height: 1.3; 157 + font-size: 0.8rem; 158 + color: hsl(var(--subtext0)); 159 + line-height: 1.3; 160 160 } 161 161 162 162 .item-end { 163 - display: flex; 164 - align-items: center; 165 - gap: var(--space-2); 166 - color: hsl(var(--subtext1)); 167 - font-size: 0.9rem; 168 - font-weight: 500; 163 + display: flex; 164 + align-items: center; 165 + gap: var(--space-2); 166 + color: hsl(var(--subtext1)); 167 + font-size: 0.9rem; 168 + font-weight: 500; 169 169 } 170 170 171 171 .chevron { 172 - font-size: 1.25rem; 173 - opacity: 0.4; 172 + font-size: 1.25rem; 173 + opacity: 0.4; 174 174 } 175 175 </style>
+42
src/composables/useFollowToggle.ts
··· 1 + import { useAuthStore } from '@/stores/auth' 2 + import type { ViewerState } from '@atcute/bluesky/types/app/actor/defs' 3 + 4 + export function useFollowToggle() { 5 + const auth = useAuthStore() 6 + async function toggle(profile: { did: string; viewer?: ViewerState }) { 7 + if (!auth.isAuthenticated || !auth.session) return 8 + const rpc = auth.getRpc() 9 + const original = profile.viewer?.following 10 + profile.viewer = profile.viewer || {} 11 + if (original) profile.viewer.following = undefined 12 + else profile.viewer.following = `at://${profile.did}/app.bsky.graph.follow/temporary` 13 + 14 + try { 15 + if (original) { 16 + const rkey = original.split('/').pop()! 17 + await rpc.post('com.atproto.repo.deleteRecord', { 18 + input: { collection: 'app.bsky.graph.follow', repo: auth.session.info.sub, rkey }, 19 + }) 20 + } else { 21 + const { data, ok } = await rpc.post('com.atproto.repo.createRecord', { 22 + input: { 23 + collection: 'app.bsky.graph.follow', 24 + repo: auth.session.info.sub, 25 + record: { 26 + $type: 'app.bsky.graph.follow', 27 + subject: profile.did, 28 + createdAt: new Date().toISOString(), 29 + }, 30 + }, 31 + }) 32 + if (ok) profile.viewer.following = data.uri 33 + } 34 + } catch (e) { 35 + // revert 36 + profile.viewer.following = original 37 + throw e 38 + } 39 + } 40 + 41 + return { toggle } 42 + }
+21
src/composables/useInfiniteScroll.ts
··· 1 + import { onBeforeUnmount } from 'vue' 2 + 3 + export function useInfiniteScroll( 4 + sentinelRef: { value: HTMLElement | null }, 5 + onIntersect: () => void, 6 + opts = { rootMargin: '200px', threshold: 0.1 }, 7 + ) { 8 + let obs: IntersectionObserver | null = null 9 + const setup = () => { 10 + if (!sentinelRef.value) return 11 + obs = new IntersectionObserver((entries) => { 12 + for (const e of entries) if (e.isIntersecting) onIntersect() 13 + }, opts) 14 + obs.observe(sentinelRef.value) 15 + } 16 + onBeforeUnmount(() => { 17 + if (obs && sentinelRef.value) obs.unobserve(sentinelRef.value) 18 + obs = null 19 + }) 20 + return { setup } 21 + }
+33
src/composables/usePagedProfiles.ts
··· 1 + import { shallowRef, ref } from 'vue' 2 + import type { AppBskyActorDefs } from '@atcute/bluesky' 3 + 4 + export function usePagedProfiles<T extends AppBskyActorDefs.ProfileView>() { 5 + const items = shallowRef<T[]>([]) 6 + const cursor = ref<string | null>(null) 7 + const loading = ref(false) 8 + const error = ref<string | null>(null) 9 + 10 + async function fetchPage( 11 + fetcher: (cursor?: string | null) => Promise<{ items: T[]; cursor?: string | null }>, 12 + reset = false, 13 + ) { 14 + if (loading.value) return 15 + loading.value = true 16 + error.value = null 17 + if (reset) { 18 + items.value = [] 19 + cursor.value = null 20 + } 21 + try { 22 + const res = await fetcher(cursor.value || undefined) 23 + items.value.push(...res.items) 24 + cursor.value = res.cursor ?? null 25 + } catch (e) { 26 + error.value = e instanceof Error ? e.message : String(e) 27 + } finally { 28 + loading.value = false 29 + } 30 + } 31 + 32 + return { items, cursor, loading, error, fetchPage } 33 + }
+23 -4
src/router/index.ts
··· 12 12 path: string 13 13 component: Component | (() => Promise<Component>) 14 14 icon?: Component | (() => Promise<Component>) 15 + defaultProps?: Record<string, unknown> 15 16 } 16 17 17 - export const pages = [ 18 + export const devPages: Page[] = [] 19 + 20 + export const mainPages: Page[] = [ 18 21 { 19 22 root: true, 20 23 label: 'Home', ··· 43 46 label: 'User Profile', 44 47 name: 'user-profile', 45 48 path: '/profile/:id', 46 - component: () => import('@/views/UserProfile.vue'), 49 + component: () => import('@/views/Profile/ProfileView.vue'), 50 + }, 51 + { 52 + root: false, 53 + label: 'Followers', 54 + name: 'user-followers', 55 + path: '/profile/:id/followers', 56 + component: () => import('@/views/Profile/FollowsView.vue'), 57 + }, 58 + { 59 + root: false, 60 + label: 'Follows', 61 + name: 'user-follows', 62 + path: '/profile/:id/follows', 63 + component: () => import('@/views/Profile/FollowsView.vue'), 47 64 }, 48 65 { 49 66 root: false, ··· 74 91 path: '/profile/:identifier/post/:rkey', 75 92 component: () => import('@/views/Post/PostView.vue'), 76 93 }, 77 - ] as const satisfies readonly Page[] 94 + ] 95 + 96 + export const pages = [...mainPages, ...devPages] as const satisfies readonly Page[] 78 97 79 98 export const stackRoots = pages.filter((page) => page.root) 80 99 export type PageNames = (typeof pages)[number]['name'] ··· 131 150 url = url.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => { 132 151 if (props[key] !== undefined) { 133 152 usedKeys.add(key) 134 - return encodeURIComponent(String(props[key])) 153 + return String(props[key]) 135 154 } 136 155 return ':' + key 137 156 })
+9 -1
src/stores/auth.ts
··· 25 25 import _KEYS from '@/utils/keys' 26 26 const KEYS = _KEYS.AUTH 27 27 28 + // TODO)) add as setting 29 + export const BSKY_APPVIEW = 'did:web:api.bsky.app#bsky_appview' 30 + 28 31 export const useAuthStore = defineStore('auth', () => { 29 32 const session = ref<Session | null>(null) 30 33 const agent = ref<OAuthUserAgent | null>(null) ··· 85 88 const data = await ok( 86 89 rpc.get('app.bsky.actor.getProfile', { 87 90 params: { actor: session.value.info.sub }, 91 + headers: { 92 + 'atproto-proxy': BSKY_APPVIEW, 93 + }, 88 94 }), 89 95 ) 90 96 profile.value = data ··· 93 99 } 94 100 } 95 101 96 - async function login(input: string) { 102 + async function login(input: string, createAccount = false) { 97 103 isLoading.value = true 98 104 error.value = null 99 105 ··· 105 111 authUrl = await createAuthorizationUrl({ 106 112 target: { type: 'pds', serviceUrl: input }, 107 113 scope, 114 + // @ts-expect-error: craete will be available soon:tm: 115 + prompt: createAccount ? 'create' : undefined, 108 116 }) 109 117 } else { 110 118 try {
+67
src/utils/haptics.ts
··· 1 + /** 2 + * this is v experimental. 3 + * for the time being, since i cba to write a capacitor plugin for the system 4 + * haptics api, we're just using the browser api. 5 + */ 6 + 7 + import { 8 + Haptics, 9 + type VibrateOptions, 10 + type NotificationOptions, 11 + type ImpactOptions, 12 + ImpactStyle, 13 + } from '@capacitor/haptics' 14 + 15 + export function isVibrationsEnabled(): boolean { 16 + return true 17 + } 18 + 19 + export function tap() { 20 + Haptics.vibrate({ duration: 1 }) 21 + } 22 + 23 + export async function impact(options?: ImpactOptions): Promise<void> { 24 + if (!isVibrationsEnabled()) return 25 + try { 26 + await Haptics.impact(options || { style: ImpactStyle.Light }) 27 + } catch {} 28 + } 29 + 30 + export async function notification(options?: NotificationOptions): Promise<void> { 31 + if (!isVibrationsEnabled()) return 32 + try { 33 + await Haptics.notification(options) 34 + } catch {} 35 + } 36 + 37 + export async function vibrate(options?: VibrateOptions): Promise<void> { 38 + if (!isVibrationsEnabled()) return 39 + try { 40 + await Haptics.vibrate(options) 41 + } catch {} 42 + } 43 + 44 + async function selectionStart(): Promise<void> { 45 + if (!isVibrationsEnabled()) return 46 + try { 47 + await Haptics.selectionStart() 48 + } catch {} 49 + } 50 + async function selectionChanged(): Promise<void> { 51 + if (!isVibrationsEnabled()) return 52 + try { 53 + await Haptics.selectionChanged() 54 + } catch {} 55 + } 56 + async function selectionEnd(): Promise<void> { 57 + if (!isVibrationsEnabled()) return 58 + try { 59 + await Haptics.selectionEnd() 60 + } catch {} 61 + } 62 + 63 + export const selection = { 64 + start: selectionStart, 65 + changed: selectionChanged, 66 + end: selectionEnd, 67 + }
+200 -176
src/views/Auth/LoginPage.vue
··· 1 1 <script setup lang="ts"> 2 - import { ref, watch } from 'vue' 3 - import { 4 - IconArrowForwardRounded, 5 - IconOpenInNewRounded, 6 - IconAlternateEmailRounded, 7 - IconCloseRounded, 8 - IconProgressActivity, 9 - } from '@iconify-prerendered/vue-material-symbols' 10 - import { simpleFetchHandler, Client, ok } from '@atcute/client' 2 + import { ref, computed } from 'vue' 3 + import { IconOpenInNewRounded } from '@iconify-prerendered/vue-material-symbols' 11 4 12 5 import { useAuthStore } from '@/stores/auth' 13 6 import PageLayout from '@/components/Navigation/PageLayout.vue' 14 7 import Button from '@/components/UI/BaseButton.vue' 15 8 import Modal from '@/components/UI/BaseModal.vue' 16 9 import TextInput from '@/components/UI/TextInput.vue' 17 - import ListItem from '@/components/UI/ListItem.vue' 18 10 19 11 const auth = useAuthStore() 20 12 21 - const handler = simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 22 - const client = new Client({ handler }) 23 - 24 - const profilePicture = ref<string | null>(null) 25 - const handle = ref('') 26 - const showCreateAccount = ref(false) 27 - const loading = ref(false) 28 - const hasError = ref(false) 29 - 30 - const handleSubmit = async () => { 31 - if (!handle.value) return 32 - await auth.login(handle.value) 13 + type Provider = { 14 + name: string 15 + url: string 16 + subtitle?: string 17 + location: string 33 18 } 34 - const pdsSubmit = async (pds: string) => { 35 - await auth.login(pds) 36 - } 37 - 38 - let debounceTimer: ReturnType<typeof setTimeout> 39 - 40 - watch(handle, (newHandle) => { 41 - clearTimeout(debounceTimer) 42 - hasError.value = false 43 - loading.value = true 44 - 45 - if (!newHandle) { 46 - profilePicture.value = null 47 - loading.value = false 48 - return 49 - } 50 - 51 - debounceTimer = setTimeout(async () => { 52 - try { 53 - const cleanHandle = newHandle.startsWith('@') ? newHandle.slice(1) : newHandle 54 - const data = ok( 55 - await client.get('app.bsky.actor.getProfile', { 56 - params: { actor: cleanHandle }, 57 - }), 58 - ) 59 - 60 - profilePicture.value = data.avatar || null 61 - hasError.value = false 62 - } catch (error) { 63 - profilePicture.value = null 64 - hasError.value = true 65 - console.error('Error fetching profile:', error) 66 - } finally { 67 - loading.value = false 68 - } 69 - }, 500) 70 - }) 71 - 72 - const providerList: Array<{ name: string; subtitle?: string; url: string }> = [ 73 - { name: 'Bluesky PBC', subtitle: 'bsky.social', url: 'https://bsky.social/' }, 74 - { name: 'selfhosted.social', url: 'https://selfhosted.social/' }, 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 + }, 75 28 { 76 29 name: 'Tophhie Social', 77 30 subtitle: 'pds.tophhie.cloud', 78 31 url: 'https://pds.tophhie.cloud', 32 + location: 'GB', 79 33 }, 80 - { name: 'Blacksky', subtitle: 'A PDS for the black community.', url: 'https://blacksky.app/' }, 81 - { name: 'Spark', subtitle: 'pds.sprk.so', url: 'https://pds.sprk.so' }, 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' }, 82 52 ] 53 + 54 + const isLoading = computed(() => auth.isLoading) 55 + const showPdsModal = ref(false) 56 + const pdsInput = ref('') 57 + const pdsError = ref('') 58 + 59 + async function pdsSubmit(pds: string, providerList = false) { 60 + if (!pds || isLoading.value) return 61 + await auth.login(pds, providerList) 62 + } 63 + 64 + function openPdsModal() { 65 + pdsInput.value = '' 66 + pdsError.value = '' 67 + showPdsModal.value = true 68 + } 69 + 70 + async function submitPds() { 71 + const val = pdsInput.value.trim() 72 + if (!val) { 73 + pdsError.value = 'Enter a PDS URL or handle (e.g. https://pds.example or awesome.cat)' 74 + return 75 + } 76 + showPdsModal.value = false 77 + await auth.login(val) 78 + } 83 79 </script> 84 80 85 81 <template> 86 - <PageLayout title="Login"> 82 + <PageLayout title="Sign in"> 87 83 <div class="login-view"> 88 - <div class="header"> 84 + <header class="header"> 89 85 <h1 class="title">Sign in</h1> 90 - <p class="subtitle">Enter your AT Protocol handle to continue.</p> 91 - </div> 86 + <p class="subtitle">Pick a provider to sign in with.</p> 87 + </header> 92 88 93 - <form @submit.prevent="handleSubmit" class="form-stack"> 94 - <TextInput 95 - v-model="handle" 96 - placeholder="awesome.cat" 97 - :error="auth.error || undefined" 98 - @keydown.enter="handleSubmit" 99 - autofocus 89 + <div class="button-row compact"> 90 + <Button 91 + variant="primary" 92 + size="lg" 93 + :block="true" 94 + :loading="isLoading" 95 + @click="pdsSubmit('https://bsky.social')" 100 96 > 101 - <template #prefix> 102 - <div v-if="loading" class="text-input-prefix spin"> 103 - <IconProgressActivity /> 104 - </div> 105 - <div v-else-if="profilePicture" class="input-avatar"> 106 - <img :src="profilePicture" alt="Avatar" /> 107 - </div> 108 - <div v-else class="text-input-prefix"> 109 - <IconCloseRounded v-if="hasError" style="color: hsl(var(--danger))" /> 110 - <IconAlternateEmailRounded v-else /> 111 - </div> 112 - </template> 113 - </TextInput> 97 + <span class="btn-inner"> 98 + <span>Sign in with Bluesky</span> 99 + </span> 100 + </Button> 114 101 115 - <div class="actions"> 116 - <Button type="button" variant="subtle-alt" @click="showCreateAccount = true"> 117 - Create account 118 - </Button> 119 - <Button type="submit" variant="primary" :loading="auth.isLoading" :disabled="!handle"> 120 - Next <IconArrowForwardRounded /> 102 + <div class="secondary-row"> 103 + <Button variant="text" @click="openPdsModal" :disabled="isLoading"> 104 + Sign in with AT Protocol 121 105 </Button> 122 106 </div> 123 - </form> 107 + </div> 108 + 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 + <p v-if="auth.error" class="error-text">{{ auth.error }}</p> 124 135 </div> 125 136 126 - <Modal v-model:open="showCreateAccount" title="Create Account"> 127 - <div class="create-account-content"> 128 - <p class="modal-text"> 129 - To use Bluebell, you need to create an Atmosphere account. Here are some open providers 130 - where you can register. 131 - </p> 137 + <Modal v-model:open="showPdsModal" title="Sign in with PDS"> 138 + <div class="modal-body"> 139 + <label for="pds-input" class="sr-only">PDS URL or handle</label> 132 140 133 - <div class="provider-list"> 134 - <ListItem 135 - v-for="provider in providerList" 136 - :key="provider.name" 137 - :title="provider.name" 138 - :subtitle="provider.subtitle" 139 - @click="pdsSubmit(provider.url)" 140 - clickable 141 - :disabled="auth.isLoading" 142 - > 143 - <template #end><IconOpenInNewRounded /></template> 144 - </ListItem> 145 - </div> 141 + <TextInput 142 + id="pds-input" 143 + v-model="pdsInput" 144 + placeholder="https://pds.example or example.cat" 145 + :error="pdsError" 146 + inputmode="url" 147 + aria-describedby="pds-error" 148 + @keydown.enter.prevent="submitPds" 149 + style="margin-top: 1rem" 150 + /> 146 151 147 - <p class="modal-subtext"> 148 - Make sure to to read the provider's terms of service and privacy policy before creating an 149 - account. 150 - </p> 152 + <div id="pds-error" role="alert" v-if="pdsError">{{ pdsError }}</div> 151 153 </div> 154 + 152 155 <template #footer> 153 - <Button variant="ghost" @click="showCreateAccount = false">Close</Button> 156 + <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 + > 154 160 </template> 155 161 </Modal> 156 162 </PageLayout> ··· 158 164 159 165 <style scoped lang="scss"> 160 166 .login-view { 161 - max-width: 400px; 162 167 width: 100%; 163 168 display: flex; 164 169 flex-direction: column; 165 170 gap: 1rem; 171 + margin: 0 auto; 166 172 } 167 173 168 174 .header { ··· 183 189 line-height: 1.5; 184 190 } 185 191 } 186 - 187 - .form-stack { 192 + .button-row { 188 193 display: flex; 189 194 flex-direction: column; 190 - gap: 1rem; 195 + gap: 0.5rem; 196 + } 197 + .btn-inner { 198 + display: inline-flex; 199 + align-items: center; 200 + gap: 0.6rem; 201 + } 202 + .secondary-row { 203 + display: flex; 204 + justify-content: center; 205 + } 191 206 192 - .actions { 193 - display: flex; 194 - justify-content: flex-end; 195 - gap: 1rem; 196 - } 207 + .provider-list { 208 + border-radius: 1rem; 209 + border: 1px solid hsla(var(--surface2) / 0.5); 210 + overflow: hidden; 211 + } 197 212 198 - .text-input-prefix { 199 - display: flex; 200 - align-items: center; 213 + .list-item { 214 + width: 100%; 215 + display: flex; 216 + align-items: center; 217 + gap: 1rem; 218 + padding: 0.75rem 1rem; 219 + text-align: left; 201 220 202 - &.spin { 203 - animation: spin 1s linear infinite; 204 - color: hsl(var(--subtext0)); 221 + border-color: transparent; 222 + border-bottom: 1px solid hsla(var(--surface2) / 0.5); 223 + background: transparent; 224 + cursor: pointer; 205 225 206 - @keyframes spin { 207 - from { 208 - transform: rotate(0deg); 209 - } 210 - to { 211 - transform: rotate(360deg); 212 - } 213 - } 214 - } 226 + &:hover { 227 + background: hsla(var(--surface2) / 0.2); 215 228 } 216 229 217 - .input-avatar { 218 - display: flex; 219 - align-items: center; 220 - justify-content: center; 221 - padding-left: 0.5rem; 222 - padding-right: 0.5rem; 223 - height: 100%; 230 + &:focus-visible { 231 + outline-color: transparent; 232 + background-color: hsla(var(--accent) / 0.2); 233 + } 224 234 225 - img { 226 - width: 2rem; 227 - height: 2rem; 228 - 229 - border-radius: 50%; 230 - object-fit: cover; 231 - background-color: hsl(var(--surface0)); 232 - border: 1px solid hsl(var(--surface2)); 233 - } 235 + &:last-child { 236 + border-bottom: none; 234 237 } 235 238 } 236 239 237 - .create-account-content { 240 + .content-grid { 241 + flex: 1; 238 242 display: flex; 239 243 flex-direction: column; 240 - gap: 1rem; 241 - padding-top: 0.5rem; 244 + gap: 0.2rem; 245 + min-width: 0; 246 + } 242 247 243 - .modal-text { 248 + .top-row { 249 + display: flex; 250 + align-items: center; 251 + gap: 0.5rem; 252 + 253 + .name { 254 + font-weight: 600; 255 + font-size: 0.95rem; 244 256 color: hsl(var(--text)); 245 - line-height: 1.5; 257 + white-space: nowrap; 246 258 } 247 259 248 - .modal-subtext { 249 - font-size: 0.875rem; 250 - color: hsl(var(--subtext0)); 251 - line-height: 1.4; 260 + .location { 261 + font-size: 0.7rem; 262 + padding: 0.1rem 0.5rem; 263 + border-radius: 1rem; 264 + background: hsla(var(--accent) / 0.25); 265 + color: hsl(var(--text)); 252 266 } 267 + } 253 268 254 - .provider-list { 255 - display: flex; 256 - flex-direction: column; 257 - border: 1px solid hsla(var(--surface2) / 0.5); 258 - border-radius: var(--radius-lg); 259 - overflow: hidden; 260 - } 269 + .subtitle { 270 + font-size: 0.8rem; 271 + color: hsl(var(--subtext0)); 272 + line-height: 1.4; 273 + } 274 + 275 + .action { 276 + color: hsl(var(--subtext0)); 277 + display: flex; 278 + align-items: center; 279 + } 280 + 281 + .error-text { 282 + color: hsl(var(--red)); 283 + font-weight: 600; 284 + margin-top: 0.5rem; 261 285 } 262 286 </style>
+222
src/views/Profile/FollowersView.vue
··· 1 + <script lang="ts" setup> 2 + import { ref, onMounted, watch, computed, onBeforeUnmount } from 'vue' 3 + import type { ActorIdentifier } from '@atcute/lexicons' 4 + import { AppBskyActorDefs } from '@atcute/bluesky' 5 + import { BSKY_APPVIEW, useAuthStore } from '@/stores/auth' 6 + 7 + import PageLayout from '@/components/Navigation/PageLayout.vue' 8 + import AppLink from '@/components/Navigation/AppLink.vue' 9 + import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 10 + import Button from '@/components/UI/BaseButton.vue' 11 + 12 + const props = defineProps<{ id: ActorIdentifier }>() 13 + const auth = useAuthStore() 14 + 15 + const follows = ref<AppBskyActorDefs.ProfileView[]>([]) 16 + const cursor = ref<string | null>(null) 17 + const isLoading = ref(false) 18 + const isError = ref<string | null>(null) 19 + const loadMoreTrigger = ref<HTMLElement | null>(null) 20 + let obs: IntersectionObserver | null = null 21 + 22 + const isEmpty = computed(() => !isLoading.value && follows.value.length === 0 && !isError.value) 23 + 24 + async function fetchFollows(reset = false) { 25 + if (isLoading.value) return 26 + isLoading.value = true 27 + isError.value = null 28 + 29 + if (reset) { 30 + follows.value = [] 31 + cursor.value = null 32 + } 33 + 34 + try { 35 + const rpc = auth.getRpc() 36 + const { data, ok } = await rpc.get('app.bsky.graph.getFollowers', { 37 + params: { 38 + actor: props.id, 39 + limit: 50, 40 + cursor: cursor.value || undefined, 41 + }, 42 + headers: { 'atproto-proxy': BSKY_APPVIEW }, 43 + }) 44 + 45 + if (!ok) { 46 + isError.value = (data && data.error) || 'Failed to fetch follows' 47 + return 48 + } 49 + 50 + follows.value.push(...(data.followers || [])) 51 + cursor.value = data.cursor || null 52 + } catch (e) { 53 + console.error('fetchFollows error', e) 54 + isError.value = e instanceof Error ? e.message : 'Unknown error' 55 + } finally { 56 + isLoading.value = false 57 + } 58 + } 59 + 60 + async function toggleFollowRow(profile: AppBskyActorDefs.ProfileView) { 61 + if (!auth.isAuthenticated || !auth.session) return 62 + const rpc = auth.getRpc() 63 + const original = profile.viewer?.following 64 + 65 + profile.viewer = profile.viewer || {} 66 + if (original) { 67 + profile.viewer.following = undefined 68 + } else { 69 + profile.viewer.following = `at://${profile.did}/app.bsky.graph.follow/temporary` 70 + } 71 + 72 + try { 73 + if (original) { 74 + const rkey = original.split('/').pop()! 75 + await rpc.post('com.atproto.repo.deleteRecord', { 76 + input: { collection: 'app.bsky.graph.follow', repo: auth.session.info.sub, rkey }, 77 + }) 78 + } else { 79 + const { data, ok } = await rpc.post('com.atproto.repo.createRecord', { 80 + input: { 81 + collection: 'app.bsky.graph.follow', 82 + repo: auth.session.info.sub, 83 + record: { 84 + $type: 'app.bsky.graph.follow', 85 + subject: profile.did, 86 + createdAt: new Date().toISOString(), 87 + }, 88 + }, 89 + }) 90 + if (ok) 91 + profile.viewer 92 + ? (profile.viewer.following = data.uri) 93 + : (profile.viewer = { following: data.uri }) 94 + } 95 + } catch (e) { 96 + console.error('toggleFollowRow failed', e) 97 + profile.viewer.following = original 98 + } 99 + } 100 + 101 + const isMutual = (p: AppBskyActorDefs.ProfileView) => 102 + !!(p.viewer?.following && p.viewer?.followedBy) 103 + 104 + const isFollowingRow = (p: AppBskyActorDefs.ProfileView) => !!p.viewer?.following 105 + 106 + function followButtonLabel(p: AppBskyActorDefs.ProfileView) { 107 + if (isMutual(p)) return 'Mutuals' 108 + return isFollowingRow(p) ? 'Following' : 'Follow' 109 + } 110 + 111 + function setupObserver() { 112 + if (!loadMoreTrigger.value) return 113 + obs = new IntersectionObserver( 114 + (entries) => { 115 + for (const ent of entries) { 116 + if (ent.isIntersecting && cursor.value && !isLoading.value) { 117 + fetchFollows(false) 118 + } 119 + } 120 + }, 121 + { root: null, rootMargin: '200px', threshold: 0.1 }, 122 + ) 123 + obs.observe(loadMoreTrigger.value) 124 + } 125 + 126 + onMounted(async () => { 127 + await fetchFollows(true) 128 + setupObserver() 129 + }) 130 + 131 + onBeforeUnmount(() => { 132 + if (obs && loadMoreTrigger.value) obs.unobserve(loadMoreTrigger.value) 133 + obs = null 134 + }) 135 + 136 + watch( 137 + () => props.id, 138 + () => { 139 + fetchFollows(true) 140 + }, 141 + ) 142 + </script> 143 + 144 + <template> 145 + <PageLayout title="Following" no-padding> 146 + <div class="follows-list" role="list"> 147 + <div v-if="isLoading && follows.length === 0" class="skeletons"> 148 + <SkeletonLoader v-for="n in 6" :key="n" width="100%" height="72px" /> 149 + </div> 150 + 151 + <div v-else-if="isError" class="error-state"> 152 + <p>Failed to load follows - {{ isError }}</p> 153 + <Button @click="fetchFollows(true)">Retry</Button> 154 + </div> 155 + 156 + <div v-else-if="isEmpty" class="empty-state"> 157 + <p>This user isn't following anyone yet.</p> 158 + </div> 159 + 160 + <AppLink 161 + v-for="follow in follows" 162 + :key="follow.did" 163 + class="follow" 164 + name="user-profile" 165 + :params="{ id: follow.did }" 166 + role="listitem" 167 + > 168 + <img 169 + class="avatar" 170 + :src="follow.avatar" 171 + :alt="`${follow.displayName || follow.handle}'s avatar`" 172 + loading="lazy" 173 + /> 174 + <div class="info"> 175 + <div class="top"> 176 + <div class="display-name">{{ follow.displayName || follow.handle }}</div> 177 + <span class="handle">@{{ follow.handle }}</span> 178 + <span v-if="follow.pronouns" class="dot" aria-hidden="true">·</span> 179 + <span v-if="follow.pronouns" class="pronouns">{{ follow.pronouns }}</span> 180 + </div> 181 + <div class="meta"> 182 + <span v-if="follow.description" class="bio">{{ follow.description }}</span> 183 + </div> 184 + </div> 185 + 186 + <button 187 + class="follow-btn" 188 + :class="{ mutual: isMutual(follow), following: isFollowingRow(follow) }" 189 + @click.stop.prevent="toggleFollowRow(follow)" 190 + :aria-pressed="!!follow.viewer?.following" 191 + :title=" 192 + isMutual(follow) 193 + ? 'Mutuals - click to unfollow' 194 + : follow.viewer?.following 195 + ? 'Unfollow' 196 + : 'Follow' 197 + " 198 + > 199 + {{ followButtonLabel(follow) }} 200 + </button> 201 + </AppLink> 202 + 203 + <div ref="loadMoreTrigger" class="load-more__sentinel" /> 204 + <div class="load-more__fallback" v-if="cursor && !isLoading"> 205 + <Button variant="ghost" @click="fetchFollows(false)">Load more</Button> 206 + </div> 207 + <div v-if="isLoading && follows.length > 0" class="loading-more"> 208 + <SkeletonLoader width="100%" height="72px" /> 209 + </div> 210 + </div> 211 + </PageLayout> 212 + </template> 213 + 214 + <style scoped lang="scss"> 215 + .follows-list { 216 + /* padding: 1rem; */ 217 + display: flex; 218 + flex-direction: column; 219 + /* gap: 0.5rem; */ 220 + min-height: 200px; 221 + } 222 + </style>
+368
src/views/Profile/FollowingView.vue
··· 1 + <script lang="ts" setup> 2 + import { ref, onMounted, watch, computed, onBeforeUnmount } from 'vue' 3 + import type { ActorIdentifier } from '@atcute/lexicons' 4 + import { AppBskyActorDefs } from '@atcute/bluesky' 5 + import { BSKY_APPVIEW, useAuthStore } from '@/stores/auth' 6 + 7 + import PageLayout from '@/components/Navigation/PageLayout.vue' 8 + import AppLink from '@/components/Navigation/AppLink.vue' 9 + import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 10 + import Button from '@/components/UI/BaseButton.vue' 11 + 12 + const props = defineProps<{ id: ActorIdentifier }>() 13 + const auth = useAuthStore() 14 + 15 + const follows = ref<AppBskyActorDefs.ProfileView[]>([]) 16 + const cursor = ref<string | null>(null) 17 + const isLoading = ref(false) 18 + const isError = ref<string | null>(null) 19 + const loadMoreTrigger = ref<HTMLElement | null>(null) 20 + let obs: IntersectionObserver | null = null 21 + 22 + const isEmpty = computed(() => !isLoading.value && follows.value.length === 0 && !isError.value) 23 + 24 + async function fetchFollows(reset = false) { 25 + if (isLoading.value) return 26 + isLoading.value = true 27 + isError.value = null 28 + 29 + if (reset) { 30 + follows.value = [] 31 + cursor.value = null 32 + } 33 + 34 + try { 35 + const rpc = auth.getRpc() 36 + const { data, ok } = await rpc.get('app.bsky.graph.getFollows', { 37 + params: { 38 + actor: props.id, 39 + limit: 50, 40 + cursor: cursor.value || undefined, 41 + }, 42 + headers: { 'atproto-proxy': BSKY_APPVIEW }, 43 + }) 44 + 45 + if (!ok) { 46 + isError.value = (data && data.error) || 'Failed to fetch follows' 47 + return 48 + } 49 + 50 + follows.value.push(...(data.follows || [])) 51 + cursor.value = data.cursor || null 52 + } catch (e) { 53 + console.error('fetchFollows error', e) 54 + isError.value = e instanceof Error ? e.message : 'Unknown error' 55 + } finally { 56 + isLoading.value = false 57 + } 58 + } 59 + 60 + async function toggleFollowRow(profile: AppBskyActorDefs.ProfileView) { 61 + if (!auth.isAuthenticated || !auth.session) return 62 + const rpc = auth.getRpc() 63 + const original = profile.viewer?.following 64 + 65 + profile.viewer = profile.viewer || {} 66 + if (original) { 67 + profile.viewer.following = undefined 68 + } else { 69 + profile.viewer.following = `at://${profile.did}/app.bsky.graph.follow/temporary` 70 + } 71 + 72 + try { 73 + if (original) { 74 + const rkey = original.split('/').pop()! 75 + await rpc.post('com.atproto.repo.deleteRecord', { 76 + input: { collection: 'app.bsky.graph.follow', repo: auth.session.info.sub, rkey }, 77 + }) 78 + } else { 79 + const { data, ok } = await rpc.post('com.atproto.repo.createRecord', { 80 + input: { 81 + collection: 'app.bsky.graph.follow', 82 + repo: auth.session.info.sub, 83 + record: { 84 + $type: 'app.bsky.graph.follow', 85 + subject: profile.did, 86 + createdAt: new Date().toISOString(), 87 + }, 88 + }, 89 + }) 90 + if (ok) 91 + profile.viewer 92 + ? (profile.viewer.following = data.uri) 93 + : (profile.viewer = { following: data.uri }) 94 + } 95 + } catch (e) { 96 + console.error('toggleFollowRow failed', e) 97 + profile.viewer!.following = original 98 + } 99 + } 100 + 101 + const isMutual = (p: AppBskyActorDefs.ProfileView) => 102 + !!(p.viewer?.following && p.viewer?.followedBy) 103 + 104 + const isFollowingRow = (p: AppBskyActorDefs.ProfileView) => !!p.viewer?.following 105 + 106 + function followButtonLabel(p: AppBskyActorDefs.ProfileView) { 107 + if (isMutual(p)) return 'Mutuals' 108 + return isFollowingRow(p) ? 'Following' : 'Follow' 109 + } 110 + 111 + function setupObserver() { 112 + if (!loadMoreTrigger.value) return 113 + obs = new IntersectionObserver( 114 + (entries) => { 115 + for (const ent of entries) { 116 + if (ent.isIntersecting && cursor.value && !isLoading.value) { 117 + fetchFollows(false) 118 + } 119 + } 120 + }, 121 + { root: null, rootMargin: '200px', threshold: 0.1 }, 122 + ) 123 + obs.observe(loadMoreTrigger.value) 124 + } 125 + 126 + onMounted(async () => { 127 + await fetchFollows(true) 128 + setupObserver() 129 + }) 130 + 131 + onBeforeUnmount(() => { 132 + if (obs && loadMoreTrigger.value) obs.unobserve(loadMoreTrigger.value) 133 + obs = null 134 + }) 135 + 136 + watch( 137 + () => props.id, 138 + () => { 139 + fetchFollows(true) 140 + }, 141 + ) 142 + </script> 143 + 144 + <template> 145 + <PageLayout title="Following" no-padding> 146 + <div class="follows-list" role="list"> 147 + <div v-if="isLoading && follows.length === 0" class="skeletons"> 148 + <SkeletonLoader v-for="n in 6" :key="n" width="100%" height="72px" /> 149 + </div> 150 + 151 + <div v-else-if="isError" class="error-state"> 152 + <p>Failed to load follows - {{ isError }}</p> 153 + <Button @click="fetchFollows(true)">Retry</Button> 154 + </div> 155 + 156 + <div v-else-if="isEmpty" class="empty-state"> 157 + <p>This user isn't following anyone yet.</p> 158 + </div> 159 + 160 + <AppLink 161 + v-for="follow in follows" 162 + :key="follow.did" 163 + class="follow" 164 + name="user-profile" 165 + :params="{ id: follow.did }" 166 + role="listitem" 167 + > 168 + <img 169 + class="avatar" 170 + :src="follow.avatar" 171 + :alt="`${follow.displayName || follow.handle}'s avatar`" 172 + loading="lazy" 173 + /> 174 + <div class="info"> 175 + <div class="top"> 176 + <div class="display-name">{{ follow.displayName || follow.handle }}</div> 177 + <span class="handle">@{{ follow.handle }}</span> 178 + <span v-if="follow.pronouns" class="dot" aria-hidden="true">·</span> 179 + <span v-if="follow.pronouns" class="pronouns">{{ follow.pronouns }}</span> 180 + </div> 181 + <div class="meta"> 182 + <span v-if="follow.description" class="bio">{{ follow.description }}</span> 183 + </div> 184 + </div> 185 + 186 + <button 187 + class="follow-btn" 188 + :class="{ mutual: isMutual(follow), following: isFollowingRow(follow) }" 189 + @click.stop.prevent="toggleFollowRow(follow)" 190 + :aria-pressed="!!follow.viewer?.following" 191 + :title=" 192 + isMutual(follow) 193 + ? 'Mutuals - click to unfollow' 194 + : follow.viewer?.following 195 + ? 'Unfollow' 196 + : 'Follow' 197 + " 198 + > 199 + {{ followButtonLabel(follow) }} 200 + </button> 201 + </AppLink> 202 + 203 + <div ref="loadMoreTrigger" class="load-more__sentinel" /> 204 + <div class="load-more__fallback" v-if="cursor && !isLoading"> 205 + <Button variant="ghost" @click="fetchFollows(false)">Load more</Button> 206 + </div> 207 + <div v-if="isLoading && follows.length > 0" class="loading-more"> 208 + <SkeletonLoader width="100%" height="72px" /> 209 + </div> 210 + </div> 211 + </PageLayout> 212 + </template> 213 + 214 + <style scoped lang="scss"> 215 + .follows-list { 216 + /* padding: 1rem; */ 217 + display: flex; 218 + flex-direction: column; 219 + /* gap: 0.5rem; */ 220 + min-height: 200px; 221 + } 222 + 223 + .follow { 224 + display: flex; 225 + align-items: center; 226 + gap: 0.75rem; 227 + padding: 0.75rem; 228 + /* border-radius: 0.75rem; */ 229 + /* background: hsla(var(--base) / 0.6); */ 230 + border-bottom: 1px solid hsla(var(--surface2) / 0.35); 231 + text-decoration: none; 232 + color: inherit; 233 + 234 + &:hover { 235 + background: hsla(var(--surface0) / 0.2); 236 + } 237 + 238 + .avatar { 239 + width: 3rem; 240 + height: 3rem; 241 + border-radius: 50%; 242 + object-fit: cover; 243 + flex: 0 0 auto; 244 + } 245 + 246 + .info { 247 + flex: 1; 248 + display: flex; 249 + flex-direction: column; 250 + overflow: hidden; 251 + 252 + .top { 253 + display: flex; 254 + align-items: center; 255 + gap: 0.5rem; 256 + min-width: 0; 257 + font-size: 0.9rem; 258 + 259 + .display-name { 260 + font-weight: 700; 261 + white-space: nowrap; 262 + text-overflow: ellipsis; 263 + overflow: hidden; 264 + } 265 + 266 + .handle { 267 + color: hsl(var(--subtext1)); 268 + font-size: 0.9rem; 269 + } 270 + 271 + .pronouns { 272 + color: hsl(var(--subtext0)); 273 + white-space: nowrap; 274 + } 275 + 276 + .badge { 277 + margin-left: auto; 278 + font-size: 0.72rem; 279 + padding: 0.15rem 0.4rem; 280 + border-radius: 999px; 281 + background: hsla(var(--surface2) / 0.45); 282 + color: hsl(var(--subtext1)); 283 + font-weight: 600; 284 + } 285 + 286 + .badge.mutual { 287 + background: hsla(var(--accent) / 0.12); 288 + color: hsl(var(--accent)); 289 + } 290 + } 291 + 292 + .meta { 293 + color: hsl(var(--subtext0)); 294 + font-size: 0.88rem; 295 + margin-top: 0.25rem; 296 + overflow: hidden; 297 + text-overflow: ellipsis; 298 + white-space: nowrap; 299 + } 300 + } 301 + 302 + .follow-btn { 303 + flex: 0 0 auto; 304 + padding: 0.35rem 0.75rem; 305 + border-radius: 999px; 306 + border: none; 307 + cursor: pointer; 308 + color: hsl(var(--crust)); 309 + font-weight: 700; 310 + 311 + background: hsla(var(--accent) / 0.85); 312 + 313 + &:hover { 314 + background: hsla(var(--accent) / 1); 315 + } 316 + &:active { 317 + background: hsla(var(--accent) / 0.75); 318 + } 319 + 320 + &.following { 321 + background: transparent; 322 + border: 1px solid hsla(var(--surface2) / 0.75); 323 + color: hsl(var(--text)); 324 + &:hover { 325 + background: hsla(var(--surface0) / 0.9); 326 + } 327 + &:active { 328 + background: hsla(var(--surface0) / 0.5); 329 + } 330 + } 331 + /* &.mutual { 332 + background: hsla(var(--accent) / 0.85); 333 + color: hsl(var(--base)); 334 + border: 1px solid hsla(var(--surface2) / 0.45); 335 + &:hover { 336 + background: hsla(var(--accent) / 1); 337 + } 338 + &:active { 339 + background: hsla(var(--accent) / 0.6); 340 + } 341 + } */ 342 + } 343 + } 344 + 345 + .empty-state, 346 + .error-state { 347 + padding: 2rem; 348 + display: flex; 349 + flex-direction: column; 350 + align-items: center; 351 + gap: 0.75rem; 352 + color: hsl(var(--subtext0)); 353 + } 354 + 355 + .load_more { 356 + &__sentinel { 357 + height: 1px; 358 + 359 + &__fallback { 360 + display: flex; 361 + justify-content: center; 362 + padding: 1rem; 363 + margin-top: 1rem; 364 + margin-bottom: 8rem; 365 + } 366 + } 367 + } 368 + </style>
+111
src/views/Profile/FollowsView.vue
··· 1 + <script setup lang="ts"> 2 + import { computed, ref, onMounted } from 'vue' 3 + import type { AppBskyActorDefs } from '@atcute/bluesky' 4 + 5 + import { usePagedProfiles } from '@/composables/usePagedProfiles' 6 + import { useInfiniteScroll } from '@/composables/useInfiniteScroll' 7 + 8 + import ProfileRow from '@/components/Profile/ProfileRow.vue' 9 + import PageLayout from '@/components/Navigation/PageLayout.vue' 10 + import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 11 + import Button from '@/components/UI/BaseButton.vue' 12 + 13 + import { useAuthStore, BSKY_APPVIEW } from '@/stores/auth' 14 + import type { ActorIdentifier } from '@atcute/lexicons' 15 + 16 + const props = defineProps<{ 17 + id?: string 18 + routeName?: string 19 + }>() 20 + 21 + const mode = computed(() => { 22 + if (props.routeName === 'user-followers') return 'followers' 23 + if (props.routeName === 'user-follows') return 'follows' 24 + return 'follows' 25 + }) 26 + 27 + const auth = useAuthStore() 28 + 29 + const { 30 + items: follows, 31 + cursor, 32 + loading, 33 + error, 34 + fetchPage, 35 + } = usePagedProfiles<AppBskyActorDefs.ProfileView>() 36 + const sentinel = ref<HTMLElement | null>(null) 37 + 38 + const fetcher = async (c?: string | null) => { 39 + const rpc = auth.getRpc() 40 + console.log(mode.value) 41 + const endpoint = 42 + mode.value === 'followers' ? 'app.bsky.graph.getFollowers' : 'app.bsky.graph.getFollows' 43 + 44 + const { data, ok } = await rpc.get(endpoint, { 45 + params: { actor: props.id as ActorIdentifier, limit: 50, cursor: c ?? undefined }, 46 + headers: { 'atproto-proxy': BSKY_APPVIEW }, 47 + }) 48 + if (!ok) throw new Error((data && data.error) || 'Failed') 49 + 50 + console.log(props) 51 + 52 + return { 53 + // @ts-expect-error: its ok typescript. .. 54 + items: mode.value === 'followers' ? data.followers || [] : data.follows || [], 55 + cursor: data.cursor ?? null, 56 + } 57 + } 58 + 59 + const { setup } = useInfiniteScroll(sentinel, () => { 60 + if (cursor.value && !loading.value) fetchPage(fetcher, false) 61 + }) 62 + 63 + onMounted(async () => { 64 + await fetchPage(fetcher, true) 65 + setup() 66 + }) 67 + </script> 68 + 69 + <template> 70 + <PageLayout :title="mode === 'followers' ? 'Followers' : 'Following'" noPadding> 71 + <div class="follows-list" role="list"> 72 + <template v-if="loading && follows.length === 0"> 73 + <SkeletonLoader v-for="n in 6" :key="n" /> 74 + </template> 75 + <div v-else-if="error"> 76 + {{ error }} <Button @click="() => fetchPage(fetcher, true)">Retry</Button> 77 + </div> 78 + <ProfileRow v-for="profile in follows" :key="profile.did" :profile="profile" /> 79 + <div ref="sentinel" style="height: 1px"></div> 80 + <div v-if="cursor && !loading"> 81 + <Button @click="() => fetchPage(fetcher, false)">Load more</Button> 82 + </div> 83 + </div> 84 + </PageLayout> 85 + </template> 86 + 87 + <style lang="scss" scoped> 88 + .empty-state, 89 + .error-state { 90 + padding: 2rem; 91 + display: flex; 92 + flex-direction: column; 93 + align-items: center; 94 + gap: 0.75rem; 95 + color: hsl(var(--subtext0)); 96 + } 97 + 98 + .load_more { 99 + &__sentinel { 100 + height: 1px; 101 + 102 + &__fallback { 103 + display: flex; 104 + justify-content: center; 105 + padding: 1rem; 106 + margin-top: 1rem; 107 + margin-bottom: 8rem; 108 + } 109 + } 110 + } 111 + </style>
+3
src/views/SettingsPage.vue
··· 1 1 <script lang="ts" setup> 2 2 import { ref, computed } from 'vue' 3 + 3 4 import { useThemeStore, AccentColours } from '@/stores/theme' 4 5 import { useNavigationStore } from '@/stores/navigation' 5 6 import { useAuthStore } from '@/stores/auth' ··· 159 160 > 160 161 </ListItem> 161 162 </ListGroup> 163 + 164 + <ListGroup title="Developer"> </ListGroup> 162 165 163 166 <!-- modals --> 164 167 <Modal v-model:open="showThemeModal" title="Select Theme" width="640px">
+9 -5
src/views/UserProfile.vue src/views/Profile/ProfileView.vue
··· 20 20 import Button from '@/components/UI/BaseButton.vue' 21 21 import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 22 22 import SVG from '@/components/UI/SVG.vue' 23 + 23 24 import BluebellLogo from '@/assets/icons/bluebell.svg?raw' 24 25 import type { ActorIdentifier } from '@atcute/lexicons' 26 + import AppLink from '@/components/Navigation/AppLink.vue' 25 27 26 28 const props = defineProps<{ id: string }>() 27 29 ··· 266 268 <component :is="isFollowing ? IconRemoveRounded : IconAddRounded" /> 267 269 {{ isFollowing ? 'Following' : 'Follow' }} 268 270 </Button> 269 - <Button variant="secondary" icon size="sm" flat><IconMoreHoriz /></Button> 271 + <Button variant="secondary" icon size="sm" flat> 272 + <IconMoreHoriz /> 273 + </Button> 270 274 </div> 271 275 </div> 272 276 ··· 283 287 </div> 284 288 285 289 <div class="stats-row"> 286 - <div class="stat-item"> 290 + <AppLink class="stat-item" name="user-followers" :params="{ id: profile.did }"> 287 291 <span class="stat-val">{{ formatCount(profile.followersCount) }}</span> 288 292 <span class="stat-label">Followers</span> 289 - </div> 290 - <div class="stat-item"> 293 + </AppLink> 294 + <AppLink class="stat-item" name="user-follows" :params="{ id: profile.did }"> 291 295 <span class="stat-val">{{ formatCount(profile.followsCount) }}</span> 292 296 <span class="stat-label">Following</span> 293 - </div> 297 + </AppLink> 294 298 <div class="stat-item"> 295 299 <span class="stat-val">{{ formatCount(profile.postsCount) }}</span> 296 300 <span class="stat-label">Posts</span>