diff --git a/web/src/lib/components/repo/issues/IssueCard.stories.svelte b/web/src/lib/components/repo/issues/IssueCard.stories.svelte
new file mode 100644
index 00000000..3a462985
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueCard.stories.svelte
@@ -0,0 +1,31 @@
+
+
+
+
+
diff --git a/web/src/lib/components/repo/issues/IssueCard.svelte b/web/src/lib/components/repo/issues/IssueCard.svelte
new file mode 100644
index 00000000..3b693367
--- /dev/null
+++ b/web/src/lib/components/repo/issues/IssueCard.svelte
@@ -0,0 +1,47 @@
+
+
+
diff --git a/web/src/routes/[handle]/[repo]/issues/+page.svelte b/web/src/routes/[handle]/[repo]/issues/+page.svelte
new file mode 100644
index 00000000..4c80616e
--- /dev/null
+++ b/web/src/routes/[handle]/[repo]/issues/+page.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/web/src/routes/[handle]/[repo]/issues/+page.ts b/web/src/routes/[handle]/[repo]/issues/+page.ts
new file mode 100644
index 00000000..8596ca8e
--- /dev/null
+++ b/web/src/routes/[handle]/[repo]/issues/+page.ts
@@ -0,0 +1,56 @@
+import { createBobbinClient } from "$lib/api/client";
+import { count } from "$lib/api/count";
+import { IdentityCache } from "$lib/api/identity";
+import { listIssues } from "$lib/api/records";
+import { didFromUri, rkeyFromUri } from "$lib/api/uri";
+import type { IssueSummary } from "$lib/components/repo/types";
+import type { PageLoad } from "./$types";
+
+export const load: PageLoad = async (event) => {
+ const parent = await event.parent();
+ const state = event.url.searchParams.get("state") === "closed" ? "closed" : "open";
+ const repoDid = parent.repo.repoDid;
+
+ // no repoDid means bobbin never indexed this repo, so there is nothing to list
+ if (!repoDid) {
+ return {
+ state: state as "open" | "closed",
+ issues: [] as IssueSummary[],
+ openCount: parent.counts.issues,
+ closedCount: 0
+ };
+ }
+
+ const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch });
+
+ const [page, closed] = await Promise.all([
+ listIssues(ctx, repoDid, { state }),
+ // the layout only knows the open count; the closed tab needs its own
+ count(ctx, "sh.tangled.repo.countIssues", repoDid, { state: "closed" }).catch(() => null)
+ ]);
+
+ const identity = new IdentityCache(ctx);
+ const issues: IssueSummary[] = await Promise.all(
+ page.items.map(async (item): Promise => {
+ const authorDid = didFromUri(item.uri);
+ const author = await identity.resolve(authorDid).catch(() => null);
+ return {
+ uri: item.uri,
+ rkey: rkeyFromUri(item.uri),
+ title: item.value.title,
+ state: item.state === "closed" ? "closed" : "open",
+ authorDid,
+ authorHandle: author?.handle ?? authorDid,
+ createdAt: item.value.createdAt,
+ commentCount: item.commentCount
+ };
+ })
+ );
+
+ return {
+ state: state as "open" | "closed",
+ issues,
+ openCount: parent.counts.issues,
+ closedCount: closed?.count ?? 0
+ };
+};