JavaScript-optional public web frontend for Bluesky anartia.kelinci.net
sveltekit atcute bluesky typescript svelte
7
fork

Configure Feed

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

feat: lists

Mary e2c1e534 545e7bc8

+456 -1
+116
src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/+layout.svelte
··· 1 + <script lang="ts"> 2 + import type { ClassValue } from 'svelte/elements'; 3 + 4 + import { base } from '$app/paths'; 5 + import { page } from '$app/state'; 6 + import type { LayoutProps } from './$types'; 7 + 8 + import { parseAtUri } from '$lib/types/at-uri'; 9 + 10 + import ListAside from './components/list-aside.svelte'; 11 + import ListMetaTags from './components/list-meta-tags.svelte'; 12 + 13 + const { data, children }: LayoutProps = $props(); 14 + 15 + const currentRouteId = $derived(page.route.id); 16 + 17 + const uri = $derived(parseAtUri(data.list.uri)); 18 + const listUrl = $derived.by(() => { 19 + return `${base}/${uri.repo}/lists/${uri.rkey}`; 20 + }); 21 + 22 + const cn = (routeId: string): ClassValue => { 23 + const id = `/(app)/[actor=didOrHandle]/lists/[rkey=rkey]${routeId}`; 24 + return ['tab', currentRouteId === id && 'is-active']; 25 + }; 26 + </script> 27 + 28 + <svelte:head> 29 + <link rel="canonical" href="https://bsky.app/profile/{uri.repo}/lists/{uri.rkey}" /> 30 + <link rel="alternate" href={data.list.uri} /> 31 + </svelte:head> 32 + 33 + <ListMetaTags list={data.list} /> 34 + 35 + {#key data.list.uri} 36 + <div class="list-layout"> 37 + <div class="aside"> 38 + <ListAside list={data.list} /> 39 + </div> 40 + 41 + <div class="main"> 42 + <div class="list-tabs" data-sveltekit-keepfocus> 43 + {#if data.list.purpose === 'app.bsky.graph.defs#curatelist'} 44 + <a class={cn('/posts')} href="{listUrl}/posts">Posts</a> 45 + {/if} 46 + 47 + <a class={cn('/members')} href="{listUrl}/members">Members</a> 48 + 49 + <div class="spacer"></div> 50 + </div> 51 + 52 + {@render children()} 53 + </div> 54 + </div> 55 + {/key} 56 + 57 + <style> 58 + .list-layout { 59 + display: grid; 60 + grid-template-columns: minmax(0, 1fr); 61 + grid-template-areas: 'aside' 'main'; 62 + justify-content: center; 63 + gap: 8px; 64 + margin: 24px auto 0; 65 + max-width: 480px; 66 + 67 + @media (width >= 640px) { 68 + grid-template-columns: minmax(255px, 320px) minmax(0, 600px); 69 + grid-template-areas: 'aside main'; 70 + max-width: 960px; 71 + } 72 + } 73 + 74 + .aside { 75 + display: flex; 76 + grid-area: aside; 77 + flex-direction: column; 78 + gap: 8px; 79 + 80 + @media (width >= 640px) { 81 + position: sticky; 82 + top: 0; 83 + max-height: 100dvh; 84 + overflow-y: auto; 85 + } 86 + } 87 + 88 + .main { 89 + grid-area: main; 90 + padding-bottom: 24px; 91 + } 92 + 93 + .list-tabs { 94 + display: flex; 95 + position: sticky; 96 + top: 0; 97 + flex-wrap: wrap; 98 + z-index: 1; 99 + border-bottom: 1px solid var(--divider-sm); 100 + background: var(--bg-primary); 101 + } 102 + .tab { 103 + padding: 12px 16px; 104 + font-weight: 600; 105 + font-size: 1rem; 106 + line-height: 1.5rem; 107 + 108 + &:hover { 109 + text-decoration: underline; 110 + } 111 + 112 + &.is-active { 113 + color: var(--text-primary); 114 + } 115 + } 116 + </style>
+30
src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/+layout.ts
··· 1 + import { simpleFetchHandler, XRPC } from '@atcute/client'; 2 + 3 + import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 + import type { LayoutLoad } from './$types'; 5 + 6 + import { resolveHandle } from '$lib/queries/handle'; 7 + import { makeAtUri } from '$lib/types/at-uri'; 8 + import { isDid, type Did } from '$lib/types/identity'; 9 + 10 + export const load: LayoutLoad = async ({ params, fetch }) => { 11 + const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 12 + 13 + let did: Did; 14 + if (isDid(params.actor)) { 15 + did = params.actor; 16 + } else { 17 + did = await resolveHandle({ rpc, handle: params.actor }); 18 + } 19 + 20 + const { data } = await rpc.get('app.bsky.graph.getList', { 21 + params: { 22 + list: makeAtUri(did, 'app.bsky.graph.list', params.rkey), 23 + limit: 1, 24 + }, 25 + }); 26 + 27 + const view = data.list; 28 + 29 + return { list: view }; 30 + };
-1
src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/+page.svelte
··· 1 - <div>lists</div>
+18
src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/+page.ts
··· 1 + import { redirect } from '@sveltejs/kit'; 2 + import type { PageLoad } from './$types'; 3 + import { base } from '$app/paths'; 4 + 5 + export const load: PageLoad = async ({ params, parent }) => { 6 + const { list } = await parent(); 7 + 8 + const baseUrl = `${base}/${list.creator.did}/lists/${params.rkey}`; 9 + 10 + switch (list.purpose) { 11 + case 'app.bsky.graph.defs#curatelist': { 12 + redirect(302, `${baseUrl}/posts`); 13 + } 14 + default: { 15 + redirect(302, `${baseUrl}/members`); 16 + } 17 + } 18 + };
+122
src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/components/list-aside.svelte
··· 1 + <script lang="ts"> 2 + import type { AppBskyGraphDefs } from '@atcute/client/lexicons'; 3 + 4 + import { base } from '$app/paths'; 5 + 6 + import { parseAtUri } from '$lib/types/at-uri'; 7 + import { formatLongNumber } from '$lib/utils/intl/number'; 8 + 9 + import Avatar from '$lib/components/avatar.svelte'; 10 + import SquareArrowTopRightOutlined from '$lib/components/central-icons/square-arrow-top-right-outlined.svelte'; 11 + import OverflowMenu from '$lib/components/overflow-menu.svelte'; 12 + import RichtextRawRenderer from '$lib/components/richtext-raw-renderer.svelte'; 13 + import RichtextRenderer from '$lib/components/richtext-renderer.svelte'; 14 + 15 + interface Props { 16 + list: AppBskyGraphDefs.ListView; 17 + } 18 + 19 + const { list }: Props = $props(); 20 + 21 + const uri = $derived(parseAtUri(list.uri)); 22 + 23 + const creatorUrl = $derived(`${base}/${list.creator.did}`); 24 + </script> 25 + 26 + <div class="list-aside"> 27 + <Avatar type="list" src={list.avatar} size="xl" /> 28 + 29 + <OverflowMenu 30 + class="list-overflow" 31 + items={[ 32 + { 33 + label: `Open in Bluesky app`, 34 + href: `https://bsky.app/profile/${list.creator.did}/lists/${uri.rkey}`, 35 + external: true, 36 + icon: SquareArrowTopRightOutlined, 37 + }, 38 + { 39 + label: `Open in PDSls`, 40 + href: `https://pdsls.dev/${list.uri}`, 41 + external: true, 42 + icon: SquareArrowTopRightOutlined, 43 + }, 44 + ]} 45 + /> 46 + 47 + <p dir="auto" class="display-name">{list.name.trim()}</p> 48 + 49 + {#if list.description?.trim()} 50 + {#if list.descriptionFacets === undefined} 51 + <RichtextRawRenderer text={list.description ?? ''} /> 52 + {:else} 53 + <RichtextRenderer text={list.description ?? ''} facets={list.descriptionFacets} /> 54 + {/if} 55 + {/if} 56 + 57 + <p class="metric"> 58 + {list.listItemCount === 1 59 + ? `${formatLongNumber(list.listItemCount)} member` 60 + : `${formatLongNumber(list.listItemCount ?? 0)} members`} 61 + </p> 62 + 63 + <div class="creator"> 64 + <Avatar profile={list.creator} size="xs" href={creatorUrl} tabindex={-1} /> 65 + <a href={creatorUrl} class="handle">{list.creator.handle}</a> 66 + </div> 67 + </div> 68 + 69 + <style> 70 + .list-aside { 71 + display: flex; 72 + position: relative; 73 + flex-direction: column; 74 + background: var(--bg-primary); 75 + padding: 16px; 76 + min-width: 0; 77 + 78 + :global(.list-overflow) { 79 + position: absolute; 80 + top: 12px; 81 + right: 12px; 82 + } 83 + } 84 + 85 + .display-name { 86 + margin: 16px 0 4px 0; 87 + font-weight: 700; 88 + font-size: 1.25rem; 89 + line-height: 1.75rem; 90 + overflow-wrap: break-word; 91 + } 92 + 93 + .metric { 94 + margin: 12px 0 0 0; 95 + color: var(--text-blurb); 96 + font-size: 0.8125rem; 97 + line-height: 1.25rem; 98 + 99 + .display-name + & { 100 + margin: 0; 101 + } 102 + } 103 + 104 + .creator { 105 + display: flex; 106 + gap: 8px; 107 + margin: 16px 0 0 0; 108 + min-width: 0; 109 + color: var(--text-blurb); 110 + font-weight: 500; 111 + } 112 + .handle { 113 + overflow: hidden; 114 + color: inherit; 115 + text-overflow: ellipsis; 116 + white-space: nowrap; 117 + 118 + &:hover { 119 + text-decoration: underline; 120 + } 121 + } 122 + </style>
+42
src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/components/list-meta-tags.svelte
··· 1 + <script lang="ts"> 2 + import type { AppBskyGraphDefs } from '@atcute/client/lexicons'; 3 + 4 + import { PUBLIC_APP_NAME, PUBLIC_APP_URL } from '$env/static/public'; 5 + 6 + import { parseAtUri } from '$lib/types/at-uri'; 7 + import { purposeToLabel } from '$lib/utils/bluesky/lists'; 8 + 9 + interface Props { 10 + list: AppBskyGraphDefs.ListView; 11 + } 12 + 13 + const { list }: Props = $props(); 14 + 15 + const uri = $derived(parseAtUri(list.uri)); 16 + 17 + const description = $derived.by(() => { 18 + const desc = list.description?.trim(); 19 + 20 + let str = ''; 21 + 22 + str += `${purposeToLabel(list.purpose)} by @${list.creator.handle}`; 23 + 24 + if (desc) { 25 + str += `\n\n${desc}`; 26 + } 27 + 28 + return str; 29 + }); 30 + </script> 31 + 32 + <svelte:head> 33 + <meta property="og:site_name" content={PUBLIC_APP_NAME} /> 34 + <meta property="twitter:card" content="summary" /> 35 + <meta property="og:url" content="{PUBLIC_APP_URL}/{uri.repo}/feeds/{uri.rkey}" /> 36 + <meta property="og:title" content={list.name.trim()} /> 37 + <meta property="og:description" content={description} /> 38 + 39 + {#if list.avatar} 40 + <meta property="og:image" content={list.avatar.replace('@jpeg', '@png')} /> 41 + {/if} 42 + </svelte:head>
+31
src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/members/+page.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/state'; 3 + import { PUBLIC_APP_NAME } from '$env/static/public'; 4 + import type { PageProps } from './$types'; 5 + 6 + import { paginate } from '$lib/utils/pagination'; 7 + 8 + import PageListing from '$lib/components/page/page-listing.svelte'; 9 + import ProfileItem from '$lib/components/profiles/profile-item.svelte'; 10 + 11 + const { data }: PageProps = $props(); 12 + 13 + const { rootUrl, nextUrl } = $derived(paginate(page.url, data.members.cursor)); 14 + 15 + const title = $derived.by(() => { 16 + const list = data.list; 17 + const creator = list.creator; 18 + 19 + return `Members in ${list.name} by @${creator.handle} — ${PUBLIC_APP_NAME}`; 20 + }); 21 + </script> 22 + 23 + <svelte:head> 24 + <title>{title}</title> 25 + </svelte:head> 26 + 27 + <PageListing subject="profiles" {rootUrl} {nextUrl}> 28 + {#each data.members.items as item (item.subject.did)} 29 + <ProfileItem item={item.subject} /> 30 + {/each} 31 + </PageListing>
+29
src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/members/+page.ts
··· 1 + import { simpleFetchHandler, XRPC } from '@atcute/client'; 2 + 3 + import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 + import type { PageLoad } from './$types'; 5 + 6 + import { makeAtUri } from '$lib/types/at-uri'; 7 + import { isDid, type Did } from '$lib/types/identity'; 8 + 9 + export const load: PageLoad = async ({ url, params, fetch, parent }) => { 10 + const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 11 + 12 + let did: Did; 13 + if (isDid(params.actor)) { 14 + did = params.actor; 15 + } else { 16 + const parentData = await parent(); 17 + did = parentData.list.creator.did as Did; 18 + } 19 + 20 + const { data } = await rpc.get('app.bsky.graph.getList', { 21 + params: { 22 + list: makeAtUri(did, 'app.bsky.graph.list', params.rkey), 23 + limit: 50, 24 + cursor: url.searchParams.get('cursor') || undefined, 25 + }, 26 + }); 27 + 28 + return { members: { cursor: data.cursor, items: data.items } }; 29 + };
+37
src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/posts/+page.svelte
··· 1 + <script lang="ts"> 2 + import { base } from '$app/paths'; 3 + import { page } from '$app/state'; 4 + import { PUBLIC_APP_NAME } from '$env/static/public'; 5 + import type { PageProps } from './$types'; 6 + 7 + import { paginate } from '$lib/utils/pagination'; 8 + 9 + import PageListing from '$lib/components/page/page-listing.svelte'; 10 + import PostFeedItem from '$lib/components/timeline/post-feed-item.svelte'; 11 + import { parseAtUri } from '$lib/types/at-uri'; 12 + 13 + const { data }: PageProps = $props(); 14 + 15 + const uri = $derived(parseAtUri(data.list.uri)); 16 + 17 + const { rootUrl, nextUrl } = $derived( 18 + paginate(page.url, data.timeline.cursor, `${base}/${uri.repo}/lists/${uri.rkey}/posts`), 19 + ); 20 + 21 + const title = $derived.by(() => { 22 + const list = data.list; 23 + const creator = list.creator; 24 + 25 + return `${list.name} by @${creator.handle} — ${PUBLIC_APP_NAME}`; 26 + }); 27 + </script> 28 + 29 + <svelte:head> 30 + <title>{title}</title> 31 + </svelte:head> 32 + 33 + <PageListing subject="timeline" {rootUrl} {nextUrl}> 34 + {#each data.timeline.items as item (item.id)} 35 + <PostFeedItem {item} /> 36 + {/each} 37 + </PageListing>
+31
src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/posts/+page.ts
··· 1 + import { simpleFetchHandler, XRPC } from '@atcute/client'; 2 + 3 + import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 + import type { PageLoad } from './$types'; 5 + 6 + import { isDid, type Did } from '$lib/types/identity'; 7 + import { makeAtUri } from '$lib/types/at-uri'; 8 + import { fetchTimeline, TimelineType } from '$lib/queries/timeline'; 9 + 10 + export const load: PageLoad = async ({ url, params, fetch, parent }) => { 11 + const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 12 + 13 + let did: Did; 14 + if (isDid(params.actor)) { 15 + did = params.actor; 16 + } else { 17 + const parentData = await parent(); 18 + did = parentData.list.creator.did as Did; 19 + } 20 + 21 + const timeline = await fetchTimeline({ 22 + rpc, 23 + params: { 24 + type: TimelineType.USER_LIST, 25 + list: makeAtUri(did, 'app.bsky.graph.list', params.rkey), 26 + cursor: url.searchParams.get('cursor') || undefined, 27 + }, 28 + }); 29 + 30 + return { timeline }; 31 + };