wip bsky client for the web & android
0
fork

Configure Feed

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

feat: profile page

authored by

willow and committed by
vi
c04b9002 c71715f0

+858 -166
+1 -1
.editorconfig
··· 1 1 [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}] 2 2 charset = utf-8 3 3 indent_size = 2 4 - indent_style = space 4 + indent_style = tab 5 5 insert_final_newline = true 6 6 trim_trailing_whitespace = true 7 7 end_of_line = lf
+12 -1
index.html
··· 2 2 <html lang=""> 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 - <link rel="icon" href="/bluebell.svg" /> 5 + <link 6 + rel="icon" 7 + href="/bluebell-dark.svg" 8 + media="(prefers-color-scheme: light)" 9 + type="image/svg+xml" 10 + /> 11 + <link 12 + rel="icon" 13 + href="/bluebell-light.svg" 14 + media="(prefers-color-scheme: dark)" 15 + type="image/svg+xml" 16 + /> 6 17 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 18 <title>Bluebell</title> 8 19 </head>
+152 -147
src/components/UI/BaseButton.vue
··· 1 1 <script setup lang="ts"> 2 2 withDefaults( 3 - defineProps<{ 4 - variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'subtle' | 'subtle-alt' 5 - size?: 'sm' | 'md' | 'lg' 6 - icon?: boolean 7 - block?: boolean 8 - loading?: boolean 9 - disabled?: boolean 10 - type?: 'button' | 'submit' | 'reset' 11 - }>(), 12 - { 13 - variant: 'primary', 14 - size: 'md', 15 - type: 'button', 16 - disabled: false, 17 - }, 3 + defineProps<{ 4 + variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'subtle' | 'subtle-alt' 5 + size?: 'sm' | 'md' | 'lg' 6 + icon?: boolean 7 + block?: boolean 8 + loading?: boolean 9 + disabled?: boolean 10 + flat?: boolean 11 + type?: 'button' | 'submit' | 'reset' 12 + }>(), 13 + { 14 + variant: 'primary', 15 + size: 'md', 16 + type: 'button', 17 + disabled: false, 18 + }, 18 19 ) 19 20 20 21 const emit = defineEmits<{ 21 - (e: 'click', event: MouseEvent): void 22 + (e: 'click', event: MouseEvent): void 22 23 }>() 23 24 </script> 24 25 25 26 <template> 26 - <button 27 - :type="type" 28 - class="th-btn" 29 - :class="[ 30 - `variant-${variant}`, 31 - `size-${size}`, 32 - { 'is-icon': icon, 'is-block': block, 'is-loading': loading }, 33 - ]" 34 - :disabled="disabled || loading" 35 - @click.stop="emit('click', $event)" 36 - > 37 - <span v-if="loading" class="spinner"></span> 38 - <slot v-else /> 39 - </button> 27 + <button 28 + :type="type" 29 + class="th-btn" 30 + :class="[ 31 + `variant-${variant}`, 32 + `size-${size}`, 33 + { 'is-icon': icon, 'is-block': block, 'is-loading': loading, 'is-flat': flat }, 34 + ]" 35 + :disabled="disabled || loading" 36 + @click.stop="emit('click', $event)" 37 + > 38 + <span v-if="loading" class="spinner"></span> 39 + <slot v-else /> 40 + </button> 40 41 </template> 41 42 42 43 <style scoped> 43 44 .th-btn { 44 - display: inline-flex; 45 - align-items: center; 46 - justify-content: center; 47 - gap: 0.5rem; 48 - border: 1px solid transparent; 49 - border-radius: var(--radius-md); 50 - font-family: inherit; 51 - font-weight: 600; 52 - line-height: 1; 53 - cursor: pointer; 54 - position: relative; 55 - overflow: hidden; 45 + display: inline-flex; 46 + align-items: center; 47 + justify-content: center; 48 + gap: 0.5rem; 49 + border: 1px solid transparent; 50 + border-radius: var(--radius-sm); 51 + font-family: inherit; 52 + font-weight: 600; 53 + line-height: 1; 54 + cursor: pointer; 55 + position: relative; 56 + overflow: hidden; 56 57 57 - &:disabled { 58 - opacity: 0.5; 59 - cursor: not-allowed; 60 - pointer-events: none; 61 - } 58 + &.is-flat { 59 + border: none !important; 60 + } 62 61 63 - &.is-block { 64 - width: 100%; 65 - display: flex; 66 - } 62 + &:disabled { 63 + opacity: 0.5; 64 + cursor: not-allowed; 65 + pointer-events: none; 66 + } 67 + 68 + &.is-block { 69 + width: 100%; 70 + display: flex; 71 + } 67 72 68 - background-color: hsla(var(--bg-colour) / 0.9); 69 - color: hsl(var(--text-colour)); 70 - border-color: hsla(var(--border-colour) / 0.2); 73 + background-color: hsla(var(--bg-colour) / 0.9); 74 + color: hsl(var(--text-colour)); 75 + border-color: hsla(var(--border-colour) / 0.2); 71 76 72 - &:hover:not(:disabled) { 73 - background-color: hsla(var(--bg-colour) / 1); 74 - border-color: hsla(var(--border-colour) / 0.5); 75 - } 77 + &:hover:not(:disabled) { 78 + background-color: hsla(var(--bg-colour) / 1); 79 + border-color: hsla(var(--border-colour) / 0.5); 80 + } 76 81 77 - &:active:not(:disabled) { 78 - background-color: hsla(var(--bg-colour) / 0.7); 79 - border-color: hsla(var(--border-colour) / 0.5); 80 - } 82 + &:active:not(:disabled) { 83 + background-color: hsla(var(--bg-colour) / 0.7); 84 + border-color: hsla(var(--border-colour) / 0.5); 85 + } 81 86 } 82 87 83 88 /* Sizes */ 84 89 .size-sm { 85 - font-size: 0.75rem; 86 - padding: 0.375rem 0.75rem; 87 - &.is-icon { 88 - padding: 0; 89 - width: 2rem; 90 - height: 2rem; 91 - } 90 + font-size: 0.75rem; 91 + padding: 0.375rem 0.75rem; 92 + &.is-icon { 93 + padding: 0; 94 + width: 2rem; 95 + height: 2rem; 96 + } 92 97 } 93 98 .size-md { 94 - font-size: 0.875rem; 95 - padding: 0.625rem 1.25rem; 96 - &.is-icon { 97 - padding: 0; 98 - width: 2.5rem; 99 - height: 2.5rem; 100 - } 99 + font-size: 0.875rem; 100 + padding: 0.625rem 1.25rem; 101 + &.is-icon { 102 + padding: 0; 103 + width: 2.5rem; 104 + height: 2.5rem; 105 + } 101 106 } 102 107 .size-lg { 103 - font-size: 1rem; 104 - padding: 0.875rem 1.75rem; 105 - &.is-icon { 106 - padding: 0; 107 - width: 3rem; 108 - height: 3rem; 109 - } 108 + font-size: 1rem; 109 + padding: 0.875rem 1.75rem; 110 + &.is-icon { 111 + padding: 0; 112 + width: 3rem; 113 + height: 3rem; 114 + } 110 115 } 111 116 112 117 .variant-primary { 113 - --bg-colour: var(--accent); 114 - --text-colour: var(--base); 115 - --border-colour: var(--accent); 118 + --bg-colour: var(--accent); 119 + --text-colour: var(--base); 120 + --border-colour: var(--accent); 116 121 } 117 122 118 123 .variant-secondary { 119 - --bg-colour: var(--surface0); 120 - --text-colour: var(--text); 121 - --border-colour: var(--surface2); 124 + --bg-colour: var(--surface0); 125 + --text-colour: var(--text); 126 + --border-colour: var(--surface2); 122 127 } 123 128 124 129 .variant-subtle { 125 - --bg-colour: var(--accent); 126 - --text-colour: var(--accent); 127 - --border-colour: var(--accent); 130 + --bg-colour: var(--accent); 131 + --text-colour: var(--accent); 132 + --border-colour: var(--accent); 128 133 129 - border-color: hsla(var(--accent) / 0.05); 130 - background-color: hsla(var(--accent) / 0.05); 134 + border-color: hsla(var(--accent) / 0.05); 135 + background-color: hsla(var(--accent) / 0.05); 131 136 132 - &:hover:not(:disabled) { 133 - background-color: hsla(var(--accent) / 0.15); 134 - border-color: hsla(var(--accent) / 0.1); 135 - } 136 - &:active:not(:disabled) { 137 - background-color: hsla(var(--accent) / 0.1); 138 - border-color: hsla(var(--accent) / 0.1); 139 - } 137 + &:hover:not(:disabled) { 138 + background-color: hsla(var(--accent) / 0.15); 139 + border-color: hsla(var(--accent) / 0.1); 140 + } 141 + &:active:not(:disabled) { 142 + background-color: hsla(var(--accent) / 0.1); 143 + border-color: hsla(var(--accent) / 0.1); 144 + } 140 145 } 141 146 142 147 .variant-subtle-alt { 143 - border-color: transparent; 144 - background-color: hsla(var(--subtext0) / 0.04); 145 - color: hsl(var(--subtext0)); 148 + border-color: transparent; 149 + background-color: hsla(var(--subtext0) / 0.04); 150 + color: hsl(var(--subtext0)); 146 151 147 - &:hover:not(:disabled) { 148 - background-color: hsla(var(--subtext0) / 0.06); 149 - border-color: transparent; 150 - } 151 - &:active:not(:disabled) { 152 - background-color: hsla(var(--subtext0) / 0.02); 153 - border-color: transparent; 154 - } 152 + &:hover:not(:disabled) { 153 + background-color: hsla(var(--subtext0) / 0.06); 154 + border-color: transparent; 155 + } 156 + &:active:not(:disabled) { 157 + background-color: hsla(var(--subtext0) / 0.02); 158 + border-color: transparent; 159 + } 155 160 } 156 161 157 162 .variant-ghost { 158 - --bg-colour: transparent; 159 - --text-colour: var(--subtext0); 160 - --border-colour: transparent; 163 + --bg-colour: transparent; 164 + --text-colour: var(--subtext0); 165 + --border-colour: transparent; 161 166 162 - border-color: transparent; 167 + border-color: transparent; 163 168 164 - &:hover:not(:disabled) { 165 - background-color: hsla(var(--surface2) / 0.25); 166 - border-color: hsla(var(--surface2) / 0); 167 - } 168 - &:active:not(:disabled) { 169 - background-color: hsla(var(--surface1) / 0.25); 170 - border-color: hsla(var(--surface2) / 0); 171 - } 169 + &:hover:not(:disabled) { 170 + background-color: hsla(var(--surface2) / 0.25); 171 + border-color: hsla(var(--surface2) / 0); 172 + } 173 + &:active:not(:disabled) { 174 + background-color: hsla(var(--surface1) / 0.25); 175 + border-color: hsla(var(--surface2) / 0); 176 + } 172 177 } 173 178 174 179 .variant-danger { 175 - --background-colour: var(--red); 176 - --text-colour: var(--red); 177 - --border-colour: var(--red); 180 + --background-colour: var(--red); 181 + --text-colour: var(--red); 182 + --border-colour: var(--red); 178 183 179 - border-color: hsla(var(--red) / 0.05); 180 - background-color: hsla(var(--red) / 0.25); 184 + border-color: hsla(var(--red) / 0.05); 185 + background-color: hsla(var(--red) / 0.25); 181 186 182 - &:hover:not(:disabled) { 183 - background-color: hsla(var(--red) / 0.4); 184 - border-color: hsla(var(--red) / 0.3); 185 - } 186 - &:active:not(:disabled) { 187 - background-color: hsla(var(--red) / 0.3); 188 - border-color: hsla(var(--red) / 0.3); 189 - } 187 + &:hover:not(:disabled) { 188 + background-color: hsla(var(--red) / 0.4); 189 + border-color: hsla(var(--red) / 0.3); 190 + } 191 + &:active:not(:disabled) { 192 + background-color: hsla(var(--red) / 0.3); 193 + border-color: hsla(var(--red) / 0.3); 194 + } 190 195 } 191 196 192 197 /* Spinner */ 193 198 .spinner { 194 - width: 1em; 195 - height: 1em; 196 - border: 2px solid currentColor; 197 - border-right-color: transparent; 198 - border-radius: 50%; 199 - animation: spin 0.75s linear infinite; 199 + width: 1em; 200 + height: 1em; 201 + border: 2px solid currentColor; 202 + border-right-color: transparent; 203 + border-radius: 50%; 204 + animation: spin 0.75s linear infinite; 200 205 } 201 206 @keyframes spin { 202 - to { 203 - transform: rotate(360deg); 204 - } 207 + to { 208 + transform: rotate(360deg); 209 + } 205 210 } 206 211 </style>
+693 -17
src/views/UserProfile.vue
··· 1 - <script lang="ts" setup> 2 - import PageLayout from "@/components/Navigation/PageLayout.vue"; 3 - import AppLink from "@/components/Navigation/AppLink.vue"; 1 + <script setup lang="ts"> 2 + import { ref, computed, watch, onMounted, onUnmounted } from 'vue' 3 + import { AppBskyActorDefs, AppBskyFeedDefs } from '@atcute/bluesky' 4 + import { ok } from '@atcute/client' 5 + import { 6 + IconAddRounded, 7 + IconRemoveRounded, 8 + IconMoreHoriz, 9 + IconGlobe, 10 + IconCalendarMonthRounded, 11 + IconArrowDownwardRounded, 12 + } from '@iconify-prerendered/vue-material-symbols' 13 + 14 + import { useAuthStore } from '@/stores/auth' 15 + import { usePostStore } from '@/stores/posts' 16 + 17 + import PageLayout from '@/components/Navigation/PageLayout.vue' 18 + import FeedItem from '@/components/Feed/FeedItem.vue' 19 + import Button from '@/components/UI/BaseButton.vue' 20 + import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 21 + import SVG from '@/components/UI/SVG.vue' 22 + import BluebellLogo from '@/assets/icons/bluebell.svg?raw' 23 + import type { ActorIdentifier } from '@atcute/lexicons' 24 + 25 + const props = defineProps<{ id: string }>() 26 + 27 + const auth = useAuthStore() 28 + const postStore = usePostStore() 29 + 30 + const profile = ref<AppBskyActorDefs.ProfileViewDetailed | null>(null) 31 + const feed = ref<AppBskyFeedDefs.FeedViewPost[]>([]) 32 + const cursor = ref<string | undefined>(undefined) 33 + 34 + const loadingProfile = ref(true) 35 + const loadingFeed = ref(true) 36 + const error = ref<string | null>(null) 37 + const scrollY = ref(0) 38 + 39 + type Tab = { 40 + label: string 41 + value: TabType 42 + } 43 + 44 + type TabType = 'posts_no_replies' | 'posts_with_replies' | 'posts_with_media' 45 + const tabs: Tab[] = [ 46 + { label: 'Posts', value: 'posts_no_replies' }, 47 + { label: 'Replies', value: 'posts_with_replies' }, 48 + { label: 'Media', value: 'posts_with_media' }, 49 + ] 50 + const activeTab = ref<TabType>('posts_no_replies') 51 + 52 + const formatCount = (num: number | undefined) => { 53 + if (!num) return '0' 54 + return new Intl.NumberFormat(window.navigator.language, { 55 + notation: 'compact', 56 + maximumFractionDigits: 1, 57 + }).format(num) 58 + } 59 + 60 + const formatUrl = (url: string) => { 61 + try { 62 + const parsed = new URL(url) 63 + return parsed.hostname.replace('www.', '') 64 + } catch { 65 + return url 66 + } 67 + } 68 + 69 + const isSelf = computed(() => auth.session?.info.sub === profile.value?.did) 70 + const isFollowing = computed(() => !!profile.value?.viewer?.following) 71 + 72 + const bannerStyle = computed(() => { 73 + if (!profile.value?.banner) return {} 74 + return { backgroundImage: `url(${profile.value.banner})` } 75 + }) 76 + 77 + const heroStyle = computed(() => { 78 + const offset = scrollY.value * 0.25 79 + const scale = 1 + (scrollY.value < 0 ? Math.abs(scrollY.value) / 400 : 0) 80 + return { 81 + transform: `translate3d(0, ${offset}px, 0) scale(${scale})`, 82 + filter: `blur(${Math.min(scrollY.value / 500, 1)}rem)`, 83 + } 84 + }) 85 + 86 + const headerOpacity = computed(() => { 87 + const threshold = 200 88 + return Math.min(Math.max((scrollY.value - threshold) / 100, 0), 1) 89 + }) 90 + 91 + const handleScroll = (e: Event) => { 92 + const target = e.target as HTMLElement 93 + scrollY.value = target.scrollTop 94 + } 95 + 96 + const fetchProfile = async () => { 97 + loadingProfile.value = true 98 + try { 99 + const rpc = auth.getRpc() 100 + const data = await ok( 101 + rpc.get('app.bsky.actor.getProfile', { params: { actor: props.id as ActorIdentifier } }), 102 + ) 103 + profile.value = data 104 + } catch (e) { 105 + if (e instanceof Error) error.value = e.message 106 + else error.value = 'An unknown error occurred :c' 107 + console.error(e) 108 + } finally { 109 + loadingProfile.value = false 110 + } 111 + } 112 + 113 + const fetchFeed = async (reset = false) => { 114 + if (!profile.value) return 115 + if (reset) { 116 + feed.value = [] 117 + cursor.value = undefined 118 + loadingFeed.value = true 119 + } 120 + 121 + try { 122 + const rpc = auth.getRpc() 123 + const data = await ok( 124 + rpc.get('app.bsky.feed.getAuthorFeed', { 125 + params: { 126 + actor: profile.value.did, 127 + filter: activeTab.value, 128 + cursor: cursor.value, 129 + limit: 30, 130 + }, 131 + }), 132 + ) 133 + 134 + const newPosts = data.feed.map((item) => { 135 + item.post = postStore.mergePost(item.post) 136 + return item 137 + }) 138 + 139 + if (reset) feed.value = newPosts 140 + else feed.value.push(...newPosts) 141 + 142 + cursor.value = data.cursor 143 + } catch (e) { 144 + console.error(e) 145 + } finally { 146 + loadingFeed.value = false 147 + } 148 + } 149 + 150 + const toggleFollow = async () => { 151 + if (!profile.value || !auth.isAuthenticated) return 152 + const rpc = auth.getRpc() 153 + const originalState = profile.value.viewer?.following 154 + 155 + if (originalState) { 156 + profile.value.viewer!.following = undefined 157 + profile.value.followersCount = (profile.value.followersCount || 1) - 1 158 + } else { 159 + profile.value.viewer = profile.value.viewer || {} 160 + profile.value.viewer.following = `at://${profile.value.did}/app.bsky.graph.follow/temporary` 161 + profile.value.followersCount = (profile.value.followersCount || 0) + 1 162 + } 163 + 164 + try { 165 + if (originalState) { 166 + const rkey = originalState.split('/').pop()! 167 + await rpc.post('com.atproto.repo.deleteRecord', { 168 + input: { collection: 'app.bsky.graph.follow', repo: auth.session!.info.sub, rkey }, 169 + }) 170 + } else { 171 + const { uri } = await ok( 172 + rpc.post('com.atproto.repo.createRecord', { 173 + input: { 174 + collection: 'app.bsky.graph.follow', 175 + repo: auth.session!.info.sub, 176 + record: { 177 + $type: 'app.bsky.graph.follow', 178 + subject: profile.value.did, 179 + createdAt: new Date().toISOString(), 180 + }, 181 + }, 182 + }), 183 + ) 184 + if (profile.value.viewer) profile.value.viewer.following = uri 185 + } 186 + } catch (e) { 187 + console.error('follow toggle failed', e) 188 + if (profile.value.viewer) profile.value.viewer.following = originalState 189 + } 190 + } 191 + 192 + watch( 193 + () => props.id, 194 + () => { 195 + fetchProfile().then(() => fetchFeed(true)) 196 + }, 197 + ) 198 + watch(activeTab, () => { 199 + fetchFeed(true) 200 + }) 201 + 202 + onMounted(() => { 203 + fetchProfile().then(() => fetchFeed(true)) 204 + const scrollContainer = document.querySelector('.page-content') 205 + if (scrollContainer) scrollContainer.addEventListener('scroll', handleScroll) 206 + }) 4 207 5 - defineProps<{ 6 - id: string; 7 - otherProp?: string; 8 - }>(); 208 + onUnmounted(() => { 209 + const scrollContainer = document.querySelector('.page-content') 210 + if (scrollContainer) scrollContainer.removeEventListener('scroll', handleScroll) 211 + }) 9 212 </script> 10 213 11 214 <template> 12 - <PageLayout :title="`user ${id}`"> 13 - <p>user id: {{ id }}</p> 14 - <AppLink name="home">back home</AppLink> 215 + <PageLayout :title="profile?.handle || 'Profile'" noPadding> 216 + <template #app-bar> 217 + <div class="header-fade-wrapper" :style="{ opacity: headerOpacity }"> 218 + <div class="mini-avatar" v-if="profile?.avatar"> 219 + <img :src="profile.avatar" alt="avatar" /> 220 + </div> 221 + <div class="header-text"> 222 + <span class="header-name">{{ profile?.displayName || profile?.handle }}</span> 223 + <span class="header-handle" v-if="profile?.displayName">@{{ profile?.handle }}</span> 224 + </div> 225 + </div> 226 + </template> 227 + 228 + <div class="profile-container"> 229 + <div class="hero-wrapper"> 230 + <div class="hero-bg" :style="[bannerStyle, heroStyle]"></div> 231 + <div class="hero-overlay"></div> 232 + </div> 233 + 234 + <div class="content-stack"> 235 + <div class="identity-card-wrapper" v-if="profile"> 236 + <div class="identity-card"> 237 + <div class="card-top-row"> 238 + <div class="avatar-large"> 239 + <img v-if="profile.avatar" :src="profile.avatar" /> 240 + <div v-else class="fallback"><SVG :icon="BluebellLogo" /></div> 241 + </div> 242 + 243 + <div class="action-buttons"> 244 + <Button v-if="isSelf" variant="secondary" size="sm" flat>Edit Profile</Button> 245 + <Button 246 + :variant="isFollowing ? 'secondary' : 'primary'" 247 + size="sm" 248 + flat 249 + @click="toggleFollow" 250 + > 251 + <component :is="isFollowing ? IconRemoveRounded : IconAddRounded" /> 252 + {{ isFollowing ? 'Following' : 'Follow' }} 253 + </Button> 254 + <Button variant="secondary" icon size="sm" flat><IconMoreHoriz /></Button> 255 + </div> 256 + </div> 257 + 258 + <div class="card-names"> 259 + <h1 class="display-name">{{ profile.displayName || profile.handle }}</h1> 260 + <div class="handle-row"> 261 + <span class="handle">@{{ profile.handle }}</span> 262 + <span class="badge" v-if="isSelf"> It's you!! </span> 263 + <span class="badge" v-if="profile.viewer?.followedBy && profile.viewer?.following"> 264 + Mutuals 265 + </span> 266 + <span class="badge" v-else-if="profile.viewer?.followedBy"> Follows you </span> 267 + </div> 268 + </div> 15 269 16 - <AppLink 17 - name="user-profile" 18 - :params="{ id: String(Math.floor(Math.random() * 1000)) }" 19 - > 20 - go to view random user 21 - </AppLink> 270 + <div class="stats-row"> 271 + <div class="stat-item"> 272 + <span class="stat-val">{{ formatCount(profile.followersCount) }}</span> 273 + <span class="stat-label">Followers</span> 274 + </div> 275 + <div class="stat-item"> 276 + <span class="stat-val">{{ formatCount(profile.followsCount) }}</span> 277 + <span class="stat-label">Following</span> 278 + </div> 279 + <div class="stat-item"> 280 + <span class="stat-val">{{ formatCount(profile.postsCount) }}</span> 281 + <span class="stat-label">Posts</span> 282 + </div> 283 + </div> 284 + 285 + <div class="bio-section" v-if="profile.description"> 286 + {{ profile.description }} 287 + </div> 288 + 289 + <div class="meta-details"> 290 + <div class="meta-pill" v-if="profile.indexedAt"> 291 + <IconCalendarMonthRounded /> 292 + Joined 293 + {{ 294 + new Date(profile.indexedAt).toLocaleDateString(undefined, { 295 + month: 'short', 296 + year: 'numeric', 297 + }) 298 + }} 299 + </div> 300 + <div class="meta-pill" v-if="profile.pronouns"> 301 + {{ profile.pronouns }} 302 + </div> 303 + <a 304 + class="meta-pill" 305 + v-if="profile.website" 306 + :href="profile.website" 307 + target="_blank" 308 + rel="noopener" 309 + > 310 + <IconGlobe /> 311 + {{ formatUrl(profile.website) }} 312 + </a> 313 + </div> 314 + </div> 315 + </div> 316 + 317 + <div v-else-if="loadingProfile" class="identity-card-wrapper"> 318 + <div class="identity-card loading"> 319 + <SkeletonLoader width="80px" height="80px" style="border-radius: 50%" /> 320 + <SkeletonLoader width="60%" height="2rem" style="margin-top: 1rem" /> 321 + <SkeletonLoader width="40%" height="1rem" style="margin-top: 0.5rem" /> 322 + </div> 323 + </div> 324 + 325 + <div class="sticky-tabs"> 326 + <div class="tabs-inner"> 327 + <button 328 + v-for="tab in tabs" 329 + :key="tab.value" 330 + class="tab-btn" 331 + :class="{ active: activeTab === tab.value }" 332 + @click="activeTab = tab.value" 333 + > 334 + {{ tab.label }} 335 + </button> 336 + <div class="active-indicator" :class="activeTab"></div> 337 + </div> 338 + </div> 339 + 340 + <div class="feed-container"> 341 + <div v-if="loadingFeed && feed.length === 0" class="loading-feed"> 342 + <SkeletonLoader 343 + width="100%" 344 + height="100px" 345 + v-for="n in 5" 346 + :key="n" 347 + style="margin-bottom: 1rem; border-radius: 1rem" 348 + /> 349 + </div> 350 + 351 + <div v-else-if="feed.length === 0" class="empty-feed"> 352 + <IconArrowDownwardRounded class="big-icon" /> 353 + <p>Nothing to see here yet.</p> 354 + </div> 355 + 356 + <div v-else class="feed-list"> 357 + <FeedItem v-for="item in feed" :key="item.post.uri" :item="item" /> 358 + <div class="load-more" v-if="cursor"> 359 + <Button variant="ghost" @click="fetchFeed(false)" :loading="loadingFeed"> 360 + Load more posts 361 + </Button> 362 + </div> 363 + </div> 364 + </div> 365 + </div> 366 + </div> 22 367 </PageLayout> 23 368 </template> 24 369 25 - <style scoped> 370 + <style scoped lang="scss"> 371 + .header-fade-wrapper { 372 + display: flex; 373 + align-items: center; 374 + gap: 0.5rem; 375 + transition: none; 376 + 377 + .mini-avatar { 378 + width: 2rem; 379 + height: 2rem; 380 + border-radius: 50%; 381 + overflow: hidden; 382 + border: 1px solid hsla(var(--surface2) / 0.5); 383 + 384 + img { 385 + width: 100%; 386 + height: 100%; 387 + object-fit: cover; 388 + } 389 + } 390 + 391 + .header-text { 392 + display: flex; 393 + flex-direction: column; 394 + line-height: 1.1; 395 + .header-name { 396 + font-weight: 700; 397 + font-size: 0.95rem; 398 + } 399 + .header-handle { 400 + font-size: 0.75rem; 401 + color: hsl(var(--subtext0)); 402 + } 403 + } 404 + } 405 + 406 + .profile-container { 407 + position: relative; 408 + min-height: 100vh; 409 + background-color: hsl(var(--base)); 410 + } 411 + 412 + .hero-wrapper { 413 + position: absolute; 414 + left: 0; 415 + width: 100%; 416 + height: 22rem; 417 + overflow: hidden; 418 + z-index: 0; 419 + 420 + .hero-bg { 421 + position: absolute; 422 + inset: -20px; 423 + background-color: hsl(var(--surface1)); 424 + background-size: cover; 425 + background-position: center; 426 + will-change: transform; 427 + transition: none; 428 + } 429 + 430 + .hero-overlay { 431 + position: absolute; 432 + inset: 0; 433 + background: linear-gradient(to bottom, hsla(var(--base) / 0) 0%, hsla(var(--base) / 0.6) 100%); 434 + backdrop-filter: blur(0px); 435 + } 436 + } 437 + 438 + .content-stack { 439 + position: relative; 440 + z-index: 1; 441 + padding-top: 14rem; 442 + display: flex; 443 + flex-direction: column; 444 + 445 + .identity-card-wrapper { 446 + padding: 0 1rem; 447 + margin-bottom: 1rem; 448 + } 449 + 450 + .identity-card { 451 + background: hsla(var(--base) / 0.75); 452 + backdrop-filter: blur(1.5rem); 453 + -webkit-backdrop-filter: blur(1.5rem); 454 + border: 1px solid hsla(var(--surface2) / 0.5); 455 + border-radius: 1.5rem; 456 + padding: 1.5rem; 457 + 458 + display: flex; 459 + flex-direction: column; 460 + gap: 0.5rem; 461 + 462 + &.loading { 463 + min-height: 200px; 464 + align-items: center; 465 + justify-content: center; 466 + } 467 + } 468 + 469 + .card-top-row { 470 + display: flex; 471 + justify-content: space-between; 472 + align-items: flex-start; 473 + margin-top: -3rem; 474 + } 475 + 476 + .avatar-large { 477 + width: 5.5rem; 478 + height: 5.5rem; 479 + border-radius: 50%; 480 + overflow: hidden; 481 + 482 + img { 483 + width: 100%; 484 + height: 100%; 485 + object-fit: cover; 486 + } 487 + .fallback { 488 + width: 100%; 489 + height: 100%; 490 + display: flex; 491 + align-items: center; 492 + justify-content: center; 493 + color: hsl(var(--accent)); 494 + } 495 + } 496 + 497 + .action-buttons { 498 + display: flex; 499 + gap: 0.5rem; 500 + margin-top: 3rem; 501 + } 502 + 503 + .card-names { 504 + .display-name { 505 + font-size: 1.75rem; 506 + font-weight: 800; 507 + color: hsl(var(--text)); 508 + line-height: 1; 509 + } 510 + 511 + .handle-row { 512 + display: flex; 513 + align-items: center; 514 + gap: 0.5rem; 515 + margin-top: 0.25rem; 516 + 517 + .handle { 518 + color: hsl(var(--subtext0)); 519 + font-size: 0.95rem; 520 + } 521 + 522 + .badge { 523 + font-size: 0.7rem; 524 + background: hsla(var(--surface2) / 0.5); 525 + color: hsl(var(--subtext1)); 526 + padding: 0.1rem 0.4rem; 527 + border-radius: 4px; 528 + font-weight: 600; 529 + } 530 + } 531 + } 532 + 533 + .stats-row { 534 + display: flex; 535 + gap: 1.5rem; 536 + padding: 0.5rem 0; 537 + border-bottom: 1px solid hsla(var(--surface2) / 0.5); 538 + 539 + .stat-item { 540 + display: flex; 541 + align-items: baseline; 542 + gap: 0.3rem; 543 + cursor: pointer; 544 + transition: opacity 0.2s; 545 + 546 + &:hover { 547 + opacity: 0.7; 548 + } 549 + 550 + .stat-val { 551 + font-weight: 700; 552 + color: hsl(var(--text)); 553 + } 554 + .stat-label { 555 + font-size: 0.85rem; 556 + color: hsl(var(--subtext0)); 557 + } 558 + } 559 + } 560 + 561 + .bio-section { 562 + white-space: pre-wrap; 563 + color: hsl(var(--text)); 564 + line-height: 1.5; 565 + font-size: 0.95rem; 566 + } 567 + 568 + .meta-details { 569 + display: flex; 570 + flex-wrap: wrap; 571 + gap: 0.5rem; 572 + 573 + .meta-pill { 574 + display: flex; 575 + align-items: center; 576 + 577 + gap: 0.3rem; 578 + font-size: 0.8rem; 579 + color: hsl(var(--subtext0)); 580 + background: hsla(var(--surface0) / 0.5); 581 + padding: 0.25rem 0.75rem; 582 + border-radius: 5rem; 583 + text-decoration: none; 584 + user-select: none; 585 + 586 + &:hover, 587 + &:focus-visible { 588 + background: hsla(var(--surface0) / 0.6); 589 + } 590 + &:active { 591 + background: hsla(var(--surface0) / 0.45); 592 + } 593 + } 594 + } 595 + } 596 + 597 + .sticky-tabs { 598 + position: sticky; 599 + top: 0.5rem; 600 + display: inline-flex; 601 + z-index: 10; 602 + margin-bottom: 0.5rem; 603 + 604 + width: auto; 605 + min-width: 0; 606 + 607 + left: 50%; 608 + transform: translateX(-50%); 609 + padding: 0.25rem; 610 + 611 + background: hsla(var(--base) / 0.9); 612 + border: 1px solid hsla(var(--surface2) / 0.5); 613 + border-radius: 10rem; 614 + max-width: 16rem; 615 + 616 + .tabs-inner { 617 + display: inline-flex; 618 + position: relative; 619 + flex: 1; 620 + gap: 0.25rem; 621 + min-width: 0; 622 + } 623 + 624 + .tab-btn { 625 + background: none; 626 + border: none; 627 + flex: 1; 628 + padding: 0.5rem 0; 629 + z-index: 1; 630 + font-weight: 600; 631 + color: hsl(var(--subtext0)); 632 + border-radius: 10rem; 633 + text-transform: capitalize; 634 + cursor: pointer; 635 + 636 + &:hover, 637 + &:focus-visible { 638 + background: hsla(var(--surface2) / 0.3); 639 + } 640 + &:active { 641 + background: hsla(var(--surface2) / 0.2); 642 + } 643 + 644 + &.active { 645 + color: hsl(var(--crust)); 646 + } 647 + } 648 + 649 + .active-indicator { 650 + position: absolute; 651 + bottom: 0; 652 + height: 100%; 653 + width: 33.33%; 654 + 655 + background-color: hsl(var(--accent) / 1); 656 + border-radius: 10rem; 657 + transition-timing-function: var(--ease-spring); 658 + 659 + &.posts_no_replies { 660 + transform: translateX(0%); 661 + } 662 + &.posts_with_replies { 663 + transform: translateX(100%); 664 + } 665 + &.posts_with_media { 666 + transform: translateX(200%); 667 + } 668 + } 669 + } 670 + 671 + .feed-container { 672 + min-height: 50vh; 673 + background: hsl(var(--base)); 674 + 675 + .loading-feed { 676 + padding: 1rem; 677 + } 678 + .feed-list { 679 + padding-bottom: 4rem; 680 + } 681 + 682 + .empty-feed { 683 + padding: 4rem 1rem; 684 + display: flex; 685 + flex-direction: column; 686 + align-items: center; 687 + color: hsl(var(--subtext0)); 688 + gap: 1rem; 689 + 690 + .big-icon { 691 + font-size: 3rem; 692 + opacity: 0.5; 693 + } 694 + } 695 + 696 + .load-more { 697 + padding: 2rem; 698 + display: flex; 699 + justify-content: center; 700 + } 701 + } 26 702 </style>