@wtfalch/threads 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Tallis Falch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The one design problem a package with tables has in this estate: migrations
3
+ * are hand-written SQL, numbered per app, applied by psql on boot, additive.
4
+ * A package cannot own a number in an app's sequence. So it does not try:
5
+ * this copies any migration the app has not yet copied into the app's
6
+ * `drizzle/` as the next numbers, and records which in a small manifest. The
7
+ * diff shows the SQL, the app's own migrate script applies it, and the
8
+ * integration tier that applies every file to an empty Postgres catches a bad
9
+ * one first, as it does for the app's own.
10
+ */
11
+ export declare const MANIFEST = ".threads-migrations.json";
12
+ export interface Manifest {
13
+ /** package file name -> the name it was copied to in the app */
14
+ copied: Record<string, string>;
15
+ }
16
+ export interface CopyResult {
17
+ copied: Array<{
18
+ from: string;
19
+ to: string;
20
+ }>;
21
+ manifest: Manifest;
22
+ }
23
+ export declare function copyMigrations(opts: {
24
+ /** The package's migrations directory. */
25
+ from: string;
26
+ /** The app's migrations directory, `drizzle/` by convention. */
27
+ to: string;
28
+ /** Stamped into the header of each copied file. */
29
+ version?: string;
30
+ }): CopyResult;
31
+ export declare function describe(result: CopyResult): string;
@@ -0,0 +1,58 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ /**
4
+ * The one design problem a package with tables has in this estate: migrations
5
+ * are hand-written SQL, numbered per app, applied by psql on boot, additive.
6
+ * A package cannot own a number in an app's sequence. So it does not try:
7
+ * this copies any migration the app has not yet copied into the app's
8
+ * `drizzle/` as the next numbers, and records which in a small manifest. The
9
+ * diff shows the SQL, the app's own migrate script applies it, and the
10
+ * integration tier that applies every file to an empty Postgres catches a bad
11
+ * one first, as it does for the app's own.
12
+ */
13
+ export const MANIFEST = '.threads-migrations.json';
14
+ const NUMBERED = /^(\d{4})_(.+\.sql)$/;
15
+ function readManifest(dir) {
16
+ const file = join(dir, MANIFEST);
17
+ if (!existsSync(file))
18
+ return { copied: {} };
19
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
20
+ return { copied: parsed.copied ?? {} };
21
+ }
22
+ function nextNumber(dir) {
23
+ let max = -1;
24
+ for (const name of readdirSync(dir)) {
25
+ const m = NUMBERED.exec(name);
26
+ if (m?.[1])
27
+ max = Math.max(max, Number(m[1]));
28
+ }
29
+ return max + 1;
30
+ }
31
+ export function copyMigrations(opts) {
32
+ mkdirSync(opts.to, { recursive: true });
33
+ const manifest = readManifest(opts.to);
34
+ const copied = [];
35
+ const sources = readdirSync(opts.from)
36
+ .filter((n) => NUMBERED.test(n))
37
+ .sort();
38
+ let next = nextNumber(opts.to);
39
+ for (const name of sources) {
40
+ if (manifest.copied[name])
41
+ continue;
42
+ const rest = NUMBERED.exec(name)?.[2] ?? name;
43
+ const target = `${String(next).padStart(4, '0')}_${rest}`;
44
+ const body = readFileSync(join(opts.from, name), 'utf8');
45
+ const header = `-- Copied from @wtfalch/threads${opts.version ? ` ${opts.version}` : ''} (migrations/${name}) by threads-migrations.\n-- Do not edit here; the next package version ships the next file.\n\n`;
46
+ writeFileSync(join(opts.to, target), header + body);
47
+ manifest.copied[name] = target;
48
+ copied.push({ from: name, to: target });
49
+ next += 1;
50
+ }
51
+ writeFileSync(join(opts.to, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
52
+ return { copied, manifest };
53
+ }
54
+ export function describe(result) {
55
+ if (result.copied.length === 0)
56
+ return 'threads-migrations: nothing to copy';
57
+ return result.copied.map((c) => `threads-migrations: ${c.from} -> ${basename(c.to)}`).join('\n');
58
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { copyMigrations, describe } from './copy.js';
6
+ /**
7
+ * `threads-migrations [dir]` — copy this package's migrations the app has not
8
+ * yet copied into `dir` (default `drizzle`) as the next numbers. Idempotent;
9
+ * run it after every upgrade of @wtfalch/threads, then commit what it wrote.
10
+ */
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const from = join(here, '..', 'migrations');
13
+ const to = resolve(process.cwd(), process.argv[2] ?? 'drizzle');
14
+ let version;
15
+ try {
16
+ version = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8')).version;
17
+ }
18
+ catch {
19
+ version = undefined;
20
+ }
21
+ console.log(describe(copyMigrations({ from, to, version })));
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Who may read, write or moderate is the host's decision.
3
+ *
4
+ * The package decides nothing about it: every query and every action asks the
5
+ * three gates the host passed at mount, giving them the subject and the
6
+ * person, or nobody. The forum's defaults are one choice; a comment section
7
+ * under a private document is another; the package cannot tell them apart and
8
+ * must not try.
9
+ */
10
+ /** What the session says about a person. The shape `@wtfalch/auth` returns, minus the claims. */
11
+ export interface Who {
12
+ id: string;
13
+ name: string | null;
14
+ email: string | null;
15
+ emailVerified: boolean;
16
+ }
17
+ /** What a thread hangs off. A forum category is `{ kind: 'category', id: <category id> }`. */
18
+ export interface Subject {
19
+ kind: string;
20
+ id: string;
21
+ }
22
+ export declare const CATEGORY = "category";
23
+ export declare function categorySubject(categoryId: number | string): Subject;
24
+ export type Gate = (subject: Subject, who: Who | null) => boolean | Promise<boolean>;
25
+ export interface Gates {
26
+ /** May this person see the threads under this subject. */
27
+ read: Gate;
28
+ /** May this person post or comment under this subject. */
29
+ write: Gate;
30
+ /** May this person pin, lock, hide, move, set status, mute and ban here. */
31
+ moderate: Gate;
32
+ }
33
+ /**
34
+ * The forum's defaults: anyone reads, a person with a verified address
35
+ * writes, and moderation is whatever the app says, usually a role from its
36
+ * authz vocabulary. Nothing here is special to a category: an app that wants
37
+ * a public comment section under every page passes the same gates.
38
+ */
39
+ export declare function forumGates(opts: {
40
+ moderate: (who: Who) => boolean | Promise<boolean>;
41
+ }): Gates;
42
+ export type ErrorCode = 'forbidden' | 'not_found' | 'locked' | 'standing' | 'rate_limited' | 'invalid';
43
+ /** Every refusal the package makes, with a code the host can turn into a sentence. */
44
+ export declare class ThreadsError extends Error {
45
+ readonly code: ErrorCode;
46
+ constructor(code: ErrorCode, message: string);
47
+ }
48
+ export declare function isThreadsError(e: unknown): e is ThreadsError;
package/dist/gates.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Who may read, write or moderate is the host's decision.
3
+ *
4
+ * The package decides nothing about it: every query and every action asks the
5
+ * three gates the host passed at mount, giving them the subject and the
6
+ * person, or nobody. The forum's defaults are one choice; a comment section
7
+ * under a private document is another; the package cannot tell them apart and
8
+ * must not try.
9
+ */
10
+ export const CATEGORY = 'category';
11
+ export function categorySubject(categoryId) {
12
+ return { kind: CATEGORY, id: String(categoryId) };
13
+ }
14
+ /**
15
+ * The forum's defaults: anyone reads, a person with a verified address
16
+ * writes, and moderation is whatever the app says, usually a role from its
17
+ * authz vocabulary. Nothing here is special to a category: an app that wants
18
+ * a public comment section under every page passes the same gates.
19
+ */
20
+ export function forumGates(opts) {
21
+ return {
22
+ read: () => true,
23
+ write: (_subject, who) => who?.emailVerified === true,
24
+ moderate: (_subject, who) => (who === null ? false : opts.moderate(who)),
25
+ };
26
+ }
27
+ /** Every refusal the package makes, with a code the host can turn into a sentence. */
28
+ export class ThreadsError extends Error {
29
+ code;
30
+ constructor(code, message) {
31
+ super(message);
32
+ this.name = 'ThreadsError';
33
+ this.code = code;
34
+ }
35
+ }
36
+ export function isThreadsError(e) {
37
+ return e instanceof ThreadsError;
38
+ }
@@ -0,0 +1,4 @@
1
+ export * from './schema.js';
2
+ export * from './gates.js';
3
+ export * from './tree.js';
4
+ export * from './threads.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './schema.js';
2
+ export * from './gates.js';
3
+ export * from './tree.js';
4
+ export * from './threads.js';
@@ -0,0 +1,185 @@
1
+ -- @wtfalch/threads 0001: the tables a forum, a feedback board and a comment
2
+ -- section share. Copied into an app's drizzle/ sequence by
3
+ -- `threads-migrations`, applied by the app's own migrate script. Hand-written,
4
+ -- additive, one transaction per file, like everything else in the estate.
5
+ --
6
+ -- Every user-owned row keys by the issuer's user id (auth.wtfalch.dev): a
7
+ -- string, never a uuid, never a foreign key to anything outside these tables.
8
+ -- Who may read, write or moderate is the host's decision, passed in as gates;
9
+ -- nothing here decides it, and there is no row-level security underneath.
10
+
11
+ CREATE EXTENSION IF NOT EXISTS ltree;
12
+
13
+ -- What this package knows about a person. Upserted on every write from the
14
+ -- session (`seen`), so the name on a post follows the issuer rather than
15
+ -- drifting. The email moves only once the issuer has verified it.
16
+ CREATE TABLE IF NOT EXISTS "thread_people" (
17
+ "id" text PRIMARY KEY,
18
+ "name" text,
19
+ "email" text,
20
+ "standing" text NOT NULL DEFAULT 'ok' CHECK ("standing" IN ('ok', 'muted', 'banned')),
21
+ "last_seen_at" timestamp with time zone NOT NULL DEFAULT now(),
22
+ "created_at" timestamp with time zone NOT NULL DEFAULT now()
23
+ );
24
+
25
+ -- A forum section. `kind` is the whole difference between a discussion board
26
+ -- and a feedback board: feedback threads carry a status and take votes.
27
+ CREATE TABLE IF NOT EXISTS "thread_categories" (
28
+ "id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
29
+ "slug" text NOT NULL UNIQUE,
30
+ "name" text NOT NULL,
31
+ "description" text,
32
+ "kind" text NOT NULL DEFAULT 'discussion' CHECK ("kind" IN ('discussion', 'feedback')),
33
+ "position" integer NOT NULL DEFAULT 0,
34
+ "created_at" timestamp with time zone NOT NULL DEFAULT now()
35
+ );
36
+
37
+ -- A thread hangs off a subject: a category (`subject_kind = 'category'`,
38
+ -- `subject_id` the category id) or whatever the host names, with the host's
39
+ -- id. A thread under a host subject has no title and no body of its own; the
40
+ -- subject is the post, and there is exactly one such thread per subject.
41
+ CREATE TABLE IF NOT EXISTS "threads" (
42
+ "id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
43
+ "subject_kind" text NOT NULL,
44
+ "subject_id" text NOT NULL,
45
+ "author_id" text REFERENCES "thread_people"("id"),
46
+ "title" text,
47
+ "slug" text,
48
+ "body" text,
49
+ "pinned" boolean NOT NULL DEFAULT false,
50
+ "locked" boolean NOT NULL DEFAULT false,
51
+ "hidden" boolean NOT NULL DEFAULT false,
52
+ "status" text CHECK ("status" IN ('open', 'planned', 'in_progress', 'done', 'declined')),
53
+ "duplicate_of" bigint REFERENCES "threads"("id"),
54
+ "comment_count" integer NOT NULL DEFAULT 0,
55
+ "vote_count" integer NOT NULL DEFAULT 0,
56
+ "last_activity_at" timestamp with time zone NOT NULL DEFAULT now(),
57
+ "created_at" timestamp with time zone NOT NULL DEFAULT now(),
58
+ "updated_at" timestamp with time zone NOT NULL DEFAULT now(),
59
+ CHECK ("subject_kind" <> 'category' OR "title" IS NOT NULL)
60
+ );
61
+ CREATE INDEX IF NOT EXISTS "threads_subject_idx"
62
+ ON "threads" ("subject_kind", "subject_id", "pinned" DESC, "last_activity_at" DESC);
63
+ CREATE UNIQUE INDEX IF NOT EXISTS "threads_host_subject_idx"
64
+ ON "threads" ("subject_kind", "subject_id") WHERE "subject_kind" <> 'category';
65
+ CREATE INDEX IF NOT EXISTS "threads_author_idx" ON "threads" ("author_id", "created_at");
66
+ CREATE INDEX IF NOT EXISTS "threads_search_idx"
67
+ ON "threads" USING GIN (to_tsvector('simple', coalesce("title", '') || ' ' || coalesce("body", '')));
68
+
69
+ -- A tree of any depth. `reply_to` is the parent; `path` is the chain of
70
+ -- zero-padded ids from the top of the thread to this comment, so ordering by
71
+ -- it walks the tree depth-first in creation order, and `path <@ x` is a
72
+ -- subtree. The trigger below writes `path`, so no caller can get it wrong.
73
+ CREATE TABLE IF NOT EXISTS "thread_comments" (
74
+ "id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
75
+ "thread_id" bigint NOT NULL REFERENCES "threads"("id") ON DELETE CASCADE,
76
+ "reply_to" bigint REFERENCES "thread_comments"("id"),
77
+ "path" ltree NOT NULL,
78
+ "author_id" text NOT NULL REFERENCES "thread_people"("id"),
79
+ "body" text NOT NULL,
80
+ "hidden" boolean NOT NULL DEFAULT false,
81
+ "created_at" timestamp with time zone NOT NULL DEFAULT now(),
82
+ "updated_at" timestamp with time zone NOT NULL DEFAULT now()
83
+ );
84
+ CREATE INDEX IF NOT EXISTS "thread_comments_thread_path_idx" ON "thread_comments" ("thread_id", "path");
85
+ CREATE INDEX IF NOT EXISTS "thread_comments_path_gist_idx" ON "thread_comments" USING GIST ("path");
86
+ CREATE INDEX IF NOT EXISTS "thread_comments_author_idx" ON "thread_comments" ("author_id", "created_at");
87
+
88
+ CREATE OR REPLACE FUNCTION "thread_comments_path"() RETURNS trigger AS $$
89
+ DECLARE
90
+ parent_path ltree;
91
+ parent_thread bigint;
92
+ BEGIN
93
+ IF NEW."reply_to" IS NULL THEN
94
+ NEW."path" := text2ltree(lpad(NEW."id"::text, 12, '0'));
95
+ ELSE
96
+ SELECT "path", "thread_id" INTO parent_path, parent_thread
97
+ FROM "thread_comments" WHERE "id" = NEW."reply_to";
98
+ IF parent_path IS NULL THEN
99
+ RAISE EXCEPTION 'reply_to % does not exist', NEW."reply_to";
100
+ END IF;
101
+ IF parent_thread <> NEW."thread_id" THEN
102
+ RAISE EXCEPTION 'reply_to % is in thread %, not %', NEW."reply_to", parent_thread, NEW."thread_id";
103
+ END IF;
104
+ NEW."path" := parent_path || text2ltree(lpad(NEW."id"::text, 12, '0'));
105
+ END IF;
106
+ RETURN NEW;
107
+ END $$ LANGUAGE plpgsql;
108
+
109
+ DROP TRIGGER IF EXISTS "thread_comments_path_trg" ON "thread_comments";
110
+ CREATE TRIGGER "thread_comments_path_trg"
111
+ BEFORE INSERT ON "thread_comments"
112
+ FOR EACH ROW EXECUTE FUNCTION "thread_comments_path"();
113
+
114
+ -- Counts kept by trigger, so a list page never counts comments per row.
115
+ CREATE OR REPLACE FUNCTION "thread_comments_count"() RETURNS trigger AS $$
116
+ BEGIN
117
+ IF TG_OP = 'INSERT' THEN
118
+ UPDATE "threads"
119
+ SET "comment_count" = "comment_count" + 1, "last_activity_at" = NEW."created_at"
120
+ WHERE "id" = NEW."thread_id";
121
+ RETURN NEW;
122
+ ELSIF TG_OP = 'DELETE' THEN
123
+ UPDATE "threads" SET "comment_count" = "comment_count" - 1 WHERE "id" = OLD."thread_id";
124
+ RETURN OLD;
125
+ END IF;
126
+ RETURN NULL;
127
+ END $$ LANGUAGE plpgsql;
128
+
129
+ DROP TRIGGER IF EXISTS "thread_comments_count_trg" ON "thread_comments";
130
+ CREATE TRIGGER "thread_comments_count_trg"
131
+ AFTER INSERT OR DELETE ON "thread_comments"
132
+ FOR EACH ROW EXECUTE FUNCTION "thread_comments_count"();
133
+
134
+ -- One vote per person per thread. The action layer allows it in feedback
135
+ -- categories only; the table does not know what a category is.
136
+ CREATE TABLE IF NOT EXISTS "thread_votes" (
137
+ "thread_id" bigint NOT NULL REFERENCES "threads"("id") ON DELETE CASCADE,
138
+ "person_id" text NOT NULL REFERENCES "thread_people"("id"),
139
+ "created_at" timestamp with time zone NOT NULL DEFAULT now(),
140
+ PRIMARY KEY ("thread_id", "person_id")
141
+ );
142
+
143
+ CREATE OR REPLACE FUNCTION "thread_votes_count"() RETURNS trigger AS $$
144
+ BEGIN
145
+ IF TG_OP = 'INSERT' THEN
146
+ UPDATE "threads" SET "vote_count" = "vote_count" + 1 WHERE "id" = NEW."thread_id";
147
+ RETURN NEW;
148
+ ELSIF TG_OP = 'DELETE' THEN
149
+ UPDATE "threads" SET "vote_count" = "vote_count" - 1 WHERE "id" = OLD."thread_id";
150
+ RETURN OLD;
151
+ END IF;
152
+ RETURN NULL;
153
+ END $$ LANGUAGE plpgsql;
154
+
155
+ DROP TRIGGER IF EXISTS "thread_votes_count_trg" ON "thread_votes";
156
+ CREATE TRIGGER "thread_votes_count_trg"
157
+ AFTER INSERT OR DELETE ON "thread_votes"
158
+ FOR EACH ROW EXECUTE FUNCTION "thread_votes_count"();
159
+
160
+ -- Who hears about a thread, and why. `muted` keeps the row so the reason is
161
+ -- not lost when a person stops listening.
162
+ CREATE TABLE IF NOT EXISTS "thread_subscriptions" (
163
+ "thread_id" bigint NOT NULL REFERENCES "threads"("id") ON DELETE CASCADE,
164
+ "person_id" text NOT NULL REFERENCES "thread_people"("id"),
165
+ "reason" text NOT NULL CHECK ("reason" IN ('author', 'commented', 'voted', 'manual')),
166
+ "muted" boolean NOT NULL DEFAULT false,
167
+ "created_at" timestamp with time zone NOT NULL DEFAULT now(),
168
+ PRIMARY KEY ("thread_id", "person_id")
169
+ );
170
+
171
+ -- Mail to send, never awaited in a request. Claimed with
172
+ -- FOR UPDATE SKIP LOCKED by whatever loop the app runs.
173
+ CREATE TABLE IF NOT EXISTS "thread_outbox" (
174
+ "id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
175
+ "person_id" text NOT NULL REFERENCES "thread_people"("id"),
176
+ "thread_id" bigint NOT NULL REFERENCES "threads"("id") ON DELETE CASCADE,
177
+ "comment_id" bigint REFERENCES "thread_comments"("id") ON DELETE CASCADE,
178
+ "kind" text NOT NULL CHECK ("kind" IN ('comment', 'status')),
179
+ "created_at" timestamp with time zone NOT NULL DEFAULT now(),
180
+ "claimed_at" timestamp with time zone,
181
+ "sent_at" timestamp with time zone,
182
+ "attempts" integer NOT NULL DEFAULT 0,
183
+ "last_error" text
184
+ );
185
+ CREATE INDEX IF NOT EXISTS "thread_outbox_pending_idx" ON "thread_outbox" ("created_at") WHERE "sent_at" IS NULL;
@@ -0,0 +1,9 @@
1
+ import type { Category } from '../schema.js';
2
+ export interface CategoryListProps {
3
+ categories: Category[];
4
+ basePath: string;
5
+ /** Thread count per category id, when the page has it. */
6
+ counts?: Record<string, number>;
7
+ }
8
+ /** The forum's front page: its sections, each a link. */
9
+ export declare function CategoryList({ categories, basePath, counts }: CategoryListProps): import("react").JSX.Element;
@@ -0,0 +1,7 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { Empty, Pill, Row, Rows } from '@wtfalch/design';
4
+ /** The forum's front page: its sections, each a link. */
5
+ export function CategoryList({ categories, basePath, counts }) {
6
+ return (_jsx(Rows, { label: "Categories", empty: _jsx(Empty, { children: "No categories yet." }), children: categories.map((c) => (_jsx(Row, { name: _jsx("a", { className: "threads-link", href: `${basePath}/c/${c.slug}`, children: c.name }), pills: c.kind === 'feedback' ? _jsx(Pill, { inRow: true, children: "Feedback" }) : undefined, hint: c.description ?? undefined, trail: counts && counts[String(c.id)] !== undefined ? (_jsx(Pill, { quiet: true, children: counts[String(c.id)] })) : undefined }, c.id))) }));
7
+ }
@@ -0,0 +1,19 @@
1
+ import type { Outcome } from './format.js';
2
+ export interface CommentFormProps {
3
+ /** The app's Server Action, or anything that returns an error sentence or nothing. */
4
+ onSubmit: (body: string) => Promise<Outcome>;
5
+ label?: string;
6
+ submitLabel?: string;
7
+ placeholder?: string;
8
+ /** Shown beside the submit, for the reply form's "Cancel". */
9
+ onCancel?: () => void;
10
+ /** Called after a submit the action did not refuse. */
11
+ onDone?: () => void;
12
+ autoFocus?: boolean;
13
+ }
14
+ /**
15
+ * One textarea and one button, the way every form in the estate is shaped:
16
+ * `Field` wires the label and the error, the action answers with a sentence
17
+ * or nothing, and the button is busy rather than disabled while it runs.
18
+ */
19
+ export declare function CommentForm({ onSubmit, label, submitLabel, placeholder, onCancel, onDone, autoFocus, }: CommentFormProps): import("react").JSX.Element;
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, Callout, Field, Textarea } from '@wtfalch/design';
4
+ import { useState, useTransition } from 'react';
5
+ /**
6
+ * One textarea and one button, the way every form in the estate is shaped:
7
+ * `Field` wires the label and the error, the action answers with a sentence
8
+ * or nothing, and the button is busy rather than disabled while it runs.
9
+ */
10
+ export function CommentForm({ onSubmit, label = 'Comment', submitLabel = 'Post', placeholder, onCancel, onDone, autoFocus, }) {
11
+ const [body, setBody] = useState('');
12
+ const [error, setError] = useState(null);
13
+ const [pending, start] = useTransition();
14
+ const submit = () => {
15
+ const text = body.trim();
16
+ if (!text) {
17
+ setError('Write something first.');
18
+ return;
19
+ }
20
+ start(async () => {
21
+ const result = await onSubmit(text);
22
+ if (result?.error) {
23
+ setError(result.error);
24
+ return;
25
+ }
26
+ setBody('');
27
+ setError(null);
28
+ onDone?.();
29
+ });
30
+ };
31
+ return (_jsxs("form", { className: "threads-form", onSubmit: (e) => {
32
+ e.preventDefault();
33
+ submit();
34
+ }, children: [_jsx(Field, { label: label, error: error, labelHidden: true, children: (f) => (_jsx(Textarea, { ...f, name: "body", rows: 4, value: body, placeholder: placeholder ?? 'Markdown is fine.', onChange: (e) => setBody(e.target.value), autoFocus: autoFocus, disabled: pending })) }), _jsxs("div", { className: "threads-form-actions", children: [onCancel && (_jsx(Button, { kind: "ghost", size: "sm", onPress: onCancel, disabled: pending, children: "Cancel" })), _jsx(Button, { type: "submit", kind: "primary", size: "sm", busy: pending, disabled: pending, children: submitLabel })] }), error && !pending && body.trim() === '' ? null : null, error && _jsx(Callout, { tone: "bad", children: error })] }));
35
+ }
@@ -0,0 +1,42 @@
1
+ import type { Who } from '../gates.js';
2
+ import type { CommentRow } from '../threads.js';
3
+ import { type Outcome } from './format.js';
4
+ export interface CommentSectionProps {
5
+ /** Every comment of the thread (or of the subtree), as `comments()` returns them. */
6
+ comments: CommentRow[];
7
+ who: Who | null;
8
+ /** The host's write gate, already asked. */
9
+ canWrite: boolean;
10
+ /** The host's moderate gate, already asked. Shows hide/unhide. */
11
+ canModerate?: boolean;
12
+ /** The thread is locked: no forms, and a line saying so. */
13
+ locked?: boolean;
14
+ /** Build the tree under this comment only, with it at depth 0. */
15
+ root?: number;
16
+ /** Fold below this depth. Eight is the reference; 0 renders flat; Infinity never folds. */
17
+ collapseAt?: number;
18
+ /** Where "N more replies" goes: `${continueBase}/${commentId}`. Omit to fold without a link. */
19
+ continueBase?: string;
20
+ /** Where "sign in to comment" goes, for a reader with no session. */
21
+ signInHref?: string;
22
+ onComment: (input: {
23
+ body: string;
24
+ replyTo: number | null;
25
+ }) => Promise<Outcome>;
26
+ onHideComment?: (input: {
27
+ id: number;
28
+ hidden: boolean;
29
+ }) => Promise<Outcome>;
30
+ /** What to say when there is nothing yet. */
31
+ emptyText?: string;
32
+ }
33
+ /**
34
+ * The discussion under a post, as a tree of any depth.
35
+ *
36
+ * Rows come in ordered by `path`, so the tree is one pass; past `collapseAt`
37
+ * a node keeps its place and its children become a count and a link, which
38
+ * is Reddit's answer to a thread deeper than a page can indent. A hidden
39
+ * comment keeps its place too and loses its words, so replies to it still
40
+ * make sense.
41
+ */
42
+ export declare function CommentSection({ comments, who, canWrite, canModerate, locked, root, collapseAt, continueBase, signInHref, onComment, onHideComment, emptyText, }: CommentSectionProps): import("react").JSX.Element;
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, Empty, Markdown } from '@wtfalch/design';
4
+ import { useState } from 'react';
5
+ import { buildTree, collapse } from '../tree.js';
6
+ import { CommentForm } from './CommentForm.js';
7
+ import { ago, plural } from './format.js';
8
+ /**
9
+ * The discussion under a post, as a tree of any depth.
10
+ *
11
+ * Rows come in ordered by `path`, so the tree is one pass; past `collapseAt`
12
+ * a node keeps its place and its children become a count and a link, which
13
+ * is Reddit's answer to a thread deeper than a page can indent. A hidden
14
+ * comment keeps its place too and loses its words, so replies to it still
15
+ * make sense.
16
+ */
17
+ export function CommentSection({ comments, who, canWrite, canModerate = false, locked = false, root, collapseAt = 8, continueBase, signInHref, onComment, onHideComment, emptyText = 'Nothing here yet.', }) {
18
+ const tree = collapse(buildTree(comments, root === undefined ? {} : { root }), collapseAt);
19
+ const [replyingTo, setReplyingTo] = useState(null);
20
+ return (_jsxs("section", { className: "threads-stack", "aria-label": "Comments", children: [tree.length === 0 ? (_jsx(Empty, { children: emptyText })) : (_jsx("div", { className: "threads-comments", children: tree.map((node) => (_jsx(CommentNode, { node: node, who: who, canWrite: canWrite && !locked, canModerate: canModerate, continueBase: continueBase, replyingTo: replyingTo, setReplyingTo: setReplyingTo, onComment: onComment, onHideComment: onHideComment }, node.id))) })), locked ? (_jsx("p", { className: "threads-note", children: "This thread is locked." })) : canWrite ? (_jsx(CommentForm, { label: "Add a comment", submitLabel: "Comment", onSubmit: (body) => onComment({ body, replyTo: null }) })) : who === null && signInHref ? (_jsxs("p", { className: "threads-note", children: [_jsx("a", { href: signInHref, children: "Sign in" }), " to join the discussion."] })) : who === null ? null : (_jsx("p", { className: "threads-note", children: "Verify your email address to join the discussion." }))] }));
21
+ }
22
+ function CommentNode({ node, who, canWrite, canModerate, continueBase, replyingTo, setReplyingTo, onComment, onHideComment, }) {
23
+ const replying = replyingTo === node.id;
24
+ const mine = who !== null && who.id === node.authorId;
25
+ return (_jsxs("article", { className: "threads-comment", "data-depth": node.depth, id: `c${node.id}`, children: [_jsxs("header", { className: "threads-comment-head", children: [_jsx("span", { className: "threads-comment-author", children: node.authorName ?? 'Someone' }), _jsxs("span", { className: "threads-meta", children: [_jsx("a", { href: `#c${node.id}`, children: ago(node.createdAt) }), node.hidden && _jsx("span", { children: "hidden" }), mine && _jsx("span", { children: "you" })] })] }), _jsx("div", { className: "threads-comment-body", children: node.body === null ? (_jsx("p", { className: "threads-comment-hidden", children: "This comment was hidden by a moderator." })) : (_jsx(Markdown, { text: node.body })) }), _jsxs("div", { className: "threads-comment-actions", children: [canWrite && (_jsx(Button, { kind: "ghost", size: "sm", onPress: () => setReplyingTo(replying ? null : node.id), children: "Reply" })), canModerate && onHideComment && (_jsx(Button, { kind: "ghost", size: "sm", onPress: () => void onHideComment({ id: node.id, hidden: !node.hidden }), children: node.hidden ? 'Unhide' : 'Hide' }))] }), replying && (_jsx(CommentForm, { label: "Reply", submitLabel: "Reply", autoFocus: true, onCancel: () => setReplyingTo(null), onDone: () => setReplyingTo(null), onSubmit: (body) => onComment({ body, replyTo: node.id }) })), node.children.length > 0 && (_jsx("div", { className: "threads-children", children: node.children.map((child) => (_jsx(CommentNode, { node: child, who: who, canWrite: canWrite, canModerate: canModerate, continueBase: continueBase, replyingTo: replyingTo, setReplyingTo: setReplyingTo, onComment: onComment, onHideComment: onHideComment }, child.id))) })), node.folded > 0 && (_jsx("p", { className: "threads-fold", children: continueBase ? (_jsxs("a", { href: `${continueBase}/${node.id}`, children: ["Continue this thread (", plural(node.folded, 'more reply', 'more replies'), ")"] })) : (_jsx("span", { className: "threads-note", children: plural(node.folded, 'more reply', 'more replies') })) }))] }));
26
+ }
@@ -0,0 +1,18 @@
1
+ import type { Category } from '../schema.js';
2
+ import type { Outcome } from './format.js';
3
+ export interface NewThreadFormProps {
4
+ categories: Pick<Category, 'id' | 'name' | 'kind'>[];
5
+ /** Preselected, when the form is opened from inside a category. */
6
+ categoryId?: number;
7
+ /** Prefilled, for a deep link like `/forum/new?title=`. */
8
+ title?: string;
9
+ /** The app's Server Action. It redirects to the new thread on success. */
10
+ onPost: (input: {
11
+ categoryId: number;
12
+ title: string;
13
+ body: string;
14
+ }) => Promise<Outcome>;
15
+ onCancel?: () => void;
16
+ }
17
+ /** A title, a body, a category. The action validates for real; this only catches the empty case. */
18
+ export declare function NewThreadForm({ categories, categoryId, title: initialTitle, onPost, onCancel, }: NewThreadFormProps): import("react").JSX.Element;
@@ -0,0 +1,29 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, Callout, Field, Input, Select, Textarea } from '@wtfalch/design';
4
+ import { useState, useTransition } from 'react';
5
+ /** A title, a body, a category. The action validates for real; this only catches the empty case. */
6
+ export function NewThreadForm({ categories, categoryId, title: initialTitle = '', onPost, onCancel, }) {
7
+ const [categoryValue, setCategory] = useState(String(categoryId ?? categories[0]?.id ?? ''));
8
+ const [title, setTitle] = useState(initialTitle);
9
+ const [body, setBody] = useState('');
10
+ const [error, setError] = useState(null);
11
+ const [pending, start] = useTransition();
12
+ const kind = categories.find((c) => String(c.id) === categoryValue)?.kind;
13
+ return (_jsxs("form", { className: "threads-form", onSubmit: (e) => {
14
+ e.preventDefault();
15
+ if (!title.trim() || !body.trim() || !categoryValue) {
16
+ setError('A title, a body and a category, please.');
17
+ return;
18
+ }
19
+ start(async () => {
20
+ const r = await onPost({
21
+ categoryId: Number(categoryValue),
22
+ title: title.trim(),
23
+ body: body.trim(),
24
+ });
25
+ if (r?.error)
26
+ setError(r.error);
27
+ });
28
+ }, children: [categories.length > 1 && (_jsx(Field, { label: "Category", children: (f) => (_jsx(Select, { ...f, value: categoryValue, onChange: (e) => setCategory(e.target.value), disabled: pending, block: true, children: categories.map((c) => (_jsx("option", { value: String(c.id), children: c.name }, c.id))) })) })), _jsx(Field, { label: "Title", hint: kind === 'feedback' ? 'One request per thread, so people can vote on it.' : undefined, children: (f) => (_jsx(Input, { ...f, name: "title", value: title, onChange: (e) => setTitle(e.target.value), maxLength: 200, required: true, block: true, disabled: pending })) }), _jsx(Field, { label: "Body", hint: "Markdown is fine.", children: (f) => (_jsx(Textarea, { ...f, name: "body", rows: 8, value: body, onChange: (e) => setBody(e.target.value), required: true, disabled: pending })) }), error && _jsx(Callout, { tone: "bad", children: error }), _jsxs("div", { className: "threads-form-actions", children: [onCancel && (_jsx(Button, { kind: "ghost", onPress: onCancel, disabled: pending, children: "Cancel" })), _jsx(Button, { type: "submit", kind: "primary", busy: pending, disabled: pending, children: pending ? 'Posting…' : 'Post' })] })] }));
29
+ }
@@ -0,0 +1,10 @@
1
+ import type { ThreadStatus } from '../schema.js';
2
+ import type { ThreadRow } from '../threads.js';
3
+ export interface RoadmapProps {
4
+ groups: Record<ThreadStatus, ThreadRow[]>;
5
+ basePath: string;
6
+ /** Statuses to show, in order. Default: planned, in progress, open, done, declined. */
7
+ statuses?: ThreadStatus[];
8
+ }
9
+ /** Every feedback thread, grouped by where it stands. Empty groups are skipped. */
10
+ export declare function Roadmap({ groups, basePath, statuses }: RoadmapProps): import("react").JSX.Element;