This repository has no description
0

Configure Feed

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

web/components: add single issue page

chunked up into several components

author
oppiliappan
committer
dawn
date (Jul 31, 2026, 10:54 PM +0300) commit 432d6b1c parent 55495f77 change-id otmwpvkq
+711
+35
web/src/lib/components/repo/issues/IssueBody.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import IssueBody from "./IssueBody.svelte"; 4 + 5 + // normally produced by $lib/markup (sanitised); inlined here for the story 6 + const sampleHtml = `<p>I'd like a <code>/trending</code> view that shows more than the five repositories currently listed in the timeline sidebar.</p> 7 + <ul><li>discover active projects</li><li>understand what's happening</li></ul> 8 + <pre><code>curl https://tangled.org/trending</code></pre>`; 9 + 10 + const { Story } = defineMeta({ 11 + title: "Repo/Issues/IssueBody", 12 + component: IssueBody, 13 + tags: ["autodocs"], 14 + argTypes: { 15 + body: { control: "text" }, 16 + bodyHtml: { control: "text" } 17 + }, 18 + args: { 19 + body: "I'd like a `/trending` view.", 20 + bodyHtml: sampleHtml 21 + } 22 + }); 23 + </script> 24 + 25 + <!-- rendered markdown (bodyHtml present) --> 26 + <Story name="Rendered" /> 27 + 28 + <!-- fallback to raw source when rendering was skipped (body too large / null html) --> 29 + <Story 30 + name="Plain fallback" 31 + args={{ body: "raw markdown that was not rendered\n\n- item one\n- item two", bodyHtml: null }} 32 + /> 33 + 34 + <!-- empty body renders nothing --> 35 + <Story name="Empty" args={{ body: "", bodyHtml: null }} />
+17
web/src/lib/components/repo/issues/IssueBody.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + // raw markdown source, used as a fallback when rendering was skipped 4 + body: string; 5 + // sanitised html from $lib/markup, or null if the body was empty/too large 6 + bodyHtml: string | null; 7 + } 8 + 9 + let { body, bodyHtml }: Props = $props(); 10 + </script> 11 + 12 + {#if bodyHtml} 13 + <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in $lib/markup --> 14 + <article class="markup mt-4">{@html bodyHtml}</article> 15 + {:else if body} 16 + <article class="mt-4 whitespace-pre-wrap text-foreground-default">{body}</article> 17 + {/if}
+27
web/src/lib/components/repo/issues/IssueHeader.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import IssueHeader from "./IssueHeader.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "Repo/Issues/IssueHeader", 7 + component: IssueHeader, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + title: { control: "text" }, 11 + rkey: { control: "text" } 12 + }, 13 + args: { 14 + title: "Add a full trending repositories view", 15 + rkey: "3mrkgcxb4m422" 16 + } 17 + }); 18 + </script> 19 + 20 + <Story name="Default" /> 21 + <Story 22 + name="Long title" 23 + args={{ 24 + title: 25 + "Investigate intermittent failures when pushing large packfiles over the git smart protocol" 26 + }} 27 + />
+16
web/src/lib/components/repo/issues/IssueHeader.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + title: string; 4 + // short identifier shown after the title, i.e. the issue record's rkey 5 + rkey: string; 6 + } 7 + 8 + let { title, rkey }: Props = $props(); 9 + </script> 10 + 11 + <header class="pb-2"> 12 + <h1 class="text-2xl"> 13 + {title} 14 + <span class="text-foreground-subtle">#{rkey}</span> 15 + </h1> 16 + </header>
+28
web/src/lib/components/repo/issues/IssueInfoBar.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import IssueInfoBar from "./IssueInfoBar.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "Repo/Issues/IssueInfoBar", 7 + component: IssueInfoBar, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + state: { 11 + control: { type: "inline-radio" }, 12 + options: ["open", "closed"] 13 + }, 14 + authorHandle: { control: "text" }, 15 + authorDid: { control: "text" }, 16 + createdAt: { control: "text" } 17 + }, 18 + args: { 19 + state: "open", 20 + authorHandle: "joshka.net", 21 + authorDid: "did:plc:3p7ejjxnufohsygt5vbyxb2i", 22 + createdAt: "2025-06-01T10:30:00Z" 23 + } 24 + }); 25 + </script> 26 + 27 + <Story name="Open" /> 28 + <Story name="Closed" args={{ state: "closed" }} />
+35
web/src/lib/components/repo/issues/IssueInfoBar.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + import IssueStatePill from "./IssueStatePill.svelte"; 4 + import User from "$lib/components/ui/User.svelte"; 5 + import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 6 + 7 + interface Props { 8 + state: "open" | "closed"; 9 + authorHandle: string; 10 + authorDid: string; 11 + createdAt: string; 12 + actions?: Snippet; 13 + } 14 + 15 + let { state, authorHandle, authorDid, createdAt, actions }: Props = $props(); 16 + </script> 17 + 18 + <div class="flex flex-wrap items-center gap-x-1 gap-y-1.5 text-sm text-foreground-subtle"> 19 + <IssueStatePill {state} /> 20 + 21 + <span class="ml-1 flex items-center gap-1"> 22 + opened by 23 + <User handle={authorHandle} did={authorDid} /> 24 + </span> 25 + 26 + <span class="before:mr-1 before:content-['·']"> 27 + <TimeAgo value={createdAt} /> 28 + </span> 29 + 30 + {#if actions} 31 + <div class="ml-auto flex items-center gap-2"> 32 + {@render actions()} 33 + </div> 34 + {/if} 35 + </div>
+13
web/src/lib/components/repo/issues/IssueLabelPanel.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import IssueLabelPanel from "./IssueLabelPanel.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "Repo/Issues/IssueLabelPanel", 7 + component: IssueLabelPanel, 8 + tags: ["autodocs"] 9 + }); 10 + </script> 11 + 12 + <!-- stub component; renders placeholder text until labels/area/assignee are wired up --> 13 + <Story name="Default" />
+6
web/src/lib/components/repo/issues/IssueLabelPanel.svelte
··· 1 + <script lang="ts"> 2 + </script> 3 + 4 + <aside class="flex flex-col gap-6 text-sm text-foreground-subtle"> 5 + <div>TODO: label panel</div> 6 + </aside>
+41
web/src/lib/components/repo/issues/IssueList.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import IssueList from "./IssueList.svelte"; 4 + import type { IssueSummary } from "$lib/components/repo/types"; 5 + 6 + const make = ( 7 + rkey: string, 8 + title: string, 9 + state: IssueSummary["state"], 10 + commentCount: number 11 + ): IssueSummary => ({ 12 + uri: `at://did:plc:3p7ejjxnufohsygt5vbyxb2i/sh.tangled.repo.issue/${rkey}`, 13 + rkey, 14 + title, 15 + state, 16 + authorHandle: "joshka.net", 17 + authorDid: "did:plc:3p7ejjxnufohsygt5vbyxb2i", 18 + createdAt: "2025-06-01T10:30:00Z", 19 + commentCount 20 + }); 21 + 22 + const issues: IssueSummary[] = [ 23 + make("3mrkgcxb4m422", "Add a full trending repositories view", "open", 3), 24 + make("3mrkgcxb4m423", "Packfile push times out over the smart protocol", "open", 0), 25 + make("3mrkgcxb4m424", "Dark mode contrast on the issue label panel", "closed", 7) 26 + ]; 27 + 28 + const { Story } = defineMeta({ 29 + title: "Repo/Issues/IssueList", 30 + component: IssueList, 31 + tags: ["autodocs"], 32 + args: { 33 + ownerHandle: "tangled.org", 34 + repoName: "core", 35 + issues 36 + } 37 + }); 38 + </script> 39 + 40 + <Story name="Default" /> 41 + <Story name="Empty" args={{ issues: [] }} />
+23
web/src/lib/components/repo/issues/IssueList.svelte
··· 1 + <script lang="ts"> 2 + import EmptyState from "$lib/components/ui/EmptyState.svelte"; 3 + import IssueCard from "./IssueCard.svelte"; 4 + import type { IssueSummary } from "$lib/components/repo/types"; 5 + 6 + interface Props { 7 + ownerHandle: string; 8 + repoName: string; 9 + issues: IssueSummary[]; 10 + } 11 + 12 + let { ownerHandle, repoName, issues }: Props = $props(); 13 + </script> 14 + 15 + {#if issues.length === 0} 16 + <EmptyState message="No issues yet." /> 17 + {:else} 18 + <div class="flex flex-col gap-2"> 19 + {#each issues as issue (issue.rkey)} 20 + <IssueCard {ownerHandle} {repoName} {issue} /> 21 + {/each} 22 + </div> 23 + {/if}
+17
web/src/lib/components/repo/issues/IssueSearch.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import IssueSearch from "./IssueSearch.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "Repo/Issues/IssueSearch", 7 + component: IssueSearch, 8 + tags: ["autodocs"] 9 + }); 10 + </script> 11 + 12 + <!-- seeds its value from the url ?q= param; the clear button appears once typed into --> 13 + <Story name="Default"> 14 + <div class="max-w-sm"> 15 + <IssueSearch /> 16 + </div> 17 + </Story>
+42
web/src/lib/components/repo/issues/IssueSearch.svelte
··· 1 + <script lang="ts"> 2 + import { page } from "$app/state"; 3 + import Search from "$icon/search"; 4 + import X from "$icon/x"; 5 + 6 + let form: HTMLFormElement; 7 + // seed from the url so the query (and the clear button) survive a submit 8 + let value = $state(page.url.searchParams.get("q") ?? ""); 9 + 10 + const clear = () => { 11 + value = ""; 12 + form.requestSubmit(); 13 + }; 14 + </script> 15 + 16 + <form bind:this={form} class="flex" method="GET"> 17 + <!-- TODO: wire up search query submission --> 18 + <div class="relative flex w-full items-center"> 19 + <Search 20 + class="pointer-events-none absolute left-2 size-4 text-foreground-placeholder" 21 + aria-hidden="true" 22 + /> 23 + <input 24 + bind:value 25 + class="h-full w-full rounded border border-border-default bg-background-default py-1.5 pr-8 pl-8 text-sm outline-none focus:border-border-strong" 26 + type="text" 27 + name="q" 28 + placeholder="Search issues..." 29 + aria-label="Search issues" 30 + /> 31 + {#if value} 32 + <button 33 + type="button" 34 + onclick={clear} 35 + class="absolute right-2 flex items-center text-foreground-placeholder transition-colors hover:text-foreground-default" 36 + aria-label="Clear search" 37 + > 38 + <X class="size-4" aria-hidden="true" /> 39 + </button> 40 + {/if} 41 + </div> 42 + </form>
+22
web/src/lib/components/repo/issues/IssueStatePill.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import IssueStatePill from "./IssueStatePill.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "Repo/Issues/IssueStatePill", 7 + component: IssueStatePill, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + state: { 11 + control: { type: "inline-radio" }, 12 + options: ["open", "closed"] 13 + } 14 + }, 15 + args: { 16 + state: "open" 17 + } 18 + }); 19 + </script> 20 + 21 + <Story name="Open" /> 22 + <Story name="Closed" args={{ state: "closed" }} />
+38
web/src/lib/components/repo/issues/IssueStatePill.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const issueStatePill = tv({ 5 + base: "inline-flex items-center gap-1.5 rounded px-2 py-1 text-sm text-foreground-on-emphasis select-none", 6 + variants: { 7 + state: { 8 + open: "bg-background-success-emphasis", 9 + closed: "bg-background-neutral-emphasis" 10 + } 11 + }, 12 + defaultVariants: { 13 + state: "open" 14 + } 15 + }); 16 + 17 + export type IssueStatePillVariants = VariantProps<typeof issueStatePill>; 18 + </script> 19 + 20 + <script lang="ts"> 21 + import CircleDot from "$icon/circle-dot"; 22 + import Ban from "$icon/ban"; 23 + 24 + interface Props { 25 + state: IssueStatePillVariants["state"]; 26 + class?: string; 27 + } 28 + 29 + let { state = "open", class: className }: Props = $props(); 30 + 31 + const Icon = $derived(state === "closed" ? Ban : CircleDot); 32 + const label = $derived(state === "closed" ? "Closed" : "Open"); 33 + </script> 34 + 35 + <span class={issueStatePill({ state, class: className })}> 36 + <Icon class="size-3 shrink-0" aria-hidden="true" /> 37 + {label} 38 + </span>
+28
web/src/lib/components/repo/issues/IssueToolbar.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import IssueToolbar from "./IssueToolbar.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "Repo/Issues/IssueToolbar", 7 + component: IssueToolbar, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + state: { 11 + control: { type: "inline-radio" }, 12 + options: ["open", "closed"] 13 + }, 14 + openCount: { control: "number" }, 15 + closedCount: { control: "number" } 16 + }, 17 + args: { 18 + ownerHandle: "tangled.org", 19 + repoName: "core", 20 + state: "open", 21 + openCount: 285, 22 + closedCount: 128 23 + } 24 + }); 25 + </script> 26 + 27 + <Story name="Open" /> 28 + <Story name="Closed" args={{ state: "closed" }} />
+64
web/src/lib/components/repo/issues/IssueToolbar.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import CircleDot from "$icon/circle-dot"; 4 + import CircleX from "$icon/circle-x"; 5 + import Plus from "$icon/plus"; 6 + import Button from "$lib/components/ui/Button.svelte"; 7 + import ButtonGroup from "$lib/components/ui/ButtonGroup.svelte"; 8 + import IssueSearch from "./IssueSearch.svelte"; 9 + 10 + interface Props { 11 + ownerHandle: string; 12 + repoName: string; 13 + state: "open" | "closed"; 14 + openCount: number; 15 + closedCount: number; 16 + } 17 + 18 + let { ownerHandle, repoName, state, openCount, closedCount }: Props = $props(); 19 + 20 + const base = $derived(`/${ownerHandle}/${repoName}/issues`); 21 + </script> 22 + 23 + <!-- 24 + grid mirrors the appview: [group | search | new]. on mobile the search spans the 25 + full width on its own row while the group and new button share the row below it. 26 + --> 27 + <div class="grid grid-cols-[auto_1fr_auto] gap-2"> 28 + <div class="col-span-3 sm:col-span-1 sm:col-start-2"> 29 + <IssueSearch /> 30 + </div> 31 + 32 + <ButtonGroup class="sm:row-start-1"> 33 + <!-- TODO: wire up state filter navigation --> 34 + <Button 35 + href={resolve(`${base}?state=open` as "/")} 36 + variant={state === "open" ? "default" : "ghost"} 37 + size="sm" 38 + icon={CircleDot} 39 + > 40 + <span class="hidden sm:inline">{openCount}</span> 41 + Open 42 + </Button> 43 + <Button 44 + href={resolve(`${base}?state=closed` as "/")} 45 + variant={state === "closed" ? "default" : "ghost"} 46 + size="sm" 47 + icon={CircleX} 48 + > 49 + <span class="hidden sm:inline">{closedCount}</span> 50 + Closed 51 + </Button> 52 + </ButtonGroup> 53 + 54 + <!-- TODO: point at the new issue route once it exists --> 55 + <Button 56 + href={resolve(`${base}/new` as "/")} 57 + variant="primary" 58 + size="sm" 59 + icon={Plus} 60 + class="col-start-3 row-start-2 sm:row-start-1" 61 + > 62 + New 63 + </Button> 64 + </div>
+177
web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte
··· 1 + <script lang="ts"> 2 + import IssueHeader from "$lib/components/repo/issues/IssueHeader.svelte"; 3 + import IssueInfoBar from "$lib/components/repo/issues/IssueInfoBar.svelte"; 4 + import IssueBody from "$lib/components/repo/issues/IssueBody.svelte"; 5 + import IssueLabelPanel from "$lib/components/repo/issues/IssueLabelPanel.svelte"; 6 + import CommentList from "$lib/components/comment/CommentList.svelte"; 7 + import CommentBox from "$lib/components/comment/CommentBox.svelte"; 8 + import SignupPrompt from "$lib/components/ui/SignupPrompt.svelte"; 9 + import IssueForm from "$lib/components/repo/issues/IssueForm.svelte"; 10 + import ErrorAlert from "$lib/components/ui/Error.svelte"; 11 + import Pencil from "$icon/pencil"; 12 + import Trash2 from "$icon/trash-2"; 13 + import { goto } from "$app/navigation"; 14 + import { resolve } from "$app/paths"; 15 + import { renderMarkup } from "$lib/markup"; 16 + import { deleteIssue } from "$lib/api/issue"; 17 + import type { IssueRecord, RecordView } from "$lib/api/records"; 18 + import type { ThreadInput } from "$lib/components/comment/comments"; 19 + import { getAuth } from "$lib/auth.svelte"; 20 + 21 + let { data } = $props(); 22 + 23 + const auth = getAuth(); 24 + 25 + let issue = $derived(data.issue); 26 + 27 + const isAuthor = $derived(auth.currentUser?.did === issue.authorDid); 28 + const issuesBase = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/issues`); 29 + 30 + let editing = $state(false); 31 + let deleting = $state(false); 32 + let deleteError = $state<string | null>(null); 33 + 34 + let comments = $derived(data.comments); 35 + 36 + // optimistic update 37 + const insertComment = ({ comment, replyTo }: ThreadInput) => { 38 + if (replyTo === null) { 39 + comments = [...comments, { self: comment, replies: [] }]; 40 + } else { 41 + comments = comments.map((thread) => 42 + thread.self.uri === replyTo ? { ...thread, replies: [...thread.replies, comment] } : thread 43 + ); 44 + } 45 + }; 46 + 47 + const updateComment = ({ comment }: ThreadInput) => { 48 + comments = comments.map((thread) => ({ 49 + self: thread.self.uri === comment.uri ? comment : thread.self, 50 + replies: thread.replies.map((reply) => (reply.uri === comment.uri ? comment : reply)) 51 + })); 52 + }; 53 + 54 + const removeComment = (uri: string) => { 55 + comments = comments 56 + .filter((thread) => thread.self.uri !== uri) 57 + .map((thread) => ({ 58 + ...thread, 59 + replies: thread.replies.filter((reply) => reply.uri !== uri) 60 + })); 61 + }; 62 + 63 + const handleSaved = async (saved: RecordView<IssueRecord>) => { 64 + const { title, body = "" } = saved.value; 65 + const bodyHtml = body ? await renderMarkup(body, data.markup).catch(() => null) : null; 66 + issue = { ...issue, title, body, bodyHtml }; 67 + editing = false; 68 + }; 69 + 70 + const handleDelete = async () => { 71 + const agent = auth.agent; 72 + if (!agent || deleting) return; 73 + if (!confirm("Delete this issue? This cannot be undone.")) return; 74 + deleting = true; 75 + deleteError = null; 76 + try { 77 + await deleteIssue(agent, issue.rkey); 78 + await goto(resolve(issuesBase as "/")); 79 + } catch (err) { 80 + deleteError = err instanceof Error ? err.message : "Failed to delete issue"; 81 + deleting = false; 82 + } 83 + }; 84 + </script> 85 + 86 + <svelte:head> 87 + <title 88 + >{issue.title} &middot; Issue #{issue.rkey} &middot; {data.repo.ownerHandle}/{data.repo.name} 89 + &middot; Tangled</title 90 + > 91 + </svelte:head> 92 + 93 + {#snippet issueActions()} 94 + <button 95 + type="button" 96 + aria-label="Edit issue" 97 + class="cursor-pointer text-foreground-subtle hover:text-foreground-default" 98 + onclick={() => (editing = true)} 99 + > 100 + <Pencil class="size-3" /> 101 + </button> 102 + <button 103 + type="button" 104 + aria-label="Delete issue" 105 + disabled={deleting} 106 + class="cursor-pointer text-foreground-danger hover:text-foreground-danger-strong disabled:opacity-50" 107 + onclick={handleDelete} 108 + > 109 + <Trash2 class="size-3" /> 110 + </button> 111 + {/snippet} 112 + 113 + <div class="mt-2 grid grid-cols-1 gap-4 lg:grid-cols-10"> 114 + <!-- conversation --> 115 + <div class="lg:col-span-8"> 116 + <section class="rounded bg-background-default px-6 py-4 text-foreground-default shadow-sm"> 117 + {#if editing} 118 + <IssueForm 119 + mode="edit" 120 + repoDid={data.repo.repoDid ?? ""} 121 + markup={data.markup} 122 + rkey={issue.rkey} 123 + createdAt={issue.createdAt} 124 + title={issue.title} 125 + body={issue.body} 126 + onsaved={handleSaved} 127 + oncancel={() => (editing = false)} 128 + /> 129 + {:else} 130 + <IssueHeader title={issue.title} rkey={issue.rkey} /> 131 + <IssueInfoBar 132 + state={issue.state} 133 + authorHandle={issue.authorHandle} 134 + authorDid={issue.authorDid} 135 + createdAt={issue.createdAt} 136 + actions={isAuthor ? issueActions : undefined} 137 + /> 138 + <IssueBody body={issue.body} bodyHtml={issue.bodyHtml} /> 139 + {#if deleteError} 140 + <div class="mt-3"> 141 + <ErrorAlert label={deleteError} /> 142 + </div> 143 + {/if} 144 + {/if} 145 + </section> 146 + 147 + <div class="mt-4 flex flex-col gap-4"> 148 + <CommentList 149 + threads={comments} 150 + subjectUri={issue.uri} 151 + subjectCid={issue.cid} 152 + markup={data.markup} 153 + onsubmitted={insertComment} 154 + onedited={updateComment} 155 + ondeleted={removeComment} 156 + /> 157 + 158 + {#if auth.currentUser} 159 + <CommentBox 160 + subjectUri={issue.uri} 161 + subjectCid={issue.cid} 162 + authorDid={auth.currentUser.did} 163 + authorHandle={auth.currentUser.handle} 164 + markup={data.markup} 165 + onsubmitted={insertComment} 166 + /> 167 + {:else} 168 + <SignupPrompt /> 169 + {/if} 170 + </div> 171 + </div> 172 + 173 + <!-- label panel --> 174 + <div class="lg:col-span-2"> 175 + <IssueLabelPanel /> 176 + </div> 177 + </div>
+82
web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts
··· 1 + import { error } from "@sveltejs/kit"; 2 + import { createBobbinClient } from "$lib/api/client"; 3 + import { IdentityCache } from "$lib/api/identity"; 4 + import { getIssue, listComments, listIssueStates } from "$lib/api/records"; 5 + import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 6 + import { renderMarkup } from "$lib/markup"; 7 + import { buildCommentThreads, type ThreadInput } from "$lib/components/comment/comments"; 8 + import type { PageLoad } from "./$types"; 9 + 10 + export const load: PageLoad = async (event) => { 11 + const parent = await event.parent(); 12 + const uri = decodeURIComponent(event.params.aturi); 13 + 14 + if (!uri.startsWith("at://")) error(404, "Issue not found"); 15 + 16 + const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 17 + 18 + const record = await getIssue(ctx, uri).catch(() => null); 19 + if (!record) error(404, "Issue not found"); 20 + 21 + const authorDid = didFromUri(record.uri); 22 + const identities = new IdentityCache(ctx); 23 + const markupOpts = { 24 + repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, 25 + ref: parent.repo.defaultBranch, 26 + host: event.url.host 27 + }; 28 + 29 + const [author, states, comments] = await Promise.all([ 30 + identities.resolve(authorDid).catch(() => null), 31 + listIssueStates(ctx, record.uri, { limit: 1, order: "desc" }).catch(() => null), 32 + listComments(ctx, record.uri, { order: "asc", limit: 100 }).catch(() => null) 33 + ]); 34 + 35 + const latest = states?.items[0]?.value.state; 36 + const state = latest?.endsWith(".closed") ? ("closed" as const) : ("open" as const); 37 + 38 + const body = record.value.body ?? ""; 39 + const bodyHtml = body ? await renderMarkup(body, markupOpts) : null; 40 + 41 + const commentItems = comments?.items ?? []; 42 + const threadInputs: ThreadInput[] = await Promise.all( 43 + commentItems.map(async (item): Promise<ThreadInput> => { 44 + const commentDid = didFromUri(item.uri); 45 + const commentBody = item.value.body?.text ?? ""; 46 + const [doc, commentBodyHtml] = await Promise.all([ 47 + identities.resolve(commentDid).catch(() => null), 48 + commentBody ? renderMarkup(commentBody, markupOpts) : Promise.resolve(null) 49 + ]); 50 + return { 51 + comment: { 52 + uri: item.uri, 53 + cid: item.cid, 54 + rkey: rkeyFromUri(item.uri), 55 + authorDid: commentDid, 56 + authorHandle: doc?.handle ?? commentDid, 57 + createdAt: item.value.createdAt, 58 + body: commentBody, 59 + bodyHtml: commentBodyHtml 60 + }, 61 + replyTo: item.value.replyTo?.uri ?? null 62 + }; 63 + }) 64 + ); 65 + 66 + return { 67 + issue: { 68 + uri: record.uri, 69 + cid: record.cid, 70 + rkey: rkeyFromUri(record.uri), 71 + title: record.value.title, 72 + body, 73 + bodyHtml, 74 + state, 75 + authorDid, 76 + authorHandle: author?.handle ?? authorDid, 77 + createdAt: record.value.createdAt 78 + }, 79 + comments: buildCommentThreads(threadInputs), 80 + markup: markupOpts 81 + }; 82 + };