bo5tym52p: List and create comments on post cards

This commit is contained in:
2026-10-19 14:29:07 -06:00
parent de535a500d
commit 5a33546af2
11 changed files with 596 additions and 2 deletions
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse, type NextRequest } from "next/server";
import { createComment, listComments } from "@/lib/comments";
import { SESSION_COOKIE } from "@/lib/cookie";
// GET /api/posts/:id/comments -> feed GET /api/v1/posts/{id}/comments?limit=20&page=1
// POST /api/posts/:id/comments -> feed POST /api/v1/posts/{id}/comments
// body { content } -> feed { content, type: "text", status: "published" }
// 401 { error: "sign_in" } without a session.
export const runtime = "nodejs";
type Ctx = { params: Promise<{ id: string }> };
export async function GET(req: NextRequest, ctx: Ctx) {
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
const { id } = await ctx.params;
const r = await listComments(sessionId, id);
if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status });
return NextResponse.json({ comments: r.comments });
}
export async function POST(req: NextRequest, ctx: Ctx) {
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
const { id } = await ctx.params;
let content: unknown = null;
try {
content = ((await req.json()) as { content?: unknown })?.content;
} catch {
content = null;
}
const r = await createComment(sessionId, id, content);
if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status });
return NextResponse.json({ comment: r.comment });
}
+14
View File
@@ -47,3 +47,17 @@ button:disabled { opacity: .5; cursor: not-allowed; }
.composer textarea { font: inherit; width: 100%; padding: 12px; border: 1px solid #999; border-radius: 0; resize: vertical; background: #fff; color: var(--ink); }
.composer textarea:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.composer .composer-post { align-self: flex-end; margin: 0; padding: 10px 24px; border-radius: 0; }
.comments { display: flex; flex-direction: column; gap: 10px; padding-top: 10px; border-top: 1px solid #eee; }
.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.comment p { margin: 2px 0 0; font-size: 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
.comment-head { display: flex; gap: 8px; align-items: baseline; font-size: 13px; }
.comment-head time { color: var(--grey); font-size: 12px; }
.comment.removed .comment-removed { color: var(--grey); font-style: italic; }
.comments-empty { margin: 0; color: var(--grey); font-size: 14px; }
.comment-error { margin: 0; color: #a8141b; font-size: 13px; }
.comment-form { display: flex; gap: 8px; }
.comment-form input { flex: 1; margin: 0; }
.comment-form button { margin: 0; }
.link-btn { background: transparent; color: inherit; text-decoration: underline; padding: 0; margin: 0; font-size: inherit; width: auto; }
.comments-toggle { background: transparent; color: var(--grey); padding: 4px 8px; margin: 0; font-size: 14px; font-weight: 400; width: auto; }
+61
View File
@@ -0,0 +1,61 @@
import type { CommentItem } from "@/lib/comment-model";
import { relativeTime } from "@/lib/feed-model";
export interface CommentsPanelProps {
comments: CommentItem[];
loading?: boolean;
/** Error text naming the action that failed. The list stays shown. */
error?: string | null;
value: string;
busy?: boolean;
onChange?: (v: string) => void;
onSubmit?: () => void;
onRetry?: () => void;
now?: number;
}
export function CommentsPanel(p: CommentsPanelProps) {
const empty = !p.value.trim();
return (
<div className="comments" data-testid="comments-panel">
{p.loading ? <span className="spinner" role="status" aria-label="Loading comments" /> : null}
{!p.loading && p.comments.length === 0 && !p.error ? (
<p className="comments-empty">No comments yet.</p>
) : null}
<ul className="comment-list">
{p.comments.map((c) => (
<li key={c.key} className={c.removed ? "comment removed" : "comment"} data-testid="comment">
<div className="comment-head">
<strong>{c.authorName}</strong>
<time dateTime={c.createdAt ?? undefined}>{relativeTime(c.createdAt, p.now)}</time>
</div>
{c.removed ? <p className="comment-removed">This comment was removed.</p> : <p>{c.content}</p>}
</li>
))}
</ul>
{p.error ? (
<p className="comment-error" role="alert">
{p.error}{" "}
{p.onRetry ? <button type="button" className="link-btn" onClick={p.onRetry}>Try again</button> : null}
</p>
) : null}
<form
className="comment-form"
onSubmit={(e) => {
e.preventDefault();
if (!empty && !p.busy) p.onSubmit?.();
}}
>
<input
type="text"
aria-label="Add a comment"
placeholder="Add a comment"
value={p.value}
maxLength={5000}
onChange={(e) => p.onChange?.(e.target.value)}
/>
<button type="submit" disabled={empty || p.busy}>{p.busy ? "Posting" : "Post"}</button>
</form>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { CommentError, commentErrorText, loadComments, postComment } from "@/lib/comment-client";
import type { CommentItem } from "@/lib/comment-model";
import { CommentsPanel } from "./CommentsPanel";
/** Opened comments for one post: loads on mount, posts a text comment into the list. */
export function CommentsSection({ postId, onSignedOut }: { postId: string; onSignedOut?: () => void }) {
const [comments, setComments] = useState<CommentItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [value, setValue] = useState("");
const [busy, setBusy] = useState(false);
const fail = useCallback(
(e: unknown, action: "load" | "post") => {
if (e instanceof CommentError && e.signedOut) onSignedOut?.();
setError(commentErrorText(action));
},
[onSignedOut],
);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setComments(await loadComments(postId));
} catch (e) {
fail(e, "load");
} finally {
setLoading(false);
}
}, [postId, fail]);
useEffect(() => {
void load();
}, [load]);
const submit = useCallback(async () => {
if (!value.trim() || busy) return;
setBusy(true);
setError(null);
try {
setComments(await postComment(postId, value, comments));
setValue("");
} catch (e) {
fail(e, "post"); // the list and the typed text stay in place
} finally {
setBusy(false);
}
}, [postId, value, busy, comments, fail]);
return (
<CommentsPanel
comments={comments}
loading={loading}
error={error}
value={value}
busy={busy}
onChange={setValue}
onSubmit={() => void submit()}
onRetry={error === commentErrorText("load") ? () => void load() : undefined}
/>
);
}
+1
View File
@@ -199,6 +199,7 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
onToggleLike={(p) => void toggleLike(p)}
likeBusy={likeBusy}
likeErrors={likeErrors}
onSignedOut={onSignedOut}
composer={{ value: draft, busy: posting, error: postFailed, onChange: changeDraft, onSubmit: () => void publish() }}
sentinelRef={sentinel}
/>
+2
View File
@@ -16,6 +16,7 @@ export interface FeedViewProps {
onToggleLike?: (post: CardPost) => void;
likeBusy?: ReadonlySet<string>;
likeErrors?: ReadonlySet<string>;
onSignedOut?: () => void;
composer?: ComposerProps;
sentinelRef?: Ref<HTMLDivElement>;
now?: number;
@@ -63,6 +64,7 @@ export function FeedView(p: FeedViewProps) {
onToggleLike={p.onToggleLike}
likeBusy={p.likeBusy?.has(post.key)}
likeError={p.likeErrors?.has(post.key)}
onSignedOut={p.onSignedOut}
/>)
)}
{p.loadMoreFailed ? (
+17 -2
View File
@@ -1,3 +1,7 @@
"use client";
import { useState } from "react";
import { CommentsSection } from "@/components/CommentsSection";
import { VideoPlayer } from "@/components/VideoPlayer";
import { initials, relativeTime, type CardPost } from "@/lib/feed-model";
@@ -7,6 +11,7 @@ export interface PostCardProps {
onToggleLike?: (post: CardPost) => void;
likeBusy?: boolean;
likeError?: boolean;
onSignedOut?: () => void;
}
function Heart({ filled }: { filled: boolean }) {
@@ -19,7 +24,8 @@ function Heart({ filled }: { filled: boolean }) {
);
}
export function PostCard({ post, now, onToggleLike, likeBusy, likeError }: PostCardProps) {
export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut }: PostCardProps) {
const [open, setOpen] = useState(false);
return (
<article className="post" data-testid="post-card">
<header className="post-head">
@@ -60,11 +66,20 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError }: PostC
>
<Heart filled={post.isLiked} /> <span className="like-count">{post.likesCount}</span>
</button>
<span aria-label={`${post.commentsCount} comments`}>{post.commentsCount} comments</span>
<button
type="button"
className="comments-toggle"
aria-expanded={open}
disabled={!post.id}
onClick={() => setOpen((o) => !o)}
>
{post.commentsCount} comments
</button>
{likeError ? (
<span className="like-error" role="alert">Could not update like.</span>
) : null}
</footer>
{open && post.id ? <CommentsSection postId={post.id} onSignedOut={onSignedOut} /> : null}
</article>
);
}
+70
View File
@@ -0,0 +1,70 @@
// Browser-side comment calls. Each throws CommentError naming the action.
import { appendComment, type CommentItem } from "./comment-model";
export type CommentAction = "load" | "post";
export class CommentError extends Error {
constructor(public readonly action: CommentAction, public readonly signedOut: boolean) {
super(signedOut ? "sign_in" : `comment_${action}_failed`);
}
}
export function commentErrorText(action: CommentAction): string {
return action === "load" ? "Could not load comments." : "Could not post comment.";
}
async function parse(res: Response, action: CommentAction): Promise<Record<string, unknown>> {
if (res.status === 401) throw new CommentError(action, true);
if (!res.ok) throw new CommentError(action, false);
try {
return (await res.json()) as Record<string, unknown>;
} catch {
throw new CommentError(action, false);
}
}
export async function loadComments(postId: string, fetchImpl: typeof fetch = fetch): Promise<CommentItem[]> {
let res: Response;
try {
res = await fetchImpl(`/api/posts/${encodeURIComponent(postId)}/comments`, { credentials: "same-origin" });
} catch {
throw new CommentError("load", false);
}
const b = await parse(res, "load");
return Array.isArray(b.comments) ? (b.comments as CommentItem[]) : [];
}
/** Post a comment and return the list as it should now read. Throws and returns nothing on failure. */
export async function postComment(
postId: string,
text: string,
current: CommentItem[],
fetchImpl: typeof fetch = fetch,
): Promise<CommentItem[]> {
const content = text.trim();
if (!content) return current;
let res: Response;
try {
res = await fetchImpl(`/api/posts/${encodeURIComponent(postId)}/comments`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
} catch {
throw new CommentError("post", false);
}
const b = await parse(res, "post");
const made = b.comment as CommentItem | null | undefined;
if (made && typeof made === "object") return appendComment(current, made);
// The feed did not return the new comment; read the list back.
try {
return await loadComments(postId, fetchImpl);
} catch {
return appendComment(current, {
key: `local#${current.length}`, id: null, authorName: "You",
createdAt: new Date().toISOString(), removed: false, content,
});
}
}
+71
View File
@@ -0,0 +1,71 @@
// Pure comment model: normalise raw feed comments into what the panel shows.
// A comment with deletedAt set is "removed" and never carries its old text.
export interface CommentItem {
key: string;
id: string | null;
authorName: string;
createdAt: string | null;
removed: boolean;
/** Empty when removed. */
content: string;
}
type Obj = Record<string, unknown>;
function isObj(v: unknown): v is Obj {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function str(v: unknown): string | null {
return typeof v === "string" && v.trim() ? v : null;
}
function nameOf(author: Obj): string {
const display = str(author.displayName);
if (display) return display.trim();
const full = [str(author.firstName), str(author.lastName)].filter(Boolean).join(" ").trim();
return full || "Unknown";
}
export function toComment(raw: unknown, index = 0): CommentItem | null {
if (!isObj(raw)) return null;
const id = typeof raw.id === "string" || typeof raw.id === "number" ? String(raw.id) : null;
const createdAt = str(raw.createdAt);
const removed = raw.deletedAt !== null && raw.deletedAt !== undefined && raw.deletedAt !== false && raw.deletedAt !== "";
const author = isObj(raw.author) ? raw.author : isObj(raw.user) ? raw.user : {};
return {
key: id ?? `${createdAt ?? "unknown"}#${index}`,
id,
authorName: nameOf(author),
createdAt,
removed,
content: removed ? "" : typeof raw.content === "string" ? raw.content : "",
};
}
/** Find the comment array in a feed response body. */
export function commentsFromBody(body: unknown): unknown[] {
const root = isObj(body) ? body : {};
const data = root.data;
const candidates: unknown[] = [
data, isObj(data) ? data.comments : null, isObj(data) ? data.items : null,
root.comments, root.items,
];
for (const c of candidates) if (Array.isArray(c)) return c;
return [];
}
export function toComments(raws: unknown[]): CommentItem[] {
const out: CommentItem[] = [];
raws.forEach((r, i) => {
const c = toComment(r, i);
if (c) out.push(c);
});
return out;
}
/** Add a comment to the end of the list unless its id is already there. */
export function appendComment(list: CommentItem[], c: CommentItem): CommentItem[] {
if (c.id && list.some((x) => x.id === c.id)) return list;
return [...list, c];
}
+116
View File
@@ -0,0 +1,116 @@
import { FEED_BASE_URL } from "./env";
import { commentsFromBody, toComment, toComments, type CommentItem } from "./comment-model";
import { ensureFreshSession, refreshSession } from "./session-refresh";
// Comment list / create proxy. One feed call per request with the person's
// server-held feed token, plus one refresh-and-retry after a 401 rejection.
// Neither the token nor comment text is logged.
export const MAX_COMMENT_LENGTH = 5_000;
const POST_ID = /^[A-Za-z0-9_-]{1,128}$/;
type Err<E extends string> = { ok: false; status: 400 | 401 | 502; error: "sign_in" | "bad_post" | E };
export type ListCommentsResult = { ok: true; comments: CommentItem[] } | Err<"comments_unavailable">;
export type CreateCommentResult =
| { ok: true; comment: CommentItem | null }
| Err<"empty_comment" | "comment_failed">;
interface Options {
fetchImpl?: typeof fetch;
now?: number;
}
function rec(v: unknown): Record<string, unknown> {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
}
function url(postId: string, query = "") {
return `${FEED_BASE_URL}/api/v1/posts/${encodeURIComponent(postId)}/comments${query}`;
}
async function callWithRefresh(
sessionId: string,
first: Awaited<ReturnType<typeof ensureFreshSession>>,
make: (token: string) => Promise<Response>,
options: Options,
): Promise<Response | "sign_in"> {
let session = first;
if (!session) return "sign_in";
let res = await make(session.feedAccessToken);
if (res.status === 401) {
session = await refreshSession(sessionId, options);
if (!session) return "sign_in";
res = await make(session.feedAccessToken);
if (res.status === 401) return "sign_in";
}
return res;
}
export async function listComments(
sessionId: string | undefined,
postId: string,
options: Options = {},
): Promise<ListCommentsResult> {
const signIn = { ok: false, status: 401, error: "sign_in" } as const;
const failed = { ok: false, status: 502, error: "comments_unavailable" } as const;
const doFetch = options.fetchImpl ?? fetch;
const session = await ensureFreshSession(sessionId, options);
if (!session || !sessionId) return signIn;
if (!POST_ID.test(postId)) return { ok: false, status: 400, error: "bad_post" };
try {
const res = await callWithRefresh(
sessionId, session,
(t) => doFetch(url(postId, "?limit=20&page=1"), {
method: "GET", headers: { Accept: "application/json", Authorization: `Bearer ${t}` },
}),
options,
);
if (res === "sign_in") return signIn;
if (res.status !== 200) return failed;
return { ok: true, comments: toComments(commentsFromBody(await res.json())) };
} catch {
return failed;
}
}
export async function createComment(
sessionId: string | undefined,
postId: string,
content: unknown,
options: Options = {},
): Promise<CreateCommentResult> {
const signIn = { ok: false, status: 401, error: "sign_in" } as const;
const failed = { ok: false, status: 502, error: "comment_failed" } as const;
const doFetch = options.fetchImpl ?? fetch;
const session = await ensureFreshSession(sessionId, options);
if (!session || !sessionId) return signIn;
if (!POST_ID.test(postId)) return { ok: false, status: 400, error: "bad_post" };
if (typeof content !== "string" || !content.trim() || content.length > MAX_COMMENT_LENGTH) {
return { ok: false, status: 400, error: "empty_comment" };
}
try {
const res = await callWithRefresh(
sessionId, session,
(t) => doFetch(url(postId), {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify({ content, type: "text", status: "published" }),
}),
options,
);
if (res === "sign_in") return signIn;
if (res.status !== 200 && res.status !== 201) return failed;
let comment: CommentItem | null = null;
try {
const body = rec(await res.json());
const data = rec(body.data);
const raw = Object.keys(rec(data.comment)).length ? data.comment : data.id !== undefined ? data : null;
comment = raw ? toComment(raw) : null;
} catch {
comment = null;
}
return { ok: true, comment };
} catch {
return failed;
}
}