8zbhzr21j: Restyle post cards, comments and the own-post menu to the redesign spec
This commit is contained in:
@@ -8,6 +8,7 @@ import "../styles/layout.css";
|
|||||||
import "../styles/buttons.css";
|
import "../styles/buttons.css";
|
||||||
import "../styles/avatar.css";
|
import "../styles/avatar.css";
|
||||||
import "../styles/signin.css";
|
import "../styles/signin.css";
|
||||||
|
import "../styles/post.css";
|
||||||
import "../styles/legacy.css";
|
import "../styles/legacy.css";
|
||||||
|
|
||||||
const manrope = Manrope({
|
const manrope = Manrope({
|
||||||
|
|||||||
@@ -227,8 +227,8 @@ export interface IconButtonProps extends NativeButton {
|
|||||||
export function IconButton({ label, icon, shape = "square", size = "md", className, type = "button", ...rest }: IconButtonProps) {
|
export function IconButton({ label, icon, shape = "square", size = "md", className, type = "button", ...rest }: IconButtonProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
{...rest}
|
|
||||||
type={type}
|
type={type}
|
||||||
|
{...rest}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={rest.title ?? label}
|
title={rest.title ?? label}
|
||||||
className={cx("btn-gloss", shape === "round" && "btn-gloss-round", size === "sm" && "btn-gloss-sm", className)}
|
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 type { CommentItem } from "@/lib/comment-model";
|
||||||
|
import { HexAvatar } from "@/components/HexAvatar";
|
||||||
|
import { IconButton, SendIcon } from "@/components/Buttons";
|
||||||
import { relativeTime } from "@/lib/feed-model";
|
import { relativeTime } from "@/lib/feed-model";
|
||||||
|
|
||||||
export interface CommentsPanelProps {
|
export interface CommentsPanelProps {
|
||||||
@@ -17,30 +19,33 @@ export interface CommentsPanelProps {
|
|||||||
export function CommentsPanel(p: CommentsPanelProps) {
|
export function CommentsPanel(p: CommentsPanelProps) {
|
||||||
const empty = !p.value.trim();
|
const empty = !p.value.trim();
|
||||||
return (
|
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 ? <span className="spinner" role="status" aria-label="Loading comments" /> : null}
|
||||||
{!p.loading && p.comments.length === 0 && !p.error ? (
|
{!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}
|
) : null}
|
||||||
<ul className="comment-list">
|
<ul className="post-cm-list">
|
||||||
{p.comments.map((c) => (
|
{p.comments.map((c) => (
|
||||||
<li key={c.key} className={c.removed ? "comment removed" : "comment"} data-testid="comment">
|
<li key={c.key} className="post-c" data-testid="comment">
|
||||||
<div className="comment-head">
|
<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>
|
<strong>{c.authorName}</strong>
|
||||||
<time dateTime={c.createdAt ?? undefined}>{relativeTime(c.createdAt, p.now)}</time>
|
<time dateTime={c.createdAt ?? undefined}>{relativeTime(c.createdAt, p.now)}</time>
|
||||||
</div>
|
</div>
|
||||||
{c.removed ? <p className="comment-removed">This comment was removed.</p> : <p>{c.content}</p>}
|
{c.removed ? <p>This comment was removed.</p> : <p>{c.content}</p>}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{p.error ? (
|
{p.error ? (
|
||||||
<p className="comment-error" role="alert">
|
<p className="post-error" role="alert">
|
||||||
{p.error}{" "}
|
{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>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<form
|
<form
|
||||||
className="comment-form"
|
className="post-cm-form"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!empty && !p.busy) p.onSubmit?.();
|
if (!empty && !p.busy) p.onSubmit?.();
|
||||||
@@ -54,7 +59,7 @@ export function CommentsPanel(p: CommentsPanelProps) {
|
|||||||
maxLength={5000}
|
maxLength={5000}
|
||||||
onChange={(e) => p.onChange?.(e.target.value)}
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+120
-30
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { HexAvatar } from "@/components/HexAvatar";
|
import { HexAvatar } from "@/components/HexAvatar";
|
||||||
import { CommentsSection } from "@/components/CommentsSection";
|
import { CommentsSection } from "@/components/CommentsSection";
|
||||||
import { VideoPlayer } from "@/components/VideoPlayer";
|
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 {
|
export interface PostCardProps {
|
||||||
post: CardPost;
|
post: CardPost;
|
||||||
@@ -20,9 +21,19 @@ export interface PostCardProps {
|
|||||||
deleteError?: boolean;
|
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 }) {
|
function Heart({ filled }: { filled: boolean }) {
|
||||||
return (
|
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"
|
fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2"
|
||||||
strokeLinejoin="round">
|
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" />
|
<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) {
|
export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState(false);
|
const [menu, setMenu] = useState(false);
|
||||||
const [confirming, setConfirming] = 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 (
|
return (
|
||||||
<article className="post" data-testid="post-card">
|
<article className="post" data-testid="post-card">
|
||||||
<header className="post-head">
|
<header className="post-head">
|
||||||
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
||||||
<div className="post-who">
|
<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>
|
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
|
||||||
</div>
|
</div>
|
||||||
{canDelete && onDelete ? (
|
{own ? (
|
||||||
<div className="post-menu">
|
<div className="post-menu" ref={menuBox}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="menu-btn"
|
className={menu ? "post-menu-btn post-menu-btn-open" : "post-menu-btn"}
|
||||||
aria-label="More actions"
|
aria-label="More actions"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={menu}
|
aria-expanded={menu}
|
||||||
disabled={deleteBusy}
|
disabled={deleteBusy}
|
||||||
onClick={() => { setMenu((m) => !m); setConfirming(false); }}
|
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>
|
</button>
|
||||||
{menu ? (
|
{menu ? (
|
||||||
<div className="menu" role="menu">
|
<div className="post-pop" role="menu">
|
||||||
{confirming ? (
|
{confirming ? (
|
||||||
<>
|
<div className="post-ask">
|
||||||
<span className="menu-ask">Delete this post?</span>
|
<p className="post-ask-title">Delete this post?</p>
|
||||||
<button type="button" role="menuitem" className="menu-danger" disabled={deleteBusy}
|
<p className="post-ask-sub">This cannot be undone.</p>
|
||||||
onClick={() => { setMenu(false); setConfirming(false); onDelete(post); }}>
|
<div className="post-ask-row">
|
||||||
Confirm delete
|
<SweepButton
|
||||||
</button>
|
label="Cancel"
|
||||||
<button type="button" role="menuitem" onClick={() => { setConfirming(false); setMenu(false); }}>
|
role="menuitem"
|
||||||
Cancel
|
onClick={() => { setConfirming(false); setMenu(false); }}
|
||||||
</button>
|
/>
|
||||||
</>
|
<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
|
Delete post
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -78,13 +169,11 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</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.text ? <p className="post-text">{post.text}</p> : null}
|
||||||
{post.media.map((m, i) =>
|
{post.media.map((m, i) =>
|
||||||
m.kind === "video" && (m.mp4Url || m.hlsUrl) ? (
|
m.kind === "video" && (m.mp4Url || m.hlsUrl) ? (
|
||||||
<div className="post-media post-video" key={i}>
|
<PostVideo key={i} media={m} author={post.authorName} />
|
||||||
<VideoPlayer mp4Url={m.mp4Url} hlsUrl={m.hlsUrl} poster={m.src} label={`Video from ${post.authorName}`} />
|
|
||||||
</div>
|
|
||||||
) : m.src ? (
|
) : m.src ? (
|
||||||
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
|
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
@@ -96,28 +185,29 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
|
|||||||
</div>
|
</div>
|
||||||
) : null,
|
) : null,
|
||||||
)}
|
)}
|
||||||
<footer className="post-stats">
|
<footer className="post-acts">
|
||||||
<button
|
<button
|
||||||
type="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-pressed={post.isLiked}
|
||||||
aria-label={post.isLiked ? `Unlike, ${post.likesCount} likes` : `Like, ${post.likesCount} likes`}
|
aria-label={post.isLiked ? `Unlike, ${post.likesCount} likes` : `Like, ${post.likesCount} likes`}
|
||||||
disabled={likeBusy || !post.id || !onToggleLike}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="comments-toggle"
|
className={open ? "post-act post-comments-toggle post-act-open" : "post-act post-comments-toggle"}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
disabled={!post.id}
|
disabled={!post.id}
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
>
|
>
|
||||||
|
<CommentIcon />
|
||||||
{post.commentsCount} comments
|
{post.commentsCount} comments
|
||||||
</button>
|
</button>
|
||||||
{likeError ? (
|
{likeError ? (
|
||||||
<span className="like-error" role="alert">Could not update like.</span>
|
<span className="post-error" role="alert">Could not update like.</span>
|
||||||
) : null}
|
) : null}
|
||||||
</footer>
|
</footer>
|
||||||
{open && post.id ? <CommentsSection postId={post.id} onSignedOut={onSignedOut} /> : null}
|
{open && post.id ? <CommentsSection postId={post.id} onSignedOut={onSignedOut} /> : null}
|
||||||
|
|||||||
+22
-1
@@ -3,14 +3,16 @@
|
|||||||
// Dev-only: the real signed-in shell with fixture data and no network calls.
|
// Dev-only: the real signed-in shell with fixture data and no network calls.
|
||||||
// Likes, search and the composer work on local state so the panels can be
|
// Likes, search and the composer work on local state so the panels can be
|
||||||
// checked by hand. Used by src/app/dev/feed/page.tsx.
|
// checked by hand. Used by src/app/dev/feed/page.tsx.
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { DiscoverRailsView } from "@/components/DiscoverRails";
|
import { DiscoverRailsView } from "@/components/DiscoverRails";
|
||||||
import { ExpertSearchView } from "@/components/ExpertSearch";
|
import { ExpertSearchView } from "@/components/ExpertSearch";
|
||||||
import { FeedView } from "@/components/FeedView";
|
import { FeedView } from "@/components/FeedView";
|
||||||
|
import { toComments } from "@/lib/comment-model";
|
||||||
import { toCardPosts, type CardPost } from "@/lib/feed-model";
|
import { toCardPosts, type CardPost } from "@/lib/feed-model";
|
||||||
import {
|
import {
|
||||||
FIXTURE_CATEGORIES,
|
FIXTURE_CATEGORIES,
|
||||||
|
FIXTURE_COMMENTS,
|
||||||
FIXTURE_DISCOVER,
|
FIXTURE_DISCOVER,
|
||||||
FIXTURE_FEED_ITEMS,
|
FIXTURE_FEED_ITEMS,
|
||||||
FIXTURE_NOW,
|
FIXTURE_NOW,
|
||||||
@@ -24,6 +26,25 @@ export function FeedPreview() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [category, setCategory] = useState<string | null>(null);
|
const [category, setCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// The comments calls answer from the fixtures; the preview never reaches the network.
|
||||||
|
useEffect(() => {
|
||||||
|
const real = window.fetch;
|
||||||
|
const lists: Record<string, unknown[]> = { ...FIXTURE_COMMENTS };
|
||||||
|
window.fetch = async (input, init) => {
|
||||||
|
const m = /\/api\/posts\/([^/]+)\/comments/.exec(String(input));
|
||||||
|
if (!m) return real(input, init);
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
if (init?.method === "POST") {
|
||||||
|
const { content } = JSON.parse(String(init.body)) as { content: string };
|
||||||
|
const made = { id: `local-${Date.now()}`, content, createdAt: new Date(FIXTURE_NOW).toISOString(), author: { displayName: FIXTURE_PERSON.name } };
|
||||||
|
lists[id] = [...(lists[id] ?? []), made];
|
||||||
|
return Response.json({ comment: toComments([made])[0] });
|
||||||
|
}
|
||||||
|
return Response.json({ comments: toComments(lists[id] ?? []) });
|
||||||
|
};
|
||||||
|
return () => { window.fetch = real; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// With no query or category, show every fixture result so the list scrolls.
|
// With no query or category, show every fixture result so the list scrolls.
|
||||||
const results = useMemo(() => {
|
const results = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
|
|||||||
+14
-1
@@ -22,6 +22,19 @@ export function placeholderImage(hue: number, label = ""): string {
|
|||||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Not a real file: preload is off, so the preview video shows only its poster, play button and length. */
|
||||||
|
const FIXTURE_VIDEO_URL = "https://cdn.feed.sharedservices.vt.com/dev-fixture.mp4";
|
||||||
|
|
||||||
|
/** Comments for the preview, in the shape /api/posts/[id]/comments returns. p2 holds a removed one; p4 has none. */
|
||||||
|
export const FIXTURE_COMMENTS: Record<string, unknown[]> = {
|
||||||
|
p1: [{ id: "c1", content: "Congratulations, the flow reads well.", createdAt: "2026-10-19T11:50:00Z", author: { displayName: "Priya Natarajan" } }],
|
||||||
|
p2: [
|
||||||
|
{ id: "c2", content: "Number two changed how I price renewals.", createdAt: "2026-10-19T10:40:00Z", author: { displayName: "Marcus Bell" } },
|
||||||
|
{ id: "c3", content: "removed text", deletedAt: "2026-10-19T11:00:00Z", createdAt: "2026-10-19T10:55:00Z", author: { displayName: "Elena Kovac" } },
|
||||||
|
{ id: "c4", content: "Saving this for the next pricing review.", createdAt: "2026-10-19T11:30:00Z", author: { displayName: "Samuel Okafor" } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const FIXTURE_FEED_USER_ID = "dev-user-1";
|
export const FIXTURE_FEED_USER_ID = "dev-user-1";
|
||||||
|
|
||||||
export const FIXTURE_PERSON: Person = {
|
export const FIXTURE_PERSON: Person = {
|
||||||
@@ -73,7 +86,7 @@ export const FIXTURE_FEED_ITEMS: unknown[] = [
|
|||||||
id: "p3",
|
id: "p3",
|
||||||
content: "A short walkthrough of how we run weekly reviews with a remote team.",
|
content: "A short walkthrough of how we run weekly reviews with a remote team.",
|
||||||
author: author("a3", "Marcus Bell"),
|
author: author("a3", "Marcus Bell"),
|
||||||
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(210, "Weekly reviews"), order: 0 }],
|
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(210, "Weekly reviews"), mp4Url: FIXTURE_VIDEO_URL, durationSeconds: 154, order: 0 }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+14
-1
@@ -9,6 +9,18 @@ export interface CardMedia {
|
|||||||
hlsUrl?: string | null;
|
hlsUrl?: string | null;
|
||||||
/** Direct progressive MP4 on the feed CDN (videos only). Preferred for playback. */
|
/** Direct progressive MP4 on the feed CDN (videos only). Preferred for playback. */
|
||||||
mp4Url?: string | null;
|
mp4Url?: string | null;
|
||||||
|
/** Video length in whole seconds, when the feed reports it. */
|
||||||
|
durationSeconds?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "m:ss" for a duration in seconds, or null when it is missing or not positive. */
|
||||||
|
export function formatDuration(seconds: number | null | undefined): string | null {
|
||||||
|
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return null;
|
||||||
|
const total = Math.round(seconds);
|
||||||
|
const h = Math.floor(total / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = String(total % 60).padStart(2, "0");
|
||||||
|
return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardPost {
|
export interface CardPost {
|
||||||
@@ -92,7 +104,8 @@ function toMedia(raw: unknown): CardMedia[] {
|
|||||||
const hls = muxHlsUrl(m.hlsUrl);
|
const hls = muxHlsUrl(m.hlsUrl);
|
||||||
const mp4 = feedMp4Url(m.mp4Url);
|
const mp4 = feedMp4Url(m.mp4Url);
|
||||||
// Keep a video even with no thumbnail or playable URL yet (just published).
|
// Keep a video even with no thumbnail or playable URL yet (just published).
|
||||||
out.push({ kind: "video", src: thumb, hlsUrl: hls, mp4Url: mp4 });
|
const dur = typeof m.durationSeconds === "number" ? m.durationSeconds : typeof m.duration === "number" ? m.duration : null;
|
||||||
|
out.push({ kind: "video", src: thumb, hlsUrl: hls, mp4Url: mp4, ...(dur ? { durationSeconds: dur } : {}) });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
|
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
|
||||||
|
|||||||
@@ -9,17 +9,6 @@ input { font: inherit; padding: 12px; border: 1px solid var(--line-2); border-ra
|
|||||||
input:focus-visible, button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
input:focus-visible, button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||||
button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; border: 0; border-radius: 8px; background: var(--accent); color: white; cursor: pointer; }
|
button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; border: 0; border-radius: 8px; background: var(--accent); color: white; cursor: pointer; }
|
||||||
.error { margin: 0 0 4px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 8px; color: var(--danger); background: var(--accent-soft); font-size: 14px; }
|
.error { margin: 0 0 4px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 8px; color: var(--danger); background: var(--accent-soft); font-size: 14px; }
|
||||||
.post { background: var(--tile); border: 1px solid var(--line); border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
.post-head { display: flex; align-items: center; gap: 12px; }
|
|
||||||
.post-who { display: flex; flex-direction: column; font-size: 14px; min-width: 0; }
|
|
||||||
.post-who time { color: var(--ink-3); font-size: 13px; }
|
|
||||||
.post-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5; }
|
|
||||||
.post-media img { display: block; width: 100%; height: auto; border-radius: 8px; }
|
|
||||||
.post-video { background: black; border-radius: 8px; overflow: hidden; }
|
|
||||||
.post-video-pending { color: white; padding: 48px 12px; text-align: center; }
|
|
||||||
.post-video video { display: block; width: 100%; max-height: 520px; background: black; }
|
|
||||||
.post-stats { display: flex; gap: 16px; color: var(--ink-3); font-size: 14px; }
|
|
||||||
.heart { color: var(--accent); }
|
|
||||||
.feed-message { text-align: center; color: var(--ink-3); padding: 32px 16px; }
|
.feed-message { text-align: center; color: var(--ink-3); padding: 32px 16px; }
|
||||||
.feed-message button { margin-top: 8px; }
|
.feed-message button { margin-top: 8px; }
|
||||||
.feed-center { display: grid; place-items: center; padding: 16px; }
|
.feed-center { display: grid; place-items: center; padding: 16px; }
|
||||||
@@ -27,13 +16,6 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde
|
|||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
.new-posts { position: sticky; top: 12px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
|
.new-posts { position: sticky; top: 12px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
|
||||||
.sentinel { height: 1px; }
|
.sentinel { height: 1px; }
|
||||||
.like-btn { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 4px 8px; background: transparent; color: var(--ink-3); border-radius: 8px; font-size: 14px; font-weight: 400; }
|
|
||||||
.like-btn:hover:not(:disabled) { color: var(--accent); background: var(--accent-soft); }
|
|
||||||
.like-btn.liked { color: var(--accent); }
|
|
||||||
.like-btn:disabled { opacity: 1; cursor: default; }
|
|
||||||
.like-btn:disabled:not(.liked) { color: var(--ink-3); }
|
|
||||||
.like-error { color: var(--danger); font-size: 13px; align-self: center; }
|
|
||||||
.post-stats { align-items: center; }
|
|
||||||
.composer { background: var(--tile); border: 1px solid var(--line); border-radius: 0; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
|
.composer { background: var(--tile); border: 1px solid var(--line); border-radius: 0; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
.composer-label { margin: 0; }
|
.composer-label { margin: 0; }
|
||||||
.composer textarea { font: inherit; width: 100%; padding: 12px; border: 1px solid var(--line-2); border-radius: 0; resize: vertical; background: var(--tile-2); color: var(--ink); }
|
.composer textarea { font: inherit; width: 100%; padding: 12px; border: 1px solid var(--line-2); border-radius: 0; resize: vertical; background: var(--tile-2); color: var(--ink); }
|
||||||
@@ -49,27 +31,7 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde
|
|||||||
.composer-filename { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.composer-filename { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.composer-remove { margin: 0; padding: 6px 12px; border-radius: 0; }
|
.composer-remove { margin: 0; padding: 6px 12px; border-radius: 0; }
|
||||||
|
|
||||||
.comments { display: flex; flex-direction: column; gap: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
|
|
||||||
.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(--ink-3); font-size: 12px; }
|
|
||||||
.comment.removed .comment-removed { color: var(--ink-3); font-style: italic; }
|
|
||||||
.comments-empty { margin: 0; color: var(--ink-3); font-size: 14px; }
|
|
||||||
.comment-error { margin: 0; color: var(--danger); 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(--ink-3); padding: 4px 8px; margin: 0; font-size: 14px; font-weight: 400; width: auto; }
|
|
||||||
|
|
||||||
.post-menu { position: relative; margin-left: auto; }
|
|
||||||
.menu-btn { margin: 0; padding: 2px 10px; background: transparent; color: var(--ink-3); border-radius: 8px; font-size: 18px; line-height: 1; letter-spacing: 1px; }
|
|
||||||
.menu { position: absolute; right: 0; top: 100%; z-index: 5; display: flex; flex-direction: column; gap: 2px; min-width: 150px; padding: 6px; background: var(--tile); border: 1px solid var(--line-2); border-radius: 10px; box-shadow: var(--shadow-pop); }
|
|
||||||
.menu button { margin: 0; text-align: left; background: transparent; color: inherit; padding: 6px 8px; border-radius: 6px; font-size: 14px; font-weight: 400; }
|
|
||||||
.menu button:hover { background: var(--tile-2); }
|
|
||||||
.menu .menu-danger { color: var(--danger); }
|
|
||||||
.menu-ask { font-size: 13px; color: var(--ink-3); padding: 4px 8px; }
|
|
||||||
|
|
||||||
.rail-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
.rail-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
.rail-card { background: var(--tile); border: 1px solid var(--line); border-radius: 8px; padding: 12px; display: flex; gap: 10px; }
|
.rail-card { background: var(--tile); border: 1px solid var(--line); border-radius: 8px; padding: 12px; display: flex; gap: 10px; }
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/*
|
||||||
|
* Post card, comments and the own-post menu (spec "Post card", ADR 0001).
|
||||||
|
* Prefixed `post-` classes, tokens only. Every button sets margin, padding,
|
||||||
|
* font and color itself so the legacy `button` rule never leaks in.
|
||||||
|
* --on-accent (white) appears only over video and on crimson faces.
|
||||||
|
*/
|
||||||
|
@keyframes post-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes post-pop { from { opacity: 0; transform: translateY(-6px) scale(.96); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes post-beat { 0% { transform: scale(1); } 30% { transform: scale(1.4); } 60% { transform: scale(.9); } 100% { transform: scale(1); } }
|
||||||
|
|
||||||
|
.post {
|
||||||
|
--post-soft: color-mix(in srgb, var(--ink), transparent 92%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 16px 10px;
|
||||||
|
background: var(--tile);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
animation: post-rise .55s var(--ease) both;
|
||||||
|
transition: border-color .3s, box-shadow .3s;
|
||||||
|
}
|
||||||
|
.post:hover { border-color: var(--line-2); box-shadow: var(--shadow-tile); }
|
||||||
|
|
||||||
|
.post-head { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.post-who { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||||
|
.post-who strong { display: flex; align-items: center; font-size: 14.5px; font-weight: 700; }
|
||||||
|
.post-who time { font-size: 12.5px; color: var(--ink-3); }
|
||||||
|
.post-you {
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line-2);
|
||||||
|
color: var(--ink-2);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.post-text { margin: 0; font-size: 15px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.post-error { margin: 0; align-self: center; color: var(--danger); font-size: 13px; }
|
||||||
|
|
||||||
|
/* Media: 16:9, 12px radius. */
|
||||||
|
.post-media {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: var(--r-ctl);
|
||||||
|
background: var(--tile-3);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
.post-media img,
|
||||||
|
.post-media video { display: block; width: 100%; height: 100%; object-fit: cover; border-radius: 0; }
|
||||||
|
.post-media video { object-fit: contain; background: var(--tile-3); }
|
||||||
|
.post-video-pending { display: grid; place-items: center; color: var(--ink-2); font-size: 14px; }
|
||||||
|
.post-play {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
|
margin: auto;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--on-accent);
|
||||||
|
background: color-mix(in srgb, var(--on-accent), transparent 84%);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--on-accent), transparent 60%), 0 12px 30px color-mix(in srgb, black 28%, transparent);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .3s cubic-bezier(.2,.9,.3,1.4), background-color .3s;
|
||||||
|
}
|
||||||
|
.post-play svg { width: 24px; height: 24px; margin-left: 3px; }
|
||||||
|
.post-play:hover { transform: scale(1.1); background: color-mix(in srgb, var(--on-accent), transparent 72%); }
|
||||||
|
.post-dur {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--on-accent);
|
||||||
|
background: color-mix(in srgb, black 42%, transparent);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer actions. */
|
||||||
|
.post-acts { display: flex; align-items: center; gap: 2px; margin: 0 -8px; }
|
||||||
|
.post-act {
|
||||||
|
appearance: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 38px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-2);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
opacity: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color .2s, color .2s;
|
||||||
|
}
|
||||||
|
.post-act svg { width: 20px; height: 20px; flex: none; }
|
||||||
|
.post-act:hover:not(:disabled) { background: var(--post-soft); color: var(--ink); }
|
||||||
|
.post-act:disabled { cursor: default; }
|
||||||
|
.post-like:hover:not(:disabled),
|
||||||
|
.post-like-on { color: var(--accent); }
|
||||||
|
.post-like-beat .post-heart { animation: post-beat .5s cubic-bezier(.2,.9,.3,1.4); }
|
||||||
|
.post-act-open { color: var(--ink); background: var(--post-soft); }
|
||||||
|
|
||||||
|
/* Own-post menu. */
|
||||||
|
.post-menu { position: relative; }
|
||||||
|
.post-menu-btn {
|
||||||
|
appearance: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--r-ctl);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-2);
|
||||||
|
opacity: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color .2s, color .2s;
|
||||||
|
}
|
||||||
|
.post-menu-btn svg { width: 20px; height: 20px; }
|
||||||
|
.post-menu-btn:hover:not(:disabled),
|
||||||
|
.post-menu-btn-open { background: var(--post-soft); color: var(--ink); }
|
||||||
|
.post-menu-btn:disabled { cursor: default; }
|
||||||
|
.post-pop {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
z-index: 6;
|
||||||
|
min-width: 210px;
|
||||||
|
padding: 6px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--glass);
|
||||||
|
-webkit-backdrop-filter: blur(18px);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
animation: post-pop .18s var(--ease) both;
|
||||||
|
}
|
||||||
|
.post-mi {
|
||||||
|
appearance: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--r-ctl);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.post-mi svg { width: 18px; height: 18px; flex: none; }
|
||||||
|
.post-mi:hover { background: var(--post-soft); }
|
||||||
|
.post-mi-danger { color: var(--danger); }
|
||||||
|
.post-ask { padding: 8px 6px 4px; }
|
||||||
|
.post-ask-title { margin: 0; padding: 0 4px; font-size: 13px; font-weight: 600; }
|
||||||
|
.post-ask-sub { margin: 2px 0 0; padding: 0 4px; font-size: 12.5px; color: var(--ink-3); }
|
||||||
|
.post-ask-row { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding-top: 12px; }
|
||||||
|
|
||||||
|
/* Inline comments. */
|
||||||
|
.post-cm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 -16px;
|
||||||
|
padding: 12px 16px 6px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
animation: post-rise .35s var(--ease) both;
|
||||||
|
}
|
||||||
|
.post-cm-list { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.post-cm-empty { margin: 0; font-size: 13px; color: var(--ink-3); }
|
||||||
|
.post-c { display: flex; align-items: flex-start; gap: 10px; }
|
||||||
|
.post-c-body { flex: 1; min-width: 0; padding: 8px 12px; background: var(--tile-2); border-radius: 4px 14px 14px 14px; }
|
||||||
|
.post-c-head { display: flex; align-items: baseline; gap: 8px; }
|
||||||
|
.post-c-head strong { font-size: 13px; }
|
||||||
|
.post-c-head time { font-size: 12px; color: var(--ink-3); }
|
||||||
|
.post-c-body p { margin: 2px 0 0; font-size: 13.5px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.post-c-removed p { font-style: italic; color: var(--ink-3); }
|
||||||
|
.post-cm-form { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.post-cm-form input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 44px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--r-ctl);
|
||||||
|
background: var(--tile-2);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
.post-cm-form input::placeholder { color: var(--ink-3); }
|
||||||
|
.post-cm-form input:focus-visible { outline: none; box-shadow: inset 0 0 0 1.5px var(--ink-2); }
|
||||||
|
.post-link {
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.post, .post-cm, .post-pop { animation: none; }
|
||||||
|
.post-like-beat .post-heart { animation: none; }
|
||||||
|
.post-play { transition: none; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user