Files
frontend-helper/src/components/Composer.tsx
T

321 lines
11 KiB
TypeScript

"use client";
// 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;
busy: boolean;
error: boolean;
/** The selected image or video file, when the pickers are wired. */
file?: File | null;
onChange: (value: string) => void;
/** 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";
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(() => {
if (!file) {
setPreview(null);
return;
}
const url = URL.createObjectURL(file);
setPreview(url);
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);
// One file per post: picking one kind clears the other input.
e.target.value = "";
}
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${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}
placeholder="Share an insight with the feed"
disabled={busy}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
/>
</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}
{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="composer-error" role="alert">Publishing the post failed. Your text is still here.</p>
) : null}
<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>
);
}