alpha
Login
or
Join now
alice.pds.demo.boltless.dev
/
core
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
This repository has no description
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
2
Pulls
1
Pipelines
web/components: add new issue form
author
oppiliappan
committer
dawn
date
1 week ago
(Jul 31, 2026, 10:54 PM +0300)
commit
fc770cb4
fc770cb4afbc463cbaf09fb2e0947f7bead8916b
parent
432d6b1c
432d6b1c2855aef68b2d070f68f7d2a07b14a0a0
change-id
yzmtnrsu
yzmtnrsuztlqvsuvonswvvnvoyrzlsww
+441
10 changed files
Expand all
Collapse all
Unified
Split
web
src
lib
components
repo
issues
IssueForm.stories.svelte
IssueForm.svelte
types.ts
testing
MockAuthProvider.svelte
ui
MarkdownEditor.stories.svelte
MarkdownEditor.svelte
Textarea.svelte
markup
render.ts
routes
[handle]
[repo]
issues
new
+page.server.ts
+page.svelte
+60
web/src/lib/components/repo/issues/IssueForm.stories.svelte
View file
Reviewed
···
1
1
+
<script module lang="ts">
2
2
+
import { defineMeta } from "@storybook/addon-svelte-csf";
3
3
+
import { expect, userEvent, waitFor, within } from "storybook/test";
4
4
+
import IssueForm from "./IssueForm.svelte";
5
5
+
import MockAuthProvider from "$lib/components/testing/MockAuthProvider.svelte";
6
6
+
7
7
+
// no-ops; submitting is inert here (needs an authed agent context)
8
8
+
const noop = () => undefined;
9
9
+
10
10
+
const { Story } = defineMeta({
11
11
+
title: "Repo/Issues/IssueForm",
12
12
+
component: IssueForm,
13
13
+
tags: ["autodocs"],
14
14
+
argTypes: {
15
15
+
mode: {
16
16
+
control: { type: "inline-radio" },
17
17
+
options: ["create", "edit"]
18
18
+
}
19
19
+
},
20
20
+
args: {
21
21
+
repoDid: "did:plc:wshs7t2adsemcrrd4snkeqli",
22
22
+
markup: { repo: "tangled.org/core", ref: "main", host: "tangled.org" },
23
23
+
mode: "create",
24
24
+
onsaved: noop,
25
25
+
oncancel: noop
26
26
+
}
27
27
+
});
28
28
+
</script>
29
29
+
30
30
+
<Story name="Create" />
31
31
+
<Story
32
32
+
name="Edit"
33
33
+
args={{
34
34
+
mode: "edit",
35
35
+
title: "Add a full trending repositories view",
36
36
+
body: "I'd like a `/trending` view that shows more than the five repositories in the sidebar."
37
37
+
}}
38
38
+
/>
39
39
+
40
40
+
<!-- fills the form and submits with ctrl+enter against a fake agent that rejects, showing the error alert -->
41
41
+
<Story
42
42
+
name="Failed submission"
43
43
+
play={async ({ canvasElement }) => {
44
44
+
const canvas = within(canvasElement);
45
45
+
await userEvent.type(canvas.getByLabelText("Title"), "Something broke");
46
46
+
await userEvent.type(
47
47
+
canvas.getByPlaceholderText(/describe your issue/i),
48
48
+
"details{Control>}{Enter}{/Control}"
49
49
+
);
50
50
+
await waitFor(() =>
51
51
+
expect(canvas.getByRole("alert")).toHaveTextContent(/network request failed/i)
52
52
+
);
53
53
+
}}
54
54
+
>
55
55
+
{#snippet template(args)}
56
56
+
<MockAuthProvider>
57
57
+
<IssueForm {...args} />
58
58
+
</MockAuthProvider>
59
59
+
{/snippet}
60
60
+
</Story>
+125
web/src/lib/components/repo/issues/IssueForm.svelte
View file
Reviewed
···
1
1
+
<script lang="ts">
2
2
+
import { untrack } from "svelte";
3
3
+
import { now as tidNow } from "@atcute/tid";
4
4
+
import CirclePlus from "$icon/circle-plus";
5
5
+
import Pencil from "$icon/pencil";
6
6
+
import X from "$icon/x";
7
7
+
import { putIssue } from "$lib/api/issue";
8
8
+
import { getAuth } from "$lib/auth.svelte";
9
9
+
import Button from "$lib/components/ui/Button.svelte";
10
10
+
import ErrorAlert from "$lib/components/ui/Error.svelte";
11
11
+
import Input from "$lib/components/ui/Input.svelte";
12
12
+
import MarkdownEditor from "$lib/components/ui/MarkdownEditor.svelte";
13
13
+
import Spinner from "$lib/components/ui/Spinner.svelte";
14
14
+
import { type MarkupContext } from "$lib/markup";
15
15
+
import type { IssueRecord, RecordView } from "$lib/api/records";
16
16
+
17
17
+
interface Props {
18
18
+
repoDid: string;
19
19
+
markup: MarkupContext;
20
20
+
mode?: "create" | "edit";
21
21
+
rkey?: string;
22
22
+
createdAt?: string;
23
23
+
title?: string;
24
24
+
body?: string;
25
25
+
submitLabel?: string;
26
26
+
bodyPlaceholder?: string;
27
27
+
onsaved: (saved: RecordView<IssueRecord>) => void;
28
28
+
oncancel: () => void;
29
29
+
}
30
30
+
31
31
+
let {
32
32
+
repoDid,
33
33
+
markup,
34
34
+
mode = "create",
35
35
+
rkey,
36
36
+
createdAt,
37
37
+
title: initialTitle = "",
38
38
+
body: initialBody = "",
39
39
+
submitLabel,
40
40
+
bodyPlaceholder = "Describe your issue. Markdown is supported.",
41
41
+
onsaved,
42
42
+
oncancel
43
43
+
}: Props = $props();
44
44
+
45
45
+
const auth = getAuth();
46
46
+
47
47
+
let title = $state(untrack(() => initialTitle));
48
48
+
let body = $state(untrack(() => initialBody));
49
49
+
let isPublishing = $state(false);
50
50
+
let error = $state<string | null>(null);
51
51
+
52
52
+
const submitText = $derived(submitLabel ?? (mode === "edit" ? "Save" : "Create issue"));
53
53
+
const submitIcon = $derived(mode === "edit" ? Pencil : CirclePlus);
54
54
+
const canSubmit = $derived(title.trim() !== "" && !isPublishing);
55
55
+
56
56
+
const handleSubmit = async (e: SubmitEvent) => {
57
57
+
e.preventDefault();
58
58
+
const agent = auth.agent;
59
59
+
if (!agent || !canSubmit) return;
60
60
+
isPublishing = true;
61
61
+
error = null;
62
62
+
try {
63
63
+
const targetRkey = rkey ?? tidNow();
64
64
+
const record: IssueRecord = {
65
65
+
$type: "sh.tangled.repo.issue",
66
66
+
repo: repoDid as IssueRecord["repo"],
67
67
+
title,
68
68
+
body,
69
69
+
createdAt: createdAt ?? new Date().toISOString()
70
70
+
};
71
71
+
const saved = await putIssue(agent, targetRkey, record);
72
72
+
onsaved(saved);
73
73
+
} catch (err) {
74
74
+
error = err instanceof Error ? err.message : "Failed to save issue";
75
75
+
} finally {
76
76
+
isPublishing = false;
77
77
+
}
78
78
+
};
79
79
+
</script>
80
80
+
81
81
+
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
82
82
+
<div class="flex flex-col gap-1.5">
83
83
+
<label for="issue-title" class="text-sm text-foreground-default">Title</label>
84
84
+
<Input id="issue-title" name="title" required bind:value={title} disabled={isPublishing} />
85
85
+
</div>
86
86
+
87
87
+
<div class="flex flex-col gap-1.5">
88
88
+
<span class="text-sm text-foreground-default">Body</span>
89
89
+
90
90
+
<MarkdownEditor
91
91
+
id="issue-body"
92
92
+
name="body"
93
93
+
rows={12}
94
94
+
placeholder={bodyPlaceholder}
95
95
+
{markup}
96
96
+
bind:value={body}
97
97
+
disabled={isPublishing}
98
98
+
/>
99
99
+
</div>
100
100
+
101
101
+
{#if error}
102
102
+
<ErrorAlert label={error} />
103
103
+
{/if}
104
104
+
105
105
+
<div class="flex items-center justify-end gap-2">
106
106
+
<Button
107
107
+
type="button"
108
108
+
variant="ghost"
109
109
+
icon={X}
110
110
+
disabled={isPublishing}
111
111
+
onclick={oncancel}
112
112
+
class="text-foreground-danger hover:text-foreground-danger"
113
113
+
>
114
114
+
Cancel
115
115
+
</Button>
116
116
+
<Button
117
117
+
type="submit"
118
118
+
variant="primary"
119
119
+
icon={isPublishing ? Spinner : submitIcon}
120
120
+
disabled={!canSubmit}
121
121
+
>
122
122
+
{submitText}
123
123
+
</Button>
124
124
+
</div>
125
125
+
</form>
+11
web/src/lib/components/repo/types.ts
View file
Reviewed
···
38
38
percentage: number;
39
39
share: number;
40
40
}
41
41
+
42
42
+
export interface IssueSummary {
43
43
+
uri: string;
44
44
+
rkey: string;
45
45
+
title: string;
46
46
+
state: "open" | "closed";
47
47
+
authorHandle: string;
48
48
+
authorDid: string;
49
49
+
createdAt: string;
50
50
+
commentCount: number;
51
51
+
}
+30
web/src/lib/components/testing/MockAuthProvider.svelte
View file
Reviewed
···
1
1
+
<script lang="ts">
2
2
+
// story-only helper: injects a fake auth context so form stories can exercise
3
3
+
// a real (failing) submission without a live session. the fake agent's fetch
4
4
+
// handler always rejects, so putIssue/putComment land in the form's catch block.
5
5
+
import { setContext, type Snippet } from "svelte";
6
6
+
import { AUTH_KEY, type Auth } from "$lib/auth.svelte";
7
7
+
8
8
+
interface Props {
9
9
+
// message the fake agent rejects submissions with
10
10
+
failWith?: string;
11
11
+
children: Snippet;
12
12
+
}
13
13
+
14
14
+
let { failWith = "MockAuthProvider: network request failed, this error is intentional.", children }: Props = $props();
15
15
+
16
16
+
const agent = {
17
17
+
sub: "did:plc:alice",
18
18
+
handle: () => Promise.reject(new Error(failWith))
19
19
+
};
20
20
+
21
21
+
const auth = {
22
22
+
agent,
23
23
+
currentDid: "did:plc:alice",
24
24
+
currentUser: { did: "did:plc:alice", handle: "alice.pds.tngl.boltless.dev" }
25
25
+
} as unknown as Auth;
26
26
+
27
27
+
setContext(AUTH_KEY, auth);
28
28
+
</script>
29
29
+
30
30
+
{@render children()}
+23
web/src/lib/components/ui/MarkdownEditor.stories.svelte
View file
Reviewed
···
1
1
+
<script module lang="ts">
2
2
+
import { defineMeta } from "@storybook/addon-svelte-csf";
3
3
+
import MarkdownEditor from "./MarkdownEditor.svelte";
4
4
+
5
5
+
const { Story } = defineMeta({
6
6
+
title: "UI/MarkdownEditor",
7
7
+
component: MarkdownEditor,
8
8
+
tags: ["autodocs"],
9
9
+
args: {
10
10
+
markup: { repo: "tangled.org/core", ref: "main", host: "tangled.org" },
11
11
+
placeholder: "Write some **markdown**. Switch to Preview to render it.",
12
12
+
rows: 8
13
13
+
}
14
14
+
});
15
15
+
</script>
16
16
+
17
17
+
<Story name="Empty" />
18
18
+
<Story
19
19
+
name="With content"
20
20
+
args={{
21
21
+
value: "## Hello\n\nThis is a `MarkdownEditor` with some **content**.\n\n- one\n- two"
22
22
+
}}
23
23
+
/>
+132
web/src/lib/components/ui/MarkdownEditor.svelte
View file
Reviewed
···
1
1
+
<script lang="ts">
2
2
+
import type { KeyboardEventHandler } from "svelte/elements";
3
3
+
import Eye from "$icon/eye";
4
4
+
import Pencil from "$icon/pencil";
5
5
+
import Button from "./Button.svelte";
6
6
+
import ButtonGroup from "./ButtonGroup.svelte";
7
7
+
import Textarea from "./Textarea.svelte";
8
8
+
import { renderMarkup, type MarkupContext } from "$lib/markup";
9
9
+
10
10
+
interface Props {
11
11
+
value?: string;
12
12
+
markup: MarkupContext;
13
13
+
tab?: "write" | "preview";
14
14
+
id?: string;
15
15
+
name?: string;
16
16
+
rows?: number;
17
17
+
placeholder?: string;
18
18
+
disabled?: boolean;
19
19
+
previewClass?: string;
20
20
+
autofocus?: boolean;
21
21
+
// drop the textarea/preview background so the editor blends into its surroundings
22
22
+
transparent?: boolean;
23
23
+
}
24
24
+
25
25
+
let {
26
26
+
value = $bindable(""),
27
27
+
markup,
28
28
+
tab = $bindable("write"),
29
29
+
id,
30
30
+
name,
31
31
+
rows = 12,
32
32
+
placeholder,
33
33
+
disabled = false,
34
34
+
previewClass = "min-h-40",
35
35
+
autofocus = false,
36
36
+
transparent = false
37
37
+
}: Props = $props();
38
38
+
39
39
+
const surface = $derived(transparent ? "bg-transparent" : "bg-background-default");
40
40
+
41
41
+
let previewHtml = $state<string | null>(null);
42
42
+
let previewing = $state(false);
43
43
+
let textareaEl = $state<HTMLTextAreaElement>();
44
44
+
45
45
+
// focus on mount (and when returning to the write tab) if requested
46
46
+
$effect(() => {
47
47
+
if (autofocus) textareaEl?.focus();
48
48
+
});
49
49
+
50
50
+
$effect(() => {
51
51
+
if (tab !== "preview") return;
52
52
+
const source = value;
53
53
+
if (!source.trim()) {
54
54
+
previewHtml = null;
55
55
+
previewing = false;
56
56
+
return;
57
57
+
}
58
58
+
let cancelled = false;
59
59
+
previewing = true;
60
60
+
renderMarkup(source, markup)
61
61
+
.then((html) => {
62
62
+
if (!cancelled) previewHtml = html;
63
63
+
})
64
64
+
.catch(() => {
65
65
+
if (!cancelled) previewHtml = null;
66
66
+
})
67
67
+
.finally(() => {
68
68
+
if (!cancelled) previewing = false;
69
69
+
});
70
70
+
return () => {
71
71
+
cancelled = true;
72
72
+
};
73
73
+
});
74
74
+
// ctrl/cmd+enter submits the enclosing form, mirroring the old htmx editor
75
75
+
const handleKeydown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
76
76
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
77
77
+
e.preventDefault();
78
78
+
e.currentTarget.form?.requestSubmit();
79
79
+
}
80
80
+
};
81
81
+
</script>
82
82
+
83
83
+
<div class="flex flex-col gap-1.5">
84
84
+
<ButtonGroup class="self-start">
85
85
+
<Button
86
86
+
type="button"
87
87
+
size="sm"
88
88
+
icon={Pencil}
89
89
+
variant={tab === "write" ? "default" : "ghost"}
90
90
+
onclick={() => (tab = "write")}
91
91
+
>
92
92
+
Write
93
93
+
</Button>
94
94
+
<Button
95
95
+
type="button"
96
96
+
size="sm"
97
97
+
icon={Eye}
98
98
+
variant={tab === "preview" ? "default" : "ghost"}
99
99
+
onclick={() => (tab = "preview")}
100
100
+
>
101
101
+
Preview
102
102
+
</Button>
103
103
+
</ButtonGroup>
104
104
+
105
105
+
{#if tab === "write"}
106
106
+
<Textarea
107
107
+
{id}
108
108
+
{name}
109
109
+
{rows}
110
110
+
resizeable
111
111
+
{placeholder}
112
112
+
bind:value
113
113
+
bind:element={textareaEl}
114
114
+
{disabled}
115
115
+
onkeydown={handleKeydown}
116
116
+
class={transparent ? "max-h-none bg-transparent" : "max-h-none"}
117
117
+
/>
118
118
+
{:else if previewHtml}
119
119
+
<div
120
120
+
class={`markup rounded border border-border-default ${surface} px-2.5 py-2 ${previewClass}`}
121
121
+
>
122
122
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in $lib/markup -->
123
123
+
{@html previewHtml}
124
124
+
</div>
125
125
+
{:else}
126
126
+
<div
127
127
+
class={`rounded border border-border-default ${surface} px-2.5 py-2 text-sm text-foreground-subtle italic ${previewClass}`}
128
128
+
>
129
129
+
{previewing ? "Rendering…" : "Nothing to preview."}
130
130
+
</div>
131
131
+
{/if}
132
132
+
</div>
+3
web/src/lib/components/ui/Textarea.svelte
View file
Reviewed
···
59
59
iconLeft?: Component<SvelteHTMLElements["svg"]>;
60
60
iconRight?: Component<SvelteHTMLElements["svg"]>;
61
61
class?: string;
62
62
+
element?: HTMLTextAreaElement;
62
63
}
63
64
64
65
let {
···
71
72
iconLeft,
72
73
iconRight,
73
74
class: className,
75
75
+
element = $bindable(),
74
76
...rest
75
77
}: Props = $props();
76
78
···
83
85
<IconLeft class="mt-0.5 mr-1 size-4 shrink-0 text-foreground-subtle" aria-hidden="true" />
84
86
{/if}
85
87
<textarea
88
88
+
bind:this={element}
86
89
bind:value
87
90
{disabled}
88
91
{readonly}
+11
web/src/lib/markup/render.ts
View file
Reviewed
···
11
11
if (contents.length > SOURCE_LIMIT) return null;
12
12
return renderMarkdown(contents, ctx);
13
13
};
14
14
+
15
15
+
// like renderDocument, but for content that is always markdown (issue and
16
16
+
// comment bodies) rather than a repo file with an extension to sniff
17
17
+
export const renderMarkup = async (
18
18
+
contents: string,
19
19
+
ctx: MarkupContext
20
20
+
): Promise<string | null> => {
21
21
+
const { SOURCE_LIMIT, renderMarkdown } = await import("./markdown");
22
22
+
if (contents.length > SOURCE_LIMIT) return null;
23
23
+
return renderMarkdown(contents, ctx);
24
24
+
};
+6
web/src/routes/[handle]/[repo]/issues/new/+page.server.ts
View file
Reviewed
···
1
1
+
import { requireAuth } from "$lib/auth/guards";
2
2
+
import type { PageServerLoad } from "./$types";
3
3
+
4
4
+
export const load: PageServerLoad = (event) => {
5
5
+
requireAuth(event);
6
6
+
};
+40
web/src/routes/[handle]/[repo]/issues/new/+page.svelte
View file
Reviewed
···
1
1
+
<script lang="ts">
2
2
+
import { goto } from "$app/navigation";
3
3
+
import { page } from "$app/state";
4
4
+
import { resolve } from "$app/paths";
5
5
+
import IssueForm from "$lib/components/repo/issues/IssueForm.svelte";
6
6
+
7
7
+
let { data } = $props();
8
8
+
9
9
+
const base = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/issues`);
10
10
+
11
11
+
const markup = $derived({
12
12
+
repo: `${data.repo.ownerHandle}/${data.repo.name}`,
13
13
+
ref: data.repo.defaultBranch,
14
14
+
host: page.url.host,
15
15
+
camo: data.publicConfig?.camoEnabled
16
16
+
});
17
17
+
18
18
+
// no issue detail route yet, so land back on the list after creating
19
19
+
const handleSaved = async () => {
20
20
+
await goto(resolve(base as "/"));
21
21
+
};
22
22
+
23
23
+
const handleCancel = () => {
24
24
+
void goto(resolve(base as "/"));
25
25
+
};
26
26
+
</script>
27
27
+
28
28
+
<svelte:head>
29
29
+
<title>New issue · {data.repo.ownerHandle}/{data.repo.name} · Tangled</title>
30
30
+
</svelte:head>
31
31
+
32
32
+
<section class="mt-2 rounded bg-background-default px-6 py-6 text-foreground-default shadow-sm">
33
33
+
<h1 class="mb-4 text-xl font-bold text-foreground-default">Create a new issue</h1>
34
34
+
<IssueForm
35
35
+
{markup}
36
36
+
repoDid={data.repo.repoDid ?? ""}
37
37
+
onsaved={handleSaved}
38
38
+
oncancel={handleCancel}
39
39
+
/>
40
40
+
</section>