Skip to content

Commit d96658a

Browse files
authored
Merge pull request #2 from jouwdan/claude/docs-folder-next-step-019o7v
2 parents 2f71432 + 206eda1 commit d96658a

60 files changed

Lines changed: 5267 additions & 118 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { Metadata } from 'next'
2+
import { notFound } from 'next/navigation'
3+
4+
import { requireSlot } from '@forum/theme-kit'
5+
6+
import { NewThreadForm } from '@/components/content/new-thread-form'
7+
import { getContainer } from '@/server/container'
8+
import { getActor } from '@/server/context'
9+
import { activeTheme } from '@/server/theme'
10+
import { buildNewThreadView } from '@/view/post-form'
11+
12+
export const metadata: Metadata = { title: 'New thread' }
13+
14+
function forumId(value: string): number | null {
15+
const match = /^(\d+)(?:-|$)/.exec(value)
16+
if (!match) return null
17+
const id = Number(match[1])
18+
return Number.isSafeInteger(id) && id > 0 ? id : null
19+
}
20+
21+
export default async function NewThreadPage({
22+
params,
23+
}: {
24+
params: Promise<{ slug: string }>
25+
}) {
26+
const { slug } = await params
27+
const id = forumId(slug)
28+
if (id === null) notFound()
29+
30+
const actor = await getActor()
31+
const { authorizer, forums, threadWrites } = getContainer()
32+
33+
/*
34+
* Fixture mode has no writer, so the composer does not exist there rather
35+
* than existing and failing on submit — the same rule the scheduler and the
36+
* CLI follow: never advertise a capability that is not there (D32).
37+
*/
38+
if (threadWrites === null) notFound()
39+
40+
const forum = await forums.findById(id)
41+
if (!forum || forum.type !== 'forum') notFound()
42+
43+
const matrix = await authorizer.forumMatrix(actor, id)
44+
const target = { forumId: id, forum: matrix }
45+
/*
46+
* Two checks, not one. Without `thread.view` the forum is not something this
47+
* actor may know exists, so the answer is the same 404 the listing gives;
48+
* with it but without `thread.post`, they may look and not write. The action
49+
* repeats both — rendering a page is not authorisation.
50+
*/
51+
if (!authorizer.can(actor, 'thread.view', target)) notFound()
52+
if (!authorizer.can(actor, 'thread.post', target)) notFound()
53+
54+
const rules = await threadWrites.postingRules(id)
55+
if (!rules) notFound()
56+
57+
const prefixes = await threadWrites.listPrefixes(id)
58+
59+
const view = buildNewThreadView({
60+
forum: { id: forum.id, title: forum.title, slug: forum.slug },
61+
// A closed forum still renders its composer, with the reason stated. The
62+
// alternative — a 404 — reads as "this forum vanished" to someone who was
63+
// just looking at it.
64+
errorMessage:
65+
rules.isOpen && rules.allowThreads
66+
? null
67+
: 'This forum is closed to new threads.',
68+
})
69+
70+
const PostForm = requireSlot(activeTheme, 'PostForm')
71+
72+
return (
73+
<main id="board-content" tabIndex={-1} className="flex-1">
74+
<PostForm
75+
{...view}
76+
regions={{
77+
form:
78+
rules.isOpen && rules.allowThreads ? (
79+
<NewThreadForm
80+
forumId={id}
81+
prefixes={prefixes.map((p) => ({ id: p.id, label: p.label }))}
82+
requiresPrefix={rules.requiresPrefix}
83+
canSubscribe={authorizer.can(actor, 'forum.subscribe', target)}
84+
/>
85+
) : null,
86+
// F45's island. Absent by design: the plain textarea above is the
87+
// posting path, and it must stay the whole path.
88+
toolbar: null,
89+
}}
90+
/>
91+
</main>
92+
)
93+
}

apps/forum/app/(board)/forum/[slug]/page.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import { getActor } from "@/server/context";
77
import { activeTheme } from "@/server/theme";
88
import { decodeForumCursor, encodeForumCursor } from "@/view/forum-cursor";
99
import { buildForumDisplayView } from "@/view/forum-display";
10-
11-
const THREADS_PER_PAGE = 25;
10+
import { THREADS_PER_PAGE } from "@/view/paging";
1211

1312
export const metadata: Metadata = { title: "Forum" };
1413

@@ -24,7 +23,7 @@ export default async function ForumPage({
2423
searchParams,
2524
}: {
2625
params: Promise<{ slug: string }>;
27-
searchParams: Promise<{ after?: string; page?: string }>;
26+
searchParams: Promise<{ after?: string; page?: string; posted?: string }>;
2827
}) {
2928
const [{ slug }, query] = await Promise.all([params, searchParams]);
3029
const id = forumId(slug);
@@ -34,7 +33,7 @@ export default async function ForumPage({
3433
notFound();
3534

3635
const actor = await getActor();
37-
const { forums, threads, authorizer, readState } = getContainer();
36+
const { forums, threads, authorizer, readState, threadWrites } = getContainer();
3837
const [rows, visible, read] = await Promise.all([
3938
forums.listListing(),
4039
authorizer.visibleForumIds(actor),
@@ -57,8 +56,19 @@ export default async function ForumPage({
5756
const nextHref = threadPage.nextCursor
5857
? `/forum/${id}-${forum.slug}?after=${encodeForumCursor(threadPage.nextCursor)}&page=${page + 1}`
5958
: null;
59+
/*
60+
* The composer link appears only when this actor may actually use it, and
61+
* only when the board can accept a post at all (fixture mode cannot). A link
62+
* to a page that 404s is worse than no link.
63+
*/
64+
const canPost =
65+
threadWrites !== null &&
66+
forum.type === "forum" &&
67+
authorizer.can(actor, "thread.post", { forumId: id, forum: matrix });
68+
6069
const view = buildForumDisplayView({
6170
forum,
71+
newThreadHref: canPost ? `/forum/${id}-${forum.slug}/new` : null,
6272
subforums: rows.filter(
6373
(row) => row.parentId === id && visible.includes(row.id),
6474
),
@@ -71,12 +81,27 @@ export default async function ForumPage({
7181
});
7282

7383
const ForumDisplay = requireSlot(activeTheme, "ForumDisplay");
84+
const Notice = requireSlot(activeTheme, "Notice");
7485
const ThreadRow = requireSlot(activeTheme, "ThreadRow");
7586
const SubforumList = requireSlot(activeTheme, "SubforumList");
7687
const Pagination = requireSlot(activeTheme, "Pagination");
7788

7889
return (
7990
<main id="board-content" tabIndex={-1} className="flex-1">
91+
{/*
92+
Where a held thread lands. The author cannot be sent to a thread nobody
93+
can see, so the forum tells them what happened; dismissal is the same
94+
link without the parameter, which needs no JavaScript and no state.
95+
*/}
96+
{query.posted === "moderated" && (
97+
<div className="px-6 pt-6">
98+
<Notice
99+
kind="info"
100+
message="Your thread was posted and is waiting for a moderator to approve it."
101+
dismissHref={`/forum/${id}-${forum.slug}`}
102+
/>
103+
</div>
104+
)}
80105
<ForumDisplay
81106
{...view.display}
82107
regions={{

apps/forum/app/(board)/thread/[slug]/page.tsx

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@ import { requireSlot } from '@forum/theme-kit'
66
import { getContainer } from '@/server/container'
77
import { getActor } from '@/server/context'
88
import { activeTheme } from '@/server/theme'
9+
import { POSTS_PER_PAGE } from '@/view/paging'
910
import { buildThreadView } from '@/view/thread-view'
1011

11-
const POSTS_PER_PAGE = 20
12-
1312
export const metadata: Metadata = { title: 'Thread' }
1413

1514
function threadId(value: string): number | null {
@@ -32,7 +31,7 @@ export default async function ThreadPage({
3231
searchParams,
3332
}: {
3433
params: Promise<{ slug: string }>
35-
searchParams: Promise<{ after?: string; page?: string }>
34+
searchParams: Promise<{ after?: string; page?: string; replied?: string; posted?: string }>
3635
}) {
3736
const [{ slug }, query] = await Promise.all([params, searchParams])
3837
const id = threadId(slug)
@@ -41,7 +40,7 @@ export default async function ThreadPage({
4140
if (id === null || after === null || !Number.isSafeInteger(page) || page < 1) notFound()
4241

4342
const actor = await getActor()
44-
const { forums, posts, threads, authorizer } = getContainer()
43+
const { forums, posts, threads, authorizer, threadViews, threadWrites } = getContainer()
4544
const thread = await threads.findVisibleById(id)
4645
if (!thread) notFound()
4746

@@ -50,15 +49,37 @@ export default async function ThreadPage({
5049
const matrix = await authorizer.forumMatrix(actor, forum.id)
5150
if (!authorizer.can(actor, 'thread.view', { forumId: forum.id, forum: matrix })) notFound()
5251

52+
/*
53+
* Count the view only after the permission check, and only on the first page:
54+
* paging through a long thread is one visit, and a viewer who cannot see the
55+
* thread has not viewed it. The write is buffered (F38) rather than applied to
56+
* `threads`, and a failure is swallowed — a view counter is never a reason to
57+
* fail a page that has already been authorised and read.
58+
*/
59+
if (threadViews && after === undefined) {
60+
await threadViews.record(thread.id).catch(() => undefined)
61+
}
62+
5363
const postPage = await posts.listThread(
5464
thread.id,
5565
after === undefined ? { limit: POSTS_PER_PAGE } : { afterId: after, limit: POSTS_PER_PAGE },
5666
)
5767
const nextHref = postPage.nextAfterId === null
5868
? null
5969
: `/thread/${thread.id}-${thread.slug}?after=${postPage.nextAfterId}&page=${page + 1}`
70+
/*
71+
* The reply link is offered only where the actor may actually use it, and a
72+
* locked thread offers it to nobody but a moderator — the same answer the
73+
* action gives, computed twice because a link is not authorisation.
74+
*/
75+
const canReply =
76+
threadWrites !== null &&
77+
authorizer.can(actor, 'reply.post', { forumId: forum.id, forum: matrix }) &&
78+
(!thread.isLocked || authorizer.can(actor, 'content.viewUnapproved', { forumId: forum.id, forum: matrix }))
79+
6080
const view = buildThreadView({
6181
thread,
82+
replyHref: canReply ? `/thread/${thread.id}-${thread.slug}/reply` : null,
6283
forum,
6384
page: postPage,
6485
pageNumber: page,
@@ -71,12 +92,29 @@ export default async function ThreadPage({
7192
})
7293

7394
const ThreadView = requireSlot(activeTheme, 'ThreadView')
95+
const Notice = requireSlot(activeTheme, 'Notice')
7496
const PostBit = requireSlot(activeTheme, 'PostBit')
7597
const PostActions = requireSlot(activeTheme, 'PostActions')
7698
const Pagination = requireSlot(activeTheme, 'Pagination')
7799

100+
const notice =
101+
query.replied === 'race'
102+
? 'Somebody else replied while you were writing. Your reply was posted below theirs.'
103+
: query.posted === 'moderated'
104+
? 'Your reply was posted and is waiting for a moderator to approve it.'
105+
: null
106+
78107
return (
79108
<main id="board-content" tabIndex={-1} className="flex-1">
109+
{notice !== null && (
110+
<div className="px-6 pt-6">
111+
<Notice
112+
kind="info"
113+
message={notice}
114+
dismissHref={`/thread/${thread.id}-${thread.slug}`}
115+
/>
116+
</div>
117+
)}
80118
<ThreadView
81119
{...view.view}
82120
regions={{
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { Metadata } from 'next'
2+
import { notFound } from 'next/navigation'
3+
4+
import { quotePrefill } from '@forum/threads'
5+
import { requireSlot } from '@forum/theme-kit'
6+
7+
import { ReplyForm } from '@/components/content/reply-form'
8+
import { getContainer } from '@/server/container'
9+
import { getActor } from '@/server/context'
10+
import { activeTheme } from '@/server/theme'
11+
import { buildReplyView } from '@/view/post-form'
12+
13+
export const metadata: Metadata = { title: 'Reply' }
14+
15+
function threadId(value: string): number | null {
16+
const match = /^(\d+)(?:-|$)/.exec(value)
17+
if (!match) return null
18+
const id = Number(match[1])
19+
return Number.isSafeInteger(id) && id > 0 ? id : null
20+
}
21+
22+
function quotedPostId(value: string | undefined): number | null {
23+
if (value === undefined || !/^[1-9]\d*$/.test(value)) return null
24+
const id = Number(value)
25+
return Number.isSafeInteger(id) ? id : null
26+
}
27+
28+
export default async function ReplyPage({
29+
params,
30+
searchParams,
31+
}: {
32+
params: Promise<{ slug: string }>
33+
searchParams: Promise<{ quote?: string }>
34+
}) {
35+
const [{ slug }, query] = await Promise.all([params, searchParams])
36+
const id = threadId(slug)
37+
if (id === null) notFound()
38+
39+
const actor = await getActor()
40+
const { authorizer, posts, threadWrites } = getContainer()
41+
if (threadWrites === null) notFound()
42+
43+
const target = await threadWrites.replyTarget(id)
44+
if (!target || target.visibility !== 'visible') notFound()
45+
46+
const scope = {
47+
forumId: target.forum.id,
48+
forum: await authorizer.forumMatrix(actor, target.forum.id),
49+
}
50+
if (!authorizer.can(actor, 'thread.view', scope)) notFound()
51+
if (!authorizer.can(actor, 'reply.post', scope)) notFound()
52+
53+
const moderates = authorizer.can(actor, 'content.viewUnapproved', scope)
54+
const locked = target.isLocked && !moderates
55+
56+
/*
57+
* The quote is resolved here, on the server, so quoting works with scripting
58+
* off: it is a link to this page, not a button that edits a textarea. The
59+
* quoted post is re-read through the visible-post lookup rather than trusted
60+
* from the query string — otherwise `?quote=<id>` is a way to paste any post
61+
* on the board, including one in a forum the quoter cannot see, into a forum
62+
* where everyone can.
63+
*/
64+
const quoteId = quotedPostId(query.quote)
65+
let prefill = ''
66+
if (quoteId !== null) {
67+
const quoted = await posts.findQuotable(id, quoteId)
68+
if (quoted) {
69+
prefill = quotePrefill({
70+
postId: quoted.id,
71+
authorUsername: quoted.authorUsername,
72+
message: quoted.message,
73+
})
74+
}
75+
}
76+
77+
const view = buildReplyView({
78+
thread: { id: target.threadId, title: target.title, slug: target.slug },
79+
errorMessage: locked ? 'This thread is locked.' : null,
80+
})
81+
82+
const PostForm = requireSlot(activeTheme, 'PostForm')
83+
84+
return (
85+
<main id="board-content" tabIndex={-1} className="flex-1">
86+
<PostForm
87+
{...view}
88+
regions={{
89+
form: locked ? null : (
90+
<ReplyForm
91+
threadId={target.threadId}
92+
seenLastPostId={target.lastPostId}
93+
prefill={prefill}
94+
canSubscribe={authorizer.can(actor, 'forum.subscribe', scope)}
95+
/>
96+
),
97+
toolbar: null,
98+
}}
99+
/>
100+
</main>
101+
)
102+
}

apps/forum/src/components/auth/form-controls.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ interface FieldProps {
6060
required?: boolean | undefined
6161
defaultValue?: string | undefined
6262
minLength?: number | undefined
63+
/** Mirrors a server-side limit; never the only enforcement of one. */
64+
maxLength?: number | undefined
6365
hint?: string | undefined
6466
}
6567

@@ -71,6 +73,7 @@ export function Field({
7173
required = true,
7274
defaultValue,
7375
minLength,
76+
maxLength,
7477
hint,
7578
}: FieldProps) {
7679
const hintId = hint ? `${name}-hint` : undefined
@@ -84,6 +87,7 @@ export function Field({
8487
required={required}
8588
defaultValue={defaultValue}
8689
minLength={minLength}
90+
maxLength={maxLength}
8791
aria-describedby={hintId}
8892
className="h-10 rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40"
8993
/>

0 commit comments

Comments
 (0)