appview-less bluesky client
24
fork

Configure Feed

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

fix routing when opening app from not root url, many minor improvements

dawn d47ce48d aa913b34

+156 -125
+54 -30
src/components/BskyPost.svelte
··· 40 40 import { settings } from '$lib/settings'; 41 41 import RichText from './RichText.svelte'; 42 42 import { getRelativeTime } from '$lib/date'; 43 - import { likeSource, repostSource } from '$lib'; 43 + import { likeSource, repostSource, toCanonicalUri } from '$lib'; 44 44 import ProfileInfo from './ProfileInfo.svelte'; 45 45 46 46 interface Props { ··· 72 72 const selectedDid = $derived(client.user?.did ?? null); 73 73 const isLoggedInUser = $derived($accounts.some((acc) => acc.did === did)); 74 74 75 - const aturi = $derived(`at://${did}/app.bsky.feed.post/${rkey}` as CanonicalResourceUri); 75 + const aturi = $derived(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey })); 76 76 const color = $derived(generateColorForDid(did)); 77 77 78 78 let handle: ActorIdentifier = $state('handle.invalid'); ··· 384 384 {#snippet postControls(post: PostWithUri)} 385 385 {@const myRepost = findBacklinksBy(post.uri, repostSource, selectedDid!).length > 0} 386 386 {@const myLike = findBacklinksBy(post.uri, likeSource, selectedDid!).length > 0} 387 - {#snippet control( 388 - name: string, 389 - icon: string, 390 - onClick: (e: MouseEvent) => void, 391 - isFull?: boolean, 392 - hasSolid?: boolean 393 - )} 387 + {#snippet control({ 388 + name, 389 + icon, 390 + onClick, 391 + isFull, 392 + hasSolid, 393 + canBeDisabled = true 394 + }: { 395 + name: string; 396 + icon: string; 397 + onClick: (e: MouseEvent) => void; 398 + isFull?: boolean; 399 + hasSolid?: boolean; 400 + canBeDisabled?: boolean; 401 + })} 394 402 <button 395 403 class=" 396 404 px-2 py-1.5 text-(--nucleus-fg)/90 transition-all 397 - duration-100 hover:[backdrop-filter:brightness(120%)] 405 + duration-100 not-disabled:hover:[backdrop-filter:brightness(120%)] 406 + disabled:cursor-not-allowed! 398 407 " 399 408 onclick={(e) => onClick(e)} 400 409 style="color: {isFull ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}" 401 410 title={name} 411 + disabled={canBeDisabled ? selectedDid === null : false} 402 412 > 403 413 <Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} /> 404 414 </button> 405 415 {/snippet} 406 416 <div class="mt-3 flex w-full items-center justify-between"> 407 417 <div class="flex w-fit items-center rounded-sm" style="background: {color}1f;"> 408 - {@render control('reply', 'heroicons:chat-bubble-left', () => onReply?.(post), false, true)} 409 - {@render control( 410 - 'repost', 411 - 'heroicons:arrow-path-rounded-square-20-solid', 412 - () => { 418 + {@render control({ 419 + name: 'reply', 420 + icon: 'heroicons:chat-bubble-left', 421 + hasSolid: true, 422 + onClick: () => onReply?.(post) 423 + })} 424 + {@render control({ 425 + name: 'repost', 426 + icon: 'heroicons:arrow-path-rounded-square-20-solid', 427 + onClick: () => { 413 428 if (!selectedDid) return; 414 429 if (myRepost) deletePostBacklink(client, post, repostSource); 415 430 else createPostBacklink(client, post, repostSource); 416 431 }, 417 - myRepost 418 - )} 419 - {@render control('quote', 'heroicons:paper-clip-20-solid', () => onQuote?.(post), false)} 420 - {@render control( 421 - 'like', 422 - 'heroicons:star', 423 - () => { 432 + isFull: myRepost 433 + })} 434 + {@render control({ 435 + name: 'quote', 436 + icon: 'heroicons:paper-clip-20-solid', 437 + onClick: () => onQuote?.(post) 438 + })} 439 + {@render control({ 440 + name: 'like', 441 + icon: 'heroicons:star', 442 + onClick: () => { 424 443 if (!selectedDid) return; 425 444 if (myLike) deletePostBacklink(client, post, likeSource); 426 445 else createPostBacklink(client, post, likeSource); 427 446 }, 428 - myLike, 429 - true 430 - )} 447 + isFull: myLike, 448 + hasSolid: true 449 + })} 431 450 </div> 432 451 <Dropdown 433 452 class="post-dropdown" ··· 459 478 {#snippet trigger()} 460 479 <div 461 480 class=" 462 - w-fit items-center rounded-sm transition-opacity 481 + w-fit items-center rounded-sm transition-opacity 463 482 duration-100 ease-in-out group-hover:opacity-100 464 483 {!actionsOpen && !Device.isMobile ? 'opacity-0' : ''} 465 484 " 466 485 style="background: {color}1f;" 467 486 > 468 - {@render control('actions', 'heroicons:ellipsis-horizontal-16-solid', (e) => { 469 - e.stopPropagation(); 470 - actionsOpen = !actionsOpen; 471 - actionsPos = { x: 0, y: 0 }; 487 + {@render control({ 488 + name: 'actions', 489 + icon: 'heroicons:ellipsis-horizontal-16-solid', 490 + onClick: (e: MouseEvent) => { 491 + e.stopPropagation(); 492 + actionsOpen = !actionsOpen; 493 + actionsPos = { x: 0, y: 0 }; 494 + }, 495 + canBeDisabled: false 472 496 })} 473 497 </div> 474 498 {/snippet}
+10 -7
src/components/FollowingView.svelte
··· 10 10 type Sort 11 11 } from '$lib/following'; 12 12 import FollowingItem from './FollowingItem.svelte'; 13 + import NotLoggedIn from './NotLoggedIn.svelte'; 13 14 14 15 interface Props { 15 - selectedDid: Did; 16 - selectedClient: AtpClient; 16 + client: AtpClient | undefined; 17 17 followingSort: Sort; 18 18 } 19 19 20 - let { selectedDid, selectedClient, followingSort = $bindable('active') }: Props = $props(); 20 + let { client, followingSort = $bindable('active') }: Props = $props(); 21 21 22 - const followsMap = $derived(follows.get(selectedDid)); 22 + const selectedDid = $derived(client?.user?.did); 23 + const followsMap = $derived(selectedDid ? follows.get(selectedDid) : undefined); 23 24 24 25 // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 26 let sortedFollowing = $state<{ did: Did; data: any }[]>([]); ··· 36 37 if (calculationTimer) clearTimeout(calculationTimer); 37 38 isLongCalculation = false; 38 39 39 - if (!followsMap) { 40 + if (!followsMap || !selectedDid) { 40 41 sortedFollowing = []; 41 42 return; 42 43 } ··· 137 138 </div> 138 139 139 140 <div class="min-h-0 flex-1" bind:this={listContainer}> 140 - {#if sortedFollowing.length === 0 || isLongCalculation} 141 + {#if !client} 142 + <NotLoggedIn /> 143 + {:else if sortedFollowing.length === 0 || isLongCalculation} 141 144 <div class="flex justify-center py-8"> 142 145 <div 143 146 class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" ··· 152 155 {style} 153 156 did={user.did} 154 157 stats={user.data!} 155 - client={selectedClient} 158 + {client} 156 159 sort={followingSort} 157 160 {currentTime} 158 161 />
+5
src/components/NotLoggedIn.svelte
··· 1 + <div class="flex justify-center py-4"> 2 + <p class="text-xl opacity-80"> 3 + <span class="text-4xl">x_x</span> <br /> no accounts are logged in! 4 + </p> 5 + </div>
+5 -7
src/components/PostComposer.svelte
··· 67 67 } 68 68 }); 69 69 70 - if (!res) { 71 - return err('failed to post: not logged in'); 72 - } 70 + if (!res) return err('failed to post: not logged in'); 73 71 74 - if (!res.ok) { 72 + if (!res.ok) 75 73 return err(`failed to post: ${res.data.error}: ${res.data.message ?? 'no details'}`); 76 - } 77 74 78 75 return ok({ 79 76 uri: res.data.uri, ··· 99 96 postText = ''; 100 97 info = 'posted!'; 101 98 unfocus(); 102 - setTimeout(() => (info = ''), 1000 * 0.8); 99 + setTimeout(() => (info = ''), 800); 103 100 } else { 104 - // todo: add a way to clear error 105 101 info = res.error; 102 + setTimeout(() => (info = ''), 3000); 106 103 } 107 104 }); 108 105 }; 109 106 110 107 $effect(() => { 108 + if (!client.atcute) info = 'not logged in'; 111 109 document.documentElement.style.setProperty('--acc-color', color); 112 110 if (isFocused && textareaEl) textareaEl.focus(); 113 111 });
+2 -8
src/components/ProfilePicture.svelte
··· 1 1 <script lang="ts" module> 2 - // Module-level cache for synchronous access during component recycling 2 + // we have this to prevent avatars from "flickering" 3 3 const avatarCache = new SvelteMap<string, string | null>(); 4 4 </script> 5 5 ··· 24 24 let avatarUrl = $state<string | null>(avatarCache.get(did) ?? null); 25 25 26 26 const loadProfile = async (targetDid: Did) => { 27 - // If we already have it in cache, we might want to re-validate eventually, 28 - // but for UI stability, using the cache is priority. 29 - // However, we still need to handle the case where we don't have it. 30 - if (avatarCache.has(targetDid)) avatarUrl = avatarCache.get(targetDid) ?? null; 31 - else avatarUrl = null; 27 + avatarUrl = avatarCache.get(targetDid) ?? null; 32 28 33 29 try { 34 30 const profile = await client.getProfile(targetDid); ··· 46 42 avatarCache.set(targetDid, null); 47 43 } 48 44 } else { 49 - // Don't cache errors aggressively, or maybe cache 'null' to stop retrying? 50 - // For now, just set local state. 51 45 avatarUrl = null; 52 46 } 53 47 } catch (e) {
+23 -5
src/components/ProfileView.svelte
··· 1 1 <script lang="ts"> 2 - import { AtpClient, resolveHandle } from '$lib/at/client'; 3 - import type { ActorIdentifier, AtprotoDid } from '@atcute/lexicons/syntax'; 2 + import { AtpClient, resolveDidDoc, resolveHandle } from '$lib/at/client'; 3 + import { 4 + isHandle, 5 + type ActorIdentifier, 6 + type AtprotoDid, 7 + type Handle 8 + } from '@atcute/lexicons/syntax'; 4 9 import TimelineView from './TimelineView.svelte'; 5 10 import ProfileInfo from './ProfileInfo.svelte'; 6 11 import type { State as PostComposerState } from './PostComposer.svelte'; ··· 9 14 import { img } from '$lib/cdn'; 10 15 import { isBlob } from '@atcute/lexicons/interfaces'; 11 16 import type { AppBskyActorProfile } from '@atcute/bluesky'; 17 + import { onMount } from 'svelte'; 12 18 13 19 interface Props { 14 20 client: AtpClient; ··· 20 26 let { client, actor, onBack, postComposerState = $bindable({ type: 'null' }) }: Props = $props(); 21 27 22 28 let profile = $state<AppBskyActorProfile.Main | null>(null); 29 + const displayName = $derived(profile?.displayName ?? ''); 23 30 let loading = $state(true); 24 31 let error = $state<string | null>(null); 25 32 let did = $state<AtprotoDid | null>(null); 33 + let handle = $state<Handle | null>(null); 26 34 27 35 const loadProfile = async (identifier: ActorIdentifier) => { 28 36 loading = true; 29 37 error = null; 30 38 profile = null; 39 + handle = isHandle(identifier) ? identifier : null; 31 40 32 41 const resDid = await resolveHandle(identifier); 33 42 if (resDid.ok) did = resDid.value; ··· 37 46 return; 38 47 } 39 48 49 + if (!handle) { 50 + const resHandle = await resolveDidDoc(did); 51 + if (resHandle.ok) handle = resHandle.value.handle; 52 + } 53 + 40 54 const res = await client.getProfile(did); 41 55 if (res.ok) profile = res.value; 42 56 else error = res.error; ··· 44 58 loading = false; 45 59 }; 46 60 47 - $effect(() => { 48 - loadProfile(actor as ActorIdentifier); 61 + onMount(async () => { 62 + await loadProfile(actor as ActorIdentifier); 49 63 }); 50 64 51 65 const color = $derived(did ? generateColorForDid(did) : 'var(--nucleus-fg)'); ··· 69 83 <Icon icon="heroicons:arrow-left-20-solid" width={24} /> 70 84 </button> 71 85 <h2 class="text-xl font-bold"> 72 - {profile?.displayName ?? (loading ? 'loading...' : actor || 'profile')} 86 + {displayName.length > 0 87 + ? displayName 88 + : loading 89 + ? 'loading...' 90 + : (handle ?? actor ?? 'profile')} 73 91 </h2> 74 92 </div> 75 93
+2 -5
src/components/TimelineView.svelte
··· 16 16 import Icon from '@iconify/svelte'; 17 17 import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 18 18 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 19 + import NotLoggedIn from './NotLoggedIn.svelte'; 19 20 20 21 interface Props { 21 22 client?: AtpClient | null; ··· 191 192 {/snippet} 192 193 </InfiniteLoader> 193 194 {:else} 194 - <div class="flex justify-center py-4"> 195 - <p class="text-xl opacity-80"> 196 - <span class="text-4xl">x_x</span> <br /> no accounts are logged in! 197 - </p> 198 - </div> 195 + <NotLoggedIn /> 199 196 {/if} 200 197 </div>
+5 -4
src/lib/at/client.ts
··· 37 37 import { get } from 'svelte/store'; 38 38 import { settings } from '$lib/settings'; 39 39 import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 40 - import { timestampFromCursor } from '$lib'; 40 + import { timestampFromCursor, toCanonicalUri, toResourceUri } from '$lib'; 41 41 42 42 export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot); 43 43 export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust); ··· 132 132 const collection = schema.object.shape.$type.expected; 133 133 134 134 try { 135 - // Call the cached function 136 - const rawValue = await cache.fetchRecord(`at://${repo}/${collection}/${rkey}`); 135 + const rawValue = await cache.fetchRecord( 136 + toResourceUri({ repo, collection, rkey, fragment: undefined }) 137 + ); 137 138 138 139 const parsed = safeParse(schema, rawValue.value); 139 140 if (!parsed.ok) return err(parsed.message); ··· 242 243 243 244 const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000)); 244 245 const query = fetchMicrocosm(constellationUrl, BacklinksQuery, { 245 - subject: `at://${did.value}/${collection}/${rkey}`, 246 + subject: toCanonicalUri({ did: did.value, collection, rkey }), 246 247 source, 247 248 limit: limit || 100 248 249 });
+2 -2
src/lib/at/fetch.ts
··· 9 9 import type { Backlinks } from './constellation'; 10 10 import { AppBskyFeedPost } from '@atcute/bluesky'; 11 11 import type { AtprotoDid, Did, RecordKey } from '@atcute/lexicons/syntax'; 12 - import { replySource } from '$lib'; 12 + import { replySource, toCanonicalUri } from '$lib'; 13 13 14 14 export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main }; 15 15 export type PostWithBacklinks = PostWithUri & { ··· 127 127 for (const reply of backlinks.value.records) { 128 128 if (reply.did !== postRepo) continue; 129 129 // if we already have this reply, then we already fetched this chain / are fetching it 130 - if (posts.has(`at://${reply.did}/${reply.collection}/${reply.rkey}`)) continue; 130 + if (posts.has(toCanonicalUri(reply))) continue; 131 131 const record = 132 132 cacheFn(reply.did, reply.rkey) ?? 133 133 (await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey));
+5 -2
src/lib/index.ts
··· 5 5 ParsedResourceUri, 6 6 ResourceUri 7 7 } from '@atcute/lexicons'; 8 - import type { BacklinksSource } from './at/constellation'; 8 + import type { Backlink, BacklinksSource } from './at/constellation'; 9 9 import { parse as parseTid } from '@atcute/tid'; 10 10 11 11 export const toResourceUri = (parsed: ParsedResourceUri): ResourceUri => { 12 12 return `at://${parsed.repo}${parsed.collection ? `/${parsed.collection}${parsed.rkey ? `/${parsed.rkey}` : ''}` : ''}`; 13 13 }; 14 - export const toCanonicalUri = (parsed: ParsedCanonicalResourceUri): CanonicalResourceUri => { 14 + export const toCanonicalUri = ( 15 + parsed: ParsedCanonicalResourceUri | Backlink 16 + ): CanonicalResourceUri => { 17 + if ('did' in parsed) return `at://${parsed.did}/${parsed.collection}/${parsed.rkey}`; 15 18 return `at://${parsed.repo}/${parsed.collection}/${parsed.rkey}${parsed.fragment ? `#${parsed.fragment}` : ''}`; 16 19 }; 17 20
+4 -4
src/lib/router.svelte.ts
··· 6 6 { path: '/', order: 0 }, 7 7 { path: '/following', order: 1 }, 8 8 { path: '/notifications', order: 2 }, 9 - { path: '/settings', order: 3 }, 10 9 { path: '/settings/:tab', order: 3 }, 11 10 { path: '/profile/:actor', order: 4 } 12 11 ] as const; ··· 85 84 window.addEventListener('popstate', () => this._updateState(window.location.pathname)); 86 85 } 87 86 88 - match(urlPath: string): Route { 87 + match(urlPath: string): Route | undefined { 89 88 const segments = urlPath.split('/').filter(Boolean); 90 89 const params: Record<string, string> = {}; 91 90 ··· 98 97 node = node.paramChild; 99 98 if (node.paramName) params[node.paramName] = decodeURIComponent(segment); 100 99 } else { 101 - return fallbackRoute; 100 + return undefined; 102 101 } 103 102 } 104 103 ··· 110 109 url: urlPath 111 110 } as Route<typeof node.config.path>; 112 111 113 - return fallbackRoute; 112 + return undefined; 114 113 } 115 114 116 115 updateDirection(newOrder: number, oldOrder: number) { ··· 121 120 122 121 private _updateState(url: string) { 123 122 const target = this.match(url); 123 + if (!target) return; 124 124 125 125 // save scroll position 126 126 if (typeof window !== 'undefined') this.scrollPositions.set(this.current.url, window.scrollY);
+8 -5
src/lib/state.svelte.ts
··· 21 21 likeSource, 22 22 replySource, 23 23 repostSource, 24 - timestampFromCursor 24 + timestampFromCursor, 25 + toCanonicalUri 25 26 } from '$lib'; 26 27 import { Router } from './router.svelte'; 27 28 ··· 231 232 ); 232 233 }; 233 234 235 + // this fetches up to three days of posts and interactions for using in following list 234 236 export const fetchForInteractions = async (did: AtprotoDid) => { 237 + const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000; 238 + 235 239 const client = await getClient(did); 236 - const res = await client.listRecords('app.bsky.feed.post'); 240 + const res = await client.listRecordsUntil('app.bsky.feed.post', undefined, threeDaysAgo); 237 241 if (!res.ok) return; 238 242 addPostsRaw(did, res.value); 239 243 240 244 const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1; 241 - const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000; 242 245 const timestamp = Math.min(cursorTimestamp, threeDaysAgo); 243 246 console.log(`${did}: fetchForInteractions`, res.value.cursor, timestamp); 244 247 await Promise.all([repostSource].map((s) => fetchLinksUntil(client, s, timestamp))); ··· 249 252 export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 250 253 251 254 export const getPost = (did: Did, rkey: RecordKey) => 252 - allPosts.get(did)?.get(`at://${did}/app.bsky.feed.post/${rkey}`); 255 + allPosts.get(did)?.get(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey })); 253 256 const hydrateCacheFn: Parameters<typeof hydratePosts>[3] = (did, rkey) => { 254 257 const cached = getPost(did, rkey); 255 258 return cached ? ok(cached) : undefined; ··· 359 362 if (event.kind !== 'commit') return; 360 363 361 364 const { did, commit } = event; 362 - const uri: ResourceUri = `at://${did}/${commit.collection}/${commit.rkey}`; 365 + const uri: ResourceUri = toCanonicalUri({ did, ...commit }); 363 366 if (commit.collection === 'app.bsky.feed.post') { 364 367 if (commit.operation === 'create') { 365 368 const { cid, record } = commit;
+31 -46
src/routes/+page.svelte src/routes/[...catchall]/+page.svelte
··· 46 46 if (selectedDid) localStorage.setItem('selectedDid', selectedDid); 47 47 else localStorage.removeItem('selectedDid'); 48 48 }); 49 - const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null); 49 + const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : undefined); 50 50 51 51 const loginAccount = async (account: Account) => { 52 52 if (clients.has(account.did)) return; ··· 187 187 </button> 188 188 {/snippet} 189 189 190 - {#snippet routeButton( 191 - path: (typeof currentRoute)['path'], 192 - icon: string, 193 - ariaLabel: string, 194 - iconHover?: string 195 - )} 190 + {#snippet routeButton({ 191 + route, 192 + path = route, 193 + icon, 194 + iconHover = `${icon}-solid`, 195 + ariaLabel = path.split('/').pop() ?? path 196 + }: { 197 + route: (typeof currentRoute)['path']; 198 + path?: string; 199 + icon: string; 200 + ariaLabel?: string; 201 + iconHover?: string; 202 + })} 196 203 {@render appButton( 197 204 () => router.navigate(path), 198 205 icon, 199 206 ariaLabel, 200 - path === currentRoute.path, 207 + currentRoute.path === route, 201 208 iconHover 202 209 )} 203 210 {/snippet} ··· 211 218 bind:postComposerState 212 219 /> 213 220 214 - {#if currentRoute.path === '/settings/:tab' || currentRoute.path === '/settings'} 221 + {#if currentRoute.path === '/settings/:tab'} 215 222 <div class={animClass}> 216 - <SettingsView tab={currentRoute.params.tab ?? 'advanced'} /> 223 + <SettingsView tab={currentRoute.params.tab} /> 217 224 </div> 218 - {/if} 219 - {#if currentRoute.path === '/notifications'} 225 + {:else if currentRoute.path === '/notifications'} 220 226 <div class={animClass}> 221 227 <NotificationsView /> 222 228 </div> 223 - {/if} 224 - {#if currentRoute.path === '/following'} 229 + {:else if currentRoute.path === '/following'} 225 230 <div class={animClass}> 226 - <FollowingView 227 - selectedClient={selectedClient!} 228 - selectedDid={selectedDid!} 229 - bind:followingSort 230 - /> 231 + <FollowingView client={selectedClient} bind:followingSort /> 231 232 </div> 232 - {/if} 233 - {#if currentRoute.path === '/profile/:actor'} 233 + {:else if currentRoute.path === '/profile/:actor'} 234 234 {#key currentRoute.params.actor} 235 235 <div class={animClass}> 236 236 <ProfileView 237 - client={selectedClient!} 237 + client={selectedClient ?? viewClient} 238 238 onBack={() => router.back()} 239 239 actor={currentRoute.params.actor} 240 240 bind:postComposerState ··· 270 270 271 271 <div 272 272 class=" 273 - {router.current.path === '/' || 274 - router.current.path === '/following' || 275 - router.current.path === '/profile/:actor' 276 - ? '' 277 - : 'hidden'} 273 + {['/', '/following', '/profile/:actor'].includes(router.current.path) ? '' : 'hidden'} 278 274 z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all 279 275 " 280 276 > ··· 328 324 </div> 329 325 </div> 330 326 <div class="grow"></div> 331 - {@render routeButton('/', 'heroicons:home', 'timeline', 'heroicons:home-solid')} 332 - {@render routeButton( 333 - '/following', 334 - 'heroicons:users', 335 - 'following', 336 - 'heroicons:users-solid' 337 - )} 338 - {@render routeButton( 339 - '/notifications', 340 - 'heroicons:bell', 341 - 'notifications', 342 - 'heroicons:bell-solid' 343 - )} 344 - {@render routeButton( 345 - '/settings', 346 - 'heroicons:cog-6-tooth', 347 - 'settings', 348 - 'heroicons:cog-6-tooth-solid' 349 - )} 327 + {@render routeButton({ route: '/', icon: 'heroicons:home' })} 328 + {@render routeButton({ route: '/following', icon: 'heroicons:users' })} 329 + {@render routeButton({ route: '/notifications', icon: 'heroicons:bell' })} 330 + {@render routeButton({ 331 + path: '/settings/advanced', 332 + route: '/settings/:tab', 333 + icon: 'heroicons:cog-6-tooth' 334 + })} 350 335 </div> 351 336 </div> 352 337 </div>
src/routes/+page.ts src/routes/[...catchall]/+page.ts