diff --git a/web/src/lib/components/repo/issues/IssueBody.stories.svelte b/web/src/lib/components/repo/issues/IssueBody.stories.svelte
new file mode 100644
index 00000000..87d7b9f9
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueBody.stories.svelte
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/repo/issues/IssueBody.svelte b/web/src/lib/components/repo/issues/IssueBody.svelte
new file mode 100644
index 00000000..5fd52ee1
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueBody.svelte
@@ -0,0 +1,17 @@
+
+
+{#if bodyHtml}
+
+ {@html bodyHtml}
+{:else if body}
+ {body}
+{/if}
diff --git a/web/src/lib/components/repo/issues/IssueHeader.stories.svelte b/web/src/lib/components/repo/issues/IssueHeader.stories.svelte
new file mode 100644
index 00000000..6b7fdae5
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueHeader.stories.svelte
@@ -0,0 +1,27 @@
+
+
+
+
diff --git a/web/src/lib/components/repo/issues/IssueHeader.svelte b/web/src/lib/components/repo/issues/IssueHeader.svelte
new file mode 100644
index 00000000..5a176365
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueHeader.svelte
@@ -0,0 +1,16 @@
+
+
+
+
+ {title}
+ #{rkey}
+
+
diff --git a/web/src/lib/components/repo/issues/IssueInfoBar.stories.svelte b/web/src/lib/components/repo/issues/IssueInfoBar.stories.svelte
new file mode 100644
index 00000000..dd66c199
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueInfoBar.stories.svelte
@@ -0,0 +1,28 @@
+
+
+
+
diff --git a/web/src/lib/components/repo/issues/IssueInfoBar.svelte b/web/src/lib/components/repo/issues/IssueInfoBar.svelte
new file mode 100644
index 00000000..5316c496
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueInfoBar.svelte
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+ opened by
+
+
+
+
+
+
+
+ {#if actions}
+
+ {@render actions()}
+
+ {/if}
+
diff --git a/web/src/lib/components/repo/issues/IssueLabelPanel.stories.svelte b/web/src/lib/components/repo/issues/IssueLabelPanel.stories.svelte
new file mode 100644
index 00000000..3b035df3
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueLabelPanel.stories.svelte
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/web/src/lib/components/repo/issues/IssueLabelPanel.svelte b/web/src/lib/components/repo/issues/IssueLabelPanel.svelte
new file mode 100644
index 00000000..59a20321
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueLabelPanel.svelte
@@ -0,0 +1,6 @@
+
+
+
diff --git a/web/src/lib/components/repo/issues/IssueList.stories.svelte b/web/src/lib/components/repo/issues/IssueList.stories.svelte
new file mode 100644
index 00000000..e807c130
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueList.stories.svelte
@@ -0,0 +1,41 @@
+
+
+
+
diff --git a/web/src/lib/components/repo/issues/IssueList.svelte b/web/src/lib/components/repo/issues/IssueList.svelte
new file mode 100644
index 00000000..5a17a998
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueList.svelte
@@ -0,0 +1,23 @@
+
+
+{#if issues.length === 0}
+
+{:else}
+
+ {#each issues as issue (issue.rkey)}
+
+ {/each}
+
+{/if}
diff --git a/web/src/lib/components/repo/issues/IssueSearch.stories.svelte b/web/src/lib/components/repo/issues/IssueSearch.stories.svelte
new file mode 100644
index 00000000..14c31f9a
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueSearch.stories.svelte
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/repo/issues/IssueSearch.svelte b/web/src/lib/components/repo/issues/IssueSearch.svelte
new file mode 100644
index 00000000..9b7bbfdc
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueSearch.svelte
@@ -0,0 +1,42 @@
+
+
+
diff --git a/web/src/lib/components/repo/issues/IssueStatePill.stories.svelte b/web/src/lib/components/repo/issues/IssueStatePill.stories.svelte
new file mode 100644
index 00000000..c3360fb4
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueStatePill.stories.svelte
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/web/src/lib/components/repo/issues/IssueStatePill.svelte b/web/src/lib/components/repo/issues/IssueStatePill.svelte
new file mode 100644
index 00000000..adce3822
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueStatePill.svelte
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+ {label}
+
diff --git a/web/src/lib/components/repo/issues/IssueToolbar.stories.svelte b/web/src/lib/components/repo/issues/IssueToolbar.stories.svelte
new file mode 100644
index 00000000..f3402379
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueToolbar.stories.svelte
@@ -0,0 +1,28 @@
+
+
+
+
diff --git a/web/src/lib/components/repo/issues/IssueToolbar.svelte b/web/src/lib/components/repo/issues/IssueToolbar.svelte
new file mode 100644
index 00000000..24c527a8
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueToolbar.svelte
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte
new file mode 100644
index 00000000..97a09a31
--- /dev/null
+++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte
@@ -0,0 +1,177 @@
+
+
+
+ {issue.title} · Issue #{issue.rkey} · {data.repo.ownerHandle}/{data.repo.name}
+ · Tangled
+
+
+{#snippet issueActions()}
+
+
+{/snippet}
+
+
+
+
+
+ {#if editing}
+ (editing = false)}
+ />
+ {:else}
+
+
+
+ {#if deleteError}
+
+
+
+ {/if}
+ {/if}
+
+
+
+
+
+ {#if auth.currentUser}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+
diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts
new file mode 100644
index 00000000..83ccd604
--- /dev/null
+++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts
@@ -0,0 +1,82 @@
+import { error } from "@sveltejs/kit";
+import { createBobbinClient } from "$lib/api/client";
+import { IdentityCache } from "$lib/api/identity";
+import { getIssue, listComments, listIssueStates } from "$lib/api/records";
+import { didFromUri, rkeyFromUri } from "$lib/api/uri";
+import { renderMarkup } from "$lib/markup";
+import { buildCommentThreads, type ThreadInput } from "$lib/components/comment/comments";
+import type { PageLoad } from "./$types";
+
+export const load: PageLoad = async (event) => {
+ const parent = await event.parent();
+ const uri = decodeURIComponent(event.params.aturi);
+
+ if (!uri.startsWith("at://")) error(404, "Issue not found");
+
+ const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch });
+
+ const record = await getIssue(ctx, uri).catch(() => null);
+ if (!record) error(404, "Issue not found");
+
+ const authorDid = didFromUri(record.uri);
+ const identities = new IdentityCache(ctx);
+ const markupOpts = {
+ repo: `${parent.repo.ownerHandle}/${parent.repo.name}`,
+ ref: parent.repo.defaultBranch,
+ host: event.url.host
+ };
+
+ const [author, states, comments] = await Promise.all([
+ identities.resolve(authorDid).catch(() => null),
+ listIssueStates(ctx, record.uri, { limit: 1, order: "desc" }).catch(() => null),
+ listComments(ctx, record.uri, { order: "asc", limit: 100 }).catch(() => null)
+ ]);
+
+ const latest = states?.items[0]?.value.state;
+ const state = latest?.endsWith(".closed") ? ("closed" as const) : ("open" as const);
+
+ const body = record.value.body ?? "";
+ const bodyHtml = body ? await renderMarkup(body, markupOpts) : null;
+
+ const commentItems = comments?.items ?? [];
+ const threadInputs: ThreadInput[] = await Promise.all(
+ commentItems.map(async (item): Promise => {
+ const commentDid = didFromUri(item.uri);
+ const commentBody = item.value.body?.text ?? "";
+ const [doc, commentBodyHtml] = await Promise.all([
+ identities.resolve(commentDid).catch(() => null),
+ commentBody ? renderMarkup(commentBody, markupOpts) : Promise.resolve(null)
+ ]);
+ return {
+ comment: {
+ uri: item.uri,
+ cid: item.cid,
+ rkey: rkeyFromUri(item.uri),
+ authorDid: commentDid,
+ authorHandle: doc?.handle ?? commentDid,
+ createdAt: item.value.createdAt,
+ body: commentBody,
+ bodyHtml: commentBodyHtml
+ },
+ replyTo: item.value.replyTo?.uri ?? null
+ };
+ })
+ );
+
+ return {
+ issue: {
+ uri: record.uri,
+ cid: record.cid,
+ rkey: rkeyFromUri(record.uri),
+ title: record.value.title,
+ body,
+ bodyHtml,
+ state,
+ authorDid,
+ authorHandle: author?.handle ?? authorDid,
+ createdAt: record.value.createdAt
+ },
+ comments: buildCommentThreads(threadInputs),
+ markup: markupOpts
+ };
+};