This repository has no description
0

Configure Feed

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

web/components: add components and stories for timeline

also fix up timeline styles

Signed-off-by: oppiliappan <me@oppi.li>

author
oppiliappan
committer
dawn
date (Jul 30, 2026, 7:45 PM +0300) commit 25778632 parent 35bd7af6 change-id lpttoxts
+890 -295
+45
web/src/lib/components/profile/FollowCard.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import MockAuthProvider from "$lib/components/testing/MockAuthProvider.svelte"; 4 + import FollowCard from "./FollowCard.svelte"; 5 + import type { PersonData } from "./types"; 6 + 7 + const person: PersonData = { 8 + did: "did:plc:3p7ejjxnufohsygt5vbyxb2i", 9 + handle: "joshka.net", 10 + description: "Rust, TUIs, and the occasional git plumbing yak-shave.", 11 + followers: 128, 12 + following: 87, 13 + viewerFollowRkey: null, 14 + isSelf: true 15 + }; 16 + 17 + const { Story } = defineMeta({ 18 + title: "Profile/FollowCard", 19 + component: FollowCard, 20 + tags: ["autodocs"], 21 + argTypes: { 22 + border: { control: "boolean" }, 23 + shadow: { control: "boolean" }, 24 + background: { control: "boolean" } 25 + }, 26 + args: { 27 + person, 28 + border: false, 29 + shadow: false, 30 + background: false 31 + } 32 + }); 33 + </script> 34 + 35 + <!-- default: layout-only (plain), as used on the timeline --> 36 + <Story name="Plain (timeline)" /> 37 + <Story name="Bordered" args={{ border: true, background: true }} /> 38 + 39 + <Story name="With follow button"> 40 + {#snippet template(args)} 41 + <MockAuthProvider> 42 + <FollowCard {...args} person={{ ...person, isSelf: false }} border background /> 43 + </MockAuthProvider> 44 + {/snippet} 45 + </Story>
+17 -62
web/src/lib/components/profile/FollowCard.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from "$app/paths"; 3 - import Avatar from "$lib/components/ui/Avatar.svelte"; 4 - import Users from "$icon/users"; 5 - import FollowButton from "./FollowButton.svelte"; 6 - import type { FollowChange, PersonData } from "./types"; 7 - import { createOptimisticCount } from "$lib/optimistic.svelte"; 8 - 9 - let { person }: { person: PersonData } = $props(); 10 - 11 - const followerCount = createOptimisticCount({ 12 - key: () => person.did, 13 - loaded: () => person.followers 14 - }); 2 + import Card, { type CardVariants } from "$lib/components/ui/Card.svelte"; 3 + import FollowCardContent from "./FollowCardContent.svelte"; 4 + import type { PersonData } from "./types"; 15 5 16 - const followed = (change: FollowChange) => followerCount.adjust(change.delta); 6 + let { 7 + person, 8 + border = false, 9 + shadow = false, 10 + background = false 11 + }: { 12 + person: PersonData; 13 + border?: CardVariants["border"]; 14 + shadow?: CardVariants["shadow"]; 15 + background?: CardVariants["background"]; 16 + } = $props(); 17 17 </script> 18 18 19 - <div class="flex flex-col gap-4 md:flex-row md:items-center"> 20 - <div class="flex h-24 w-24 shrink-0 items-center justify-center"> 21 - <Avatar did={person.did} handle={person.handle} size="size-24" class="p-2" /> 22 - </div> 23 - 24 - <div class="flex min-w-0 flex-1 flex-col gap-2 md:flex-row md:items-center md:justify-between"> 25 - <div class="flex min-h-0 flex-1 flex-col justify-around"> 26 - <a 27 - href={resolve(`/${person.handle}` as "/")} 28 - class="max-w-full overflow-hidden font-bold text-ellipsis whitespace-nowrap text-foreground-default hover:underline" 29 - > 30 - {person.handle} 31 - </a> 32 - {#if person.description} 33 - <p class="pb-2 text-sm break-words text-foreground-muted md:pb-2">{person.description}</p> 34 - {/if} 35 - <div 36 - class="my-2 flex max-w-full items-center gap-2 overflow-hidden text-sm text-ellipsis whitespace-nowrap text-foreground-subtle" 37 - > 38 - <Users class="size-4 shrink-0" /> 39 - <span 40 - ><a 41 - href={resolve(`/${person.handle}?tab=followers` as "/")} 42 - class="hover:text-foreground-default hover:underline">{followerCount.value} followers</a 43 - ></span 44 - > 45 - <span class="select-none after:content-['·']"></span> 46 - <span 47 - ><a 48 - href={resolve(`/${person.handle}?tab=following` as "/")} 49 - class="hover:text-foreground-default hover:underline">{person.following} following</a 50 - ></span 51 - > 52 - </div> 53 - </div> 54 - 55 - {#if !person.isSelf} 56 - <div class="order-last w-full md:order-none md:w-auto md:max-w-32"> 57 - <FollowButton 58 - profileDid={person.did} 59 - initialRkey={person.viewerFollowRkey} 60 - size="md" 61 - onCommit={followed} 62 - /> 63 - </div> 64 - {/if} 65 - </div> 66 - </div> 19 + <Card {border} {shadow} {background} padding={border || background ? "default" : "none"}> 20 + <FollowCardContent {person} /> 21 + </Card>
+65
web/src/lib/components/profile/FollowCardContent.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import Avatar from "$lib/components/ui/Avatar.svelte"; 4 + import Users from "$icon/users"; 5 + import FollowButton from "./FollowButton.svelte"; 6 + import type { FollowChange, PersonData } from "./types"; 7 + import { createOptimisticCount } from "$lib/optimistic.svelte"; 8 + 9 + let { person }: { person: PersonData } = $props(); 10 + 11 + const followerCount = createOptimisticCount({ 12 + key: () => person.did, 13 + loaded: () => person.followers 14 + }); 15 + 16 + const followed = (change: FollowChange) => followerCount.adjust(change.delta); 17 + </script> 18 + 19 + <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> 20 + <div class="flex min-w-0 flex-1 items-center gap-4"> 21 + <div class="flex h-24 w-24 shrink-0 items-center justify-center"> 22 + <Avatar did={person.did} handle={person.handle} size="size-24" class="p-2" /> 23 + </div> 24 + 25 + <div class="flex min-w-0 flex-1 flex-col justify-around"> 26 + <a 27 + href={resolve(`/${person.handle}` as "/")} 28 + class="max-w-full overflow-hidden font-bold text-ellipsis whitespace-nowrap text-foreground-default hover:underline" 29 + > 30 + {person.handle} 31 + </a> 32 + {#if person.description} 33 + <p class="pb-2 text-sm break-words text-foreground-muted md:pb-2">{person.description}</p> 34 + {/if} 35 + <div 36 + class="my-2 flex max-w-full items-center gap-2 overflow-hidden text-sm text-ellipsis whitespace-nowrap text-foreground-subtle" 37 + > 38 + <Users class="size-4 shrink-0" /> 39 + <span 40 + ><a 41 + href={resolve(`/${person.handle}?tab=followers` as "/")} 42 + class="hover:text-foreground-default hover:underline">{followerCount.value} followers</a 43 + ></span 44 + > 45 + <span class="select-none after:content-['·']"></span> 46 + <span 47 + ><a 48 + href={resolve(`/${person.handle}?tab=following` as "/")} 49 + class="hover:text-foreground-default hover:underline">{person.following} following</a 50 + ></span 51 + > 52 + </div> 53 + </div> 54 + </div> 55 + 56 + {#if !person.isSelf} 57 + <div class="w-full md:w-auto md:max-w-32"> 58 + <FollowButton 59 + profileDid={person.did} 60 + initialRkey={person.viewerFollowRkey} 61 + onCommit={followed} 62 + /> 63 + </div> 64 + {/if} 65 + </div>
+35
web/src/lib/components/profile/StringCard.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import StringCard from "./StringCard.svelte"; 4 + import type { StringCardData } from "./types"; 5 + 6 + const entry: StringCardData = { 7 + rkey: "3mrkgcxb4m422", 8 + ownerHandle: "tangled.org", 9 + filename: "flake.nix", 10 + description: "Reproducible dev shell for the appview.", 11 + createdAt: "2025-06-01T10:30:00Z", 12 + lines: 84 13 + }; 14 + 15 + const { Story } = defineMeta({ 16 + title: "Profile/StringCard", 17 + component: StringCard, 18 + tags: ["autodocs"], 19 + argTypes: { 20 + border: { control: "boolean" }, 21 + shadow: { control: "boolean" }, 22 + background: { control: "boolean" } 23 + }, 24 + args: { 25 + entry, 26 + border: true, 27 + shadow: false, 28 + background: true 29 + } 30 + }); 31 + </script> 32 + 33 + <Story name="Default" /> 34 + <Story name="Elevated" args={{ shadow: true }} /> 35 + <Story name="Plain" args={{ border: false, background: false }} />
+16 -31
web/src/lib/components/profile/StringCard.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from "$app/paths"; 3 - import { compactRelativeTime } from "$lib/format"; 2 + import Card, { type CardVariants } from "$lib/components/ui/Card.svelte"; 3 + import StringCardContent from "./StringCardContent.svelte"; 4 4 import type { StringCardData } from "./types"; 5 5 6 - let { entry }: { entry: StringCardData } = $props(); 6 + let { 7 + entry, 8 + border = true, 9 + shadow = false, 10 + background = true 11 + }: { 12 + entry: StringCardData; 13 + border?: CardVariants["border"]; 14 + shadow?: CardVariants["shadow"]; 15 + background?: CardVariants["background"]; 16 + } = $props(); 7 17 </script> 8 18 9 - <div 10 - class="flex min-h-32 flex-col gap-1 rounded border border-border-default bg-background-default px-6 py-4 shadow-xs" 11 - > 12 - <div class="flex items-center justify-between font-medium text-foreground-default"> 13 - <div class="mr-2 flex min-w-0 flex-1 items-center"> 14 - <a 15 - href={resolve(`/${entry.ownerHandle}/strings/${entry.rkey}` as "/")} 16 - class="min-w-0 truncate font-bold text-foreground-default no-underline hover:underline" 17 - > 18 - {entry.filename} 19 - </a> 20 - </div> 21 - </div> 22 - 23 - {#if entry.description} 24 - <p class="line-clamp-2 text-sm text-foreground-muted">{entry.description}</p> 25 - {/if} 26 - 27 - <div 28 - class="mt-auto flex flex-wrap items-center gap-4 pt-2 font-mono text-xs text-foreground-subtle" 29 - > 30 - <span class="shrink-0" 31 - >{entry.lines} line{entry.lines === 1 ? "" : "s"} &middot; {compactRelativeTime( 32 - entry.createdAt 33 - )}</span 34 - > 35 - </div> 36 - </div> 19 + <Card {border} {shadow} {background}> 20 + <StringCardContent {entry} /> 21 + </Card>
+34
web/src/lib/components/profile/StringCardContent.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import { compactRelativeTime } from "$lib/format"; 4 + import type { StringCardData } from "./types"; 5 + 6 + let { entry }: { entry: StringCardData } = $props(); 7 + </script> 8 + 9 + <div class="flex min-h-24 flex-col gap-1"> 10 + <div class="flex items-center justify-between font-medium text-foreground-default"> 11 + <div class="mr-2 flex min-w-0 flex-1 items-center"> 12 + <a 13 + href={resolve(`/${entry.ownerHandle}/strings/${entry.rkey}` as "/")} 14 + class="min-w-0 truncate font-bold text-foreground-default no-underline hover:underline" 15 + > 16 + {entry.filename} 17 + </a> 18 + </div> 19 + </div> 20 + 21 + {#if entry.description} 22 + <p class="line-clamp-2 text-sm text-foreground-muted">{entry.description}</p> 23 + {/if} 24 + 25 + <div 26 + class="mt-auto flex flex-wrap items-center gap-4 pt-2 font-mono text-xs text-foreground-subtle" 27 + > 28 + <span class="shrink-0" 29 + >{entry.lines} line{entry.lines === 1 ? "" : "s"} &middot; {compactRelativeTime( 30 + entry.createdAt 31 + )}</span 32 + > 33 + </div> 34 + </div>
+3 -4
web/src/lib/components/profile/tabs/StarredTab.svelte
··· 1 1 <script lang="ts"> 2 2 import { resolve } from "$app/paths"; 3 3 import RepoCard from "$lib/components/repo/RepoCard.svelte"; 4 + import Card from "$lib/components/ui/Card.svelte"; 4 5 import Section from "$lib/components/ui/Section.svelte"; 5 6 import type { StarData } from "../types"; 6 7 ··· 12 13 {#if star.kind === "repo"} 13 14 <RepoCard repo={star.repo} /> 14 15 {:else} 15 - <div 16 - class="flex items-center gap-2 rounded border border-border-default bg-background-default px-6 py-4 shadow-xs" 17 - > 16 + <Card shadow={false} class="flex items-center gap-2"> 18 17 <a 19 18 href={resolve(`/${star.ownerHandle}/strings/${star.rkey}` as "/")} 20 19 class="truncate font-bold text-foreground-default no-underline hover:underline" 21 20 > 22 21 {star.ownerHandle}/{star.rkey} 23 22 </a> 24 - </div> 23 + </Card> 25 24 {/if} 26 25 {/each} 27 26 </Section>
+54
web/src/lib/components/repo/RepoCard.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import MockAuthProvider from "$lib/components/testing/MockAuthProvider.svelte"; 4 + import RepoCard from "./RepoCard.svelte"; 5 + import type { RepoCardData } from "$lib/components/profile/types"; 6 + 7 + const repo: RepoCardData = { 8 + name: "core", 9 + repoDid: "did:plc:3p7ejjxnufohsygt5vbyxb2i", 10 + ownerHandle: "tangled.org", 11 + description: "The knot server, appview, and everything that makes tangled tick.", 12 + createdAt: "2025-06-01T10:30:00Z", 13 + language: "Go", 14 + stars: 42, 15 + forks: 7, 16 + issues: 3, 17 + pulls: 5, 18 + viewerStarRkey: null 19 + }; 20 + 21 + const { Story } = defineMeta({ 22 + title: "Repo/RepoCard", 23 + component: RepoCard, 24 + tags: ["autodocs"], 25 + argTypes: { 26 + starButton: { control: "boolean" }, 27 + showOwner: { control: "boolean" }, 28 + border: { control: "boolean" }, 29 + shadow: { control: "boolean" }, 30 + background: { control: "boolean" } 31 + }, 32 + args: { 33 + repo, 34 + starButton: false, 35 + showOwner: true, 36 + border: true, 37 + shadow: false, 38 + background: true 39 + } 40 + }); 41 + </script> 42 + 43 + <Story name="Default" /> 44 + <Story name="No owner" args={{ showOwner: false }} /> 45 + <Story name="Elevated" args={{ shadow: true }} /> 46 + <Story name="Plain (timeline)" args={{ border: false, shadow: false, background: false }} /> 47 + 48 + <Story name="With star button"> 49 + {#snippet template(args)} 50 + <MockAuthProvider> 51 + <RepoCard {...args} starButton /> 52 + </MockAuthProvider> 53 + {/snippet} 54 + </Story>
+20 -70
web/src/lib/components/repo/RepoCard.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from "$app/paths"; 3 - import BookMarked from "$icon/book-marked"; 4 - import Star from "$icon/star"; 5 - import GitFork from "$icon/git-fork"; 6 - import CircleDot from "$icon/circle-dot"; 7 - import GitPullRequest from "$icon/git-pull-request"; 8 - import StarButton from "./StarButton.svelte"; 2 + import Card, { type CardVariants } from "$lib/components/ui/Card.svelte"; 3 + import RepoCardContent from "./RepoCardContent.svelte"; 9 4 import type { RepoCardData } from "$lib/components/profile/types"; 5 + 10 6 let { 11 7 repo, 12 8 starButton = true, 13 - showOwner = true 14 - }: { repo: RepoCardData; starButton?: boolean; showOwner?: boolean } = $props(); 15 - 16 - const stats = $derived( 17 - [ 18 - { value: repo.stars, Icon: Star }, 19 - { value: repo.forks, Icon: GitFork }, 20 - { value: repo.issues, Icon: CircleDot }, 21 - { value: repo.pulls, Icon: GitPullRequest } 22 - ].filter((s) => s.value !== undefined) 23 - ); 9 + showOwner = true, 10 + border = true, 11 + shadow = false, 12 + background = true, 13 + padding = "default" 14 + }: { 15 + repo: RepoCardData; 16 + starButton?: boolean; 17 + showOwner?: boolean; 18 + border?: CardVariants["border"]; 19 + shadow?: CardVariants["shadow"]; 20 + background?: CardVariants["background"]; 21 + padding?: CardVariants["padding"]; 22 + } = $props(); 24 23 </script> 25 24 26 - <div 27 - class="flex min-h-32 flex-col gap-1 rounded border border-border-default bg-background-default px-6 py-4" 28 - > 29 - <div class="flex items-start justify-between font-medium text-foreground-default"> 30 - <div class="mt-1 mr-2 flex min-w-0 flex-1 items-center"> 31 - <BookMarked class="mr-1.5 size-4 shrink-0 text-foreground-subtle" aria-hidden="true" /> 32 - <a 33 - href={resolve(`/${repo.ownerHandle}/${repo.name}` as "/")} 34 - class="min-w-0 truncate text-foreground-default no-underline hover:underline" 35 - > 36 - {showOwner ? `${repo.ownerHandle}/${repo.name}` : repo.name} 37 - </a> 38 - </div> 39 - {#if starButton} 40 - <div class="ml-4 shrink-0"> 41 - <StarButton 42 - repoDid={repo.repoDid} 43 - repoOwnerHandle={repo.ownerHandle} 44 - repoName={repo.name} 45 - initialCount={repo.stars} 46 - initialRkey={repo.viewerStarRkey} 47 - /> 48 - </div> 49 - {/if} 50 - </div> 51 - 52 - {#if repo.description} 53 - <p class="line-clamp-2 text-sm text-foreground-muted">{repo.description}</p> 54 - {/if} 55 - 56 - {#if repo.language !== undefined || stats.length > 0} 57 - <div 58 - class="mt-auto flex flex-wrap items-center gap-4 pt-2 font-mono text-xs text-foreground-subtle" 59 - > 60 - {#if repo.language} 61 - <span class="flex items-center gap-2"> 62 - <span 63 - class="inline-block size-2.5 shrink-0 rounded-full" 64 - style="background-color: var(--color-background-success-emphasis);" 65 - ></span> 66 - <span>{repo.language}</span> 67 - </span> 68 - {/if} 69 - {#each stats as stat (stat.Icon)} 70 - <span class="flex items-center gap-1"> 71 - <stat.Icon class="size-3.5 shrink-0" aria-hidden="true" /> 72 - <span>{stat.value}</span> 73 - </span> 74 - {/each} 75 - </div> 76 - {/if} 77 - </div> 25 + <Card {border} {shadow} {background} {padding}> 26 + <RepoCardContent {repo} {starButton} {showOwner} /> 27 + </Card>
+75
web/src/lib/components/repo/RepoCardContent.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import BookMarked from "$icon/book-marked"; 4 + import Star from "$icon/star"; 5 + import GitFork from "$icon/git-fork"; 6 + import CircleDot from "$icon/circle-dot"; 7 + import GitPullRequest from "$icon/git-pull-request"; 8 + import StarButton from "./StarButton.svelte"; 9 + import type { RepoCardData } from "$lib/components/profile/types"; 10 + let { 11 + repo, 12 + starButton = true, 13 + showOwner = true 14 + }: { repo: RepoCardData; starButton?: boolean; showOwner?: boolean } = $props(); 15 + 16 + const stats = $derived( 17 + [ 18 + { value: repo.stars, Icon: Star }, 19 + { value: repo.forks, Icon: GitFork }, 20 + { value: repo.issues, Icon: CircleDot }, 21 + { value: repo.pulls, Icon: GitPullRequest } 22 + ].filter((s) => s.value !== undefined) 23 + ); 24 + </script> 25 + 26 + <div class="flex min-h-24 flex-col gap-1"> 27 + <div class="flex items-start justify-between font-medium text-foreground-default"> 28 + <div class="mt-1 mr-2 flex min-w-0 flex-1 items-center"> 29 + <BookMarked class="mr-1.5 size-4 shrink-0 text-foreground-subtle" aria-hidden="true" /> 30 + <a 31 + href={resolve(`/${repo.ownerHandle}/${repo.name}` as "/")} 32 + class="min-w-0 truncate text-foreground-default no-underline hover:underline" 33 + > 34 + {showOwner ? `${repo.ownerHandle}/${repo.name}` : repo.name} 35 + </a> 36 + </div> 37 + {#if starButton} 38 + <div class="ml-4 shrink-0"> 39 + <StarButton 40 + repoDid={repo.repoDid} 41 + repoOwnerHandle={repo.ownerHandle} 42 + repoName={repo.name} 43 + initialCount={repo.stars} 44 + initialRkey={repo.viewerStarRkey} 45 + /> 46 + </div> 47 + {/if} 48 + </div> 49 + 50 + {#if repo.description} 51 + <p class="line-clamp-2 text-sm text-foreground-muted">{repo.description}</p> 52 + {/if} 53 + 54 + {#if repo.language !== undefined || stats.length > 0} 55 + <div 56 + class="mt-auto flex flex-wrap items-center gap-4 pt-2 font-mono text-xs text-foreground-subtle" 57 + > 58 + {#if repo.language} 59 + <span class="flex items-center gap-2"> 60 + <span 61 + class="inline-block size-2.5 shrink-0 rounded-full" 62 + style="background-color: var(--color-background-success-emphasis);" 63 + ></span> 64 + <span>{repo.language}</span> 65 + </span> 66 + {/if} 67 + {#each stats as stat (stat.Icon)} 68 + <span class="flex items-center gap-1"> 69 + <stat.Icon class="size-3.5 shrink-0" aria-hidden="true" /> 70 + <span>{stat.value}</span> 71 + </span> 72 + {/each} 73 + </div> 74 + {/if} 75 + </div>
+1 -1
web/src/lib/components/repo/RepoIndexView.svelte
··· 29 29 const refsCapped = $derived(data.refs.capped); 30 30 </script> 31 31 32 - <TabPanel class="relative mx-auto w-full"> 32 + <TabPanel class="relative mx-auto w-full mt-[1px]"> 33 33 {#if data.needsUpgrade} 34 34 <div class="flex h-96 items-center justify-center text-center text-foreground-danger"> 35 35 <div>
+1
web/src/lib/components/repo/issues/IssueCard.stories.svelte
··· 29 29 <Story name="Open" /> 30 30 <Story name="Closed" args={{ issue: { ...issue, state: "closed" } }} /> 31 31 <Story name="No comments" args={{ issue: { ...issue, commentCount: 0 } }} /> 32 + <Story name="Plain" args={{ border: false, shadow: false, background: false }} />
+16 -36
web/src/lib/components/repo/issues/IssueCard.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from "$app/paths"; 3 - import User from "$lib/components/ui/User.svelte"; 4 - import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 5 - import IssueStatePill from "./IssueStatePill.svelte"; 2 + import Card, { type CardVariants } from "$lib/components/ui/Card.svelte"; 3 + import IssueCardContent from "./IssueCardContent.svelte"; 6 4 import type { IssueSummary } from "$lib/components/repo/types"; 7 5 8 6 interface Props { 9 7 ownerHandle: string; 10 8 repoName: string; 11 9 issue: IssueSummary; 10 + border?: CardVariants["border"]; 11 + shadow?: CardVariants["shadow"]; 12 + background?: CardVariants["background"]; 12 13 } 13 14 14 - let { ownerHandle, repoName, issue }: Props = $props(); 15 - 16 - const href = $derived( 17 - resolve(`/${ownerHandle}/${repoName}/issues/${encodeURIComponent(issue.uri)}` as "/") 18 - ); 15 + let { 16 + ownerHandle, 17 + repoName, 18 + issue, 19 + border = true, 20 + shadow = true, 21 + background = true 22 + }: Props = $props(); 19 23 </script> 20 24 21 - <div class="rounded bg-background-default p-4 shadow-xs border border-border-default"> 22 - <div class="pb-2"> 23 - <a {href} class="text-foreground-default no-underline"> 24 - <span class="hover:underline">{issue.title}</span> 25 - <span class="text-foreground-subtle">#{issue.rkey}</span> 26 - </a> 27 - </div> 28 - 29 - <div class="flex flex-wrap items-center gap-x-1 gap-y-1.5 text-sm text-foreground-subtle"> 30 - <IssueStatePill state={issue.state} /> 31 - 32 - <span class="ml-1 flex items-center gap-1"> 33 - <User handle={issue.authorHandle} did={issue.authorDid} /> 34 - </span> 35 - 36 - <span class="before:mr-1 before:content-['·']"> 37 - <TimeAgo value={issue.createdAt} /> 38 - </span> 39 - 40 - <span class="before:mr-1 before:content-['·']"> 41 - <a {href} class="text-foreground-subtle no-underline hover:underline"> 42 - {issue.commentCount} 43 - {issue.commentCount === 1 ? "comment" : "comments"} 44 - </a> 45 - </span> 46 - </div> 47 - </div> 25 + <Card {border} {shadow} {background} padding="sm"> 26 + <IssueCardContent {ownerHandle} {repoName} {issue} /> 27 + </Card>
+45
web/src/lib/components/repo/issues/IssueCardContent.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import User from "$lib/components/ui/User.svelte"; 4 + import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 5 + import IssueStatePill from "./IssueStatePill.svelte"; 6 + import type { IssueSummary } from "$lib/components/repo/types"; 7 + 8 + interface Props { 9 + ownerHandle: string; 10 + repoName: string; 11 + issue: IssueSummary; 12 + } 13 + 14 + let { ownerHandle, repoName, issue }: Props = $props(); 15 + 16 + const href = $derived( 17 + resolve(`/${ownerHandle}/${repoName}/issues/${encodeURIComponent(issue.uri)}` as "/") 18 + ); 19 + </script> 20 + 21 + <div class="pb-2"> 22 + <a {href} class="text-foreground-default no-underline"> 23 + <span class="hover:underline">{issue.title}</span> 24 + <span class="text-foreground-subtle">#{issue.rkey}</span> 25 + </a> 26 + </div> 27 + 28 + <div class="flex flex-wrap items-center gap-x-1 gap-y-1.5 text-sm text-foreground-subtle"> 29 + <IssueStatePill state={issue.state} /> 30 + 31 + <span class="ml-1 flex items-center gap-1"> 32 + <User handle={issue.authorHandle} did={issue.authorDid} /> 33 + </span> 34 + 35 + <span class="before:mr-1 before:content-['·']"> 36 + <TimeAgo value={issue.createdAt} /> 37 + </span> 38 + 39 + <span class="before:mr-1 before:content-['·']"> 40 + <a {href} class="text-foreground-subtle no-underline hover:underline"> 41 + {issue.commentCount} 42 + {issue.commentCount === 1 ? "comment" : "comments"} 43 + </a> 44 + </span> 45 + </div>
+30
web/src/lib/components/settings/KeyCard.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import KeyCard from "./KeyCard.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "Settings/KeyCard", 7 + component: KeyCard, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + deleting: { control: "boolean" }, 11 + border: { control: "boolean" }, 12 + shadow: { control: "boolean" }, 13 + background: { control: "boolean" } 14 + }, 15 + args: { 16 + name: "yubikey-5c", 17 + fingerprint: "SHA256:9y7Yq0h2s0aVQ3mQ5m8k1J3d4b5c6D7e8F9g0H1i2J", 18 + createdAt: "2025-06-01T10:30:00Z", 19 + deleting: false, 20 + border: true, 21 + shadow: false, 22 + background: true, 23 + onDelete: () => {} 24 + } 25 + }); 26 + </script> 27 + 28 + <Story name="Default" /> 29 + <Story name="No fingerprint" args={{ fingerprint: undefined }} /> 30 + <Story name="Deleting" args={{ deleting: true }} />
+18 -26
web/src/lib/components/settings/KeyCard.svelte
··· 1 1 <script lang="ts"> 2 - import { relativeTime } from "$lib/format"; 3 - import Button from "$lib/components/ui/Button.svelte"; 4 - import Spinner from "$lib/components/ui/Spinner.svelte"; 5 - import KeyIcon from "$icon/key"; 6 - import Trash2 from "$icon/trash-2"; 2 + import Card, { type CardVariants } from "$lib/components/ui/Card.svelte"; 3 + import KeyCardContent from "./KeyCardContent.svelte"; 7 4 8 5 interface Props { 9 6 name: string; ··· 11 8 createdAt: string; 12 9 deleting?: boolean; 13 10 onDelete: () => void; 11 + border?: CardVariants["border"]; 12 + shadow?: CardVariants["shadow"]; 13 + background?: CardVariants["background"]; 14 14 } 15 15 16 - let { name, fingerprint, createdAt, deleting = false, onDelete }: Props = $props(); 16 + let { 17 + name, 18 + fingerprint, 19 + createdAt, 20 + deleting = false, 21 + onDelete, 22 + border = true, 23 + shadow = false, 24 + background = true 25 + }: Props = $props(); 17 26 </script> 18 27 19 - <div class="flex items-center justify-between gap-2 bg-background-default"> 20 - <div class="flex min-w-0 flex-col gap-1"> 21 - <div class="flex items-center gap-2"> 22 - <KeyIcon class="size-4 shrink-0 text-foreground-muted" aria-hidden="true" /> 23 - <span class="font-bold text-foreground-default">{name}</span> 24 - </div> 25 - {#if fingerprint} 26 - <span class="truncate font-mono text-sm text-foreground-subtle">{fingerprint}</span> 27 - {/if} 28 - <span class="text-sm text-foreground-subtle">added {relativeTime(createdAt)}</span> 29 - </div> 30 - <Button 31 - variant="danger" 32 - icon={deleting ? Spinner : Trash2} 33 - disabled={deleting} 34 - onclick={onDelete} 35 - > 36 - Delete 37 - </Button> 38 - </div> 28 + <Card {border} {shadow} {background}> 29 + <KeyCardContent {name} {fingerprint} {createdAt} {deleting} {onDelete} /> 30 + </Card>
+38
web/src/lib/components/settings/KeyCardContent.svelte
··· 1 + <script lang="ts"> 2 + import { relativeTime } from "$lib/format"; 3 + import Button from "$lib/components/ui/Button.svelte"; 4 + import Spinner from "$lib/components/ui/Spinner.svelte"; 5 + import KeyIcon from "$icon/key"; 6 + import Trash2 from "$icon/trash-2"; 7 + 8 + interface Props { 9 + name: string; 10 + fingerprint?: string; 11 + createdAt: string; 12 + deleting?: boolean; 13 + onDelete: () => void; 14 + } 15 + 16 + let { name, fingerprint, createdAt, deleting = false, onDelete }: Props = $props(); 17 + </script> 18 + 19 + <div class="flex items-center justify-between gap-2"> 20 + <div class="flex min-w-0 flex-col gap-1"> 21 + <div class="flex items-center gap-2"> 22 + <KeyIcon class="size-4 shrink-0 text-foreground-muted" aria-hidden="true" /> 23 + <span class="font-bold text-foreground-default">{name}</span> 24 + </div> 25 + {#if fingerprint} 26 + <span class="truncate font-mono text-sm text-foreground-subtle">{fingerprint}</span> 27 + {/if} 28 + <span class="text-sm text-foreground-subtle">added {relativeTime(createdAt)}</span> 29 + </div> 30 + <Button 31 + variant="danger" 32 + icon={deleting ? Spinner : Trash2} 33 + disabled={deleting} 34 + onclick={onDelete} 35 + > 36 + Delete 37 + </Button> 38 + </div>
+17
web/src/lib/components/timeline/TimelineCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + import Card from "$lib/components/ui/Card.svelte"; 4 + 5 + let { header, children }: { header: Snippet; children: Snippet } = $props(); 6 + </script> 7 + 8 + <Card border shadow background padding="none"> 9 + <div 10 + class="flex flex-wrap items-center gap-2 border-b border-border-default px-6 py-3 text-sm text-foreground-muted" 11 + > 12 + {@render header()} 13 + </div> 14 + <div class="px-6 py-4"> 15 + {@render children()} 16 + </div> 17 + </Card>
+38
web/src/lib/components/timeline/TimelineFollowEvent.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import MockAuthProvider from "$lib/components/testing/MockAuthProvider.svelte"; 4 + import TimelineFollowEvent from "./TimelineFollowEvent.svelte"; 5 + import type { FollowEvent } from "$lib/api/lexicons/types/sh/tangled/feed/getTimeline"; 6 + 7 + const event = { 8 + $type: "sh.tangled.feed.getTimeline#followEvent", 9 + cid: "bafyreib2", 10 + uri: "at://did:plc:oky5czdrnfjpqslsw2a5iclo/sh.tangled.graph.follow/3l", 11 + actor: { did: "did:plc:oky5czdrnfjpqslsw2a5iclo", handle: "alice.tngl.sh" }, 12 + subject: { 13 + did: "did:plc:3p7ejjxnufohsygt5vbyxb2i", 14 + handle: "joshka.net", 15 + description: "Rust, TUIs, and the occasional git plumbing yak-shave.", 16 + followersCount: 128, 17 + followsCount: 87 18 + } 19 + } as unknown as FollowEvent; 20 + 21 + const { Story } = defineMeta({ 22 + title: "Timeline/TimelineFollowEvent", 23 + component: TimelineFollowEvent, 24 + tags: ["autodocs"], 25 + args: { 26 + event, 27 + eventAt: "2025-06-02T09:00:00Z" 28 + } 29 + }); 30 + </script> 31 + 32 + <Story name="Default"> 33 + {#snippet template(args)} 34 + <MockAuthProvider> 35 + <TimelineFollowEvent {...args} /> 36 + </MockAuthProvider> 37 + {/snippet} 38 + </Story>
+21
web/src/lib/components/timeline/TimelineFollowEvent.svelte
··· 1 + <script lang="ts"> 2 + import User from "$lib/components/ui/User.svelte"; 3 + import FollowCard from "$lib/components/profile/FollowCard.svelte"; 4 + import TimelineCard from "./TimelineCard.svelte"; 5 + import { relativeTime } from "$lib/format"; 6 + import { toPerson } from "./mappers"; 7 + import type { FollowEvent } from "$lib/api/lexicons/types/sh/tangled/feed/getTimeline"; 8 + 9 + let { event, eventAt }: { event: FollowEvent; eventAt: string } = $props(); 10 + </script> 11 + 12 + <TimelineCard> 13 + {#snippet header()} 14 + <User handle={event.actor.handle} did={event.actor.did} /> 15 + <span>followed</span> 16 + <User handle={event.subject.handle} did={event.subject.did} /> 17 + <span class="text-foreground-subtle">{relativeTime(eventAt)}</span> 18 + {/snippet} 19 + 20 + <FollowCard person={toPerson(event.subject)} border={false} shadow={false} background={false} /> 21 + </TimelineCard>
+56
web/src/lib/components/timeline/TimelineRepoEvent.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import MockAuthProvider from "$lib/components/testing/MockAuthProvider.svelte"; 4 + import TimelineRepoEvent from "./TimelineRepoEvent.svelte"; 5 + import type { RepoEvent } from "$lib/api/lexicons/types/sh/tangled/feed/getTimeline"; 6 + 7 + const owner = { did: "did:plc:3p7ejjxnufohsygt5vbyxb2i", handle: "tangled.org" }; 8 + 9 + const repo = { 10 + did: "did:plc:3p7ejjxnufohsygt5vbyxb2i", 11 + slug: "core", 12 + owner, 13 + createdAt: "2025-06-01T10:30:00Z", 14 + description: "The knot server, appview, and everything that makes tangled tick.", 15 + starCount: 42 16 + }; 17 + 18 + const created = { 19 + $type: "sh.tangled.feed.getTimeline#repoEvent", 20 + cid: "bafyreib2", 21 + uri: "at://did:plc:3p7ejjxnufohsygt5vbyxb2i/sh.tangled.repo/core", 22 + repo 23 + } as unknown as RepoEvent; 24 + 25 + const forked = { 26 + ...created, 27 + repo: { ...repo, slug: "core-fork" }, 28 + source: repo 29 + } as unknown as RepoEvent; 30 + 31 + const { Story } = defineMeta({ 32 + title: "Timeline/TimelineRepoEvent", 33 + component: TimelineRepoEvent, 34 + tags: ["autodocs"], 35 + args: { 36 + event: created, 37 + eventAt: "2025-06-02T09:00:00Z" 38 + } 39 + }); 40 + </script> 41 + 42 + <Story name="Created"> 43 + {#snippet template(args)} 44 + <MockAuthProvider> 45 + <TimelineRepoEvent {...args} /> 46 + </MockAuthProvider> 47 + {/snippet} 48 + </Story> 49 + 50 + <Story name="Forked"> 51 + {#snippet template(args)} 52 + <MockAuthProvider> 53 + <TimelineRepoEvent {...args} event={forked} /> 54 + </MockAuthProvider> 55 + {/snippet} 56 + </Story>
+34
web/src/lib/components/timeline/TimelineRepoEvent.svelte
··· 1 + <script lang="ts"> 2 + import User from "$lib/components/ui/User.svelte"; 3 + import RepoCard from "$lib/components/repo/RepoCard.svelte"; 4 + import TimelineCard from "./TimelineCard.svelte"; 5 + import { relativeTime } from "$lib/format"; 6 + import { toRepoCard, repoHref } from "./mappers"; 7 + import type { RepoEvent } from "$lib/api/lexicons/types/sh/tangled/feed/getTimeline"; 8 + 9 + let { event, eventAt }: { event: RepoEvent; eventAt: string } = $props(); 10 + </script> 11 + 12 + <TimelineCard> 13 + {#snippet header()} 14 + <User handle={event.repo.owner.handle} did={event.repo.owner.did} /> 15 + {#if event.source} 16 + <span>forked</span> 17 + <a href={repoHref(event.source)} class="text-foreground-default" 18 + >{event.source.owner.handle}/{event.source.slug}</a 19 + > 20 + <span>to {event.repo.slug}</span> 21 + {:else} 22 + <span>created {event.repo.slug}</span> 23 + {/if} 24 + <span class="text-foreground-subtle">{relativeTime(eventAt)}</span> 25 + {/snippet} 26 + 27 + <RepoCard 28 + repo={toRepoCard(event.repo)} 29 + border={false} 30 + shadow={false} 31 + background={false} 32 + padding="none" 33 + /> 34 + </TimelineCard>
+41
web/src/lib/components/timeline/TimelineStarEvent.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import MockAuthProvider from "$lib/components/testing/MockAuthProvider.svelte"; 4 + import TimelineStarEvent from "./TimelineStarEvent.svelte"; 5 + import type { StarEvent } from "$lib/api/lexicons/types/sh/tangled/feed/getTimeline"; 6 + 7 + const owner = { did: "did:plc:3p7ejjxnufohsygt5vbyxb2i", handle: "tangled.org" }; 8 + 9 + const event = { 10 + $type: "sh.tangled.feed.getTimeline#starEvent", 11 + cid: "bafyreib2", 12 + uri: "at://did:plc:oky5czdrnfjpqslsw2a5iclo/sh.tangled.feed.star/3l", 13 + actor: { did: "did:plc:oky5czdrnfjpqslsw2a5iclo", handle: "alice.tngl.sh" }, 14 + repo: { 15 + did: "did:plc:3p7ejjxnufohsygt5vbyxb2i", 16 + slug: "core", 17 + owner, 18 + createdAt: "2025-06-01T10:30:00Z", 19 + description: "The knot server, appview, and everything that makes tangled tick.", 20 + starCount: 42 21 + } 22 + } as unknown as StarEvent; 23 + 24 + const { Story } = defineMeta({ 25 + title: "Timeline/TimelineStarEvent", 26 + component: TimelineStarEvent, 27 + tags: ["autodocs"], 28 + args: { 29 + event, 30 + eventAt: "2025-06-02T09:00:00Z" 31 + } 32 + }); 33 + </script> 34 + 35 + <Story name="Default"> 36 + {#snippet template(args)} 37 + <MockAuthProvider> 38 + <TimelineStarEvent {...args} /> 39 + </MockAuthProvider> 40 + {/snippet} 41 + </Story>
+29
web/src/lib/components/timeline/TimelineStarEvent.svelte
··· 1 + <script lang="ts"> 2 + import User from "$lib/components/ui/User.svelte"; 3 + import RepoCard from "$lib/components/repo/RepoCard.svelte"; 4 + import TimelineCard from "./TimelineCard.svelte"; 5 + import { relativeTime } from "$lib/format"; 6 + import { toRepoCard, repoHref } from "./mappers"; 7 + import type { StarEvent } from "$lib/api/lexicons/types/sh/tangled/feed/getTimeline"; 8 + 9 + let { event, eventAt }: { event: StarEvent; eventAt: string } = $props(); 10 + </script> 11 + 12 + <TimelineCard> 13 + {#snippet header()} 14 + <User handle={event.actor.handle} did={event.actor.did} /> 15 + <span>starred</span> 16 + <a href={repoHref(event.repo)} class="text-foreground-default" 17 + >{event.repo.owner.handle}/{event.repo.slug}</a 18 + > 19 + <span class="text-foreground-subtle">{relativeTime(eventAt)}</span> 20 + {/snippet} 21 + 22 + <RepoCard 23 + repo={toRepoCard(event.repo)} 24 + border={false} 25 + shadow={false} 26 + background={false} 27 + padding="none" 28 + /> 29 + </TimelineCard>
+26
web/src/lib/components/timeline/mappers.ts
··· 1 + import { resolve } from "$app/paths"; 2 + import { rkeyFromUri } from "$lib/api/uri"; 3 + import type { RepoCardData, PersonData } from "$lib/components/profile/types"; 4 + import type * as ShTangledRepoDefs from "$lib/api/lexicons/types/sh/tangled/repo/defs"; 5 + import type * as ShTangledActorDefs from "$lib/api/lexicons/types/sh/tangled/actor/defs"; 6 + 7 + export const toRepoCard = (repo: ShTangledRepoDefs.RepoViewBasic): RepoCardData => ({ 8 + name: repo.slug, 9 + repoDid: repo.did, 10 + ownerHandle: repo.owner.handle, 11 + description: repo.description, 12 + createdAt: repo.createdAt, 13 + stars: repo.starCount, 14 + viewerStarRkey: repo.viewer?.star ? rkeyFromUri(repo.viewer.star) : null 15 + }); 16 + 17 + export const toPerson = (profile: ShTangledActorDefs.ProfileViewDetailed): PersonData => ({ 18 + did: profile.did, 19 + handle: profile.handle, 20 + followers: profile.followersCount, 21 + following: profile.followsCount, 22 + viewerFollowRkey: profile.viewer?.following ? rkeyFromUri(profile.viewer.following) : null 23 + }); 24 + 25 + export const repoHref = (repo: ShTangledRepoDefs.RepoViewBasic) => 26 + resolve(`/${repo.owner.handle}/${repo.slug}` as "/");
+39
web/src/lib/components/ui/Card.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Card from "./Card.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/Card", 7 + component: Card, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + border: { control: "boolean" }, 11 + shadow: { control: "boolean" }, 12 + background: { control: "boolean" }, 13 + padding: { control: { type: "select" }, options: ["default", "sm", "none"] } 14 + }, 15 + args: { 16 + border: true, 17 + shadow: false, 18 + background: true, 19 + padding: "default" 20 + } 21 + }); 22 + </script> 23 + 24 + <!-- Card content comes through its `children` snippet, provided here as Story slot content. --> 25 + <Story name="Default">Card content</Story> 26 + <Story name="Elevated" args={{ shadow: true }}>Bordered card with shadow</Story> 27 + <Story name="Plain" args={{ border: false, background: false, shadow: false, padding: "none" }}> 28 + Borderless, shadowless, background-less 29 + </Story> 30 + 31 + <Story name="Variants"> 32 + <div class="flex flex-col gap-4"> 33 + <Card border shadow={false} background>Bordered, no shadow (profile / settings)</Card> 34 + <Card border shadow background>Bordered + shadow (elevated)</Card> 35 + <Card border={false} shadow={false} background={false} padding="none" 36 + >Plain (timeline)</Card 37 + > 38 + </div> 39 + </Story>
+66
web/src/lib/components/ui/Card.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const card = tv({ 5 + base: "rounded", 6 + variants: { 7 + border: { 8 + true: "border border-border-default", 9 + false: "" 10 + }, 11 + shadow: { 12 + true: "shadow-xs", 13 + false: "" 14 + }, 15 + background: { 16 + true: "bg-background-default", 17 + false: "" 18 + }, 19 + padding: { 20 + default: "px-6 py-4", 21 + sm: "p-4", 22 + none: "" 23 + } 24 + }, 25 + defaultVariants: { 26 + border: true, 27 + shadow: false, 28 + background: true, 29 + padding: "default" 30 + } 31 + }); 32 + 33 + export type CardVariants = VariantProps<typeof card>; 34 + </script> 35 + 36 + <script lang="ts"> 37 + import type { Snippet } from "svelte"; 38 + import type { HTMLAttributes } from "svelte/elements"; 39 + 40 + interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "class" | "children"> { 41 + border?: CardVariants["border"]; 42 + shadow?: CardVariants["shadow"]; 43 + background?: CardVariants["background"]; 44 + padding?: CardVariants["padding"]; 45 + class?: string; 46 + children?: Snippet; 47 + } 48 + 49 + let { 50 + border = true, 51 + shadow = false, 52 + background = true, 53 + padding = "default", 54 + class: className, 55 + children, 56 + ...rest 57 + }: Props = $props(); 58 + 59 + const classes = $derived(card({ border, shadow, background, padding, class: className })); 60 + </script> 61 + 62 + <div class={classes} {...rest}> 63 + {#if children} 64 + {@render children()} 65 + {/if} 66 + </div>
+10 -63
web/src/routes/timeline/+page.svelte
··· 4 4 import Button from "$lib/components/ui/Button.svelte"; 5 5 import ButtonGroup from "$lib/components/ui/ButtonGroup.svelte"; 6 6 import EmptyState from "$lib/components/ui/EmptyState.svelte"; 7 - import User from "$lib/components/ui/User.svelte"; 8 - import RepoCard from "$lib/components/repo/RepoCard.svelte"; 9 - import FollowCard from "$lib/components/profile/FollowCard.svelte"; 10 - import { rkeyFromUri } from "$lib/api/uri"; 11 - import { relativeTime } from "$lib/format"; 12 - import type { RepoCardData, PersonData } from "$lib/components/profile/types"; 13 - import type * as ShTangledRepoDefs from "$lib/api/lexicons/types/sh/tangled/repo/defs"; 14 - import type * as ShTangledActorDefs from "$lib/api/lexicons/types/sh/tangled/actor/defs"; 7 + import TimelineRepoEvent from "$lib/components/timeline/TimelineRepoEvent.svelte"; 8 + import TimelineStarEvent from "$lib/components/timeline/TimelineStarEvent.svelte"; 9 + import TimelineFollowEvent from "$lib/components/timeline/TimelineFollowEvent.svelte"; 15 10 import type { PageData } from "./$types"; 16 11 17 12 let { data }: { data: PageData } = $props(); 18 - 19 - const toRepoCard = (repo: ShTangledRepoDefs.RepoViewBasic): RepoCardData => ({ 20 - name: repo.slug, 21 - repoDid: repo.did, 22 - ownerHandle: repo.owner.handle, 23 - description: repo.description, 24 - createdAt: repo.createdAt, 25 - stars: repo.starCount, 26 - viewerStarRkey: repo.viewer?.star ? rkeyFromUri(repo.viewer.star) : null 27 - }); 28 - 29 - const toPerson = (profile: ShTangledActorDefs.ProfileViewDetailed): PersonData => ({ 30 - did: profile.did, 31 - handle: profile.handle, 32 - avatar: profile.avatar, 33 - followers: profile.followersCount, 34 - following: profile.followsCount, 35 - viewerFollowRkey: profile.viewer?.following ? rkeyFromUri(profile.viewer.following) : null 36 - }); 37 - 38 - const repoHref = (repo: ShTangledRepoDefs.RepoViewBasic) => 39 - resolve(`/${repo.owner.handle}/${repo.slug}` as "/"); 40 13 </script> 41 14 42 15 <svelte:head> ··· 65 38 66 39 {#each data.feed as item (item.event.uri)} 67 40 {@const event = item.event} 68 - <div class="flex flex-col gap-2"> 69 - <div class="flex flex-wrap items-center gap-2 text-sm text-foreground-muted"> 70 - {#if event.$type === "sh.tangled.feed.getTimeline#repoEvent"} 71 - <User handle={event.repo.owner.handle} src={event.repo.owner.avatar} size="sm" /> 72 - {#if event.source} 73 - <span>forked</span> 74 - <a href={repoHref(event.source)} class="text-foreground-default" 75 - >{event.source.owner.handle}/{event.source.slug}</a 76 - > 77 - <span>to {event.repo.slug}</span> 78 - {:else} 79 - <span>created {event.repo.slug}</span> 80 - {/if} 81 - {:else if event.$type === "sh.tangled.feed.getTimeline#starEvent"} 82 - <User handle={event.actor.handle} src={event.actor.avatar} size="sm" /> 83 - <span>starred</span> 84 - <a href={repoHref(event.repo)} class="text-foreground-default" 85 - >{event.repo.owner.handle}/{event.repo.slug}</a 86 - > 87 - {:else if event.$type === "sh.tangled.feed.getTimeline#followEvent"} 88 - <User handle={event.actor.handle} src={event.actor.avatar} size="sm" /> 89 - <span>followed</span> 90 - <User handle={event.subject.handle} src={event.subject.avatar} size="sm" /> 91 - {/if} 92 - <span class="text-foreground-subtle">{relativeTime(item.eventAt)}</span> 93 - </div> 94 - 95 - {#if event.$type === "sh.tangled.feed.getTimeline#followEvent"} 96 - <FollowCard person={toPerson(event.subject)} /> 97 - {:else} 98 - <RepoCard repo={toRepoCard(event.repo)} /> 99 - {/if} 100 - </div> 41 + {#if event.$type === "sh.tangled.feed.getTimeline#repoEvent"} 42 + <TimelineRepoEvent {event} eventAt={item.eventAt} /> 43 + {:else if event.$type === "sh.tangled.feed.getTimeline#starEvent"} 44 + <TimelineStarEvent {event} eventAt={item.eventAt} /> 45 + {:else if event.$type === "sh.tangled.feed.getTimeline#followEvent"} 46 + <TimelineFollowEvent {event} eventAt={item.eventAt} /> 47 + {/if} 101 48 {:else} 102 49 <EmptyState 103 50 message={data.followingOnly ? "Nobody you follow has been busy." : "Nothing here yet."}
-2
web/src/routes/timeline/+page.ts
··· 11 11 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 12 12 const viewer = parent.auth?.did; 13 13 14 - // the following feed needs a viewer to resolve the followed set; bobbin 400s without one 15 14 const followingOnly = event.url.searchParams.get("following") === "true" && viewer !== undefined; 16 15 17 16 try { 18 - // output key is `feed`, not `items`, so the pagination.ts helpers don't apply 19 17 const { feed } = await jsonGet<ShTangledFeedGetTimeline.$output>( 20 18 ctx, 21 19 "sh.tangled.feed.getTimeline",