8zbhzr21j: Restyle post cards, comments and the own-post menu to the redesign spec

This commit is contained in:
2026-10-19 22:13:28 -06:00
parent 89064e88c1
commit 11e4ec561b
9 changed files with 432 additions and 84 deletions
+1 -1
View File
@@ -227,8 +227,8 @@ export interface IconButtonProps extends NativeButton {
export function IconButton({ label, icon, shape = "square", size = "md", className, type = "button", ...rest }: IconButtonProps) {
return (
<button
{...rest}
type={type}
{...rest}
aria-label={label}
title={rest.title ?? label}
className={cx("btn-gloss", shape === "round" && "btn-gloss-round", size === "sm" && "btn-gloss-sm", className)}
+17 -12
View File
@@ -1,4 +1,6 @@
import type { CommentItem } from "@/lib/comment-model";
import { HexAvatar } from "@/components/HexAvatar";
import { IconButton, SendIcon } from "@/components/Buttons";
import { relativeTime } from "@/lib/feed-model";
export interface CommentsPanelProps {
@@ -17,30 +19,33 @@ export interface CommentsPanelProps {
export function CommentsPanel(p: CommentsPanelProps) {
const empty = !p.value.trim();
return (
<div className="comments" data-testid="comments-panel">
<div className="post-cm" 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>
<p className="post-cm-empty">No comments yet.</p>
) : null}
<ul className="comment-list">
<ul className="post-cm-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>
<li key={c.key} className="post-c" data-testid="comment">
<HexAvatar name={c.authorName} size="sm" />
<div className={c.removed ? "post-c-body post-c-removed" : "post-c-body"}>
<div className="post-c-head">
<strong>{c.authorName}</strong>
<time dateTime={c.createdAt ?? undefined}>{relativeTime(c.createdAt, p.now)}</time>
</div>
{c.removed ? <p>This comment was removed.</p> : <p>{c.content}</p>}
</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 className="post-error" role="alert">
{p.error}{" "}
{p.onRetry ? <button type="button" className="link-btn" onClick={p.onRetry}>Try again</button> : null}
{p.onRetry ? <button type="button" className="post-link" onClick={p.onRetry}>Try again</button> : null}
</p>
) : null}
<form
className="comment-form"
className="post-cm-form"
onSubmit={(e) => {
e.preventDefault();
if (!empty && !p.busy) p.onSubmit?.();
@@ -54,7 +59,7 @@ export function CommentsPanel(p: CommentsPanelProps) {
maxLength={5000}
onChange={(e) => p.onChange?.(e.target.value)}
/>
<button type="submit" disabled={empty || p.busy}>{p.busy ? "Posting" : "Post"}</button>
<IconButton type="submit" disabled={empty || p.busy} label={p.busy ? "Posting" : "Post"} icon={<SendIcon />} />
</form>
</div>
);
+120 -30
View File
@@ -1,10 +1,11 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { HexAvatar } from "@/components/HexAvatar";
import { CommentsSection } from "@/components/CommentsSection";
import { VideoPlayer } from "@/components/VideoPlayer";
import { relativeTime, type CardPost } from "@/lib/feed-model";
import { IconButton, SweepButton } from "@/components/Buttons";
import { formatDuration, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
export interface PostCardProps {
post: CardPost;
@@ -20,9 +21,19 @@ export interface PostCardProps {
deleteError?: boolean;
}
const icon = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
function Heart({ filled }: { filled: boolean }) {
return (
<svg className="heart-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"
<svg className="post-heart" viewBox="0 0 24 24" aria-hidden="true"
fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2"
strokeLinejoin="round">
<path d="M12 21s-7.5-4.6-9.6-9.3C.9 8.4 2.7 4.5 6.4 4.5c2.1 0 3.6 1.2 4.4 2.5.4.6 1.4.6 1.8 0 .8-1.3 2.3-2.5 4.4-2.5 3.7 0 5.5 3.9 4 7.2C19.5 16.4 12 21 12 21z" />
@@ -30,46 +41,126 @@ function Heart({ filled }: { filled: boolean }) {
);
}
function CommentIcon() {
return (
<svg {...icon} className="post-act-icon">
<path d="M21 11.5a8.5 8.5 0 0 1-12.6 7.4L3 20l1.2-4.6A8.5 8.5 0 1 1 21 11.5z" />
</svg>
);
}
function TrashIcon() {
return (
<svg {...icon}>
<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />
</svg>
);
}
function PlayIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z" />
</svg>
);
}
/** A playable video: the real player, with a frosted play button and the length over it until it starts. */
function PostVideo({ media, author }: { media: CardMedia; author: string }) {
const box = useRef<HTMLDivElement>(null);
const [started, setStarted] = useState(false);
const duration = formatDuration(media.durationSeconds);
const start = () => {
setStarted(true);
void box.current?.querySelector("video")?.play().catch(() => {});
};
return (
<div className="post-media post-video" ref={box}>
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} />
{started ? null : (
<>
<button type="button" className="post-play" aria-label="Play video" onClick={start}>
<PlayIcon />
</button>
{duration ? <span className="post-dur">{duration}</span> : null}
</>
)}
</div>
);
}
export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
const [open, setOpen] = useState(false);
const [menu, setMenu] = useState(false);
const [confirming, setConfirming] = useState(false);
const [beat, setBeat] = useState(false);
const menuBox = useRef<HTMLDivElement>(null);
const own = !!(canDelete && onDelete);
useEffect(() => {
if (!menu) return;
const close = () => { setMenu(false); setConfirming(false); };
const onDown = (e: MouseEvent) => { if (!menuBox.current?.contains(e.target as Node)) close(); };
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [menu]);
return (
<article className="post" data-testid="post-card">
<header className="post-head">
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
<div className="post-who">
<strong>{post.authorName}</strong>
<strong>
{post.authorName}
{own ? <span className="post-you">You</span> : null}
</strong>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
</div>
{canDelete && onDelete ? (
<div className="post-menu">
{own ? (
<div className="post-menu" ref={menuBox}>
<button
type="button"
className="menu-btn"
className={menu ? "post-menu-btn post-menu-btn-open" : "post-menu-btn"}
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={menu}
disabled={deleteBusy}
onClick={() => { setMenu((m) => !m); setConfirming(false); }}
>
...
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="19" cy="12" r="1.8" />
</svg>
</button>
{menu ? (
<div className="menu" role="menu">
<div className="post-pop" role="menu">
{confirming ? (
<>
<span className="menu-ask">Delete this post?</span>
<button type="button" role="menuitem" className="menu-danger" disabled={deleteBusy}
onClick={() => { setMenu(false); setConfirming(false); onDelete(post); }}>
Confirm delete
</button>
<button type="button" role="menuitem" onClick={() => { setConfirming(false); setMenu(false); }}>
Cancel
</button>
</>
<div className="post-ask">
<p className="post-ask-title">Delete this post?</p>
<p className="post-ask-sub">This cannot be undone.</p>
<div className="post-ask-row">
<SweepButton
label="Cancel"
role="menuitem"
onClick={() => { setConfirming(false); setMenu(false); }}
/>
<IconButton
label="Delete"
role="menuitem"
icon={<TrashIcon />}
size="sm"
disabled={deleteBusy}
onClick={() => { setMenu(false); setConfirming(false); onDelete?.(post); }}
/>
</div>
</div>
) : (
<button type="button" role="menuitem" className="menu-danger" onClick={() => setConfirming(true)}>
<button type="button" role="menuitem" className="post-mi post-mi-danger" onClick={() => setConfirming(true)}>
<TrashIcon />
Delete post
</button>
)}
@@ -78,13 +169,11 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
</div>
) : null}
</header>
{deleteError ? <p className="like-error" role="alert">Could not delete the post.</p> : null}
{deleteError ? <p className="post-error" role="alert">Could not delete the post.</p> : null}
{post.text ? <p className="post-text">{post.text}</p> : null}
{post.media.map((m, i) =>
m.kind === "video" && (m.mp4Url || m.hlsUrl) ? (
<div className="post-media post-video" key={i}>
<VideoPlayer mp4Url={m.mp4Url} hlsUrl={m.hlsUrl} poster={m.src} label={`Video from ${post.authorName}`} />
</div>
<PostVideo key={i} media={m} author={post.authorName} />
) : m.src ? (
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -96,28 +185,29 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
</div>
) : null,
)}
<footer className="post-stats">
<footer className="post-acts">
<button
type="button"
className={post.isLiked ? "like-btn liked" : "like-btn"}
className={post.isLiked ? (beat ? "post-act post-like post-like-on post-like-beat" : "post-act post-like post-like-on") : "post-act post-like"}
aria-pressed={post.isLiked}
aria-label={post.isLiked ? `Unlike, ${post.likesCount} likes` : `Like, ${post.likesCount} likes`}
disabled={likeBusy || !post.id || !onToggleLike}
onClick={() => onToggleLike?.(post)}
onClick={() => { setBeat(!post.isLiked); onToggleLike?.(post); }}
>
<Heart filled={post.isLiked} /> <span className="like-count">{post.likesCount}</span>
<Heart filled={post.isLiked} /> <span className="post-count">{post.likesCount}</span>
</button>
<button
type="button"
className="comments-toggle"
className={open ? "post-act post-comments-toggle post-act-open" : "post-act post-comments-toggle"}
aria-expanded={open}
disabled={!post.id}
onClick={() => setOpen((o) => !o)}
>
<CommentIcon />
{post.commentsCount} comments
</button>
{likeError ? (
<span className="like-error" role="alert">Could not update like.</span>
<span className="post-error" role="alert">Could not update like.</span>
) : null}
</footer>
{open && post.id ? <CommentsSection postId={post.id} onSignedOut={onSignedOut} /> : null}