Merge pull request 'Feed browser: Desktop bento layout' (#3) from feature/feed-browser-desktop-bento-layout into develop
This commit was merged in pull request #3.
This commit is contained in:
@@ -130,7 +130,8 @@ A compact spotlight that moves on its own. Nobody has to click or swipe. These a
|
||||
- Every 2.8 seconds the next expert moves to the center with a 1.1s ease.
|
||||
- Below: the name in serif, the byline, the rating and review count. The caption fades up on each change.
|
||||
- Progress dots: the active one is a crimson bar. A counter ("3 / 10") sits in the panel header.
|
||||
- Hover pauses the rotation. `prefers-reduced-motion` stops autoplay.
|
||||
- Hover, or keyboard focus inside the carousel, pauses the rotation. `prefers-reduced-motion` stops autoplay.
|
||||
- Live region (WAI-ARIA carousel pattern): `aria-live="off"` while autoplay is rotating, so slides are not announced every 2.8 seconds; `aria-live="polite"` when rotation is paused (hover or focus) or stopped (reduced motion, or a single expert).
|
||||
|
||||
### Trending minnects
|
||||
|
||||
@@ -198,7 +199,7 @@ All sheets slide up from the bottom (450ms) over a dimmed, slightly blurred back
|
||||
|
||||
- Every control is a real `<button>`, `<a>` or `<input>` with a label. Icon-only buttons have `aria-label`.
|
||||
- The like button uses `aria-pressed` and labels its count. Comments toggles use `aria-expanded`.
|
||||
- The carousel region is `aria-live="polite"`. The non-center items are `aria-hidden`.
|
||||
- The carousel region follows the WAI-ARIA carousel pattern: `aria-live="off"` while autoplay rotates, `aria-live="polite"` while rotation is paused by hover or keyboard focus, or stopped by reduced motion. Keyboard focus inside the carousel pauses it. The non-center items are `aria-hidden`.
|
||||
- Visible focus ring on every control, `--ink` colored.
|
||||
- Touch targets at least 44px on mobile.
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Dev-only preview of the signed-in feed shell with fixture data (no Minnect
|
||||
// login, no network). Not linked from the app and returns 404 in production.
|
||||
import { notFound } from "next/navigation";
|
||||
import { FeedPreview } from "@/dev/FeedPreview";
|
||||
|
||||
export const metadata = { title: "Feed (dev)" };
|
||||
|
||||
export default function FeedDevPage() {
|
||||
if (process.env.NODE_ENV === "production") notFound();
|
||||
return <FeedPreview />;
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
--danger: #FF6B70;
|
||||
--glass: rgba(18,18,21,.72);
|
||||
--shadow-pop: 0 10px 28px rgba(0,0,0,.5);
|
||||
/* Panels (bento tiles): a faint top highlight and a soft drop. */
|
||||
--shadow-tile: inset 0 1px 0 rgba(255,255,255,.035), 0 0 0 1px rgba(255,255,255,.02), 0 24px 44px -24px rgba(0,0,0,.8);
|
||||
/* Buttons: drop shadows and the soft-depth (neumorphic) light and shade. */
|
||||
--shadow-btn: 0 10px 20px -6px rgba(0,0,0,.35);
|
||||
--shadow-float: 0 14px 28px -12px rgba(0,0,0,.55);
|
||||
@@ -42,6 +44,7 @@
|
||||
--danger: #B3121A;
|
||||
--glass: rgba(251,250,248,.74);
|
||||
--shadow-pop: 0 10px 28px rgba(26,28,31,.14);
|
||||
--shadow-tile: inset 0 1px 0 rgba(255,255,255,.035), 0 1px 2px rgba(26,28,31,.04), 0 16px 36px -20px rgba(26,28,31,.22);
|
||||
--shadow-btn: 0 10px 20px -6px rgba(26,28,31,.22);
|
||||
--shadow-float: 0 14px 28px -12px rgba(26,28,31,.3);
|
||||
--neu-d: rgba(26,28,31,.17);
|
||||
@@ -89,3 +92,35 @@
|
||||
}
|
||||
:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||
}
|
||||
|
||||
/* Reduced motion, app-wide (ADR 0001, section 6; spec "Motion"). Switches off
|
||||
card rise-ins, the feed pill and skeleton shimmer, sparks, heart bursts and
|
||||
the light sweep on buttons, the featured glow, caption fade and slide (its
|
||||
autoplay is stopped in FeaturedCarousel via matchMedia), and stops snap and smooth scrolling. State
|
||||
changes still happen instantly. The loading spinner is kept, only slowed,
|
||||
because it is the only sign that something is loading. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.post,
|
||||
.post-cm,
|
||||
.post-pop,
|
||||
.feed-pill,
|
||||
.feed-skel::after,
|
||||
.post-like-beat .post-heart,
|
||||
.btn-spark *,
|
||||
.btn-spark::before,
|
||||
.btn-spark::after,
|
||||
.btn-gloss::before,
|
||||
.btn-gloss::after,
|
||||
.btn-sweep::before,
|
||||
.account-dot,
|
||||
.featured-cf::before,
|
||||
.featured-cap,
|
||||
.minnect-card { animation: none !important; }
|
||||
.featured-item,
|
||||
.featured-ring,
|
||||
.featured-dot { transition: none !important; }
|
||||
.btn-sweep::before,
|
||||
.btn-gloss::after { transition: none !important; }
|
||||
.panel-scroll, .feed-list { scroll-behavior: auto !important; scroll-snap-type: none !important; }
|
||||
.spinner { animation-duration: 2.4s !important; }
|
||||
}
|
||||
|
||||
@@ -4,9 +4,16 @@ import { Cormorant_Garamond, Manrope } from "next/font/google";
|
||||
import { DEFAULT_THEME, THEME_SCRIPT } from "@/lib/theme";
|
||||
import "./globals.css";
|
||||
import "../styles/type.css";
|
||||
import "../styles/layout.css";
|
||||
import "../styles/buttons.css";
|
||||
import "../styles/avatar.css";
|
||||
import "../styles/signin.css";
|
||||
import "../styles/post.css";
|
||||
import "../styles/composer.css";
|
||||
import "../styles/featured.css";
|
||||
import "../styles/feed.css";
|
||||
import "../styles/trending.css";
|
||||
import "../styles/search.css";
|
||||
import "../styles/legacy.css";
|
||||
|
||||
const manrope = Manrope({
|
||||
|
||||
+7
-25
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { loadSession, type Person } from "@/lib/auth-client";
|
||||
import { SignInForm } from "./SignInForm";
|
||||
import { Logo } from "./Logo";
|
||||
import { AppShell } from "./AppShell";
|
||||
import { Feed } from "./Feed";
|
||||
import { DiscoverRails } from "./DiscoverRails";
|
||||
import { ExpertSearch } from "./ExpertSearch";
|
||||
@@ -41,28 +40,11 @@ export function App() {
|
||||
}} />;
|
||||
}
|
||||
return (
|
||||
<main className="shell">
|
||||
<header>
|
||||
<Logo size={32} />
|
||||
<div>
|
||||
<strong>{state.person.name}</strong>
|
||||
<span>{state.person.email}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="columns">
|
||||
<nav className="col-left" aria-label="Main">
|
||||
<ul>
|
||||
<li><Link href="/" aria-current="page">Feed</Link></li>
|
||||
</ul>
|
||||
<ExpertSearch />
|
||||
</nav>
|
||||
<div className="col-center">
|
||||
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
|
||||
</div>
|
||||
<aside className="col-right" aria-label="Discover">
|
||||
<DiscoverRails />
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
<AppShell
|
||||
person={state.person}
|
||||
search={<ExpertSearch />}
|
||||
feed={<Feed feedUserId={state.person.feedUserId} person={state.person} onSignedOut={() => setState({ kind: "out" })} />}
|
||||
discover={<DiscoverRails />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { Person } from "@/lib/auth-client";
|
||||
import { useTheme } from "@/lib/use-theme";
|
||||
import { HexAvatar } from "./HexAvatar";
|
||||
import { Logo } from "./Logo";
|
||||
|
||||
const iconProps = {
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.7,
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
"aria-hidden": true,
|
||||
} as const;
|
||||
|
||||
function SunIcon() {
|
||||
return (
|
||||
<svg {...iconProps}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MoonIcon() {
|
||||
return (
|
||||
<svg {...iconProps}>
|
||||
<path d="M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Left account panel: logo, wordmark, theme toggle, the person, live status. */
|
||||
export function AccountPanel({ person }: { person: Person }) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const next = theme === "dark" ? "light" : "dark";
|
||||
return (
|
||||
<section className="tile account" aria-label="Account">
|
||||
<div className="account-brand">
|
||||
<Logo size={32} />
|
||||
<span className="account-word type-wordmark">MINNECT</span>
|
||||
<button type="button" className="account-theme" aria-label={`Switch to ${next} theme`} title={`Switch to ${next} theme`} onClick={toggleTheme}>
|
||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="account-me">
|
||||
<HexAvatar name={person.name || person.email} pictureUrl={person.pictureUrl} size="lg" />
|
||||
<div className="account-me-text">
|
||||
<strong>{person.name}</strong>
|
||||
<span>{person.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-live">
|
||||
<span className="account-dot" aria-hidden="true" />
|
||||
Feed updates live
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AppShellProps {
|
||||
person: Person;
|
||||
/** Find experts panel (left column, under the account panel). */
|
||||
search: ReactNode;
|
||||
/** Feed panel (center column). */
|
||||
feed: ReactNode;
|
||||
/** Featured experts and Trending minnects panels (right column). */
|
||||
discover: ReactNode;
|
||||
}
|
||||
|
||||
/** Signed-in bento shell (spec "Desktop and tablet layout", ADR 0001 section 3). */
|
||||
export function AppShell({ person, search, feed, discover }: AppShellProps) {
|
||||
return (
|
||||
<div className="app">
|
||||
<main className="bento">
|
||||
<div className="bento-col bento-left">
|
||||
<AccountPanel person={person} />
|
||||
{search}
|
||||
</div>
|
||||
<div className="bento-col bento-center">{feed}</div>
|
||||
<aside className="bento-col bento-right" aria-label="Discover">
|
||||
{discover}
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
<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 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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
|
||||
+246
-29
@@ -1,6 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
// Composer (spec "Composer", ADR 0001). Styles live in src/styles/composer.css.
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type FocusEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { FlipButton, type FlipState } from "./Buttons";
|
||||
import { HexAvatar } from "./HexAvatar";
|
||||
import { attachmentMeta, COMPOSER_EMOJI, insertBullets } from "./composer-text";
|
||||
|
||||
export interface ComposerProps {
|
||||
value: string;
|
||||
@@ -12,17 +24,42 @@ export interface ComposerProps {
|
||||
/** When present, the composer shows image and video pickers and reports the choice. */
|
||||
onFile?: (file: File | null) => void;
|
||||
onSubmit: () => void;
|
||||
/** The signed-in person, shown as a hex avatar beside the field. */
|
||||
person?: { name: string; email?: string; pictureUrl?: string | null } | null;
|
||||
}
|
||||
|
||||
// Content types the picker offers (spec: allowed image types).
|
||||
const IMAGE_ACCEPT = "image/jpeg,image/jpg,image/png,image/gif,image/webp,image/heic";
|
||||
const VIDEO_ACCEPT = "video/mp4,video/quicktime,video/webm";
|
||||
|
||||
export function Composer({ value, busy, error, file = null, onChange, onFile, onSubmit }: ComposerProps) {
|
||||
const iconProps = {
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.7,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
"aria-hidden": true,
|
||||
};
|
||||
|
||||
/** True for Ctrl + Enter or Cmd + Enter. */
|
||||
export function isPublishShortcut(e: { key: string; ctrlKey: boolean; metaKey: boolean }): boolean {
|
||||
return e.key === "Enter" && (e.ctrlKey || e.metaKey);
|
||||
}
|
||||
|
||||
export function Composer({ value, busy, error, file = null, onChange, onFile, onSubmit, person }: ComposerProps) {
|
||||
const empty = value.trim().length === 0;
|
||||
// A post needs either text or a file.
|
||||
const canSubmit = (!empty || !!file) && !busy;
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
const text = useRef<HTMLTextAreaElement | null>(null);
|
||||
const imageInput = useRef<HTMLInputElement | null>(null);
|
||||
const videoInput = useRef<HTMLInputElement | null>(null);
|
||||
const emojiWrap = useRef<HTMLDivElement | null>(null);
|
||||
// Caret to restore after a toolbar edit re-renders the text.
|
||||
const pendingSelection = useRef<[number, number] | null>(null);
|
||||
|
||||
// Build a preview URL client-side only, and revoke it when the file changes.
|
||||
useEffect(() => {
|
||||
@@ -35,10 +72,44 @@ export function Composer({ value, busy, error, file = null, onChange, onFile, on
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
const sel = pendingSelection.current;
|
||||
if (!sel || !text.current) return;
|
||||
pendingSelection.current = null;
|
||||
text.current.focus();
|
||||
text.current.setSelectionRange(sel[0], sel[1]);
|
||||
}, [value]);
|
||||
|
||||
// Close the emoji popover on an outside click or Escape.
|
||||
useEffect(() => {
|
||||
if (!emojiOpen) return;
|
||||
function onDown(e: MouseEvent) {
|
||||
if (emojiWrap.current && !emojiWrap.current.contains(e.target as Node)) setEmojiOpen(false);
|
||||
}
|
||||
function onKey(e: globalThis.KeyboardEvent) {
|
||||
if (e.key === "Escape") setEmojiOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [emojiOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (busy) setEmojiOpen(false);
|
||||
}, [busy]);
|
||||
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (canSubmit) onSubmit();
|
||||
}
|
||||
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (!isPublishShortcut(e)) return;
|
||||
e.preventDefault();
|
||||
if (canSubmit) onSubmit();
|
||||
}
|
||||
function pick(e: ChangeEvent<HTMLInputElement>) {
|
||||
const chosen = e.target.files && e.target.files[0] ? e.target.files[0] : null;
|
||||
onFile?.(chosen);
|
||||
@@ -48,56 +119,202 @@ export function Composer({ value, busy, error, file = null, onChange, onFile, on
|
||||
function remove() {
|
||||
onFile?.(null);
|
||||
}
|
||||
function addEmoji(emoji: string) {
|
||||
// Spec: the emoji goes at the end of the text.
|
||||
const next = value + emoji;
|
||||
pendingSelection.current = [next.length, next.length];
|
||||
onChange(next);
|
||||
setEmojiOpen(false);
|
||||
}
|
||||
function addBullets() {
|
||||
const el = text.current;
|
||||
const start = el ? el.selectionStart : value.length;
|
||||
const end = el ? el.selectionEnd : value.length;
|
||||
const edit = insertBullets(value, start, end);
|
||||
pendingSelection.current = [edit.selectionStart, edit.selectionEnd];
|
||||
if (edit.text !== value) onChange(edit.text);
|
||||
else el?.focus();
|
||||
}
|
||||
function onFocus() {
|
||||
setFocused(true);
|
||||
}
|
||||
function onBlur(e: FocusEvent<HTMLFormElement>) {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);
|
||||
}
|
||||
|
||||
const isVideo = !!file && file.type.startsWith("video/");
|
||||
const open = focused || !empty || !!file;
|
||||
const flip: FlipState = busy ? "posting" : canSubmit ? "ready" : "disabled";
|
||||
|
||||
return (
|
||||
<form className="composer" onSubmit={submit}>
|
||||
<form
|
||||
className={`composer${open ? " composer-open" : ""}`}
|
||||
onSubmit={submit}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
<div className="composer-top">
|
||||
{person ? <HexAvatar name={person.name || person.email || ""} pictureUrl={person.pictureUrl} size="lg" /> : null}
|
||||
<label htmlFor="composer-text" className="composer-label">New post</label>
|
||||
<textarea
|
||||
id="composer-text"
|
||||
ref={text}
|
||||
value={value}
|
||||
rows={3}
|
||||
placeholder="Share something with the feed"
|
||||
placeholder="Share an insight with the feed"
|
||||
disabled={busy}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{onFile ? (
|
||||
<div className="composer-media">
|
||||
<label className="composer-image">
|
||||
<span>Add image</span>
|
||||
<input
|
||||
type="file"
|
||||
accept={IMAGE_ACCEPT}
|
||||
disabled={busy}
|
||||
onChange={pick}
|
||||
/>
|
||||
</label>
|
||||
<label className="composer-image composer-video">
|
||||
<span>Add video</span>
|
||||
<input type="file" accept={VIDEO_ACCEPT} disabled={busy} onChange={pick} />
|
||||
</label>
|
||||
{file ? (
|
||||
<div className="composer-preview">
|
||||
</div>
|
||||
{onFile && file ? (
|
||||
<div className={`composer-att${person ? " composer-att-indent" : ""}`}>
|
||||
<span className="composer-thumb">
|
||||
{preview && isVideo ? (
|
||||
<video src={preview} muted playsInline preload="metadata" aria-label={`Selected video: ${file.name}`} />
|
||||
) : preview ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={preview} alt={`Selected image: ${file.name}`} />
|
||||
) : null}
|
||||
<span className="composer-filename">{file.name}</span>
|
||||
<button type="button" className="composer-remove" onClick={remove} disabled={busy}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{isVideo ? (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 5.5v13l11-6.5z" fill="currentColor" />
|
||||
</svg>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="composer-att-text">
|
||||
<strong className="composer-filename">{file.name}</strong>
|
||||
<span>{attachmentMeta(file)}{busy ? ", uploading" : ""}</span>
|
||||
<div
|
||||
className={`composer-prog${busy ? " composer-prog-busy" : ""}`}
|
||||
role="progressbar"
|
||||
aria-label={busy ? "Uploading" : "Attached"}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={busy ? undefined : 100}
|
||||
>
|
||||
<i />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-tool composer-remove"
|
||||
aria-label="Remove attachment"
|
||||
title="Remove attachment"
|
||||
onClick={remove}
|
||||
disabled={busy}
|
||||
>
|
||||
<svg {...iconProps}>
|
||||
<path d="M6 6l12 12M18 6 6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="error" role="alert">Publishing the post failed. Your text is still here.</p>
|
||||
) : null}
|
||||
<button type="submit" className="composer-post" disabled={!canSubmit}>
|
||||
{busy ? "Posting" : "Post"}
|
||||
<div className="composer-bar">
|
||||
{onFile ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-tool"
|
||||
aria-label="Add image"
|
||||
title="Add image"
|
||||
disabled={busy}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
<svg {...iconProps}>
|
||||
<rect x="3" y="4" width="18" height="16" rx="3" />
|
||||
<circle cx="9" cy="10" r="1.8" />
|
||||
<path d="m21 16-5-5-9 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref={imageInput}
|
||||
className="composer-file"
|
||||
type="file"
|
||||
accept={IMAGE_ACCEPT}
|
||||
disabled={busy}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={pick}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-tool"
|
||||
aria-label="Add video"
|
||||
title="Add video"
|
||||
disabled={busy}
|
||||
onClick={() => videoInput.current?.click()}
|
||||
>
|
||||
<svg {...iconProps}>
|
||||
<rect x="2.5" y="5.5" width="13" height="13" rx="3" />
|
||||
<path d="m15.5 10 6-3.5v11l-6-3.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref={videoInput}
|
||||
className="composer-file"
|
||||
type="file"
|
||||
accept={VIDEO_ACCEPT}
|
||||
disabled={busy}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={pick}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<div className="composer-emoji-wrap" ref={emojiWrap}>
|
||||
<button
|
||||
type="button"
|
||||
className={`composer-tool${emojiOpen ? " composer-tool-on" : ""}`}
|
||||
aria-label="Add emoji"
|
||||
title="Emoji"
|
||||
aria-expanded={emojiOpen}
|
||||
aria-haspopup="dialog"
|
||||
disabled={busy}
|
||||
onClick={() => setEmojiOpen((o) => !o)}
|
||||
>
|
||||
<svg {...iconProps}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M8.5 14.5a4.5 4.5 0 0 0 7 0" />
|
||||
<path d="M9 9.5h.01M15 9.5h.01" strokeWidth={2.8} />
|
||||
</svg>
|
||||
</button>
|
||||
{emojiOpen ? (
|
||||
<div className="composer-emoji" role="dialog" aria-label="Emoji">
|
||||
<span className="composer-eyebrow">Frequently used</span>
|
||||
<div className="composer-emoji-grid">
|
||||
{COMPOSER_EMOJI.map((m) => (
|
||||
<button type="button" key={m} aria-label={`Insert ${m}`} onClick={() => addEmoji(m)}>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="composer-sep" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className="composer-tool"
|
||||
aria-label="Bulleted list"
|
||||
title="Bulleted list"
|
||||
disabled={busy}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={addBullets}
|
||||
>
|
||||
<svg {...iconProps}>
|
||||
<path d="M9 6h11M9 12h11M9 18h11" />
|
||||
<path d="M4.5 6h.01M4.5 12h.01M4.5 18h.01" strokeWidth={2.8} />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="composer-spacer" />
|
||||
<span className="composer-kbd" aria-hidden="true">
|
||||
<b>Ctrl</b> + <b>Enter</b>
|
||||
</span>
|
||||
<FlipButton state={flip} className="composer-post" type="submit" aria-keyshortcuts="Control+Enter Meta+Enter" />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DiscoverData } from "@/lib/discover";
|
||||
import { fetchDiscover } from "@/lib/discover-client";
|
||||
import { FeaturedExpertsRail } from "./FeaturedExpertsRail";
|
||||
import { FeaturedCarousel } from "./FeaturedCarousel";
|
||||
import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
|
||||
|
||||
export function DiscoverRailsView({ data }: { data: DiscoverData | null }) {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<>
|
||||
<FeaturedExpertsRail experts={data.featuredExperts} />
|
||||
<FeaturedCarousel experts={data.featuredExperts} />
|
||||
<TrendingMinnectsRail minnects={data.trendingMinnects} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SweepButton } from "@/components/Buttons";
|
||||
import { HexAvatar } from "@/components/HexAvatar";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { DiscoverCategory } from "@/lib/discover";
|
||||
@@ -17,15 +18,20 @@ export interface ExpertSearchViewProps {
|
||||
onCategory: (name: string | null) => void;
|
||||
status: SearchStatus;
|
||||
results: ExpertResult[];
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export function ExpertSearchView(p: ExpertSearchViewProps) {
|
||||
return (
|
||||
<details className="expert-search" open={p.defaultOpen}>
|
||||
<summary>Find experts</summary>
|
||||
<form role="search" onSubmit={(e) => e.preventDefault()}>
|
||||
<section className="tile panel expert-search" aria-labelledby="h-find">
|
||||
<div className="panel-head">
|
||||
<h2 id="h-find" className="type-title type-title-strong">Find experts</h2>
|
||||
</div>
|
||||
<form role="search" className="search-form" onSubmit={(e) => e.preventDefault()}>
|
||||
<label htmlFor="expert-q">Search experts</label>
|
||||
<div className="search-field">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
<input
|
||||
id="expert-q"
|
||||
type="search"
|
||||
@@ -35,44 +41,47 @@ export function ExpertSearchView(p: ExpertSearchViewProps) {
|
||||
maxLength={80}
|
||||
onChange={(e) => p.onQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
{p.categories.length > 0 ? (
|
||||
<div role="group" aria-label="Browse experts by category" className="chips">
|
||||
{p.categories.map((c) => (
|
||||
<button
|
||||
<SweepButton
|
||||
key={String(c.id)}
|
||||
type="button"
|
||||
className="chip"
|
||||
aria-pressed={p.activeCategory === c.name}
|
||||
label={c.name}
|
||||
selected={p.activeCategory === c.name}
|
||||
onClick={() => p.onCategory(p.activeCategory === c.name ? null : c.name)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div aria-live="polite" className="search-results">
|
||||
<div aria-live="polite" className="search-results panel-scroll fade-edges">
|
||||
{p.status === "loading" ? <p className="search-note">Searching...</p> : null}
|
||||
{p.status === "error" ? <p className="search-note" role="alert">Search is unavailable. Try again.</p> : null}
|
||||
{p.status === "ready" && p.results.length === 0 ? (
|
||||
<p className="search-note">No experts found.</p>
|
||||
) : null}
|
||||
{p.status === "ready" && p.results.length > 0 ? (
|
||||
<ul className="rail-list">
|
||||
<ul className="rail-list search-rows">
|
||||
{p.results.map((e) => (
|
||||
<li key={String(e.id)} className="rail-card expert-card" data-testid="search-result">
|
||||
<li key={String(e.id)} className="expert-row" data-testid="search-result">
|
||||
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
|
||||
<div className="rail-text">
|
||||
<strong className="rail-name">{e.displayName || "Expert"}</strong>
|
||||
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
|
||||
{e.rating !== null ? <span className="rail-meta">★ {e.rating.toFixed(1)}</span> : null}
|
||||
<div className="expert-text">
|
||||
<strong>{e.displayName || "Expert"}</strong>
|
||||
{e.byline ? <span>{e.byline}</span> : null}
|
||||
</div>
|
||||
{e.rating !== null ? (
|
||||
<span className="expert-rate" aria-label={`Rating ${e.rating.toFixed(1)}`}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m12 2.8 2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.6l-5.8 3.1 1.1-6.5L2.6 9.6l6.5-.9L12 2.8Z" /></svg>
|
||||
{e.rating.toFixed(1)}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,11 +93,9 @@ export function ExpertSearch() {
|
||||
const [categories, setCategories] = useState<DiscoverCategory[]>([]);
|
||||
const [status, setStatus] = useState<SearchStatus>("idle");
|
||||
const [results, setResults] = useState<ExpertResult[]>([]);
|
||||
const [open, setOpen] = useState(true);
|
||||
const seq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(window.matchMedia("(min-width: 960px)").matches);
|
||||
let live = true;
|
||||
fetchDiscover().then(
|
||||
(d) => { if (live) setCategories(d.categories); },
|
||||
@@ -117,8 +124,6 @@ export function ExpertSearch() {
|
||||
|
||||
return (
|
||||
<ExpertSearchView
|
||||
key={open ? "open" : "closed"}
|
||||
defaultOpen={open}
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
categories={categories}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import type { DiscoverExpert } from "@/lib/discover";
|
||||
import { initials } from "@/lib/feed-model";
|
||||
import { toneFor } from "./HexAvatar";
|
||||
import { featuredLiveMode, featuredSlot, startFeaturedAutoplay, type FeaturedAutoplay } from "./featured-carousel-model";
|
||||
|
||||
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
function useReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||
const mq = window.matchMedia(REDUCED_MOTION);
|
||||
setReduced(mq.matches);
|
||||
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
return reduced;
|
||||
}
|
||||
|
||||
function Star() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Photo({ expert, center }: { expert: DiscoverExpert; center: boolean }) {
|
||||
const name = expert.displayName || "Expert";
|
||||
if (expert.pictureUrl) {
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img className="featured-photo" src={expert.pictureUrl} alt={center ? name : ""} />;
|
||||
}
|
||||
return (
|
||||
<span className={`featured-photo featured-initials hex-avatar-${toneFor(name)}`} role="img" aria-label={center ? name : undefined}>
|
||||
<span aria-hidden="true">{initials(name)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FeaturedCarouselProps {
|
||||
experts: DiscoverExpert[];
|
||||
/** Width of the center photo in px: 96 on desktop, 150 to 156 on mobile. */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps) {
|
||||
const count = experts.length;
|
||||
const [index, setIndex] = useState(0);
|
||||
const reduced = useReducedMotion();
|
||||
const autoplay = useRef<FeaturedAutoplay | null>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const paused = hovered || focused;
|
||||
const pausedRef = useRef(false);
|
||||
pausedRef.current = paused;
|
||||
const rotating = count > 1 && !reduced;
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) autoplay.current?.pause();
|
||||
else autoplay.current?.resume();
|
||||
}, [paused]);
|
||||
|
||||
useEffect(() => {
|
||||
if (count < 2 || reduced) return;
|
||||
const ctl = startFeaturedAutoplay({ count, start: index % count, onChange: setIndex });
|
||||
if (pausedRef.current) ctl.pause();
|
||||
autoplay.current = ctl;
|
||||
return () => { ctl.stop(); autoplay.current = null; };
|
||||
// The index only seeds a restart; ticking must not recreate the timer.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [count, reduced]);
|
||||
|
||||
if (count === 0) return null;
|
||||
const current = index % count;
|
||||
const cap = experts[current];
|
||||
const style = { "--cfw": `${size}px` } as CSSProperties;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="tile panel rail rail-featured featured"
|
||||
aria-labelledby="featured-title"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); }}
|
||||
data-paused={paused ? "true" : undefined}
|
||||
>
|
||||
<div className="panel-head">
|
||||
<h2 id="featured-title" className="type-title type-title-strong featured-title">
|
||||
<Star />Featured experts
|
||||
</h2>
|
||||
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
|
||||
</div>
|
||||
<div className="featured-cf" style={style} aria-live={featuredLiveMode({ rotating, paused })} data-index={current}>
|
||||
{experts.map((e, i) => {
|
||||
const slot = featuredSlot(i, current, count);
|
||||
return (
|
||||
<div
|
||||
key={String(e.id)}
|
||||
className={`featured-item featured-${slot}`}
|
||||
aria-hidden={slot === "c0" ? undefined : true}
|
||||
data-testid="featured-item"
|
||||
>
|
||||
<span className="featured-ring"><Photo expert={e} center={slot === "c0"} /></span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="featured-cap" key={current}>
|
||||
<strong className="type-title type-title-strong featured-name">{cap.displayName || "Expert"}</strong>
|
||||
{cap.byline ? <span className="featured-by">{cap.byline}</span> : null}
|
||||
{cap.rating !== null || cap.reviewsCount !== null ? (
|
||||
<span className="featured-rate">
|
||||
{cap.rating !== null ? (
|
||||
<span aria-label={`Rated ${cap.rating.toFixed(1)} out of 5`}><Star />{cap.rating.toFixed(1)}</span>
|
||||
) : null}
|
||||
{cap.reviewsCount !== null ? (
|
||||
<em>({cap.reviewsCount} {cap.reviewsCount === 1 ? "review" : "reviews"})</em>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{count > 1 ? (
|
||||
<div className="featured-dots" aria-hidden="true">
|
||||
{experts.map((e, i) => (
|
||||
<span key={String(e.id)} className={i === current ? "featured-dot featured-dot-on" : "featured-dot"} />
|
||||
))}
|
||||
</div>
|
||||
) : <div className="featured-dots-gap" />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { HexAvatar } from "@/components/HexAvatar";
|
||||
import type { DiscoverExpert } from "@/lib/discover";
|
||||
|
||||
function Rating({ rating, reviews }: { rating: number | null; reviews: number | null }) {
|
||||
if (rating === null && reviews === null) return null;
|
||||
return (
|
||||
<span className="rail-meta">
|
||||
{rating !== null ? <span aria-label={`Rated ${rating.toFixed(1)} out of 5`}>★ {rating.toFixed(1)}</span> : null}
|
||||
{reviews !== null ? <span> ({reviews} {reviews === 1 ? "review" : "reviews"})</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) {
|
||||
if (experts.length === 0) return null;
|
||||
return (
|
||||
<section className="rail" aria-labelledby="rail-experts">
|
||||
<h2 id="rail-experts">Featured experts</h2>
|
||||
<ul className="rail-list">
|
||||
{experts.map((e) => (
|
||||
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
|
||||
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
|
||||
<div className="rail-text">
|
||||
<strong className="rail-name">{e.displayName || "Expert"}</strong>
|
||||
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
|
||||
<Rating rating={e.rating} reviews={e.reviewsCount} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+40
-8
@@ -3,18 +3,30 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
|
||||
import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
|
||||
import { mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
|
||||
import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
|
||||
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
||||
import { DeleteError, sendDelete } from "@/lib/delete-client";
|
||||
import type { ComposerProps } from "./Composer";
|
||||
import { FeedView, type FeedStatus } from "./FeedView";
|
||||
|
||||
const POLL_MS = 45_000;
|
||||
|
||||
export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; feedUserId?: string | null }) {
|
||||
export function Feed({
|
||||
onSignedOut,
|
||||
feedUserId,
|
||||
person,
|
||||
}: {
|
||||
onSignedOut: () => void;
|
||||
feedUserId?: string | null;
|
||||
/** The signed-in person, shown beside the composer. */
|
||||
person?: ComposerProps["person"];
|
||||
}) {
|
||||
const [posts, setPosts] = useState<CardPost[]>([]);
|
||||
const [status, setStatus] = useState<FeedStatus>("loading");
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshFailed, setRefreshFailed] = useState(false);
|
||||
const [loadMoreFailed, setLoadMoreFailed] = useState(false);
|
||||
const [pending, setPending] = useState<CardPost[]>([]);
|
||||
const [likeBusy, setLikeBusy] = useState<ReadonlySet<string>>(new Set());
|
||||
@@ -29,6 +41,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
|
||||
// re-checks the feed instead of creating a duplicate.
|
||||
const unconfirmed = useRef<{ id: string | null }>({ id: null });
|
||||
const sentinel = useRef<HTMLDivElement | null>(null);
|
||||
const list = useRef<HTMLDivElement | null>(null);
|
||||
const busy = useRef(false);
|
||||
const shown = useRef<Set<string>>(new Set());
|
||||
|
||||
@@ -43,10 +56,13 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
|
||||
[onSignedOut],
|
||||
);
|
||||
|
||||
const loadFirst = useCallback(async () => {
|
||||
// quiet: a refresh keeps the list on screen instead of swapping in the spinner.
|
||||
const loadFirst = useCallback(async (quiet = false) => {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
setStatus("loading");
|
||||
setRefreshFailed(false);
|
||||
if (quiet) setRefreshing(true);
|
||||
else setStatus("loading");
|
||||
setLoadMoreFailed(false);
|
||||
try {
|
||||
const page = await fetchFeedPage(null);
|
||||
@@ -56,9 +72,12 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
|
||||
setStatus("ready");
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
setStatus("error");
|
||||
const out = firstLoadFailure(quiet);
|
||||
if (out.fullError) setStatus("error");
|
||||
setRefreshFailed(out.refreshFailed);
|
||||
} finally {
|
||||
busy.current = false;
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [fail]);
|
||||
|
||||
@@ -93,7 +112,8 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) void loadMore();
|
||||
},
|
||||
{ rootMargin: "400px" },
|
||||
// The list scrolls inside its panel, so it is the observer root.
|
||||
{ root: list.current, rootMargin: "400px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
@@ -224,7 +244,15 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
|
||||
function showNew() {
|
||||
setPosts((cur) => mergePosts(cur, pending));
|
||||
setPending([]);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
list.current?.scrollTo({ top: 0, behavior: calm ? "auto" : "smooth" });
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (busy.current) return;
|
||||
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
list.current?.scrollTo({ top: 0, behavior: calm ? "auto" : "smooth" });
|
||||
void loadFirst(true);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -236,6 +264,9 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
|
||||
newCount={pending.length}
|
||||
onRetry={() => (status === "error" ? void loadFirst() : void loadMore())}
|
||||
onShowNew={showNew}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
refreshFailed={refreshFailed}
|
||||
onToggleLike={(p) => void toggleLike(p)}
|
||||
likeBusy={likeBusy}
|
||||
likeErrors={likeErrors}
|
||||
@@ -244,8 +275,9 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
|
||||
deleteBusy={deleteBusy}
|
||||
deleteErrors={deleteErrors}
|
||||
onSignedOut={onSignedOut}
|
||||
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish() }}
|
||||
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
|
||||
sentinelRef={sentinel}
|
||||
listRef={list}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+87
-17
@@ -1,6 +1,7 @@
|
||||
import type { Ref } from "react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { canDelete, type CardPost } from "@/lib/feed-model";
|
||||
import { Composer, type ComposerProps } from "./Composer";
|
||||
import { GrowButton, IconButton } from "./Buttons";
|
||||
import { PostCard } from "./PostCard";
|
||||
|
||||
export type FeedStatus = "loading" | "ready" | "error";
|
||||
@@ -13,6 +14,11 @@ export interface FeedViewProps {
|
||||
newCount: number;
|
||||
onRetry: () => void;
|
||||
onShowNew: () => void;
|
||||
/** Reloads the first page; the header shows the button when it is given. */
|
||||
onRefresh?: () => void;
|
||||
refreshing?: boolean;
|
||||
/** The last refresh failed; the posts stay and a small message shows in the header. */
|
||||
refreshFailed?: boolean;
|
||||
onToggleLike?: (post: CardPost) => void;
|
||||
likeBusy?: ReadonlySet<string>;
|
||||
likeErrors?: ReadonlySet<string>;
|
||||
@@ -24,6 +30,8 @@ export interface FeedViewProps {
|
||||
deleteErrors?: ReadonlySet<string>;
|
||||
composer?: ComposerProps;
|
||||
sentinelRef?: Ref<HTMLDivElement>;
|
||||
/** The scrolling feed list; the page itself never scrolls. */
|
||||
listRef?: Ref<HTMLDivElement>;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
@@ -31,40 +39,105 @@ function Spinner() {
|
||||
return <span className="spinner" role="status" aria-label="Loading" />;
|
||||
}
|
||||
|
||||
/** Shimmer placeholder card shown while the next page loads. */
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div className="feed-skel" role="status" aria-label="Loading more posts" data-testid="feed-skeleton">
|
||||
<div className="feed-skel-head">
|
||||
<span className="feed-skel-dot" />
|
||||
<span className="feed-skel-lines">
|
||||
<span className="feed-skel-line feed-skel-w40" />
|
||||
<span className="feed-skel-line feed-skel-w25" />
|
||||
</span>
|
||||
</div>
|
||||
<span className="feed-skel-line feed-skel-w100" />
|
||||
<span className="feed-skel-line feed-skel-w80" />
|
||||
<span className="feed-skel-media" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshIcon({ spinning }: { spinning: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className={spinning ? "refresh-icon refresh-icon-spin" : "refresh-icon"}
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
|
||||
<path d="M21 3v6h-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RefreshButton({ onRefresh, busy }: { onRefresh: () => void; busy?: boolean }) {
|
||||
return (
|
||||
<IconButton
|
||||
label="Refresh feed"
|
||||
shape="round"
|
||||
size="sm"
|
||||
icon={<RefreshIcon spinning={!!busy} />}
|
||||
disabled={busy}
|
||||
aria-busy={busy || undefined}
|
||||
onClick={onRefresh}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedPanel({ children, composer, onRefresh, busy, refreshFailed }: { children: ReactNode; composer?: ReactNode; onRefresh?: () => void; busy?: boolean; refreshFailed?: boolean }) {
|
||||
return (
|
||||
<section className="tile panel feed" aria-labelledby="h-feed">
|
||||
<div className="panel-head feed-head">
|
||||
<h2 id="h-feed" className="type-title type-title-strong">Home feed</h2>
|
||||
{refreshFailed ? <span className="feed-refresh-error" role="alert">Could not refresh. Try again.</span> : null}
|
||||
{onRefresh ? <RefreshButton onRefresh={onRefresh} busy={busy} /> : null}
|
||||
</div>
|
||||
{composer}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeedView(p: FeedViewProps) {
|
||||
if (p.status === "loading") {
|
||||
return (
|
||||
<section className="feed">
|
||||
<FeedPanel onRefresh={p.onRefresh} busy>
|
||||
<div className="feed-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
</section>
|
||||
</FeedPanel>
|
||||
);
|
||||
}
|
||||
if (p.status === "error") {
|
||||
return (
|
||||
<section className="feed">
|
||||
<FeedPanel onRefresh={p.onRefresh}>
|
||||
<div className="feed-message" role="alert">
|
||||
<p>The feed did not load.</p>
|
||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||
</div>
|
||||
</section>
|
||||
</FeedPanel>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section className="feed">
|
||||
{p.composer ? <Composer {...p.composer} /> : null}
|
||||
<FeedPanel composer={p.composer ? <Composer {...p.composer} /> : null} onRefresh={p.onRefresh} busy={p.refreshing} refreshFailed={p.refreshFailed}>
|
||||
<div ref={p.listRef} className="feed-list panel-scroll fade-edges">
|
||||
{p.newCount > 0 ? (
|
||||
<button type="button" className="new-posts" onClick={p.onShowNew}>
|
||||
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
|
||||
</button>
|
||||
<div className="feed-pill">
|
||||
<GrowButton label={p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`} onClick={p.onShowNew} />
|
||||
</div>
|
||||
) : null}
|
||||
{p.posts.length === 0 ? (
|
||||
<p className="feed-message">No posts yet.</p>
|
||||
) : (
|
||||
p.posts.map((post) => <PostCard
|
||||
p.posts.map((post, i) => <PostCard
|
||||
key={post.key}
|
||||
post={post}
|
||||
index={i}
|
||||
now={p.now}
|
||||
onToggleLike={p.onToggleLike}
|
||||
likeBusy={p.likeBusy?.has(post.key)}
|
||||
@@ -82,12 +155,9 @@ export function FeedView(p: FeedViewProps) {
|
||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||
</div>
|
||||
) : null}
|
||||
{p.loadingMore ? (
|
||||
<div className="feed-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : null}
|
||||
{p.loadingMore ? <SkeletonCard /> : null}
|
||||
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
|
||||
</section>
|
||||
</div>
|
||||
</FeedPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { HexAvatar } from "@/components/HexAvatar";
|
||||
import type { DiscoverMedia } from "@/lib/discover";
|
||||
|
||||
interface Props {
|
||||
@@ -8,20 +9,37 @@ interface Props {
|
||||
thumbnailUrl: string | null;
|
||||
question: string | null;
|
||||
who: string;
|
||||
pictureUrl?: string | null;
|
||||
views?: number | null;
|
||||
}
|
||||
|
||||
/** Video answers play inline; audio keeps the audio control; no url shows the thumbnail only. */
|
||||
export function MinnectMedia({ media, thumbnailUrl, question, who }: Props) {
|
||||
/**
|
||||
* The trending card's 16:9 frame. Video answers play inline as a <video> with a
|
||||
* poster; audio keeps its <audio> control under the frame; no url shows the
|
||||
* thumbnail only. The glass chip, play button, views and kind label sit over
|
||||
* the thumbnail and step aside while a video plays.
|
||||
*/
|
||||
export function MinnectMedia({ media, thumbnailUrl, question, who, pictureUrl, views = null }: Props) {
|
||||
const [videoFailed, setVideoFailed] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const video = useRef<HTMLVideoElement | null>(null);
|
||||
const audio = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const thumb = thumbnailUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="minnect-thumb" src={thumbnailUrl} alt={question ? `Thumbnail for: ${question}` : "Minnect thumbnail"} />
|
||||
) : null;
|
||||
const isVideo = media?.kind === "video" && !videoFailed;
|
||||
const isAudio = media?.kind === "audio";
|
||||
const playable = isVideo || isAudio;
|
||||
|
||||
const play = () => {
|
||||
const el = isVideo ? video.current : audio.current;
|
||||
void el?.play().catch(() => {});
|
||||
};
|
||||
|
||||
if (media?.kind === "video" && !videoFailed) {
|
||||
return (
|
||||
<>
|
||||
<div className={`trend-media${playing ? " trend-media-playing" : ""}`}>
|
||||
{isVideo ? (
|
||||
<video
|
||||
ref={video}
|
||||
className="minnect-thumb minnect-video"
|
||||
controls
|
||||
playsInline
|
||||
@@ -29,15 +47,41 @@ export function MinnectMedia({ media, thumbnailUrl, question, who }: Props) {
|
||||
src={media.url}
|
||||
poster={thumbnailUrl ?? undefined}
|
||||
aria-label={`Play answer from ${who}`}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
onError={() => setVideoFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{thumb}
|
||||
{media?.kind === "audio" ? (
|
||||
<audio className="minnect-audio" controls preload="none" src={media.url} aria-label={`Play answer from ${who}`} />
|
||||
) : thumbnailUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="minnect-thumb" src={thumbnailUrl} alt={question ? `Thumbnail for: ${question}` : "Minnect thumbnail"} />
|
||||
) : null}
|
||||
<span className="trend-scrim" aria-hidden="true" />
|
||||
<span className="trend-who">
|
||||
<HexAvatar name={who} pictureUrl={pictureUrl} size="sm" />
|
||||
<strong>{who}</strong>
|
||||
</span>
|
||||
{playable ? (
|
||||
<button type="button" className="trend-play" aria-label={`Play answer from ${who}`} onClick={play}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5Z" /></svg>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{views !== null || playable ? (
|
||||
<div className="trend-meta">
|
||||
{views !== null ? (
|
||||
<span className="trend-views">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
{views.toLocaleString("en-US")} {views === 1 ? "view" : "views"}
|
||||
</span>
|
||||
) : <span />}
|
||||
{playable ? <span className="trend-kind">{isVideo ? "Video" : "Audio"}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{isAudio ? (
|
||||
<audio ref={audio} className="minnect-audio" controls preload="none" src={media.url} aria-label={`Play answer from ${who}`} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
+125
-33
@@ -1,14 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState, type CSSProperties } 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 { commentsLabel, formatDuration, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
|
||||
|
||||
export interface PostCardProps {
|
||||
post: CardPost;
|
||||
now?: number;
|
||||
/** Position in the list; the first cards rise in one after another. */
|
||||
index?: number;
|
||||
onToggleLike?: (post: CardPost) => void;
|
||||
likeBusy?: boolean;
|
||||
likeError?: boolean;
|
||||
@@ -20,9 +23,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 +43,126 @@ function Heart({ filled }: { filled: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
|
||||
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, index = 0, 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">
|
||||
<article className="post" data-testid="post-card" style={{ "--i": index < 8 ? index : 0 } as CSSProperties}>
|
||||
<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 +171,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 +187,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)}
|
||||
>
|
||||
{post.commentsCount} comments
|
||||
<CommentIcon />
|
||||
{commentsLabel(post.commentsCount)}
|
||||
</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}
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import { HexAvatar } from "@/components/HexAvatar";
|
||||
import type { DiscoverMinnect } from "@/lib/discover";
|
||||
import { MinnectMedia } from "./MinnectMedia";
|
||||
|
||||
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
|
||||
if (minnects.length === 0) return null;
|
||||
return (
|
||||
<section className="rail" aria-labelledby="rail-trending">
|
||||
<h2 id="rail-trending">Trending minnects</h2>
|
||||
<ul className="rail-list">
|
||||
<section className="tile panel rail rail-trending" aria-labelledby="rail-trending">
|
||||
<div className="panel-head">
|
||||
<h2 id="rail-trending" className="type-title type-title-strong">Trending minnects</h2>
|
||||
</div>
|
||||
<ul className="rail-list trend-list panel-scroll fade-edges">
|
||||
{minnects.map((m) => {
|
||||
const who = m.expert.displayName || "Expert";
|
||||
return (
|
||||
<li key={String(m.id)} className="rail-card minnect-card" data-testid="minnect-card">
|
||||
<div className="minnect-who">
|
||||
<HexAvatar name={who} pictureUrl={m.expert.pictureUrl} size="sm" />
|
||||
<strong className="rail-name">{who}</strong>
|
||||
</div>
|
||||
<MinnectMedia media={m.media} thumbnailUrl={m.thumbnailUrl} question={m.question} who={who} />
|
||||
{m.question ? <p className="minnect-question">{m.question}</p> : null}
|
||||
{m.views !== null ? (
|
||||
<span className="rail-meta">{m.views.toLocaleString("en-US")} {m.views === 1 ? "view" : "views"}</span>
|
||||
) : null}
|
||||
<li key={String(m.id)} className="minnect-card" data-testid="minnect-card">
|
||||
<MinnectMedia
|
||||
media={m.media}
|
||||
thumbnailUrl={m.thumbnailUrl}
|
||||
question={m.question}
|
||||
who={who}
|
||||
pictureUrl={m.expert.pictureUrl}
|
||||
views={m.views}
|
||||
/>
|
||||
{m.question ? <p className="trend-q">{m.question}</p> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Plain-text helpers for the composer (spec "Composer", formatting decided by
|
||||
// Cesar on 2026-10-19: posts are plain text, no bold or italic).
|
||||
|
||||
export const BULLET = "• ";
|
||||
|
||||
export interface TextEdit {
|
||||
text: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the plain character "• " at the start of the line holding the caret,
|
||||
* or of every line the selection touches. A line that already starts with
|
||||
* "• " is left as it is. Nothing else in the text changes. The returned
|
||||
* selection is shifted so it still covers the same characters.
|
||||
*/
|
||||
export function insertBullets(text: string, selectionStart: number, selectionEnd: number): TextEdit {
|
||||
const start = Math.max(0, Math.min(selectionStart, selectionEnd, text.length));
|
||||
let end = Math.max(0, Math.min(Math.max(selectionStart, selectionEnd), text.length));
|
||||
// A selection that ends right after a line break does not include the next line.
|
||||
if (end > start && text[end - 1] === "\n") end -= 1;
|
||||
|
||||
// Offsets of every line start from the caret's line to the selection's last line.
|
||||
const firstLine = text.lastIndexOf("\n", start - 1) + 1;
|
||||
const lineStarts: number[] = [];
|
||||
for (let i = firstLine; i <= end; ) {
|
||||
lineStarts.push(i);
|
||||
const next = text.indexOf("\n", i);
|
||||
if (next === -1 || next >= end) break;
|
||||
i = next + 1;
|
||||
}
|
||||
|
||||
let out = "";
|
||||
let last = 0;
|
||||
let newStart = selectionStart;
|
||||
let newEnd = selectionEnd;
|
||||
for (const at of lineStarts) {
|
||||
out += text.slice(last, at);
|
||||
last = at;
|
||||
if (text.startsWith(BULLET, at)) continue;
|
||||
out += BULLET;
|
||||
if (at <= selectionStart) newStart += BULLET.length;
|
||||
if (at <= selectionEnd) newEnd += BULLET.length;
|
||||
}
|
||||
out += text.slice(last);
|
||||
return { text: out, selectionStart: newStart, selectionEnd: newEnd };
|
||||
}
|
||||
|
||||
/** "2.4 MB", "820 KB" or "512 B". */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
/** The attachment row's meta line: type and size, for example "Image, 2.4 MB". */
|
||||
export function attachmentMeta(file: { type: string; size: number }): string {
|
||||
const kind = file.type.startsWith("video/") ? "Video" : "Image";
|
||||
return `${kind}, ${formatBytes(file.size)}`;
|
||||
}
|
||||
|
||||
/** The 32 frequent emoji in the composer's emoji popover (mockup order). */
|
||||
export const COMPOSER_EMOJI = [
|
||||
"😀", "😂", "😍", "🥳", "😎", "🤔", "🙌", "👏",
|
||||
"🔥", "💡", "🚀", "🎯", "✅", "❤️", "💯", "✨",
|
||||
"🙏", "👀", "📈", "🧠", "💬", "📣", "🎉", "☕",
|
||||
"🌍", "📚", "🤝", "💪", "🌱", "⚡", "🏆", "👋",
|
||||
] as const;
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Rotation timing from the spec ("Featured experts carousel"). */
|
||||
export const FEATURED_INTERVAL_MS = 2800;
|
||||
|
||||
export type FeaturedSlot = "c0" | "l1" | "r1" | "l2" | "r2" | "l3" | "r3";
|
||||
|
||||
/**
|
||||
* Where item `i` sits when `current` is in the center: by its signed offset
|
||||
* from the current index, wrapped so the shorter way round wins. With two
|
||||
* items the other one is a single right neighbor, never an empty pair.
|
||||
*/
|
||||
export function featuredSlot(i: number, current: number, count: number): FeaturedSlot {
|
||||
let off = (((i - current) % count) + count) % count;
|
||||
if (off > count / 2) off -= count;
|
||||
if (off === 0) return "c0";
|
||||
if (off === 1) return "r1";
|
||||
if (off === -1) return "l1";
|
||||
if (off === 2) return "r2";
|
||||
if (off === -2) return "l2";
|
||||
return off > 0 ? "r3" : "l3";
|
||||
}
|
||||
|
||||
export interface FeaturedAutoplay {
|
||||
readonly index: number;
|
||||
readonly paused: boolean;
|
||||
pause(): void;
|
||||
resume(): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the index every `intervalMs` while not paused. A single item never
|
||||
* rotates. The caller stops it on unmount.
|
||||
*/
|
||||
export function startFeaturedAutoplay(opts: {
|
||||
count: number;
|
||||
onChange: (index: number) => void;
|
||||
intervalMs?: number;
|
||||
start?: number;
|
||||
}): FeaturedAutoplay {
|
||||
const { count, onChange, intervalMs = FEATURED_INTERVAL_MS } = opts;
|
||||
let index = opts.start ?? 0;
|
||||
let paused = false;
|
||||
const timer =
|
||||
count > 1
|
||||
? setInterval(() => {
|
||||
if (paused) return;
|
||||
index = (index + 1) % count;
|
||||
onChange(index);
|
||||
}, intervalMs)
|
||||
: null;
|
||||
return {
|
||||
get index() { return index; },
|
||||
get paused() { return paused; },
|
||||
pause() { paused = true; },
|
||||
resume() { paused = false; },
|
||||
stop() { if (timer) clearInterval(timer); },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WAI-ARIA carousel pattern: a rotating carousel must not announce every
|
||||
* slide, so the region is `off` while autoplay runs and `polite` when the
|
||||
* person has paused it (hover, focus) or it never rotates (reduced motion,
|
||||
* a single expert).
|
||||
*/
|
||||
export function featuredLiveMode(opts: { rotating: boolean; paused: boolean }): "off" | "polite" {
|
||||
return opts.rotating && !opts.paused ? "off" : "polite";
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
// 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
|
||||
// checked by hand. Used by src/app/dev/feed/page.tsx.
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { DiscoverRailsView } from "@/components/DiscoverRails";
|
||||
import { ExpertSearchView } from "@/components/ExpertSearch";
|
||||
import { FeedView } from "@/components/FeedView";
|
||||
import { toComments } from "@/lib/comment-model";
|
||||
import { toCardPosts, type CardPost } from "@/lib/feed-model";
|
||||
import {
|
||||
FIXTURE_CATEGORIES,
|
||||
FIXTURE_COMMENTS,
|
||||
FIXTURE_DISCOVER,
|
||||
FIXTURE_FEED_ITEMS,
|
||||
FIXTURE_NOW,
|
||||
FIXTURE_PERSON,
|
||||
FIXTURE_SEARCH_RESULTS,
|
||||
} from "./fixtures";
|
||||
|
||||
export function FeedPreview() {
|
||||
const [posts, setPosts] = useState<CardPost[]>(() => toCardPosts(FIXTURE_FEED_ITEMS));
|
||||
const [draft, setDraft] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
// Fixture switches for the new-posts pill and the loading-more skeleton.
|
||||
const [newCount, setNewCount] = useState(0);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const list = useRef<HTMLDivElement | 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.
|
||||
const results = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return q ? FIXTURE_SEARCH_RESULTS.filter((e) => e.displayName.toLowerCase().includes(q)) : FIXTURE_SEARCH_RESULTS;
|
||||
}, [query]);
|
||||
|
||||
const toggleLike = (post: CardPost) =>
|
||||
setPosts((cur) =>
|
||||
cur.map((p) =>
|
||||
p.key === post.key ? { ...p, isLiked: !p.isLiked, likesCount: p.likesCount + (p.isLiked ? -1 : 1) } : p,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: "fixed", left: "50%", bottom: 8, zIndex: 50, display: "flex", gap: 12, padding: "6px 10px", font: "12px sans-serif", background: "var(--tile-2)", color: "var(--ink)", border: "1px solid var(--line-2)", borderRadius: 8 }}>
|
||||
<label style={{ margin: 0, fontSize: 12 }}>
|
||||
<input type="checkbox" data-testid="fx-new" checked={newCount > 0} onChange={(e) => setNewCount(e.target.checked ? 3 : 0)} style={{ padding: 0 }} /> New posts (3)
|
||||
</label>
|
||||
<label style={{ margin: 0, fontSize: 12 }}>
|
||||
<input type="checkbox" data-testid="fx-more" checked={loadingMore} onChange={(e) => setLoadingMore(e.target.checked)} style={{ padding: 0 }} /> Loading more
|
||||
</label>
|
||||
</div>
|
||||
<AppShell
|
||||
person={FIXTURE_PERSON}
|
||||
search={
|
||||
<ExpertSearchView
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
categories={FIXTURE_CATEGORIES}
|
||||
activeCategory={category}
|
||||
onCategory={setCategory}
|
||||
status="ready"
|
||||
results={results}
|
||||
/>
|
||||
}
|
||||
feed={
|
||||
<FeedView
|
||||
posts={posts}
|
||||
status="ready"
|
||||
loadingMore={loadingMore}
|
||||
listRef={list}
|
||||
loadMoreFailed={false}
|
||||
newCount={newCount}
|
||||
onRetry={() => {}}
|
||||
onShowNew={() => {
|
||||
setNewCount(0);
|
||||
list.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => {
|
||||
list.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
setRefreshing(true);
|
||||
window.setTimeout(() => {
|
||||
setPosts(toCardPosts(FIXTURE_FEED_ITEMS));
|
||||
setRefreshing(false);
|
||||
}, 600);
|
||||
}}
|
||||
onToggleLike={toggleLike}
|
||||
feedUserId={FIXTURE_PERSON.feedUserId}
|
||||
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
|
||||
composer={{
|
||||
value: draft,
|
||||
busy: false,
|
||||
error: false,
|
||||
file,
|
||||
onChange: setDraft,
|
||||
onFile: setFile,
|
||||
// No network here: "publishing" just clears the composer.
|
||||
onSubmit: () => {
|
||||
setDraft("");
|
||||
setFile(null);
|
||||
},
|
||||
person: FIXTURE_PERSON,
|
||||
}}
|
||||
now={FIXTURE_NOW}
|
||||
/>
|
||||
}
|
||||
discover={<DiscoverRailsView data={FIXTURE_DISCOVER} />}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Fixture data for the dev-only preview routes (/dev/feed). Nothing here is
|
||||
// real: no Minnect person, post, token or URL. Images are inline SVG data URIs
|
||||
// so the preview never calls the network. Later tasks extend this file.
|
||||
import type { Person } from "@/lib/auth-client";
|
||||
import type { DiscoverCategory, DiscoverData, DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
|
||||
import type { ExpertResult } from "@/lib/experts-search";
|
||||
|
||||
/** Fixed "now" so relative times in the preview are stable. */
|
||||
export const FIXTURE_NOW = Date.parse("2026-10-19T12:00:00Z");
|
||||
|
||||
/** A 16:9 gradient placeholder image, drawn with hsl() so no fetch happens. */
|
||||
export function placeholderImage(hue: number, label = ""): string {
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">` +
|
||||
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
||||
`<stop offset="0" stop-color="hsl(${hue} 45% 32%)"/><stop offset="1" stop-color="hsl(${(hue + 40) % 360} 40% 14%)"/>` +
|
||||
`</linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/>` +
|
||||
(label
|
||||
? `<text x="64" y="660" font-family="system-ui,sans-serif" font-size="56" font-weight="700" fill="hsl(0 0% 100% / .85)">${label}</text>`
|
||||
: "") +
|
||||
`</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_PERSON: Person = {
|
||||
name: "Jordan Rivera",
|
||||
email: "jordan.rivera@example.com",
|
||||
pictureUrl: null,
|
||||
feedUserId: FIXTURE_FEED_USER_ID,
|
||||
};
|
||||
|
||||
const author = (id: string, displayName: string, picture: string | null = null) => ({
|
||||
id,
|
||||
displayName,
|
||||
profilePictureUrl: picture,
|
||||
});
|
||||
|
||||
/** Raw feed items in the shape /api/feed returns; run them through toCardPosts. */
|
||||
export const FIXTURE_FEED_ITEMS: unknown[] = [
|
||||
{
|
||||
type: "post",
|
||||
publishedAt: "2026-10-19T11:42:00Z",
|
||||
likesCount: 4,
|
||||
commentsCount: 1,
|
||||
post: {
|
||||
id: "p1",
|
||||
content: "Shipped the new onboarding flow this morning. The best feedback so far came from a five minute call, not a survey.",
|
||||
author: author(FIXTURE_FEED_USER_ID, "Jordan Rivera"),
|
||||
mediaItems: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "post",
|
||||
publishedAt: "2026-10-19T10:15:00Z",
|
||||
likesCount: 128,
|
||||
commentsCount: 23,
|
||||
isLiked: true,
|
||||
post: {
|
||||
id: "p2",
|
||||
content: "Three questions I ask before every pricing change:\n• Who pays more?\n• Who leaves?\n• What do we learn either way?",
|
||||
author: author("a2", "Priya Natarajan"),
|
||||
mediaItems: [{ mediaType: "image", imageUrl: placeholderImage(352, "Pricing review"), order: 0 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "post",
|
||||
publishedAt: "2026-10-19T08:30:00Z",
|
||||
likesCount: 56,
|
||||
commentsCount: 7,
|
||||
post: {
|
||||
id: "p3",
|
||||
content: "A short walkthrough of how we run weekly reviews with a remote team.",
|
||||
author: author("a3", "Marcus Bell"),
|
||||
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(210, "Weekly reviews"), mp4Url: FIXTURE_VIDEO_URL, videoDuration: 154, order: 0 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "post",
|
||||
publishedAt: "2026-10-18T19:05:00Z",
|
||||
likesCount: 9,
|
||||
commentsCount: 0,
|
||||
post: {
|
||||
id: "p4",
|
||||
content: "Reading list for anyone moving from engineering into product: start with the customer interviews, then the numbers.",
|
||||
author: author("a4", "Elena Kovac"),
|
||||
mediaItems: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "post",
|
||||
publishedAt: "2026-10-18T14:40:00Z",
|
||||
likesCount: 312,
|
||||
commentsCount: 41,
|
||||
post: {
|
||||
id: "p5",
|
||||
content: "Fundraising tip: send the update even when the month was bad. Especially when the month was bad.",
|
||||
author: author("a5", "Samuel Okafor"),
|
||||
mediaItems: [{ mediaType: "image", imageUrl: placeholderImage(30, "Monthly update"), order: 0 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "post",
|
||||
publishedAt: "2026-10-17T09:00:00Z",
|
||||
likesCount: 17,
|
||||
commentsCount: 3,
|
||||
post: {
|
||||
id: "p6",
|
||||
content: "Your first ten customers will tell you what the product is. Listen before you build the next feature.",
|
||||
author: author("a6", "Hannah Lindqvist"),
|
||||
mediaItems: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const expert = (
|
||||
id: string,
|
||||
displayName: string,
|
||||
byline: string,
|
||||
rating: number,
|
||||
reviewsCount: number,
|
||||
pictureUrl: string | null = null,
|
||||
): DiscoverExpert => ({
|
||||
id,
|
||||
slug: displayName.replace(/\s+/g, ""),
|
||||
displayName,
|
||||
byline,
|
||||
pictureUrl,
|
||||
rating,
|
||||
reviewsCount,
|
||||
available: true,
|
||||
location: { city: null, country: null },
|
||||
});
|
||||
|
||||
export const FIXTURE_FEATURED: DiscoverExpert[] = [
|
||||
expert("e1", "Priya Natarajan", "Pricing and growth advisor", 4.9, 212),
|
||||
expert("e2", "Marcus Bell", "Remote team coach", 4.8, 96),
|
||||
expert("e3", "Elena Kovac", "Product leadership", 4.7, 58),
|
||||
expert("e4", "Samuel Okafor", "Seed-stage fundraising", 5.0, 140),
|
||||
expert("e5", "Hannah Lindqvist", "Customer research", 4.6, 33),
|
||||
// Photo placeholders exercise the image path of the featured carousel; the
|
||||
// rest show initials.
|
||||
expert("e9", "Leila Haddad", "Marketplace operations", 4.9, 181, placeholderImage(10)),
|
||||
expert("e10", "Tomas Ferreira", "Hiring and team design", 4.7, 64, placeholderImage(190)),
|
||||
expert("e11", "Grace Whitfield", "Brand and storytelling", 4.8, 102, placeholderImage(300)),
|
||||
];
|
||||
|
||||
const minnect = (id: string, who: DiscoverExpert, question: string, views: number, hue: number): DiscoverMinnect => ({
|
||||
id,
|
||||
question,
|
||||
views,
|
||||
thumbnailUrl: placeholderImage(hue),
|
||||
media: id === "m2" ? { kind: "video", url: "/fixtures/answer.mp4", duration: 42, waveform: null } : id === "m1" || id === "m4" ? { kind: "audio", url: "/fixtures/answer.mp3", duration: 31, waveform: null } : null,
|
||||
expert: { slug: who.slug, displayName: who.displayName, pictureUrl: null, byline: who.byline, rating: who.rating },
|
||||
});
|
||||
|
||||
export const FIXTURE_TRENDING: DiscoverMinnect[] = [
|
||||
minnect("m1", FIXTURE_FEATURED[0], "How do I raise prices without losing my best customers?", 12840, 352),
|
||||
minnect("m2", FIXTURE_FEATURED[3], "What should a pre-seed deck show in the first three slides?", 9310, 30),
|
||||
minnect("m3", FIXTURE_FEATURED[1], "How often should a remote team meet in person?", 6120, 210),
|
||||
minnect("m4", FIXTURE_FEATURED[2], "When is the right time to hire a first product manager?", 4470, 270),
|
||||
minnect("m5", FIXTURE_FEATURED[4], "How many customer interviews are enough before building?", 2015, 150),
|
||||
];
|
||||
|
||||
export const FIXTURE_CATEGORIES: DiscoverCategory[] = [
|
||||
{ id: 1, name: "Business", icon: null, sortOrder: 1 },
|
||||
{ id: 2, name: "Coaching", icon: null, sortOrder: 2 },
|
||||
{ id: 3, name: "Finance", icon: null, sortOrder: 3 },
|
||||
{ id: 4, name: "Product", icon: null, sortOrder: 4 },
|
||||
{ id: 5, name: "Media", icon: null, sortOrder: 5 },
|
||||
];
|
||||
|
||||
export const FIXTURE_DISCOVER: DiscoverData = {
|
||||
featuredExperts: FIXTURE_FEATURED,
|
||||
trendingMinnects: FIXTURE_TRENDING,
|
||||
categories: FIXTURE_CATEGORIES,
|
||||
};
|
||||
|
||||
/** Find experts results: every featured expert plus a few more, filterable by name. */
|
||||
export const FIXTURE_SEARCH_RESULTS: ExpertResult[] = [
|
||||
...FIXTURE_FEATURED,
|
||||
expert("e6", "Daniel Moreau", "Sales operations", 4.5, 21),
|
||||
expert("e7", "Aiko Tanaka", "Brand strategy", 4.8, 77),
|
||||
expert("e8", "Rafael Duarte", "Engineering management", 4.4, 18),
|
||||
].map((e) => ({
|
||||
id: e.id,
|
||||
slug: e.slug,
|
||||
displayName: e.displayName,
|
||||
byline: e.byline,
|
||||
pictureUrl: e.pictureUrl,
|
||||
rating: e.rating,
|
||||
categories: [],
|
||||
}));
|
||||
+24
-1
@@ -9,6 +9,18 @@ export interface CardMedia {
|
||||
hlsUrl?: string | null;
|
||||
/** Direct progressive MP4 on the feed CDN (videos only). Preferred for playback. */
|
||||
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 {
|
||||
@@ -92,7 +104,8 @@ function toMedia(raw: unknown): CardMedia[] {
|
||||
const hls = muxHlsUrl(m.hlsUrl);
|
||||
const mp4 = feedMp4Url(m.mp4Url);
|
||||
// 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 = [m.videoDuration, m.durationSeconds, m.duration].find((v): v is number => typeof v === "number" && v > 0) ?? null;
|
||||
out.push({ kind: "video", src: thumb, hlsUrl: hls, mp4Url: mp4, ...(dur ? { durationSeconds: dur } : {}) });
|
||||
continue;
|
||||
}
|
||||
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
|
||||
@@ -181,6 +194,16 @@ export function upgradeVideos(current: CardPost[], fresh: CardPost[]): CardPost[
|
||||
return changed ? next : current;
|
||||
}
|
||||
|
||||
/** A failed first-page load: a refresh keeps the posts and flags an inline error; an initial load shows the full error state. */
|
||||
export function firstLoadFailure(refresh: boolean): { fullError: boolean; refreshFailed: boolean } {
|
||||
return { fullError: !refresh, refreshFailed: refresh };
|
||||
}
|
||||
|
||||
/** "1 comment", "0 comments", "2 comments". */
|
||||
export function commentsLabel(count: number): string {
|
||||
return count === 1 ? "1 comment" : `${count} comments`;
|
||||
}
|
||||
|
||||
export function relativeTime(publishedAt: string | null, now: number = Date.now()): string {
|
||||
const t = publishedAt ? Date.parse(publishedAt) : NaN;
|
||||
if (!Number.isFinite(t)) return "";
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* Composer, toolbar, emoji popover and attachment row (spec "Composer",
|
||||
* ADR 0001). Ported from the mockup's .cmp, .bar, .emo and .att rules.
|
||||
* Tokens only. The Post button is the flip reveal from buttons.css.
|
||||
*/
|
||||
.composer {
|
||||
--composer-soft: color-mix(in srgb, var(--ink), transparent 93%);
|
||||
position: relative;
|
||||
border-radius: var(--r-card);
|
||||
background: var(--tile-2);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
padding: 10px 10px 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: box-shadow .3s, background-color .3s;
|
||||
}
|
||||
.composer.composer-open {
|
||||
background: var(--tile);
|
||||
box-shadow: inset 0 0 0 1px var(--line-2), var(--shadow-float);
|
||||
}
|
||||
|
||||
.composer-top { display: flex; gap: 12px; align-items: flex-start; }
|
||||
.composer-top .hex-avatar { flex: none; }
|
||||
.composer .composer-label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
resize: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink);
|
||||
padding: 12px 2px;
|
||||
height: 50px;
|
||||
transition: height .3s var(--ease);
|
||||
}
|
||||
.composer.composer-open textarea { height: 92px; }
|
||||
.composer textarea::placeholder { color: var(--ink-3); }
|
||||
.composer textarea:disabled { color: var(--ink-2); }
|
||||
|
||||
/* Toolbar: photo, video, emoji | bulleted list ... Ctrl + Enter, Post. */
|
||||
.composer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-top: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.composer-tool {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--r-ctl);
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
transition: background-color .2s, color .2s, transform .15s;
|
||||
}
|
||||
.composer-tool svg { width: 20px; height: 20px; }
|
||||
.composer-tool:hover:not(:disabled),
|
||||
.composer-tool.composer-tool-on { background: var(--composer-soft); color: var(--ink); }
|
||||
.composer-tool:active:not(:disabled) { transform: scale(.9); }
|
||||
.composer-tool:disabled { cursor: not-allowed; color: var(--ink-3); background: transparent; }
|
||||
.composer-file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
border: 0;
|
||||
}
|
||||
.composer-sep { width: 1px; height: 20px; background: var(--line-2); margin: 0 6px; flex: none; }
|
||||
.composer-spacer { flex: 1; }
|
||||
.composer-kbd { font-size: 11.5px; color: var(--ink-3); margin-right: 10px; white-space: nowrap; }
|
||||
.composer-kbd b {
|
||||
font-weight: 700;
|
||||
color: var(--ink-2);
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--tile-2);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
/* Emoji: a frosted popover with 32 frequent emoji. */
|
||||
.composer-emoji-wrap { position: relative; }
|
||||
.composer-emoji {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 8px);
|
||||
left: -8px;
|
||||
width: 320px;
|
||||
padding: 10px;
|
||||
background: var(--glass);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.7);
|
||||
backdrop-filter: blur(20px) saturate(1.7);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--shadow-pop);
|
||||
animation: composer-pop .24s cubic-bezier(.2,.9,.3,1.25) both;
|
||||
}
|
||||
.composer-eyebrow {
|
||||
display: block;
|
||||
padding: 2px 6px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.composer-emoji-grid { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 2px; }
|
||||
.composer-emoji-grid button {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: transform .15s cubic-bezier(.2,.9,.3,1.4), background-color .15s;
|
||||
}
|
||||
.composer-emoji-grid button:hover { background: var(--composer-soft); transform: scale(1.2); }
|
||||
|
||||
/* Attachment row: thumbnail, name, type and size, progress, remove. */
|
||||
.composer-att {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 6px 0 2px;
|
||||
padding: 8px;
|
||||
border-radius: 14px;
|
||||
background: var(--tile-2);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
animation: composer-rise .35s var(--ease) both;
|
||||
}
|
||||
.composer-att.composer-att-indent { margin-left: 58px; }
|
||||
.composer-thumb {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 10px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
background: var(--tile-3);
|
||||
}
|
||||
.composer-thumb img,
|
||||
.composer-thumb video { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.composer-thumb svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--on-accent);
|
||||
filter: drop-shadow(0 1px 3px var(--accent-deep));
|
||||
}
|
||||
.composer-att-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.composer-att-text strong {
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.composer-att-text span { font-size: 12px; color: var(--ink-3); }
|
||||
.composer-prog { height: 3px; border-radius: 3px; background: var(--tile-3); overflow: hidden; }
|
||||
.composer-prog i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transform-origin: left;
|
||||
animation: composer-load 1.6s var(--ease) both;
|
||||
}
|
||||
/* While posting the upload has no byte count, so the bar sweeps. */
|
||||
.composer-prog.composer-prog-busy i { width: 40%; animation: composer-sweep 1.1s ease-in-out infinite; }
|
||||
|
||||
.composer .error { margin: 8px 0 0; }
|
||||
|
||||
/* The flip Post button keeps its own size inside the toolbar. */
|
||||
.composer-bar .composer-post { flex: none; }
|
||||
|
||||
@keyframes composer-pop { from { opacity: 0; transform: translateY(-6px) scale(.96); } to { opacity: 1; transform: none; } }
|
||||
@keyframes composer-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes composer-load { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||
@keyframes composer-sweep { from { transform: translateX(-100%); } to { transform: translateX(250%); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.composer,
|
||||
.composer textarea,
|
||||
.composer-tool,
|
||||
.composer-emoji-grid button { transition: none !important; }
|
||||
.composer-emoji,
|
||||
.composer-att,
|
||||
.composer-prog i { animation: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/* Featured experts carousel (ADR 0001, section 6; spec "Featured experts
|
||||
carousel"). Ported from the mockup's .cf rules; colors are tokens only.
|
||||
--cfw is the center photo width, set by the component's size prop. */
|
||||
|
||||
.featured { flex: none; }
|
||||
.featured-title { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.featured-title svg { width: 16px; height: 16px; color: var(--accent); }
|
||||
.featured-count { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.featured-cf {
|
||||
--cfw: 96px;
|
||||
position: relative;
|
||||
height: calc(var(--cfw) * 1.155 + 6px);
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
overflow-clip-margin: 24px;
|
||||
}
|
||||
.featured-cf::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: calc(var(--cfw) * 1.9);
|
||||
height: calc(var(--cfw) * 1.3);
|
||||
transform: translate(-50%, -50%);
|
||||
background: radial-gradient(closest-side, color-mix(in oklab, var(--accent), transparent 70%), transparent);
|
||||
filter: blur(12px);
|
||||
animation: featured-breathe 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes featured-breathe { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
|
||||
|
||||
.featured-item {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
width: var(--cfw);
|
||||
margin-left: calc(var(--cfw) / -2);
|
||||
transition: transform 1.1s var(--ease), opacity 1.1s, filter 1.1s;
|
||||
}
|
||||
.featured-ring {
|
||||
display: block;
|
||||
padding: 3px;
|
||||
background: var(--line-2);
|
||||
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
|
||||
transition: background .8s;
|
||||
}
|
||||
.featured-photo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: .866;
|
||||
object-fit: cover;
|
||||
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
|
||||
}
|
||||
/* Photos sit on the tile while loading; initials keep their HexAvatar tone. */
|
||||
img.featured-photo { background: var(--tile-3); }
|
||||
.featured-initials {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--avatar-ink);
|
||||
font-weight: 600;
|
||||
letter-spacing: .02em;
|
||||
font-size: calc(var(--cfw) * .3);
|
||||
}
|
||||
|
||||
.featured-c0 { z-index: 5; }
|
||||
.featured-c0 .featured-ring { background: linear-gradient(180deg, var(--accent-hi), var(--accent) 50%, var(--accent-lo)); }
|
||||
.featured-r1 { transform: translateX(76%) scale(.7); opacity: .85; z-index: 4; filter: grayscale(.7) brightness(.72); }
|
||||
.featured-l1 { transform: translateX(-76%) scale(.7); opacity: .85; z-index: 4; filter: grayscale(.7) brightness(.72); }
|
||||
.featured-r2 { transform: translateX(132%) scale(.48); opacity: .45; z-index: 3; filter: grayscale(1) brightness(.55); }
|
||||
.featured-l2 { transform: translateX(-132%) scale(.48); opacity: .45; z-index: 3; filter: grayscale(1) brightness(.55); }
|
||||
.featured-r3 { transform: translateX(170%) scale(.3); opacity: 0; z-index: 1; }
|
||||
.featured-l3 { transform: translateX(-170%) scale(.3); opacity: 0; z-index: 1; }
|
||||
|
||||
.featured-cap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 3px;
|
||||
padding: 12px 16px 2px;
|
||||
animation: featured-cap .8s var(--ease) both;
|
||||
}
|
||||
@keyframes featured-cap { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
.featured-name { font-size: 23px; line-height: 1.1; }
|
||||
.featured-by {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.featured-rate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.featured-rate > span { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.featured-rate svg { width: 13px; height: 13px; color: var(--accent); }
|
||||
.featured-rate em { font-style: normal; font-weight: 500; color: var(--ink-3); }
|
||||
|
||||
.featured-dots { display: flex; justify-content: center; gap: 5px; padding: 10px 0 16px; }
|
||||
.featured-dots-gap { height: 16px; }
|
||||
.featured-dot {
|
||||
display: block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--line-2);
|
||||
transition: width .5s var(--ease), background-color .5s;
|
||||
}
|
||||
.featured-dot-on { width: 18px; background: var(--accent); }
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Feed list: scroll snap, the new-posts pill and the loading-more skeleton
|
||||
* (spec "Feed list", ADR 0001). Prefixed `feed-` classes, tokens only.
|
||||
*/
|
||||
.feed .feed-list { scroll-snap-type: y proximity; }
|
||||
|
||||
.feed-message { text-align: center; color: var(--ink-3); padding: 32px 16px; }
|
||||
.feed-message button { margin-top: 8px; }
|
||||
.feed-center { display: grid; place-items: center; padding: 16px; }
|
||||
.sentinel { height: 1px; }
|
||||
|
||||
.spinner { width: 24px; height: 24px; border: 3px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* The pill floats at the top center of the list. The zero-height sticky
|
||||
wrapper takes the entrance animation, so the button keeps its own transforms
|
||||
(hover expand, press). */
|
||||
.feed-pill {
|
||||
position: sticky;
|
||||
top: 12px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 0;
|
||||
margin-bottom: -16px;
|
||||
overflow: visible;
|
||||
animation: feed-pill-in .45s var(--ease) backwards;
|
||||
}
|
||||
.feed-pill .btn-grow { flex: none; }
|
||||
@keyframes feed-pill-in { from { opacity: 0; transform: translateY(-10px) scale(.96); } to { opacity: 1; transform: none; } }
|
||||
|
||||
/* Skeleton card: same footprint as a post, with a shimmer band sweeping over it. */
|
||||
.feed-skel {
|
||||
flex: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--tile);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-card);
|
||||
scroll-snap-align: none;
|
||||
}
|
||||
.feed-skel::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--ink), transparent 90%) 50%, transparent 80%);
|
||||
transform: translateX(-100%);
|
||||
animation: feed-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes feed-shimmer { to { transform: translateX(100%); } }
|
||||
.feed-skel-head { display: flex; align-items: center; gap: 12px; }
|
||||
.feed-skel-lines { display: flex; flex-direction: column; gap: 6px; flex: 1; }
|
||||
.feed-skel-dot { width: 40px; height: 40px; border-radius: 50%; background: var(--tile-3); flex: none; }
|
||||
.feed-skel-line { display: block; height: 12px; border-radius: 6px; background: var(--tile-3); }
|
||||
.feed-skel-w25 { width: 25%; }
|
||||
.feed-skel-w40 { width: 40%; }
|
||||
.feed-skel-w80 { width: 80%; }
|
||||
.feed-skel-w100 { width: 100%; }
|
||||
.feed-skel-media { display: block; aspect-ratio: 16 / 9; border-radius: 12px; background: var(--tile-3); }
|
||||
|
||||
/* Refresh button in the feed header. Its disabled state keeps full opacity, so
|
||||
the spinning icon is the only sign that a reload is running. */
|
||||
.refresh-icon { display: block; }
|
||||
.refresh-icon-spin { animation: spin .8s linear infinite; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.refresh-icon-spin { animation: none !important; }
|
||||
}
|
||||
.feed-refresh-error { margin-left: auto; font-size: 13px; color: var(--danger); }
|
||||
@@ -0,0 +1,205 @@
|
||||
/* Signed-in shell (spec "Desktop and tablet layout", ADR 0001 sections 3 and 6).
|
||||
.app is the size container; .bento is its child, so container queries can
|
||||
style it. The page never scrolls: only the panels' own lists do. */
|
||||
|
||||
.app {
|
||||
container: app / inline-size;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
transition: background-color .45s, color .45s;
|
||||
}
|
||||
|
||||
/* Below 1001px the tablet and phone tasks own the layout. Until then the
|
||||
panels stack in one column that scrolls inside the app frame. */
|
||||
.bento {
|
||||
height: 100%;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bento-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 761px to 1000px: the feed spans the full height on the left; the account and
|
||||
search panels stack over the featured and trending panels on the right. */
|
||||
@container app (max-width: 1000px) and (min-width: 761px) {
|
||||
.bento {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bento-center { grid-column: 1; grid-row: 1 / 3; height: 100%; }
|
||||
.bento-left { grid-column: 2; grid-row: 1; }
|
||||
.bento-right { grid-column: 2; grid-row: 2; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.bento-left .expert-search { flex: 0 1 auto; max-height: 250px; }
|
||||
.bento-right .rail-trending { flex: 1 0 200px; }
|
||||
.bento .account-live { display: none; }
|
||||
}
|
||||
|
||||
/* 1001px to 1180px: three columns, narrower sides. */
|
||||
@container app (min-width: 1001px) {
|
||||
.bento {
|
||||
display: grid;
|
||||
grid-template-columns: 244px minmax(0, 1fr) 292px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bento-col { height: 100%; }
|
||||
}
|
||||
|
||||
/* 1181px and wider. */
|
||||
@container app (min-width: 1181px) {
|
||||
.bento { grid-template-columns: 272px minmax(0, 1fr) 324px; }
|
||||
}
|
||||
|
||||
/* Panels ----------------------------------------------------------------- */
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
background: var(--tile);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-panel);
|
||||
box-shadow: var(--shadow-tile);
|
||||
transition: background-color .45s, border-color .45s;
|
||||
}
|
||||
|
||||
.panel { display: flex; flex-direction: column; }
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 18px 18px 12px;
|
||||
}
|
||||
.panel-head h2 { margin: 0; font-size: 21px; }
|
||||
|
||||
/* A list that scrolls inside its panel. */
|
||||
.panel-scroll {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--line-2) transparent;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Faded top and bottom edges on a scrolling list. The mask color is only an
|
||||
alpha source, it is never painted. */
|
||||
.fade-edges {
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 16px, black calc(100% - 32px), transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0, black 16px, black calc(100% - 32px), transparent 100%);
|
||||
}
|
||||
|
||||
/* Which panels grow to fill their column. */
|
||||
.bento-left .expert-search { flex: 1 1 0; }
|
||||
.bento-center .feed { flex: 1 1 0; }
|
||||
.bento-right { justify-content: flex-start; }
|
||||
.bento-right .rail-featured { flex: none; }
|
||||
.bento-right .rail-trending { flex: 1 1 0; }
|
||||
|
||||
/* Panel contents keep their inner padding off the scroll edge. */
|
||||
.expert-search .search-form { margin: 0 14px; display: flex; flex-direction: column; }
|
||||
.expert-search .search-form input { width: 100%; }
|
||||
.expert-search .chips { padding: 0 14px; }
|
||||
.expert-search .search-results { padding: 12px 14px 24px; }
|
||||
.rail .rail-list { padding: 4px 14px 24px; }
|
||||
.feed .composer { margin: 0 14px 4px; }
|
||||
.feed .feed-list { display: flex; flex-direction: column; gap: 16px; padding: 16px 14px 32px; }
|
||||
|
||||
/* Single-column fallback (760px and below, until the Mobile milestone): lists get a usable height. */
|
||||
@container app (max-width: 760px) {
|
||||
.bento-col .panel { flex: 0 0 auto; }
|
||||
.bento-col .panel-scroll { flex: 0 0 auto; max-height: 70vh; }
|
||||
}
|
||||
|
||||
/* Account panel ---------------------------------------------------------- */
|
||||
|
||||
.account { flex: none; }
|
||||
|
||||
.account-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 12px 12px 16px;
|
||||
}
|
||||
.account-word { flex: 1; font-size: 15px; }
|
||||
|
||||
.account-theme {
|
||||
appearance: none;
|
||||
flex: none;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--r-ctl);
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
transition: background-color .2s, color .2s, transform .15s;
|
||||
}
|
||||
.account-theme:hover { background: var(--tile-2); color: var(--ink); }
|
||||
.account-theme:active { transform: scale(.9); }
|
||||
.account-theme svg { width: 20px; height: 20px; }
|
||||
|
||||
.account-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 12px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--tile-2);
|
||||
}
|
||||
.account-me-text { display: flex; flex-direction: column; min-width: 0; }
|
||||
.account-me-text strong { font-size: 14px; }
|
||||
.account-me-text span {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 18px 16px;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.account-dot {
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: account-ping 2.2s infinite;
|
||||
}
|
||||
@keyframes account-ping {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent), transparent 45%); }
|
||||
70% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent), transparent 100%); }
|
||||
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent), transparent 100%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.account-dot { animation: none !important; }
|
||||
.panel-scroll { scroll-behavior: auto !important; }
|
||||
}
|
||||
@@ -9,116 +9,7 @@ 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; }
|
||||
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; }
|
||||
.shell header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: var(--tile); border-bottom: 1px solid var(--line); }
|
||||
.shell header div { display: flex; flex-direction: column; font-size: 14px; }
|
||||
.shell header span { color: var(--ink-3); }
|
||||
.shell header { position: sticky; top: 0; z-index: 5; }
|
||||
.shell header button { margin: 0 0 0 auto; padding: 8px 14px; background: transparent; color: var(--ink); border: 1px solid var(--ink); }
|
||||
.feed { max-width: 640px; margin: 0 auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.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 button { margin-top: 8px; }
|
||||
.feed-center { display: grid; place-items: center; padding: 16px; }
|
||||
.spinner { width: 24px; height: 24px; border: 3px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.new-posts { position: sticky; top: 68px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
|
||||
.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-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:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||
.composer .composer-post { align-self: flex-end; margin: 0; padding: 10px 24px; border-radius: 0; }
|
||||
.composer-media { display: flex; flex-direction: column; gap: 8px; }
|
||||
.composer-image { display: inline-flex; align-items: center; gap: 8px; align-self: flex-start; cursor: pointer; }
|
||||
.composer-image span { padding: 6px 12px; border: 1px solid var(--line-2); background: var(--tile-2); }
|
||||
.composer-image input[type="file"] { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); border: 0; }
|
||||
.composer-preview { display: flex; align-items: center; gap: 10px; }
|
||||
.composer-preview video { width: 96px; height: 64px; object-fit: cover; border: 1px solid var(--line); background: black; }
|
||||
.composer-preview img { width: 64px; height: 64px; object-fit: cover; border: 1px solid var(--line); }
|
||||
.composer-filename { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.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; }
|
||||
|
||||
/* 3-column signed-in layout: nav | feed | rails. Wraps on narrow widths. */
|
||||
.columns { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; max-width: 1240px; margin: 0 auto; padding: 0 16px; }
|
||||
.col-left { flex: 0 0 180px; position: sticky; top: 72px; padding-top: 16px; }
|
||||
.col-left > ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.col-left a { display: block; padding: 10px 12px; border-radius: 8px; color: var(--ink); text-decoration: none; font-weight: 500; }
|
||||
.col-left a[aria-current="page"] { background: var(--ink); color: var(--bg); }
|
||||
.col-center { flex: 1 1 400px; min-width: 0; }
|
||||
.col-center .feed { padding-left: 0; padding-right: 0; }
|
||||
.col-right { flex: 0 0 300px; position: sticky; top: 72px; max-height: calc(100vh - 88px); overflow-y: auto; padding-top: 16px; display: flex; flex-direction: column; gap: 20px; }
|
||||
.rail h2 { margin: 0 0 8px; font-size: 16px; font-weight: 700; }
|
||||
.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; }
|
||||
.expert-card { align-items: center; }
|
||||
.minnect-card { flex-direction: column; }
|
||||
.rail-text { display: flex; flex-direction: column; min-width: 0; font-size: 14px; }
|
||||
.rail-name { font-size: 14px; }
|
||||
.rail-byline { color: var(--ink-3); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rail-meta { color: var(--ink-3); font-size: 13px; }
|
||||
.minnect-who { display: flex; align-items: center; gap: 8px; }
|
||||
.minnect-thumb { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 8px; background: var(--line); }
|
||||
.minnect-question { margin: 0; font-size: 14px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.minnect-video { background: black; border-radius: 0; }
|
||||
.minnect-audio { width: 100%; height: 36px; }
|
||||
@media (max-width: 959px) {
|
||||
.col-left { flex: 1 1 100%; position: static; padding-top: 8px; }
|
||||
.col-left > ul { flex-direction: row; }
|
||||
.col-center { flex: 1 1 100%; order: 3; }
|
||||
.col-right { flex: 1 1 100%; order: 2; position: static; max-height: none; overflow: visible; padding-top: 0; }
|
||||
.col-right .rail-list { flex-direction: row; overflow-x: auto; padding-bottom: 8px; }
|
||||
.col-right .rail-card { flex: 0 0 260px; }
|
||||
}
|
||||
|
||||
/* Expert search + category chips (left column). Squared corners, monochrome + red. */
|
||||
.expert-search { margin-top: 16px; }
|
||||
.expert-search summary { font-weight: 700; font-size: 16px; cursor: pointer; padding: 4px 0; }
|
||||
.expert-search form { display: flex; flex-direction: column; }
|
||||
.expert-search input { border-radius: 0; width: 100%; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
||||
.chip { margin: 0; padding: 6px 10px; border-radius: 0; background: transparent; color: var(--ink); border: 1px solid var(--ink); font-size: 13px; }
|
||||
.chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: white; }
|
||||
.search-results { margin-top: 12px; }
|
||||
.search-note { margin: 0; font-size: 14px; color: var(--ink-3); }
|
||||
@media (max-width: 959px) {
|
||||
.col-left .expert-search { flex-basis: 100%; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* 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;
|
||||
animation-delay: calc(var(--i, 0) * 70ms);
|
||||
scroll-snap-align: start;
|
||||
scroll-margin-top: 16px;
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/* Find experts panel (ADR 0001, section 6; spec "Find experts"). Ported from
|
||||
the mockup's .field, .chips, .rows and .rate rules; tokens only. The
|
||||
category chips are SweepButton (buttons.css). */
|
||||
|
||||
.search-form label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--tile-2);
|
||||
color: var(--ink-3);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
transition: box-shadow .25s, background-color .25s;
|
||||
}
|
||||
.search-field svg { width: 18px; height: 18px; flex: none; }
|
||||
.search-field:focus-within {
|
||||
background: var(--tile);
|
||||
box-shadow: inset 0 0 0 1px var(--ink-3), 0 0 0 4px var(--line);
|
||||
}
|
||||
.search-field input,
|
||||
.search-field input:focus-visible {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.search-field input::placeholder { color: var(--ink-3); }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
||||
|
||||
.search-note { margin: 0; font-size: 14px; color: var(--ink-3); }
|
||||
.search-rows { gap: 2px; }
|
||||
.expert-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 14px;
|
||||
transition: background-color .2s;
|
||||
}
|
||||
.expert-row:hover { background: var(--tile-2); }
|
||||
.expert-text { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 1px; }
|
||||
.expert-text strong { font-size: 13.5px; color: var(--ink); }
|
||||
.expert-text span { font-size: 12.5px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.expert-rate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.expert-rate svg { width: 13px; height: 13px; color: var(--accent); }
|
||||
@@ -0,0 +1,103 @@
|
||||
/* Trending minnects panel (ADR 0001, section 6; spec "Trending minnects").
|
||||
Ported from the mockup's .tr rules; tokens only. The white inside the media
|
||||
frame is text and glass over the photo, which is dark in both themes. */
|
||||
|
||||
.trend-list { gap: 16px; }
|
||||
.minnect-card { display: flex; flex-direction: column; gap: 9px; animation: trend-rise .55s var(--ease) both; }
|
||||
@keyframes trend-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.trend-media {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background: var(--tile-3);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
}
|
||||
.trend-media .minnect-thumb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 1s var(--ease);
|
||||
}
|
||||
.minnect-card:hover .trend-media:not(.trend-media-playing) .minnect-thumb { transform: scale(1.035); }
|
||||
.minnect-video { background: black; }
|
||||
|
||||
.trend-scrim {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 62%;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, transparent, color-mix(in srgb, black 62%, transparent));
|
||||
}
|
||||
.trend-who {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 10px;
|
||||
z-index: 2;
|
||||
max-width: calc(100% - 20px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 10px 3px 4px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--on-accent);
|
||||
background: color-mix(in srgb, black 34%, transparent);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.trend-who strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; }
|
||||
.trend-play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
z-index: 2;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
color: var(--on-accent);
|
||||
background: color-mix(in srgb, var(--on-accent) calc(100% - 84%), transparent);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--on-accent) calc(100% - 60%), transparent), 0 12px 30px color-mix(in srgb, black 28%, transparent);
|
||||
transition: transform .3s cubic-bezier(.2, .9, .3, 1.4), background-color .3s;
|
||||
}
|
||||
.trend-play svg { width: 18px; height: 18px; margin-left: 2px; }
|
||||
.trend-play:hover { transform: scale(1.1); background: color-mix(in srgb, var(--on-accent) calc(100% - 72%), transparent); }
|
||||
.trend-play:focus-visible { outline: 2px solid var(--on-accent); outline-offset: 2px; }
|
||||
.trend-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.trend-views { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--ink-2); }
|
||||
.trend-views svg { width: 15px; height: 15px; }
|
||||
.trend-kind { font-size: 10.5px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-3); }
|
||||
/* While a video plays its own controls take over the frame. */
|
||||
.trend-media-playing .trend-scrim,
|
||||
.trend-media-playing .trend-who,
|
||||
.trend-media-playing .trend-play { opacity: 0; visibility: hidden; }
|
||||
|
||||
.trend-q {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.45;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.minnect-audio { width: 100%; height: 36px; }
|
||||
|
||||
.rail-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Composer, isPublishShortcut } from "@/components/Composer";
|
||||
import { attachmentMeta, BULLET, COMPOSER_EMOJI, formatBytes, insertBullets } from "@/components/composer-text";
|
||||
|
||||
describe("insertBullets", () => {
|
||||
it("adds a bullet at the start of the caret's line", () => {
|
||||
expect(insertBullets("hello", 3, 3)).toEqual({ text: "• hello", selectionStart: 5, selectionEnd: 5 });
|
||||
expect(insertBullets("one\ntwo\nthree", 5, 5).text).toBe("one\n• two\nthree");
|
||||
});
|
||||
it("adds a bullet to an empty text and to an empty last line", () => {
|
||||
expect(insertBullets("", 0, 0)).toEqual({ text: BULLET, selectionStart: 2, selectionEnd: 2 });
|
||||
expect(insertBullets("a\n", 2, 2).text).toBe("a\n• ");
|
||||
});
|
||||
it("adds a bullet to every selected line and keeps the selection on the same characters", () => {
|
||||
const r = insertBullets("one\ntwo\nthree", 1, 9);
|
||||
expect(r.text).toBe("• one\n• two\n• three");
|
||||
expect(r.text.slice(r.selectionStart, r.selectionEnd)).toBe("ne\n• two\n• t");
|
||||
});
|
||||
it("does not bullet the next line when the selection ends right after a line break", () => {
|
||||
expect(insertBullets("one\ntwo", 0, 4).text).toBe("• one\ntwo");
|
||||
});
|
||||
it("leaves lines that already start with a bullet unchanged", () => {
|
||||
expect(insertBullets("• one\ntwo", 0, 9).text).toBe("• one\n• two");
|
||||
expect(insertBullets("• one", 3, 3).text).toBe("• one");
|
||||
});
|
||||
it("changes nothing but the inserted characters", () => {
|
||||
const text = "**bold** _it_\n indented";
|
||||
const r = insertBullets(text, 0, text.length);
|
||||
expect(r.text.replaceAll(BULLET, "")).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composer helpers", () => {
|
||||
it("offers 32 distinct emoji", () => {
|
||||
expect(COMPOSER_EMOJI).toHaveLength(32);
|
||||
expect(new Set(COMPOSER_EMOJI).size).toBe(32);
|
||||
});
|
||||
it("describes an attachment by type and size", () => {
|
||||
expect(formatBytes(512)).toBe("512 B");
|
||||
expect(formatBytes(2048)).toBe("2 KB");
|
||||
expect(attachmentMeta({ type: "image/png", size: 2.4 * 1024 * 1024 })).toBe("Image, 2.4 MB");
|
||||
expect(attachmentMeta({ type: "video/mp4", size: 18.2 * 1024 * 1024 })).toBe("Video, 18.2 MB");
|
||||
});
|
||||
it("publishes on Ctrl + Enter and Cmd + Enter only", () => {
|
||||
expect(isPublishShortcut({ key: "Enter", ctrlKey: true, metaKey: false })).toBe(true);
|
||||
expect(isPublishShortcut({ key: "Enter", ctrlKey: false, metaKey: true })).toBe(true);
|
||||
expect(isPublishShortcut({ key: "Enter", ctrlKey: false, metaKey: false })).toBe(false);
|
||||
expect(isPublishShortcut({ key: "a", ctrlKey: true, metaKey: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer markup", () => {
|
||||
const base = { value: "", busy: false, error: false, onChange: () => {}, onSubmit: () => {} };
|
||||
const html = (p: Partial<Parameters<typeof Composer>[0]>) => renderToStaticMarkup(createElement(Composer, { ...base, ...p }));
|
||||
|
||||
it("is collapsed when empty and open when holding text or a file", () => {
|
||||
expect(html({})).toMatch(/class="composer"/);
|
||||
expect(html({ value: "hi" })).toContain("composer composer-open");
|
||||
const f = new File(["x"], "a.png", { type: "image/png" });
|
||||
expect(html({ file: f, onFile: () => {} })).toContain("composer composer-open");
|
||||
});
|
||||
it("orders the toolbar: photo, video, emoji, divider, bulleted list, hint, Post", () => {
|
||||
const out = html({ onFile: () => {} });
|
||||
const order = ["Add image", "Add video", "Add emoji", "composer-sep", "Bulleted list", "composer-kbd", "btn-flip"];
|
||||
const at = order.map((s) => out.indexOf(s));
|
||||
expect(at.every((i) => i >= 0)).toBe(true);
|
||||
expect([...at].sort((a, b) => a - b)).toEqual(at);
|
||||
expect(out).not.toMatch(/aria-label="(Bold|Italic)"/);
|
||||
});
|
||||
it("shows the three Post states", () => {
|
||||
expect(html({})).toMatch(/btn-flip-front">Post<.*btn-flip-back[^>]*>Write first/);
|
||||
expect(html({ value: "hi" })).toMatch(/btn-flip-back[^>]*>Publish<svg/);
|
||||
const busy = html({ value: "hi", busy: true });
|
||||
expect(busy).toContain("btn-flip-busy");
|
||||
expect(busy).toContain(">Posting<");
|
||||
});
|
||||
it("shows the attachment row with name, type, size, progress and remove", () => {
|
||||
const f = new File([new Uint8Array(3 * 1024)], "clip.mp4", { type: "video/mp4" });
|
||||
const out = html({ file: f, onFile: () => {}, person: { name: "Jordan Rivera" } });
|
||||
expect(out).toContain("clip.mp4");
|
||||
expect(out).toContain("Video, 3 KB");
|
||||
expect(out).toContain('role="progressbar"');
|
||||
expect(out).toContain('aria-label="Remove attachment"');
|
||||
expect(out).toContain("composer-att-indent");
|
||||
expect(out).toContain("hex-avatar");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { FeaturedExpertsRail } from "@/components/FeaturedExpertsRail";
|
||||
import { FeaturedCarousel } from "@/components/FeaturedCarousel";
|
||||
import { TrendingMinnectsRail } from "@/components/TrendingMinnectsRail";
|
||||
import { DiscoverRailsView } from "@/components/DiscoverRails";
|
||||
import { fetchDiscover } from "@/lib/discover-client";
|
||||
@@ -18,9 +18,9 @@ const minnect = (o: Partial<DiscoverMinnect> = {}): DiscoverMinnect => ({
|
||||
});
|
||||
const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);
|
||||
|
||||
describe("FeaturedExpertsRail", () => {
|
||||
it("renders a card with name, byline, rating and reviews", () => {
|
||||
const out = html(createElement(FeaturedExpertsRail, { experts: [expert()] }));
|
||||
describe("FeaturedCarousel (static render)", () => {
|
||||
it("renders the center expert with name, byline, rating and reviews", () => {
|
||||
const out = html(createElement(FeaturedCarousel, { experts: [expert()] }));
|
||||
expect(out).toContain("<h2");
|
||||
expect(out).toContain("Ann Lee");
|
||||
expect(out).toContain("Growth coach");
|
||||
@@ -29,7 +29,7 @@ describe("FeaturedExpertsRail", () => {
|
||||
expect(out).toContain("<img");
|
||||
});
|
||||
it("falls back to initials and omits missing fields", () => {
|
||||
const out = html(createElement(FeaturedExpertsRail, {
|
||||
const out = html(createElement(FeaturedCarousel, {
|
||||
experts: [expert({ pictureUrl: null, byline: null, rating: null, reviewsCount: null })],
|
||||
}));
|
||||
expect(out).not.toContain("<img");
|
||||
@@ -37,7 +37,7 @@ describe("FeaturedExpertsRail", () => {
|
||||
expect(out).not.toContain("review");
|
||||
});
|
||||
it("renders nothing when empty", () => {
|
||||
expect(html(createElement(FeaturedExpertsRail, { experts: [] }))).toBe("");
|
||||
expect(html(createElement(FeaturedCarousel, { experts: [] }))).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("fetchExperts", () => {
|
||||
const view = (o: Partial<ExpertSearchViewProps> = {}) =>
|
||||
renderToStaticMarkup(createElement(ExpertSearchView, {
|
||||
query: "", onQuery: () => {}, categories: [{ id: 1, name: "Business", icon: null, sortOrder: 1 }],
|
||||
activeCategory: null, onCategory: () => {}, status: "idle", results: [], defaultOpen: true, ...o,
|
||||
activeCategory: null, onCategory: () => {}, status: "idle", results: [], ...o,
|
||||
}));
|
||||
|
||||
describe("ExpertSearchView", () => {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { FeaturedCarousel } from "@/components/FeaturedCarousel";
|
||||
import { FEATURED_INTERVAL_MS, featuredLiveMode, featuredSlot, startFeaturedAutoplay } from "@/components/featured-carousel-model";
|
||||
import type { DiscoverExpert } from "@/lib/discover";
|
||||
|
||||
const expert = (i: number): DiscoverExpert => ({
|
||||
id: i, slug: `e${i}`, displayName: `Expert ${i}`, byline: `Byline ${i}`, pictureUrl: i % 2 ? `https://x/${i}.jpg` : null,
|
||||
rating: 4.5, reviewsCount: i, available: true, location: { city: null, country: null },
|
||||
});
|
||||
const experts = (n: number) => Array.from({ length: n }, (_, i) => expert(i + 1));
|
||||
|
||||
describe("featuredSlot", () => {
|
||||
it("places items by offset from the current index, wrapping round", () => {
|
||||
const slots = Array.from({ length: 8 }, (_, i) => featuredSlot(i, 0, 8));
|
||||
expect(slots).toEqual(["c0", "r1", "r2", "r3", "r3", "l3", "l2", "l1"]);
|
||||
expect(featuredSlot(0, 7, 8)).toBe("r1");
|
||||
expect(featuredSlot(6, 7, 8)).toBe("l1");
|
||||
});
|
||||
it("degrades with one or two experts: no empty neighbors", () => {
|
||||
expect(featuredSlot(0, 0, 1)).toBe("c0");
|
||||
expect([featuredSlot(0, 0, 2), featuredSlot(1, 0, 2)]).toEqual(["c0", "r1"]);
|
||||
expect([featuredSlot(0, 1, 2), featuredSlot(1, 1, 2)]).toEqual(["r1", "c0"]);
|
||||
expect([0, 1, 2].map((i) => featuredSlot(i, 0, 3))).toEqual(["c0", "r1", "l1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startFeaturedAutoplay", () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it("advances every 2.8s and wraps to the start", () => {
|
||||
const seen: number[] = [];
|
||||
const ctl = startFeaturedAutoplay({ count: 3, onChange: (i) => seen.push(i) });
|
||||
expect(FEATURED_INTERVAL_MS).toBe(2800);
|
||||
vi.advanceTimersByTime(2799);
|
||||
expect(seen).toEqual([]);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(seen).toEqual([1]);
|
||||
vi.advanceTimersByTime(2800 * 2);
|
||||
expect(seen).toEqual([1, 2, 0]);
|
||||
expect(ctl.index).toBe(0);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("does not advance while paused and resumes after", () => {
|
||||
const onChange = vi.fn();
|
||||
const ctl = startFeaturedAutoplay({ count: 5, onChange });
|
||||
ctl.pause();
|
||||
expect(ctl.paused).toBe(true);
|
||||
vi.advanceTimersByTime(4000 * 3);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(ctl.index).toBe(0);
|
||||
ctl.resume();
|
||||
vi.advanceTimersByTime(2800);
|
||||
expect(onChange).toHaveBeenCalledWith(1);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("never rotates a single expert and stops cleanly", () => {
|
||||
const one = vi.fn();
|
||||
startFeaturedAutoplay({ count: 1, onChange: one });
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(one).not.toHaveBeenCalled();
|
||||
const many = vi.fn();
|
||||
const ctl = startFeaturedAutoplay({ count: 4, onChange: many });
|
||||
ctl.stop();
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(many).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeaturedCarousel markup", () => {
|
||||
it("starts with the live region off while it rotates, and hides non-center items and shows the counter and dots", () => {
|
||||
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(8) }));
|
||||
expect(out).toContain('aria-live="off"');
|
||||
expect(out.match(/data-testid="featured-item"/g)).toHaveLength(8);
|
||||
expect(out.match(/aria-hidden="true" data-testid="featured-item"/g)).toHaveLength(7);
|
||||
expect(out).toContain("featured-c0");
|
||||
expect(out).toContain(">1 / 8<");
|
||||
expect(out.match(/class="featured-dot( featured-dot-on)?"/g)).toHaveLength(8);
|
||||
expect(out.match(/featured-dot-on/g)).toHaveLength(1);
|
||||
expect(out).toContain("--cfw:96px");
|
||||
});
|
||||
it("takes a size prop for mobile", () => {
|
||||
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(3), size: 156 }));
|
||||
expect(out).toContain("--cfw:156px");
|
||||
});
|
||||
it("shows no dots for a single expert", () => {
|
||||
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(1) }));
|
||||
expect(out).not.toContain("featured-dot ");
|
||||
expect(out).toContain(">1 / 1<");
|
||||
expect(out).toContain('aria-live="polite"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("featuredLiveMode (WAI-ARIA carousel live region)", () => {
|
||||
it("is off while autoplay rotates", () => {
|
||||
expect(featuredLiveMode({ rotating: true, paused: false })).toBe("off");
|
||||
});
|
||||
it("is polite while paused by hover or focus", () => {
|
||||
expect(featuredLiveMode({ rotating: true, paused: true })).toBe("polite");
|
||||
});
|
||||
it("is polite when rotation is stopped (reduced motion or one expert)", () => {
|
||||
expect(featuredLiveMode({ rotating: false, paused: false })).toBe("polite");
|
||||
expect(featuredLiveMode({ rotating: false, paused: true })).toBe("polite");
|
||||
});
|
||||
});
|
||||
+86
-4
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { FeedView, type FeedViewProps } from "@/components/FeedView";
|
||||
import { FeedView, RefreshButton, type FeedViewProps } from "@/components/FeedView";
|
||||
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
|
||||
import { mergePosts, relativeTime, toCardPosts, upgradeVideos } from "@/lib/feed-model";
|
||||
import { commentsLabel, firstLoadFailure, formatDuration, mergePosts, relativeTime, toCardPost, toCardPosts, upgradeVideos } from "@/lib/feed-model";
|
||||
|
||||
const NOW = Date.parse("2026-10-19T12:00:00Z");
|
||||
const post = (id: string, publishedAt: string, extra: Record<string, unknown> = {}) => ({
|
||||
@@ -115,9 +115,11 @@ describe("FeedView", () => {
|
||||
expect(html).toContain("Try again");
|
||||
});
|
||||
|
||||
it("spinner while loading more and a new-posts control", () => {
|
||||
it("skeleton card while loading more and the new-posts pill", () => {
|
||||
const html = render({ posts: toCardPosts([post("1", "2026-10-19T11:00:00Z")]), loadingMore: true, newCount: 2 });
|
||||
expect(html).toContain('class="spinner"');
|
||||
expect(html).toContain('data-testid="feed-skeleton"');
|
||||
expect(html).not.toContain('class="spinner"');
|
||||
expect(html).toContain("btn-grow");
|
||||
expect(html).toContain("2 new posts");
|
||||
expect(html).not.toContain("Load more");
|
||||
});
|
||||
@@ -185,3 +187,83 @@ describe("video transcoding", () => {
|
||||
expect(live).toContain("<video");
|
||||
});
|
||||
});
|
||||
|
||||
describe("video duration", () => {
|
||||
it("reads videoDuration (seconds) first and formats it as m:ss", () => {
|
||||
const [m] = toCardPost({ type: "post", post: { id: "v", author: {}, mediaItems: [{ mediaType: "video", videoDuration: 154, duration: 9 }] } })!.media;
|
||||
expect(m.durationSeconds).toBe(154);
|
||||
expect(formatDuration(154)).toBe("2:34");
|
||||
expect(formatDuration(5)).toBe("0:05");
|
||||
expect(formatDuration(3725)).toBe("1:02:05");
|
||||
expect(formatDuration(0)).toBeNull();
|
||||
expect(formatDuration(undefined)).toBeNull();
|
||||
});
|
||||
it("falls back to durationSeconds, then duration, else none", () => {
|
||||
const dur = (item: object) => toCardPost({ type: "post", post: { id: "v", author: {}, mediaItems: [{ mediaType: "video", ...item }] } })!.media[0].durationSeconds;
|
||||
expect(dur({ durationSeconds: 30 })).toBe(30);
|
||||
expect(dur({ duration: 12 })).toBe(12);
|
||||
expect(dur({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh button", () => {
|
||||
it("is in the Home feed header with its accessible name", () => {
|
||||
const html = render({ onRefresh: () => {} });
|
||||
expect(html).toContain('aria-label="Refresh feed"');
|
||||
expect(html.indexOf("Home feed")).toBeLessThan(html.indexOf("Refresh feed"));
|
||||
});
|
||||
|
||||
it("is absent without a handler", () => {
|
||||
expect(render({})).not.toContain("Refresh feed");
|
||||
});
|
||||
|
||||
it("triggers a reload when clicked", () => {
|
||||
const onRefresh = vi.fn();
|
||||
const el = RefreshButton({ onRefresh }) as { props: { onClick: () => void } };
|
||||
el.props.onClick();
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is disabled and spinning while a reload runs", () => {
|
||||
const html = render({ onRefresh: () => {}, refreshing: true });
|
||||
expect(html).toMatch(/<button[^>]*disabled[^>]*aria-label="Refresh feed"|<button[^>]*aria-label="Refresh feed"[^>]*disabled/);
|
||||
expect(html).toContain("refresh-icon-spin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("comments label", () => {
|
||||
it("is singular for 1 and plural otherwise", () => {
|
||||
expect(commentsLabel(1)).toBe("1 comment");
|
||||
expect(commentsLabel(0)).toBe("0 comments");
|
||||
expect(commentsLabel(2)).toBe("2 comments");
|
||||
});
|
||||
|
||||
it("renders singular on a one-comment post", () => {
|
||||
const posts = toCardPosts([{ ...post("1", "2026-10-19T11:00:00Z"), commentsCount: 1 }]);
|
||||
const html = render({ posts });
|
||||
expect(html).toContain("1 comment<");
|
||||
expect(html).not.toContain("1 comments");
|
||||
});
|
||||
});
|
||||
|
||||
describe("failed refresh versus failed first load", () => {
|
||||
it("a failed refresh keeps the posts and shows an inline error with a usable button", () => {
|
||||
const posts = toCardPosts([post("1", "2026-10-19T11:00:00Z")]);
|
||||
const html = render({ posts, onRefresh: () => {}, refreshFailed: true });
|
||||
expect(html).toContain("Could not refresh. Try again.");
|
||||
expect(html).toContain("text 1");
|
||||
expect(html).not.toContain("The feed did not load.");
|
||||
expect(html).not.toMatch(/<button[^>]*disabled[^>]*aria-label="Refresh feed"|<button[^>]*aria-label="Refresh feed"[^>]*disabled/);
|
||||
});
|
||||
|
||||
it("a failed first load shows the full error state", () => {
|
||||
const html = render({ status: "error", onRefresh: () => {} });
|
||||
expect(html).toContain("The feed did not load.");
|
||||
expect(html).not.toContain("Could not refresh");
|
||||
});
|
||||
|
||||
it("the loader maps a failure to the right outcome", () => {
|
||||
expect(firstLoadFailure(true)).toEqual({ fullError: false, refreshFailed: true });
|
||||
expect(firstLoadFailure(false)).toEqual({ fullError: true, refreshFailed: false });
|
||||
});
|
||||
});
|
||||
|
||||
+2
-1
@@ -158,7 +158,8 @@ describe("Composer", () => {
|
||||
const html = renderToStaticMarkup(createElement(Composer, { ...base, file: f, onFile: () => {} }));
|
||||
expect(html).toContain("Add image");
|
||||
expect(html).toContain("photo.jpg");
|
||||
expect(html).not.toMatch(/<button[^>]*class="composer-post"[^>]*disabled/);
|
||||
expect(html).toMatch(/<button[^>]*type="submit"[^>]*class="[^"]*composer-post/);
|
||||
expect(html).not.toMatch(/<button[^>]*class="[^"]*composer-post[^"]*"[^>]*disabled/);
|
||||
});
|
||||
it("keeps the text and the image name on a publish failure", () => {
|
||||
const f = new File(["x"], "sunset.png", { type: "image/png" });
|
||||
|
||||
Reference in New Issue
Block a user