diff --git a/docs/minnect-feed-redesign.spec.md b/docs/minnect-feed-redesign.spec.md index 48c3c4e..f29e0de 100644 --- a/docs/minnect-feed-redesign.spec.md +++ b/docs/minnect-feed-redesign.spec.md @@ -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 ` + +
+ +
+ {person.name} + {person.email} +
+
+
+
+ + ); +} + +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 ( +
+
+
+ + {search} +
+
{feed}
+ +
+
+ ); +} diff --git a/src/components/Buttons.tsx b/src/components/Buttons.tsx index 8ce2346..41ac174 100644 --- a/src/components/Buttons.tsx +++ b/src/components/Buttons.tsx @@ -227,8 +227,8 @@ export interface IconButtonProps extends NativeButton { export function IconButton({ label, icon, shape = "square", size = "md", className, type = "button", ...rest }: IconButtonProps) { return ( : null} + {p.onRetry ? : null}

) : null}
{ 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)} /> - + } /> ); diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index c0e4d3c..ba84a0b 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -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(null); + const [focused, setFocused] = useState(false); + const [emojiOpen, setEmojiOpen] = useState(false); + const text = useRef(null); + const imageInput = useRef(null); + const videoInput = useRef(null); + const emojiWrap = useRef(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) { + if (!isPublishShortcut(e)) return; + e.preventDefault(); + if (canSubmit) onSubmit(); + } function pick(e: ChangeEvent) { 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) { + 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 ( -
- -