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
+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>
);
}