@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 +21 -0
- package/dist/bin/copy.d.ts +31 -0
- package/dist/bin/copy.js +58 -0
- package/dist/bin/migrations.d.ts +2 -0
- package/dist/bin/migrations.js +21 -0
- package/dist/gates.d.ts +48 -0
- package/dist/gates.js +38 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/migrations/0001_threads.sql +185 -0
- package/dist/react/CategoryList.d.ts +9 -0
- package/dist/react/CategoryList.js +7 -0
- package/dist/react/CommentForm.d.ts +19 -0
- package/dist/react/CommentForm.js +35 -0
- package/dist/react/CommentSection.d.ts +42 -0
- package/dist/react/CommentSection.js +26 -0
- package/dist/react/NewThreadForm.d.ts +18 -0
- package/dist/react/NewThreadForm.js +29 -0
- package/dist/react/Roadmap.d.ts +10 -0
- package/dist/react/Roadmap.js +12 -0
- package/dist/react/ThreadList.d.ts +21 -0
- package/dist/react/ThreadList.js +15 -0
- package/dist/react/ThreadView.d.ts +26 -0
- package/dist/react/ThreadView.js +20 -0
- package/dist/react/format.d.ts +16 -0
- package/dist/react/format.js +42 -0
- package/dist/react/index.d.ts +25 -0
- package/dist/react/index.js +17 -0
- package/dist/schema.d.ts +2085 -0
- package/dist/schema.js +129 -0
- package/dist/threads.css +182 -0
- package/dist/threads.d.ts +109 -0
- package/dist/threads.js +626 -0
- package/dist/tree.d.ts +47 -0
- package/dist/tree.js +75 -0
- package/package.json +72 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ThreadList } from './ThreadList.js';
|
|
4
|
+
import { STATUS_LABEL, STATUS_ORDER } from './format.js';
|
|
5
|
+
/** Every feedback thread, grouped by where it stands. Empty groups are skipped. */
|
|
6
|
+
export function Roadmap({ groups, basePath, statuses = STATUS_ORDER }) {
|
|
7
|
+
const shown = statuses.filter((s) => groups[s].length > 0);
|
|
8
|
+
if (shown.length === 0) {
|
|
9
|
+
return _jsx("p", { className: "threads-note", children: "Nothing on the roadmap yet." });
|
|
10
|
+
}
|
|
11
|
+
return (_jsx("div", { className: "threads-stack", children: shown.map((s) => (_jsxs("section", { className: "threads-stack-tight", "aria-labelledby": `roadmap-${s}`, children: [_jsx("h2", { id: `roadmap-${s}`, className: "threads-section-title", children: STATUS_LABEL[s] }), _jsx(ThreadList, { items: groups[s], basePath: basePath, votes: true, label: STATUS_LABEL[s] })] }, s))) }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Category } from '../schema.js';
|
|
2
|
+
import type { ThreadRow } from '../threads.js';
|
|
3
|
+
export interface ThreadListProps {
|
|
4
|
+
items: ThreadRow[];
|
|
5
|
+
/** Where the forum lives, `/forum`. Links are built from it. */
|
|
6
|
+
basePath: string;
|
|
7
|
+
/** When the list mixes categories, name each thread's. */
|
|
8
|
+
categories?: Pick<Category, 'id' | 'name'>[];
|
|
9
|
+
/** For a feedback list: show votes in the trail. */
|
|
10
|
+
votes?: boolean;
|
|
11
|
+
emptyText?: string;
|
|
12
|
+
/** Something to do when the list is empty: a "start one" button. */
|
|
13
|
+
emptyAction?: React.ReactNode;
|
|
14
|
+
label?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Threads as rows: the title truncates, the pills never do, the hint says
|
|
18
|
+
* who and how much, the trail lines up down the list. `Rows` owns the
|
|
19
|
+
* dividers and the empty case.
|
|
20
|
+
*/
|
|
21
|
+
export declare function ThreadList({ items, basePath, categories, votes, emptyText, emptyAction, label, }: ThreadListProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Empty, Pill, Row, Rows } from '@wtfalch/design';
|
|
4
|
+
import { STATUS_LABEL, STATUS_TONE, ago, plural, threadHref } from './format.js';
|
|
5
|
+
/**
|
|
6
|
+
* Threads as rows: the title truncates, the pills never do, the hint says
|
|
7
|
+
* who and how much, the trail lines up down the list. `Rows` owns the
|
|
8
|
+
* dividers and the empty case.
|
|
9
|
+
*/
|
|
10
|
+
export function ThreadList({ items, basePath, categories, votes = false, emptyText = 'No threads yet.', emptyAction, label = 'Threads', }) {
|
|
11
|
+
const byId = new Map((categories ?? []).map((c) => [String(c.id), c.name]));
|
|
12
|
+
return (_jsx(Rows, { label: label, empty: _jsx(Empty, { action: emptyAction, children: emptyText }), children: items.map((t) => (_jsx(Row, { name: _jsx("a", { className: "threads-link", href: threadHref(basePath, t), children: t.title ?? 'Untitled' }), pills: _jsxs(_Fragment, { children: [t.pinned && _jsx(Pill, { inRow: true, children: "Pinned" }), t.locked && _jsx(Pill, { inRow: true, children: "Locked" }), t.status && (_jsx(Pill, { inRow: true, tone: STATUS_TONE[t.status], children: STATUS_LABEL[t.status] })), t.duplicateOf !== null && _jsx(Pill, { inRow: true, children: "Duplicate" })] }), hint: _jsxs(_Fragment, { children: [t.authorName ?? 'Someone', " \u00B7 ", plural(t.commentCount, 'comment'), " \u00B7", ' ', ago(t.lastActivityAt), byId.size > 0 && t.subjectKind === 'category' && byId.get(t.subjectId)
|
|
13
|
+
? ` · ${byId.get(t.subjectId)}`
|
|
14
|
+
: null] }), trail: votes ? (_jsxs(Pill, { quiet: true, children: ['▲', " ", t.voteCount] })) : undefined }, t.id))) }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Who } from '../gates.js';
|
|
2
|
+
import type { ThreadStatus } from '../schema.js';
|
|
3
|
+
import type { ThreadRow } from '../threads.js';
|
|
4
|
+
import { type Outcome } from './format.js';
|
|
5
|
+
export type ModerateAction = 'pin' | 'unpin' | 'lock' | 'unlock' | 'hide' | 'unhide';
|
|
6
|
+
export interface ThreadViewProps {
|
|
7
|
+
thread: ThreadRow;
|
|
8
|
+
who: Who | null;
|
|
9
|
+
/** Where the forum lives, for the link back and the duplicate's redirect. */
|
|
10
|
+
basePath: string;
|
|
11
|
+
/** Whether this person already voted; only read when the thread has a status. */
|
|
12
|
+
voted?: boolean;
|
|
13
|
+
canWrite: boolean;
|
|
14
|
+
canModerate?: boolean;
|
|
15
|
+
onVote?: (on: boolean) => Promise<Outcome>;
|
|
16
|
+
onModerate?: (action: ModerateAction) => Promise<Outcome>;
|
|
17
|
+
onStatus?: (status: ThreadStatus) => Promise<Outcome>;
|
|
18
|
+
/** The comment section, rendered under the post. */
|
|
19
|
+
children?: React.ReactNode;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* One thread: the post, its state, what a reader may do to it, and the
|
|
23
|
+
* discussion underneath. A feedback thread shows its status and a vote; a
|
|
24
|
+
* moderator sees a bar of the things a moderator does.
|
|
25
|
+
*/
|
|
26
|
+
export declare function ThreadView({ thread, who, basePath, voted, canWrite, canModerate, onVote, onModerate, onStatus, children, }: ThreadViewProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Button, Callout, Markdown, Pill, Select } from '@wtfalch/design';
|
|
4
|
+
import { useState, useTransition } from 'react';
|
|
5
|
+
import { STATUS_LABEL, STATUS_ORDER, STATUS_TONE, ago, plural, threadHref, } from './format.js';
|
|
6
|
+
/**
|
|
7
|
+
* One thread: the post, its state, what a reader may do to it, and the
|
|
8
|
+
* discussion underneath. A feedback thread shows its status and a vote; a
|
|
9
|
+
* moderator sees a bar of the things a moderator does.
|
|
10
|
+
*/
|
|
11
|
+
export function ThreadView({ thread, who, basePath, voted = false, canWrite, canModerate = false, onVote, onModerate, onStatus, children, }) {
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
const [pending, start] = useTransition();
|
|
14
|
+
const run = (f) => start(async () => {
|
|
15
|
+
const r = await f();
|
|
16
|
+
setError(r?.error ?? null);
|
|
17
|
+
});
|
|
18
|
+
const feedback = thread.status !== null;
|
|
19
|
+
return (_jsxs("article", { className: "threads-stack", children: [_jsxs("header", { className: "threads-head", children: [_jsx("h1", { className: "threads-title", children: thread.title ?? 'Untitled' }), _jsxs("div", { className: "threads-meta", children: [thread.pinned && _jsx(Pill, { inRow: true, children: "Pinned" }), thread.locked && _jsx(Pill, { inRow: true, children: "Locked" }), thread.hidden && (_jsx(Pill, { inRow: true, tone: "warn", children: "Hidden" })), thread.status && (_jsx(Pill, { tone: STATUS_TONE[thread.status], children: STATUS_LABEL[thread.status] })), _jsxs("span", { children: [thread.authorName ?? 'Someone', " \u00B7 ", ago(thread.createdAt), " \u00B7", ' ', plural(thread.commentCount, 'comment')] })] }), thread.duplicateOf !== null && (_jsxs(Callout, { tone: "info", children: ["This was marked a duplicate.", ' ', _jsx("a", { href: threadHref(basePath, { id: thread.duplicateOf, slug: null }), children: "See the original." })] }))] }), thread.body && (_jsx("div", { className: "threads-body", children: _jsx(Markdown, { text: thread.body }) })), (feedback || canModerate) && (_jsxs("div", { className: "threads-bar", children: [feedback && (_jsxs("span", { className: "threads-vote", children: [_jsxs(Button, { size: "sm", kind: voted ? 'primary' : 'default', disabled: !canWrite || pending || !onVote || thread.duplicateOf !== null, busy: pending, onPress: () => onVote && run(() => onVote(!voted)), "aria-pressed": voted, children: ['▲', " ", thread.voteCount] }), !who && _jsx("span", { className: "threads-note", children: "Sign in to vote." })] })), _jsx("span", { className: "threads-bar-grow" }), canModerate && onModerate && (_jsxs(_Fragment, { children: [_jsx(Button, { size: "sm", kind: "ghost", disabled: pending, onPress: () => run(() => onModerate(thread.pinned ? 'unpin' : 'pin')), children: thread.pinned ? 'Unpin' : 'Pin' }), _jsx(Button, { size: "sm", kind: "ghost", disabled: pending, onPress: () => run(() => onModerate(thread.locked ? 'unlock' : 'lock')), children: thread.locked ? 'Unlock' : 'Lock' }), _jsx(Button, { size: "sm", kind: thread.hidden ? 'ghost' : 'danger', disabled: pending, onPress: () => run(() => onModerate(thread.hidden ? 'unhide' : 'hide')), children: thread.hidden ? 'Unhide' : 'Hide' })] })), canModerate && feedback && onStatus && (_jsx(Select, { size: "sm", "aria-label": "Status", value: thread.status ?? 'open', disabled: pending, onChange: (e) => run(() => onStatus(e.target.value)), children: STATUS_ORDER.map((s) => (_jsx("option", { value: s, children: STATUS_LABEL[s] }, s))) }))] })), error && _jsx(Callout, { tone: "bad", children: error }), children] }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ThreadStatus } from '../schema.js';
|
|
2
|
+
/** What a Server Action answers: a sentence when it refused, nothing when it did not. */
|
|
3
|
+
export type Outcome = {
|
|
4
|
+
error?: string;
|
|
5
|
+
} | void;
|
|
6
|
+
/** "3h ago", from a Date or the string a serialised Date became. */
|
|
7
|
+
export declare function ago(when: Date | string, now?: Date): string;
|
|
8
|
+
export declare const STATUS_LABEL: Record<ThreadStatus, string>;
|
|
9
|
+
/** The tones `Pill`, `Callout` and `Toast` share; the plan's mapping. */
|
|
10
|
+
export declare const STATUS_TONE: Record<ThreadStatus, 'info' | 'good' | 'warn' | 'bad'>;
|
|
11
|
+
export declare const STATUS_ORDER: ThreadStatus[];
|
|
12
|
+
export declare function threadHref(basePath: string, thread: {
|
|
13
|
+
id: number;
|
|
14
|
+
slug: string | null;
|
|
15
|
+
}): string;
|
|
16
|
+
export declare function plural(n: number, one: string, many?: string): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** "3h ago", from a Date or the string a serialised Date became. */
|
|
2
|
+
export function ago(when, now = new Date()) {
|
|
3
|
+
const then = typeof when === 'string' ? new Date(when) : when;
|
|
4
|
+
const s = Math.max(0, Math.round((now.getTime() - then.getTime()) / 1000));
|
|
5
|
+
if (s < 45)
|
|
6
|
+
return 'just now';
|
|
7
|
+
const m = Math.round(s / 60);
|
|
8
|
+
if (m < 60)
|
|
9
|
+
return `${m}m ago`;
|
|
10
|
+
const h = Math.round(m / 60);
|
|
11
|
+
if (h < 24)
|
|
12
|
+
return `${h}h ago`;
|
|
13
|
+
const d = Math.round(h / 24);
|
|
14
|
+
if (d < 30)
|
|
15
|
+
return `${d}d ago`;
|
|
16
|
+
const mo = Math.round(d / 30);
|
|
17
|
+
if (mo < 12)
|
|
18
|
+
return `${mo}mo ago`;
|
|
19
|
+
return `${Math.round(d / 365)}y ago`;
|
|
20
|
+
}
|
|
21
|
+
export const STATUS_LABEL = {
|
|
22
|
+
open: 'Open',
|
|
23
|
+
planned: 'Planned',
|
|
24
|
+
in_progress: 'In progress',
|
|
25
|
+
done: 'Done',
|
|
26
|
+
declined: 'Declined',
|
|
27
|
+
};
|
|
28
|
+
/** The tones `Pill`, `Callout` and `Toast` share; the plan's mapping. */
|
|
29
|
+
export const STATUS_TONE = {
|
|
30
|
+
open: 'info',
|
|
31
|
+
planned: 'info',
|
|
32
|
+
in_progress: 'warn',
|
|
33
|
+
done: 'good',
|
|
34
|
+
declined: 'bad',
|
|
35
|
+
};
|
|
36
|
+
export const STATUS_ORDER = ['planned', 'in_progress', 'open', 'done', 'declined'];
|
|
37
|
+
export function threadHref(basePath, thread) {
|
|
38
|
+
return `${basePath}/t/${thread.id}${thread.slug ? `-${thread.slug}` : ''}`;
|
|
39
|
+
}
|
|
40
|
+
export function plural(n, one, many = `${one}s`) {
|
|
41
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
42
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reference rendering, on `@wtfalch/design`. Every component here is a
|
|
3
|
+
* client component: the design package is React Aria underneath, so a Server
|
|
4
|
+
* Component in the app renders these and passes rows and Server Actions in.
|
|
5
|
+
* Mount them, or write your own over the same actions; the core does not know
|
|
6
|
+
* which.
|
|
7
|
+
*
|
|
8
|
+
* Import `@wtfalch/threads/threads.css` once, after the product stylesheet.
|
|
9
|
+
*/
|
|
10
|
+
export { CommentSection } from './CommentSection.js';
|
|
11
|
+
export type { CommentSectionProps } from './CommentSection.js';
|
|
12
|
+
export { CommentForm } from './CommentForm.js';
|
|
13
|
+
export type { CommentFormProps } from './CommentForm.js';
|
|
14
|
+
export { ThreadList } from './ThreadList.js';
|
|
15
|
+
export type { ThreadListProps } from './ThreadList.js';
|
|
16
|
+
export { ThreadView } from './ThreadView.js';
|
|
17
|
+
export type { ModerateAction, ThreadViewProps } from './ThreadView.js';
|
|
18
|
+
export { NewThreadForm } from './NewThreadForm.js';
|
|
19
|
+
export type { NewThreadFormProps } from './NewThreadForm.js';
|
|
20
|
+
export { Roadmap } from './Roadmap.js';
|
|
21
|
+
export type { RoadmapProps } from './Roadmap.js';
|
|
22
|
+
export { CategoryList } from './CategoryList.js';
|
|
23
|
+
export type { CategoryListProps } from './CategoryList.js';
|
|
24
|
+
export type { Outcome } from './format.js';
|
|
25
|
+
export { STATUS_LABEL, STATUS_ORDER, STATUS_TONE, ago, threadHref } from './format.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reference rendering, on `@wtfalch/design`. Every component here is a
|
|
3
|
+
* client component: the design package is React Aria underneath, so a Server
|
|
4
|
+
* Component in the app renders these and passes rows and Server Actions in.
|
|
5
|
+
* Mount them, or write your own over the same actions; the core does not know
|
|
6
|
+
* which.
|
|
7
|
+
*
|
|
8
|
+
* Import `@wtfalch/threads/threads.css` once, after the product stylesheet.
|
|
9
|
+
*/
|
|
10
|
+
export { CommentSection } from './CommentSection.js';
|
|
11
|
+
export { CommentForm } from './CommentForm.js';
|
|
12
|
+
export { ThreadList } from './ThreadList.js';
|
|
13
|
+
export { ThreadView } from './ThreadView.js';
|
|
14
|
+
export { NewThreadForm } from './NewThreadForm.js';
|
|
15
|
+
export { Roadmap } from './Roadmap.js';
|
|
16
|
+
export { CategoryList } from './CategoryList.js';
|
|
17
|
+
export { STATUS_LABEL, STATUS_ORDER, STATUS_TONE, ago, threadHref } from './format.js';
|