8zbhzr21j: Restyle post cards, comments and the own-post menu to the redesign spec
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user