bo5tym52p: List and create comments on post cards
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user