an static landing page for your PDS that displays users & their bluesky posts. pds.wlo.moe
bluesky pds atproto

feat: impl. pds-wide and per-profile post views

vt3e.cat fa47ed07 b6a05733

verified
+1 -1
src/components/AccountList/AccountItem.vue
··· 73 <style lang="css" scoped> 74 .account { 75 background: hsla(var(--mantle) / 1); 76 - border: 1px solid hsla(var(--overlay0) / 0.2); 77 border-radius: 0.5rem; 78 cursor: pointer; 79 overflow: hidden;
··· 73 <style lang="css" scoped> 74 .account { 75 background: hsla(var(--mantle) / 1); 76 + border: 1px solid var(--border); 77 border-radius: 0.5rem; 78 cursor: pointer; 79 overflow: hidden;
+5 -4
src/components/PageHeader.vue
··· 13 14 defineProps<{ 15 title: string 16 - caption: string | string[] 17 }>() 18 </script> 19 ··· 73 <style scoped> 74 header { 75 padding: 0.5rem; 76 - border-bottom: 1px solid hsla(var(--overlay0) / 0.2); 77 } 78 79 .title-row { 80 display: flex; 81 align-items: center; 82 gap: 0.25rem; 83 - margin-bottom: 0.25rem; 84 } 85 86 .title-row__button { ··· 132 overflow: hidden; 133 } 134 135 - header .hint { 136 display: flex; 137 gap: 0.25rem; 138 margin: 0;
··· 13 14 defineProps<{ 15 title: string 16 + caption?: string | string[] 17 }>() 18 </script> 19 ··· 73 <style scoped> 74 header { 75 padding: 0.5rem; 76 + border-bottom: 1px solid var(--border); 77 } 78 79 .title-row { 80 display: flex; 81 align-items: center; 82 gap: 0.25rem; 83 } 84 85 .title-row__button { ··· 131 overflow: hidden; 132 } 133 134 + .hint { 135 + margin-top: 0.25rem; 136 + 137 display: flex; 138 gap: 0.25rem; 139 margin: 0;
+192
src/components/PostItem.vue
···
··· 1 + <script setup lang="ts"> 2 + import { useProfilesStore } from '@/stores/profiles' 3 + import type { Post } from '@/stores/posts' 4 + import { APP_CONFIG } from '@/config' 5 + import { computed } from 'vue' 6 + const props = defineProps<{ p: Post }>() 7 + 8 + const profileStore = useProfilesStore() 9 + const profile = computed( 10 + () => 11 + profileStore.profiles.find((pr) => pr.did === props.p.authorDid)?.profile, 12 + ) 13 + 14 + function timeAgo(input: string | Date | number): string { 15 + const date = input instanceof Date ? input : new Date(input) 16 + if (isNaN(date.getTime())) return 'invalid date' 17 + 18 + const now = new Date() 19 + let seconds = Math.floor((now.getTime() - date.getTime()) / 1000) 20 + const isFuture = seconds < 0 21 + seconds = Math.abs(seconds) 22 + 23 + if (seconds < 5) return 'just now' 24 + const units = [ 25 + { sec: 31536000, name: 'year' }, 26 + { sec: 2592000, name: 'month' }, 27 + { sec: 86400, name: 'day' }, 28 + { sec: 3600, name: 'hour' }, 29 + { sec: 60, name: 'minute' }, 30 + { sec: 1, name: 'second' }, 31 + ] 32 + 33 + for (const { sec, name } of units) { 34 + const count = Math.floor(seconds / sec) 35 + if (count >= 1) { 36 + const plural = count > 1 ? 's' : '' 37 + return isFuture 38 + ? `in ${count} ${name}${plural}` 39 + : `${count} ${name}${plural} ago` 40 + } 41 + } 42 + 43 + return 'just now' 44 + } 45 + 46 + const postUrl = computed( 47 + () => 48 + `${APP_CONFIG.bskyClient}/profile/${props.p.authorDid}/post/${props.p.rkey}`, 49 + ) 50 + const replyTo = computed(() => { 51 + const parentUri = props.p.value.reply?.parent?.uri 52 + if (!parentUri) return null 53 + 54 + const m = parentUri.match(/^at:\/\/([^/]+)\/[^/]+\/([^/?#]+)(?:[/?#].*)?$/) 55 + if (!m) return null 56 + const authorDid = m[1] 57 + const rkey = m[2] 58 + return { 59 + did: authorDid, 60 + profile: 61 + profileStore.profiles.find((pr) => pr.did === authorDid)?.profile || null, 62 + url: `${APP_CONFIG.bskyClient}/profile/${authorDid}/post/${rkey}`, 63 + } 64 + }) 65 + </script> 66 + 67 + <template> 68 + <article class="post" @click="console.log(p)" v-if="profile"> 69 + <div class="author-avatar"> 70 + <img 71 + v-if="profile.avatar" 72 + :src="profile.avatar" 73 + alt="Avatar" 74 + width="40" 75 + height="40" 76 + aria-hidden="true" 77 + /> 78 + </div> 79 + <div class="post-main"> 80 + <div class="post-head"> 81 + <div class="post-author"> 82 + <RouterLink :to="`/profile/${p.authorDid}`" class="author-did">{{ 83 + profile.displayName || profile.handle 84 + }}</RouterLink> 85 + <span class="post-date">{{ timeAgo(p.createdAt) }}</span> 86 + <p> 87 + <small v-if="replyTo"> 88 + replying to 89 + <RouterLink :to="`/profile/${replyTo.did}`">{{ 90 + replyTo.profile 91 + ? replyTo.profile.displayName || `@${replyTo.profile.handle}` 92 + : replyTo.did 93 + }}</RouterLink> 94 + </small> 95 + </p> 96 + </div> 97 + </div> 98 + <div class="post-body"> 99 + <p v-if="p.value.text">{{ p.value.text }}</p> 100 + <p v-else class="no-text">[no text]</p> 101 + </div> 102 + <div class="post-footer"> 103 + <a :href="postUrl" target="_blank" rel="noopener noreferrer"> 104 + view post 105 + </a> 106 + <a 107 + v-if="replyTo" 108 + :href="replyTo.url" 109 + target="_blank" 110 + rel="noopener noreferrer" 111 + > 112 + view thread 113 + </a> 114 + </div> 115 + </div> 116 + </article> 117 + </template> 118 + 119 + <style scoped> 120 + .post { 121 + border-bottom: 1px dashed var(--border-strong); 122 + padding: 0.75rem; 123 + display: flex; 124 + flex-direction: row; 125 + gap: 0.5rem; 126 + } 127 + 128 + .author-avatar { 129 + flex-shrink: 0; 130 + width: 40px; 131 + height: 40px; 132 + border-radius: 50%; 133 + overflow: hidden; 134 + background-color: hsla(var(--overlay0) / 0.1); 135 + img { 136 + width: 100%; 137 + height: 100%; 138 + object-fit: cover; 139 + object-position: center; 140 + display: block; 141 + } 142 + } 143 + 144 + .post-head { 145 + display: flex; 146 + justify-content: space-between; 147 + margin-bottom: 0.25rem; 148 + .post-author { 149 + font-weight: 700; 150 + font-size: 0.85rem; 151 + color: hsla(var(--subtext1) / 1); 152 + display: flex; 153 + gap: 0.5rem; 154 + align-items: baseline; 155 + } 156 + .post-date { 157 + font-weight: 500; 158 + font-size: 0.75rem; 159 + color: hsla(var(--subtext0) / 1); 160 + } 161 + } 162 + .post-body { 163 + color: hsla(var(--text) / 1); 164 + min-width: 0; 165 + 166 + p { 167 + margin: 0; 168 + white-space: pre-wrap; 169 + word-break: break-word; 170 + } 171 + 172 + .no-text { 173 + color: hsla(var(--subtext0) / 1); 174 + font-style: italic; 175 + } 176 + } 177 + .post-footer { 178 + margin-top: 0.5rem; 179 + display: flex; 180 + gap: 0.5rem; 181 + align-items: center; 182 + 183 + a { 184 + font-size: 0.75rem; 185 + color: hsla(var(--blue) / 1); 186 + text-decoration: none; 187 + &:hover { 188 + text-decoration: underline; 189 + } 190 + } 191 + } 192 + </style>
+1 -1
src/components/ThemeSwitcher.vue
··· 70 gap: 0.25rem; 71 72 background: hsla(var(--base) / 1); 73 - border: 1px solid hsla(var(--overlay0) / 0.15); 74 padding: 0.25rem 0.5rem; 75 border-radius: 0.25rem; 76
··· 70 gap: 0.25rem; 71 72 background: hsla(var(--base) / 1); 73 + border: 1px solid var(--border-light); 74 padding: 0.25rem 0.5rem; 75 border-radius: 0.25rem; 76
+1
src/config/schema.ts
··· 5 bskyService: z.url().default('https://public.api.bsky.app'), 6 appName: z.string().default('pds-landing'), 7 mode: z.enum(['development', 'production']).default('development'), 8 description: z 9 .string() 10 .default(
··· 5 bskyService: z.url().default('https://public.api.bsky.app'), 6 appName: z.string().default('pds-landing'), 7 mode: z.enum(['development', 'production']).default('development'), 8 + bskyClient: z.url().default('https://bsky.app'), 9 description: z 10 .string() 11 .default(
+7
src/css/base.css
··· 60 background-color: hsl(var(--overlay0)); 61 color: hsl(var(--text)); 62 }
··· 60 background-color: hsl(var(--overlay0)); 61 color: hsl(var(--text)); 62 } 63 + 64 + body { 65 + --border-raw: var(--overlay0); 66 + --border-light: hsla(var(--overlay0) / 0.1); 67 + --border: hsla(var(--overlay0) / 0.2); 68 + --border-strong: hsla(var(--overlay0) / 0.3); 69 + }
+19 -13
src/pages/HomeView.vue
··· 9 import AccountSection from '@/components/Sections/AccountSection.vue' 10 import { APP_CONFIG } from '@/config' 11 import { useProfilesStore } from '@/stores/profiles' 12 13 const profilesStore = useProfilesStore() 14 15 onMounted(async () => { 16 await profilesStore.init() 17 }) 18 19 const appName = APP_CONFIG?.appName ?? 'pds-landing' ··· 27 28 const select = (did: string | null) => profilesStore.selectDid(did) 29 30 - onMounted(async () => { 31 - if (adminConfig?.did) { 32 - const res = await profilesStore.fetchDid(adminConfig.did as Did) 33 - adminProfile.value = res.profile 34 - } 35 - }) 36 - 37 watch( 38 isMobile, 39 (mobile) => { ··· 54 watch( 55 () => route.path, 56 (p) => { 57 - currentTab.value = tabFromRoutePath(p) 58 }, 59 { immediate: true }, 60 ) 61 watch(currentTab, (tab) => { 62 const routeTab = tabFromRoutePath(route.path) 63 - if (routeTab !== tab) router.push({ path: `/${tab}` }).catch(() => {}) 64 }) 65 </script> 66 ··· 179 display: flex; 180 flex-direction: column; 181 gap: 0.5rem; 182 - border: 1px solid hsla(var(--overlay0) / 0.2); 183 border-radius: 0.5rem; 184 background: hsla(var(--base) / 1); 185 186 section { 187 margin-top: 0.5rem; 188 - border-top: 1px dashed hsla(var(--overlay0) / 0.5); 189 padding-top: 0.5rem; 190 h3 { 191 font-size: 0.85rem; ··· 283 .main { 284 flex: 1 1 auto; 285 overflow-y: auto; 286 - border: 1px solid hsla(var(--overlay0) / 0.2); 287 border-radius: 0.5rem; 288 background: hsla(var(--base) / 1); 289 } ··· 299 300 .admin { 301 margin-top: 0.5rem; 302 - border-top: 1px dashed hsla(var(--overlay0) / 0.5); 303 padding-top: 0.5rem; 304 305 .admin-extra {
··· 9 import AccountSection from '@/components/Sections/AccountSection.vue' 10 import { APP_CONFIG } from '@/config' 11 import { useProfilesStore } from '@/stores/profiles' 12 + import { usePostsStore } from '@/stores/posts' 13 14 + const postsStore = usePostsStore() 15 const profilesStore = useProfilesStore() 16 17 onMounted(async () => { 18 + if (adminConfig?.did) { 19 + profilesStore 20 + .fetchDid(adminConfig.did as Did) 21 + .then((profile) => { 22 + adminProfile.value = profile?.profile ?? null 23 + }) 24 + .catch(() => { 25 + adminProfile.value = null 26 + }) 27 + } 28 + 29 await profilesStore.init() 30 + await postsStore.loadFeedFromDids(profilesStore.dids) 31 }) 32 33 const appName = APP_CONFIG?.appName ?? 'pds-landing' ··· 41 42 const select = (did: string | null) => profilesStore.selectDid(did) 43 44 watch( 45 isMobile, 46 (mobile) => { ··· 61 watch( 62 () => route.path, 63 (p) => { 64 + currentTab.value = tabFromRoutePath(p as unknown as string) 65 }, 66 { immediate: true }, 67 ) 68 watch(currentTab, (tab) => { 69 const routeTab = tabFromRoutePath(route.path) 70 + if (routeTab !== tab.value) router.push({ path: `/${tab}` }).catch(() => {}) 71 }) 72 </script> 73 ··· 186 display: flex; 187 flex-direction: column; 188 gap: 0.5rem; 189 + border: 1px solid var(--border); 190 border-radius: 0.5rem; 191 background: hsla(var(--base) / 1); 192 193 section { 194 margin-top: 0.5rem; 195 + border-top: 1px dashed var(--border-strong); 196 padding-top: 0.5rem; 197 h3 { 198 font-size: 0.85rem; ··· 290 .main { 291 flex: 1 1 auto; 292 overflow-y: auto; 293 + border: 1px solid var(--border); 294 border-radius: 0.5rem; 295 background: hsla(var(--base) / 1); 296 } ··· 306 307 .admin { 308 margin-top: 0.5rem; 309 padding-top: 0.5rem; 310 311 .admin-extra {
+33
src/pages/PostsView.vue
··· 1 <script setup lang="ts"> 2 import PageHeader from '@/components/PageHeader.vue' 3 </script> 4 5 <template> 6 <PageHeader title="posts." caption="posts from users on this pds!" /> 7 </template>
··· 1 <script setup lang="ts"> 2 import PageHeader from '@/components/PageHeader.vue' 3 + import PostItem from '@/components/PostItem.vue' 4 + 5 + import { usePostsStore } from '@/stores/posts' 6 + const postsStore = usePostsStore() 7 </script> 8 9 <template> 10 <PageHeader title="posts." caption="posts from users on this pds!" /> 11 + <section class="block posts-list"> 12 + <div v-if="postsStore.loading" class="loading">loading posts...</div> 13 + <div v-else-if="postsStore.error" class="error">{{ postsStore.error }}</div> 14 + 15 + <div 16 + v-if="!postsStore.loading && postsStore.feed.length === 0" 17 + class="empty" 18 + > 19 + no posts found. 20 + </div> 21 + 22 + <template v-if="!postsStore.loading"> 23 + <div v-for="p in postsStore.feed" :key="p.uri + p.cid"> 24 + <PostItem :p="p" /> 25 + </div> 26 + </template> 27 + </section> 28 </template> 29 + 30 + <style scoped> 31 + .posts-list { 32 + padding: 0.5rem; 33 + } 34 + .loading, 35 + .error, 36 + .empty { 37 + padding: 0.5rem; 38 + color: hsla(var(--subtext0) / 1); 39 + } 40 + </style>
+125 -41
src/pages/ProfileView.vue
··· 1 <script setup lang="ts"> 2 import PageHeader from '@/components/PageHeader.vue' 3 4 import { useRoute } from 'vue-router' 5 - import { watch, computed } from 'vue' 6 - import type { Did } from '@atcute/lexicons' 7 8 import { useProfilesStore } from '@/stores/profiles' 9 import { onUnmounted } from 'vue' 10 11 const profilesStore = useProfilesStore() 12 const selectedProfile = computed(() => profilesStore.selectedProfile) 13 14 const route = useRoute() 15 16 watch( 17 () => route.params.did, 18 - (newDid) => { 19 - if (newDid) profilesStore.selectDid(newDid as Did) 20 window.scrollTo(0, 0) 21 }, 22 { immediate: true }, ··· 39 </script> 40 41 <template> 42 - <PageHeader 43 - :title="`${selectedProfile?.profile?.displayName || selectedProfile?.profile?.handle}'s profile`" 44 - :caption="stats" 45 - /> 46 - <div 47 - :class="{ profile: true, 'no-banner': !selectedProfile?.profile?.banner }" 48 - > 49 - <div class="profile-banner"> 50 - <img 51 - v-if="selectedProfile?.profile?.banner" 52 - :src="selectedProfile?.profile?.banner" 53 - class="banner-image" 54 - alt="profile banner" 55 - aria-hidden="true" 56 - /> 57 - <div class="profile-avatar"> 58 <img 59 - v-if="selectedProfile?.profile?.avatar" 60 - :src="selectedProfile?.profile?.avatar" 61 - alt="profile avatar" 62 aria-hidden="true" 63 /> 64 </div> 65 </div> 66 67 - <section class="block profile-info"> 68 - <h2 class="profile-name"> 69 - {{ 70 - selectedProfile?.profile?.displayName || 71 - `@${selectedProfile?.profile?.handle}` 72 - }} 73 - </h2> 74 - <a 75 - :href="`https://${selectedProfile?.profile?.handle}`" 76 - class="profile-handle" 77 - > 78 - @{{ selectedProfile?.profile?.handle }} 79 </a> 80 - <p v-if="selectedProfile?.profile?.description" class="profile-bio"> 81 - {{ selectedProfile?.profile?.description }} 82 - </p> 83 - </section> 84 - </div> 85 </template> 86 87 <style scoped> ··· 109 left: 1rem; 110 width: 8rem; 111 height: 8rem; 112 - border: 3px solid hsla(var(--overlay0) / 0.5); 113 border-radius: 50%; 114 overflow: hidden; 115 background: hsla(var(--surface0) / 1); ··· 155 word-break: break-word; 156 } 157 } 158 } 159 </style>
··· 1 <script setup lang="ts"> 2 import PageHeader from '@/components/PageHeader.vue' 3 + import PostItem from '@/components/PostItem.vue' 4 5 import { useRoute } from 'vue-router' 6 + import { watch, computed, ref } from 'vue' 7 8 import { useProfilesStore } from '@/stores/profiles' 9 import { onUnmounted } from 'vue' 10 + import { type Post, usePostsStore } from '@/stores/posts' 11 + import { APP_CONFIG } from '@/config' 12 13 const profilesStore = useProfilesStore() 14 + const postsStore = usePostsStore() 15 + 16 const selectedProfile = computed(() => profilesStore.selectedProfile) 17 + const selectedDid = ref<string | null>(null) 18 + const profileUrl = computed(() => { 19 + return `${APP_CONFIG.bskyClient}/profile/${selectedDid.value}` 20 + }) 21 22 const route = useRoute() 23 + const posts = ref<Post[]>([]) 24 25 watch( 26 () => route.params.did, 27 + async (newDid) => { 28 + if (newDid) { 29 + const did = Array.isArray(newDid) ? newDid[0] : newDid 30 + selectedDid.value = did 31 + profilesStore.selectDid(did) 32 + const userPosts = await postsStore.loadUserPosts(did, 100) 33 + posts.value = userPosts 34 + } else { 35 + posts.value = [] 36 + } 37 window.scrollTo(0, 0) 38 }, 39 { immediate: true }, ··· 56 </script> 57 58 <template> 59 + <template v-if="selectedProfile"> 60 + <PageHeader 61 + :title="`${selectedProfile?.profile?.displayName || selectedProfile?.profile?.handle}'s profile`" 62 + :caption="stats" 63 + /> 64 + <div 65 + :class="{ profile: true, 'no-banner': !selectedProfile?.profile?.banner }" 66 + > 67 + <div class="profile-banner"> 68 <img 69 + v-if="selectedProfile?.profile?.banner" 70 + :src="selectedProfile?.profile?.banner" 71 + class="banner-image" 72 + alt="profile banner" 73 aria-hidden="true" 74 /> 75 + <div class="profile-avatar"> 76 + <img 77 + v-if="selectedProfile?.profile?.avatar" 78 + :src="selectedProfile?.profile?.avatar" 79 + alt="profile avatar" 80 + aria-hidden="true" 81 + /> 82 + </div> 83 </div> 84 + 85 + <section class="block profile-info"> 86 + <h2 class="profile-name"> 87 + {{ 88 + selectedProfile?.profile?.displayName || 89 + `@${selectedProfile?.profile?.handle}` 90 + }} 91 + </h2> 92 + <a 93 + :href="`https://${selectedProfile?.profile?.handle}`" 94 + class="profile-handle" 95 + > 96 + @{{ selectedProfile?.profile?.handle }} 97 + </a> 98 + <p v-if="selectedProfile?.profile?.description" class="profile-bio"> 99 + {{ selectedProfile?.profile?.description }} 100 + </p> 101 + 102 + <a :href="profileUrl" rel="noopener noreferrer" target="_blank"> 103 + view on bluesky 104 + </a> 105 + </section> 106 + 107 + <section class="block profile-posts"> 108 + <div v-if="postsStore.loading" class="loading">loading posts...</div> 109 + <div v-if="postsStore.error" class="error">{{ postsStore.error }}</div> 110 + <div v-if="!postsStore.loading && posts.length === 0" class="empty"> 111 + no posts. 112 + </div> 113 + 114 + <template v-if="!postsStore.loading"> 115 + <PostItem :p="p" v-for="p in posts" :key="p.rkey" /> 116 + </template> 117 + </section> 118 </div> 119 + </template> 120 + <template v-else> 121 + <PageHeader title="profile." /> 122 + <div class="no-profile"> 123 + <h2>no profile found</h2> 124 + <p>this profile couldn't be found on this pds.</p> 125 126 + <a :href="profileUrl" rel="noopener noreferrer" target="_blank"> 127 + view on bluesky 128 </a> 129 + </div> 130 + </template> 131 </template> 132 133 <style scoped> ··· 155 left: 1rem; 156 width: 8rem; 157 height: 8rem; 158 + border: 3px solid var(--border-strong); 159 border-radius: 50%; 160 overflow: hidden; 161 background: hsla(var(--surface0) / 1); ··· 201 word-break: break-word; 202 } 203 } 204 + } 205 + 206 + .profile-posts { 207 + width: 100%; 208 + padding: 0.5rem; 209 + border-top: 1px solid var(--border); 210 + } 211 + 212 + .no-profile { 213 + padding: 1rem; 214 + 215 + h2 { 216 + margin: 0; 217 + font-size: 1.5rem; 218 + font-weight: bold; 219 + color: hsla(var(--text) / 1); 220 + } 221 + 222 + p { 223 + font-size: 1rem; 224 + color: hsla(var(--subtext0) / 1); 225 + } 226 + 227 + a { 228 + display: inline-block; 229 + margin-top: 1rem; 230 + font-size: 1rem; 231 + &:hover { 232 + text-decoration: underline; 233 + } 234 + } 235 + } 236 + 237 + .loading, 238 + .error, 239 + .empty { 240 + padding: 0.5rem; 241 + color: hsla(var(--subtext0) / 1); 242 } 243 </style>
+142
src/stores/posts.ts
···
··· 1 + import type { ComAtprotoRepoListRecords } from '@atcute/atproto' 2 + import type { AppBskyFeedPost } from '@atcute/bluesky' 3 + import type { Did } from '@atcute/lexicons' 4 + import { defineStore } from 'pinia' 5 + import { ref } from 'vue' 6 + import { pdsClient } from '@/api/clients' 7 + 8 + export type Post = { 9 + uri: string 10 + cid: string 11 + value: AppBskyFeedPost.Main 12 + createdAt: string 13 + rkey: string 14 + authorDid: Did 15 + } 16 + 17 + export const usePostsStore = defineStore('posts', () => { 18 + const feed = ref<Post[]>([]) 19 + const userPosts = ref<Record<Did, Post[]>>({}) 20 + const loading = ref(false) 21 + const error = ref<string | null>(null) 22 + 23 + async function fetchPostsFromRepo(did: Did, limit = 50, cursor?: string) { 24 + try { 25 + const { ok, data } = await pdsClient.get('com.atproto.repo.listRecords', { 26 + params: { 27 + repo: did, 28 + collection: 'app.bsky.feed.post', 29 + limit, 30 + cursor, 31 + }, 32 + }) 33 + 34 + if (!ok) 35 + throw new Error( 36 + `failed to fetch posts for ${did}: ${data.error ?? 'unknown'}`, 37 + ) 38 + 39 + const typed = data as ComAtprotoRepoListRecords.$output 40 + 41 + const records = Array.isArray(typed.records) ? typed.records : [] 42 + 43 + const posts = records 44 + .map((rec) => { 45 + const val = rec.value as AppBskyFeedPost.Main 46 + return { 47 + uri: rec.uri, 48 + cid: rec.cid, 49 + value: val, 50 + createdAt: val.createdAt, 51 + rkey: rec.uri.split('/').pop() ?? '', 52 + authorDid: did, 53 + } as Post 54 + }) 55 + .filter(Boolean) 56 + 57 + return { 58 + posts, 59 + cursor: typed.cursor ?? null, 60 + } 61 + } catch (err: unknown) { 62 + const msg = err instanceof Error ? err.message : String(err) 63 + throw new Error(msg) 64 + } 65 + } 66 + 67 + async function loadUserPosts(did: Did, limit = 100) { 68 + loading.value = true 69 + error.value = null 70 + try { 71 + const { posts } = await fetchPostsFromRepo(did, limit) 72 + userPosts.value = { 73 + ...userPosts.value, 74 + [did]: posts, 75 + } 76 + return posts 77 + } catch (err: unknown) { 78 + error.value = err instanceof Error ? err.message : String(err) 79 + userPosts.value[did] = [] 80 + return [] 81 + } finally { 82 + loading.value = false 83 + } 84 + } 85 + 86 + async function loadFeedFromDids(dids: Did[], perUserLimit = 100) { 87 + loading.value = true 88 + error.value = null 89 + try { 90 + const promises = dids.map(async (did) => { 91 + try { 92 + const { posts } = await fetchPostsFromRepo(did, perUserLimit) 93 + return posts 94 + } catch { 95 + return [] as Post[] 96 + } 97 + }) 98 + 99 + const results = await Promise.all(promises) 100 + const aggregated = results.flat() 101 + 102 + aggregated.sort((a, b) => { 103 + const ta = Date.parse(a.createdAt) 104 + const tb = Date.parse(b.createdAt) 105 + return tb - ta 106 + }) 107 + 108 + feed.value = aggregated 109 + return aggregated 110 + } catch (err: unknown) { 111 + error.value = err instanceof Error ? err.message : String(err) 112 + feed.value = [] 113 + return [] 114 + } finally { 115 + loading.value = false 116 + } 117 + } 118 + 119 + function clearFeed() { 120 + feed.value = [] 121 + } 122 + 123 + function clearUserPosts(did?: Did) { 124 + if (did) { 125 + delete userPosts.value[did] 126 + return 127 + } 128 + userPosts.value = {} 129 + } 130 + 131 + return { 132 + feed, 133 + userPosts, 134 + loading, 135 + error, 136 + fetchPostsFromRepo, 137 + loadUserPosts, 138 + loadFeedFromDids, 139 + clearFeed, 140 + clearUserPosts, 141 + } 142 + })