v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,52 +0,0 @@
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import {
|
||||
DEFAULT_FEED_TAB,
|
||||
FEED_TABS,
|
||||
type FeedTab,
|
||||
} from "../config/feedTabs.ts";
|
||||
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
|
||||
import { TabBar } from "./TabBar.tsx";
|
||||
import { CategorySwitcher } from "./CategorySwitcher.tsx";
|
||||
|
||||
/**
|
||||
* Feed sort tabs (hot/new/journal/followed). Navigation stays within the
|
||||
* current scope: `~` for all categories, or the active category slug — so the
|
||||
* tabs link to `/~/new`, `/music/new`, etc.
|
||||
*/
|
||||
export function FeedTabBar() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { categorySlug, feedTab } = useParams();
|
||||
|
||||
const [preferredTab] = useDefaultFeedTab();
|
||||
const defaultTab: FeedTab = preferredTab === "followed" && !user
|
||||
? DEFAULT_FEED_TAB
|
||||
: preferredTab;
|
||||
const active: FeedTab =
|
||||
(FEED_TABS as readonly string[]).includes(feedTab ?? "") &&
|
||||
(feedTab !== "followed" || !!user)
|
||||
? (feedTab as FeedTab)
|
||||
: defaultTab;
|
||||
|
||||
const scope = categorySlug ?? "~";
|
||||
|
||||
const tabs = [
|
||||
{ key: "hot" as FeedTab, label: <Trans>Hot</Trans> },
|
||||
{ key: "new" as FeedTab, label: <Trans>New</Trans> },
|
||||
{ key: "journal" as FeedTab, label: <Trans>Journal</Trans> },
|
||||
...(user
|
||||
? [{ key: "followed" as FeedTab, label: <Trans>Followed</Trans> }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTab={active}
|
||||
onChange={(t) => navigate(`/${scope}/${t}`)}
|
||||
leading={<CategorySwitcher />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import { useWSPresence } from "../hooks/useWS.ts";
|
||||
import { useRoomBeside } from "../hooks/useRoomBeside.ts";
|
||||
|
||||
// Past this many, the row ends in a "+N" counter rather than growing, so a
|
||||
// busy evening doesn't push the feed tabs out of the header.
|
||||
const MAX_SEATS = 6;
|
||||
// However tight the row, it keeps an avatar and the counter — past that, the
|
||||
// tabs beside it scroll instead.
|
||||
const MIN_SEATS = 2;
|
||||
// Each seat (avatar or counter) is 32px wide and overlaps the one before it
|
||||
// by 10px (.index-presence-avatar's negative margin), so adds 22px.
|
||||
const SEAT = 32;
|
||||
const SEAT_STEP = 22;
|
||||
|
||||
/**
|
||||
* Who's online, as a row of overlapping avatars. Given `fitWithin` and
|
||||
* `fitBeside`, it gives up seats to what's beside it — as many avatars as the
|
||||
* room left by the tabs allows, the rest folded into the counter.
|
||||
*/
|
||||
export function PresenceRow(
|
||||
{ fitWithin, fitBeside }: { fitWithin?: string; fitBeside?: string },
|
||||
) {
|
||||
const { onlineUsers } = useWSPresence();
|
||||
const [el, setEl] = useState<HTMLDivElement | null>(null);
|
||||
const room = useRoomBeside(el, { within: fitWithin, beside: fitBeside });
|
||||
|
||||
if (onlineUsers.length === 0) return null;
|
||||
|
||||
const seats = room === null ? MAX_SEATS : Math.min(
|
||||
MAX_SEATS,
|
||||
Math.max(MIN_SEATS, 1 + Math.floor((room - SEAT) / SEAT_STEP)),
|
||||
);
|
||||
// The counter takes the last seat, so the row never runs past `seats`.
|
||||
const shown = onlineUsers.length > seats
|
||||
? onlineUsers.slice(0, seats - 1)
|
||||
: onlineUsers;
|
||||
const rest = onlineUsers.slice(shown.length);
|
||||
const restNames = rest.map((u) => u.username).join(", ");
|
||||
|
||||
return (
|
||||
<div className="index-presence" ref={setEl}>
|
||||
{shown.map((u) => (
|
||||
<Link
|
||||
key={u.userId}
|
||||
to={`/users/${u.username}`}
|
||||
title={u.username}
|
||||
className="index-presence-avatar"
|
||||
>
|
||||
<Avatar
|
||||
userId={u.userId}
|
||||
username={u.username}
|
||||
hasAvatar={u.hasAvatar}
|
||||
size={SEAT}
|
||||
version={u.avatarVersion}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
{rest.length > 0 && (
|
||||
<span
|
||||
className="index-presence-avatar index-presence-more"
|
||||
title={restNames}
|
||||
aria-label={t`${rest.length} more online: ${restNames}`}
|
||||
role="img"
|
||||
>
|
||||
+{rest.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useCategories } from "../hooks/useCategories.ts";
|
||||
import { useCategories } from "../../hooks/useCategories.ts";
|
||||
|
||||
/**
|
||||
* Multi-select of categories rendered as toggle chips. Renders nothing when no
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useCategories } from "../hooks/useCategories.ts";
|
||||
import { DEFAULT_FEED_TAB } from "../config/feedTabs.ts";
|
||||
import { useCategories } from "../../hooks/useCategories.ts";
|
||||
import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts";
|
||||
|
||||
/**
|
||||
* Header dropdown that switches the feed scope between "All" and a category,
|
||||
74
src/components/chat/ChatEditBox.tsx
Normal file
74
src/components/chat/ChatEditBox.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
|
||||
import { COUNT_FROM, MAX_CHAT_LENGTH, sendable } from "./message.ts";
|
||||
|
||||
export interface ChatEditBoxProps {
|
||||
initial: string;
|
||||
onSave: (body: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const editorRef = useRef<TextEditorHandle>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus without letting the browser scroll the list mid-transition (the
|
||||
// message box is still easing to its new height); once it has settled, bring
|
||||
// the whole edit box, Save included, into view.
|
||||
useEffect(() => {
|
||||
editorRef.current?.focus({ atEnd: true, preventScroll: true });
|
||||
const box = boxRef.current;
|
||||
if (!box) return;
|
||||
const settle = parseFloat(
|
||||
getComputedStyle(box).getPropertyValue("--dur-reveal"),
|
||||
) * 1000 || 250;
|
||||
const id = setTimeout(
|
||||
() => box.scrollIntoView({ block: "nearest", behavior: "smooth" }),
|
||||
settle + 20,
|
||||
);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
if (!sendable(draft)) return;
|
||||
onSave(draft.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-edit animate-in" ref={boxRef}>
|
||||
<TextEditor
|
||||
ref={editorRef}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
rows={2}
|
||||
autoResize
|
||||
maxLength={MAX_CHAT_LENGTH}
|
||||
countFrom={COUNT_FROM}
|
||||
className="comment-reply-textarea chat-input"
|
||||
onSubmit={save}
|
||||
onCancel={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="chat-edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={save}
|
||||
disabled={!sendable(draft)}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
src/components/chat/ChatMessageItem.tsx
Normal file
197
src/components/chat/ChatMessageItem.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Link } from "react-router";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import { ConfirmModal } from "../ui/ConfirmModal.tsx";
|
||||
import { Tooltip } from "../ui/Tooltip.tsx";
|
||||
import { useHeightTransition } from "../../hooks/useHeightTransition.ts";
|
||||
import { relativeTime } from "../../utils/relativeTime.ts";
|
||||
import type { ChatMessage } from "../../model.ts";
|
||||
import { ChatEditBox } from "./ChatEditBox.tsx";
|
||||
import { ReplyRef } from "./ReplyRef.tsx";
|
||||
import { messageTextClass } from "./message.ts";
|
||||
import { clockTime } from "./time.ts";
|
||||
|
||||
export interface ChatMessageItemProps {
|
||||
message: ChatMessage;
|
||||
/** Continues the previous message's run: no avatar or author line. */
|
||||
grouped: boolean;
|
||||
mentionsMe: boolean;
|
||||
/** The replied-to message has been deleted since this one loaded. */
|
||||
replyDeleted: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
flashed: boolean;
|
||||
editing: boolean;
|
||||
onStartEdit: (id: string) => void;
|
||||
onEndEdit: () => void;
|
||||
onEdit: (id: string, body: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onReply: (message: ChatMessage) => void;
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ChatMessageItem(
|
||||
{
|
||||
message,
|
||||
grouped,
|
||||
mentionsMe,
|
||||
replyDeleted,
|
||||
canEdit,
|
||||
canDelete,
|
||||
flashed,
|
||||
editing,
|
||||
onStartEdit,
|
||||
onEndEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
onJumpTo,
|
||||
}: ChatMessageItemProps,
|
||||
) {
|
||||
const bodyRef = useHeightTransition<HTMLDivElement>(editing);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const actions = !editing && (
|
||||
<span
|
||||
className={`chat-message-actions${
|
||||
grouped ? " chat-message-actions--float" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-icon-btn"
|
||||
onClick={() => onReply(message)}
|
||||
title={t`Reply`}
|
||||
aria-label={t`Reply`}
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-icon-btn"
|
||||
onClick={() => onStartEdit(message.id)}
|
||||
title={t`Edit`}
|
||||
aria-label={t`Edit`}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-icon-btn chat-icon-btn--delete"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
title={t`Delete`}
|
||||
aria-label={t`Delete`}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const edited = message.updatedAt && (
|
||||
<Tooltip text={t`Edited ${message.updatedAt.toLocaleString()}`}>
|
||||
<span className="chat-message-edited">
|
||||
{grouped
|
||||
? <Trans>edited</Trans>
|
||||
: <Trans>edited {relativeTime(message.updatedAt)}</Trans>}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`chat-message${grouped ? " chat-message--grouped" : ""}${
|
||||
mentionsMe ? " chat-message--mention" : ""
|
||||
}${flashed ? " chat-message--flash" : ""}`}
|
||||
data-mid={message.id}
|
||||
>
|
||||
{grouped
|
||||
? (
|
||||
// The avatar column holds the time instead, shown on hover.
|
||||
<span className="chat-message-gutter">
|
||||
<Tooltip text={message.createdAt.toLocaleString()}>
|
||||
<time dateTime={message.createdAt.toISOString()}>
|
||||
{clockTime(message.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<Avatar
|
||||
userId={message.userId}
|
||||
username={message.authorUsername}
|
||||
hasAvatar={!!message.authorAvatarMime}
|
||||
size={32}
|
||||
/>
|
||||
)}
|
||||
<div className="chat-message-body" ref={bodyRef}>
|
||||
{message.replyToId && (
|
||||
<ReplyRef
|
||||
replyToId={message.replyToId}
|
||||
author={replyDeleted ? undefined : message.replyToAuthor}
|
||||
body={message.replyToBody}
|
||||
onJumpTo={onJumpTo}
|
||||
/>
|
||||
)}
|
||||
{!grouped && (
|
||||
<div className="chat-message-meta">
|
||||
<Link
|
||||
to={`/users/${message.authorUsername}`}
|
||||
className="chat-message-author"
|
||||
>
|
||||
{message.authorUsername}
|
||||
</Link>
|
||||
<Tooltip text={message.createdAt.toLocaleString()}>
|
||||
<time
|
||||
className="chat-message-time"
|
||||
dateTime={message.createdAt.toISOString()}
|
||||
>
|
||||
{relativeTime(message.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
{edited}
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing
|
||||
? (
|
||||
<ChatEditBox
|
||||
initial={message.body}
|
||||
onSave={(body) => {
|
||||
if (body !== message.body) onEdit(message.id, body);
|
||||
onEndEdit();
|
||||
}}
|
||||
onCancel={onEndEdit}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Markdown className={messageTextClass(message.body)}>
|
||||
{message.body}
|
||||
</Markdown>
|
||||
)}
|
||||
|
||||
{grouped && !editing && edited}
|
||||
</div>
|
||||
{grouped && actions}
|
||||
|
||||
{confirmDelete && (
|
||||
<ConfirmModal
|
||||
message={t`Delete this message?`}
|
||||
confirmLabel={t`Delete`}
|
||||
onConfirm={() => {
|
||||
setConfirmDelete(false);
|
||||
onDelete(message.id);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,37 +9,29 @@ import {
|
||||
} from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Link } from "react-router";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import { ConfirmModal } from "./ConfirmModal.tsx";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
||||
import { useWS, useWSChat } from "../hooks/useWS.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { can } from "../utils/permissions.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { markdownToPlainText, mentionsUser } from "../utils/chatText.ts";
|
||||
import { isEmojiOnly } from "../utils/editorText.ts";
|
||||
import { i18n } from "../i18n.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import { Modal } from "../ui/Modal.tsx";
|
||||
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
|
||||
import { useWS, useWSChat } from "../../hooks/useWS.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { can } from "../../utils/permissions.ts";
|
||||
import { readDraft, writeDraft } from "../../utils/chatDraft.ts";
|
||||
import { mentionsUser } from "../../utils/chatText.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import type {
|
||||
ChatReplyTarget,
|
||||
PendingChatMessage,
|
||||
} from "../contexts/WSContext.ts";
|
||||
} from "../../contexts/WSContext.ts";
|
||||
import {
|
||||
type ChatMessage,
|
||||
deserializeChatMessage,
|
||||
type PublicUser,
|
||||
type RawChatMessage,
|
||||
} from "../model.ts";
|
||||
} from "../../model.ts";
|
||||
import { ChatMessageItem } from "./ChatMessageItem.tsx";
|
||||
import { PendingItem } from "./PendingItem.tsx";
|
||||
import { COUNT_FROM, MAX_CHAT_LENGTH, sendable, snippet } from "./message.ts";
|
||||
import { dayLabel, isSameDay } from "./time.ts";
|
||||
|
||||
// Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts.
|
||||
const MAX_CHAT_LENGTH = 2000;
|
||||
// The counter only shows once a message is getting near the limit.
|
||||
const COUNT_FROM = 1800;
|
||||
// Matches the server's default page size; a full page means there may be more.
|
||||
const PAGE_SIZE = 50;
|
||||
// Resync after a reconnect walks forward from the oldest loaded message in
|
||||
@@ -78,469 +70,6 @@ function compareMessages(a: ChatMessage, b: ChatMessage): number {
|
||||
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
||||
}
|
||||
|
||||
// What the server holds a message to: its length once trimmed.
|
||||
function sendable(draft: string): boolean {
|
||||
const body = draft.trim();
|
||||
return body.length > 0 && body.length <= MAX_CHAT_LENGTH;
|
||||
}
|
||||
|
||||
// A message of nothing but a few emoji is shown large, as chats do.
|
||||
function messageTextClass(body: string): string {
|
||||
return isEmojiOnly(body)
|
||||
? "chat-message-text chat-message-text--jumbo"
|
||||
: "chat-message-text";
|
||||
}
|
||||
|
||||
function snippet(body: string): string {
|
||||
return markdownToPlainText(body, t`image`);
|
||||
}
|
||||
|
||||
// The draft and reply target outlive the chat — closing it, or reloading the
|
||||
// tab, keeps a half-written message.
|
||||
interface StoredDraft {
|
||||
draft: string;
|
||||
replyTarget: ChatReplyTarget | null;
|
||||
}
|
||||
|
||||
function draftKey(userId: string): string {
|
||||
return `chat-draft:${userId}`;
|
||||
}
|
||||
|
||||
function readDraft(userId: string | undefined): StoredDraft {
|
||||
if (userId) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(draftKey(userId));
|
||||
const parsed = raw ? JSON.parse(raw) : null;
|
||||
if (parsed && typeof parsed.draft === "string") {
|
||||
return { draft: parsed.draft, replyTarget: parsed.replyTarget ?? null };
|
||||
}
|
||||
} catch {
|
||||
// Blocked storage or a mangled entry: start empty.
|
||||
}
|
||||
}
|
||||
return { draft: "", replyTarget: null };
|
||||
}
|
||||
|
||||
function writeDraft(userId: string | undefined, value: StoredDraft): void {
|
||||
if (!userId) return;
|
||||
try {
|
||||
if (value.draft || value.replyTarget) {
|
||||
sessionStorage.setItem(draftKey(userId), JSON.stringify(value));
|
||||
} else {
|
||||
sessionStorage.removeItem(draftKey(userId));
|
||||
}
|
||||
} catch {
|
||||
// The draft just won't survive a reload.
|
||||
}
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
// Human label for a day separator: "Today"/"Yesterday" for the two most recent
|
||||
// days, otherwise a localized full date.
|
||||
function dayLabel(date: Date): string {
|
||||
const now = new Date();
|
||||
if (isSameDay(date, now)) return t`Today`;
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (isSameDay(date, yesterday)) return t`Yesterday`;
|
||||
return date.toLocaleDateString(i18n.locale, {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: now.getFullYear() === date.getFullYear() ? undefined : "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// Hours and minutes only, for the narrow gutter beside a grouped message — the
|
||||
// day period ("PM") is dropped to fit; its tooltip has the full timestamp.
|
||||
function clockTime(date: Date): string {
|
||||
return new Intl.DateTimeFormat(i18n.locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).formatToParts(date)
|
||||
.filter((part) => part.type !== "dayPeriod")
|
||||
.map((part) => part.value)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
interface ChatEditBoxProps {
|
||||
initial: string;
|
||||
onSave: (body: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const editorRef = useRef<TextEditorHandle>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus without letting the browser scroll the list mid-transition (the
|
||||
// message box is still easing to its new height); once it has settled, bring
|
||||
// the whole edit box, Save included, into view.
|
||||
useEffect(() => {
|
||||
editorRef.current?.focus({ atEnd: true, preventScroll: true });
|
||||
const box = boxRef.current;
|
||||
if (!box) return;
|
||||
const settle = parseFloat(
|
||||
getComputedStyle(box).getPropertyValue("--dur-reveal"),
|
||||
) * 1000 || 250;
|
||||
const id = setTimeout(
|
||||
() => box.scrollIntoView({ block: "nearest", behavior: "smooth" }),
|
||||
settle + 20,
|
||||
);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
if (!sendable(draft)) return;
|
||||
onSave(draft.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-edit animate-in" ref={boxRef}>
|
||||
<TextEditor
|
||||
ref={editorRef}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
rows={2}
|
||||
autoResize
|
||||
maxLength={MAX_CHAT_LENGTH}
|
||||
countFrom={COUNT_FROM}
|
||||
className="comment-reply-textarea chat-input"
|
||||
onSubmit={save}
|
||||
onCancel={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="chat-edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={save}
|
||||
disabled={!sendable(draft)}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReplyRefProps {
|
||||
replyToId: string;
|
||||
author?: string;
|
||||
body?: string;
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
// Subtle one-line reference to the replied-to message; clicking jumps to it.
|
||||
// Falls back to a muted note when the target was deleted.
|
||||
function ReplyRef({ replyToId, author, body, onJumpTo }: ReplyRefProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-reply-ref"
|
||||
onClick={() => onJumpTo(replyToId)}
|
||||
disabled={!author}
|
||||
>
|
||||
<span className="chat-reply-ref-mark">↩</span>
|
||||
{author
|
||||
? (
|
||||
<>
|
||||
<span className="chat-reply-ref-author">{author}</span>
|
||||
<span className="chat-reply-ref-text">{snippet(body ?? "")}</span>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<span className="chat-reply-ref-text">
|
||||
<Trans>deleted message</Trans>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatMessageItemProps {
|
||||
message: ChatMessage;
|
||||
/** Continues the previous message's run: no avatar or author line. */
|
||||
grouped: boolean;
|
||||
mentionsMe: boolean;
|
||||
/** The replied-to message has been deleted since this one loaded. */
|
||||
replyDeleted: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
flashed: boolean;
|
||||
editing: boolean;
|
||||
onStartEdit: (id: string) => void;
|
||||
onEndEdit: () => void;
|
||||
onEdit: (id: string, body: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onReply: (message: ChatMessage) => void;
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
function ChatMessageItem(
|
||||
{
|
||||
message,
|
||||
grouped,
|
||||
mentionsMe,
|
||||
replyDeleted,
|
||||
canEdit,
|
||||
canDelete,
|
||||
flashed,
|
||||
editing,
|
||||
onStartEdit,
|
||||
onEndEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
onJumpTo,
|
||||
}: ChatMessageItemProps,
|
||||
) {
|
||||
const bodyRef = useHeightTransition<HTMLDivElement>(editing);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const actions = !editing && (
|
||||
<span
|
||||
className={`chat-message-actions${
|
||||
grouped ? " chat-message-actions--float" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-icon-btn"
|
||||
onClick={() => onReply(message)}
|
||||
title={t`Reply`}
|
||||
aria-label={t`Reply`}
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-icon-btn"
|
||||
onClick={() => onStartEdit(message.id)}
|
||||
title={t`Edit`}
|
||||
aria-label={t`Edit`}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-icon-btn chat-icon-btn--delete"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
title={t`Delete`}
|
||||
aria-label={t`Delete`}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const edited = message.updatedAt && (
|
||||
<Tooltip text={t`Edited ${message.updatedAt.toLocaleString()}`}>
|
||||
<span className="chat-message-edited">
|
||||
{grouped
|
||||
? <Trans>edited</Trans>
|
||||
: <Trans>edited {relativeTime(message.updatedAt)}</Trans>}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`chat-message${grouped ? " chat-message--grouped" : ""}${
|
||||
mentionsMe ? " chat-message--mention" : ""
|
||||
}${flashed ? " chat-message--flash" : ""}`}
|
||||
data-mid={message.id}
|
||||
>
|
||||
{grouped
|
||||
? (
|
||||
// The avatar column holds the time instead, shown on hover.
|
||||
<span className="chat-message-gutter">
|
||||
<Tooltip text={message.createdAt.toLocaleString()}>
|
||||
<time dateTime={message.createdAt.toISOString()}>
|
||||
{clockTime(message.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<Avatar
|
||||
userId={message.userId}
|
||||
username={message.authorUsername}
|
||||
hasAvatar={!!message.authorAvatarMime}
|
||||
size={32}
|
||||
/>
|
||||
)}
|
||||
<div className="chat-message-body" ref={bodyRef}>
|
||||
{message.replyToId && (
|
||||
<ReplyRef
|
||||
replyToId={message.replyToId}
|
||||
author={replyDeleted ? undefined : message.replyToAuthor}
|
||||
body={message.replyToBody}
|
||||
onJumpTo={onJumpTo}
|
||||
/>
|
||||
)}
|
||||
{!grouped && (
|
||||
<div className="chat-message-meta">
|
||||
<Link
|
||||
to={`/users/${message.authorUsername}`}
|
||||
className="chat-message-author"
|
||||
>
|
||||
{message.authorUsername}
|
||||
</Link>
|
||||
<Tooltip text={message.createdAt.toLocaleString()}>
|
||||
<time
|
||||
className="chat-message-time"
|
||||
dateTime={message.createdAt.toISOString()}
|
||||
>
|
||||
{relativeTime(message.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
{edited}
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing
|
||||
? (
|
||||
<ChatEditBox
|
||||
initial={message.body}
|
||||
onSave={(body) => {
|
||||
if (body !== message.body) onEdit(message.id, body);
|
||||
onEndEdit();
|
||||
}}
|
||||
onCancel={onEndEdit}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Markdown className={messageTextClass(message.body)}>
|
||||
{message.body}
|
||||
</Markdown>
|
||||
)}
|
||||
|
||||
{grouped && !editing && edited}
|
||||
</div>
|
||||
{grouped && actions}
|
||||
|
||||
{confirmDelete && (
|
||||
<ConfirmModal
|
||||
message={t`Delete this message?`}
|
||||
confirmLabel={t`Delete`}
|
||||
onConfirm={() => {
|
||||
setConfirmDelete(false);
|
||||
onDelete(message.id);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PendingItemProps {
|
||||
pending: PendingChatMessage;
|
||||
grouped: boolean;
|
||||
user: PublicUser;
|
||||
onRetry: (nonce: string) => void;
|
||||
onDiscard: (nonce: string) => void;
|
||||
onRestore: (pending: PendingChatMessage) => void;
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
// A message on its way: dimmed while sending; if it fails, it says why and
|
||||
// offers to try again, take it back into the composer, or drop it.
|
||||
function PendingItem(
|
||||
{ pending, grouped, user, onRetry, onDiscard, onRestore, onJumpTo }:
|
||||
PendingItemProps,
|
||||
) {
|
||||
const failed = pending.status === "failed";
|
||||
return (
|
||||
<div
|
||||
className={`chat-message chat-message--pending${
|
||||
grouped ? " chat-message--grouped" : ""
|
||||
}${failed ? " chat-message--failed" : ""}`}
|
||||
>
|
||||
{grouped ? <span className="chat-message-gutter" /> : (
|
||||
<Avatar
|
||||
userId={user.id}
|
||||
username={user.username}
|
||||
hasAvatar={!!user.avatarMime}
|
||||
size={32}
|
||||
/>
|
||||
)}
|
||||
<div className="chat-message-body">
|
||||
{pending.replyTo && (
|
||||
<ReplyRef
|
||||
replyToId={pending.replyTo.id}
|
||||
author={pending.replyTo.author}
|
||||
body={pending.replyTo.body}
|
||||
onJumpTo={onJumpTo}
|
||||
/>
|
||||
)}
|
||||
{!grouped && (
|
||||
<div className="chat-message-meta">
|
||||
<span className="chat-message-author">{user.username}</span>
|
||||
</div>
|
||||
)}
|
||||
<Markdown className={messageTextClass(pending.body)}>
|
||||
{pending.body}
|
||||
</Markdown>
|
||||
<div
|
||||
className="chat-pending-status"
|
||||
role={failed ? "alert" : undefined}
|
||||
>
|
||||
{failed
|
||||
? (
|
||||
<>
|
||||
<span className="chat-pending-error">{pending.error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-text-btn"
|
||||
onClick={() => onRetry(pending.nonce)}
|
||||
>
|
||||
<Trans>Retry</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-text-btn"
|
||||
onClick={() => onRestore(pending)}
|
||||
>
|
||||
<Trans>Edit</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-text-btn"
|
||||
onClick={() => onDiscard(pending.nonce)}
|
||||
>
|
||||
<Trans>Discard</Trans>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: <Trans>Sending…</Trans>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Row =
|
||||
| { kind: "sep"; key: string; label: string }
|
||||
| { kind: "new"; key: string }
|
||||
93
src/components/chat/PendingItem.tsx
Normal file
93
src/components/chat/PendingItem.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import type { PendingChatMessage } from "../../contexts/WSContext.ts";
|
||||
import type { PublicUser } from "../../model.ts";
|
||||
import { ReplyRef } from "./ReplyRef.tsx";
|
||||
import { messageTextClass } from "./message.ts";
|
||||
|
||||
export interface PendingItemProps {
|
||||
pending: PendingChatMessage;
|
||||
grouped: boolean;
|
||||
user: PublicUser;
|
||||
onRetry: (nonce: string) => void;
|
||||
onDiscard: (nonce: string) => void;
|
||||
onRestore: (pending: PendingChatMessage) => void;
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
// A message on its way: dimmed while sending; if it fails, it says why and
|
||||
// offers to try again, take it back into the composer, or drop it.
|
||||
export function PendingItem(
|
||||
{ pending, grouped, user, onRetry, onDiscard, onRestore, onJumpTo }:
|
||||
PendingItemProps,
|
||||
) {
|
||||
const failed = pending.status === "failed";
|
||||
return (
|
||||
<div
|
||||
className={`chat-message chat-message--pending${
|
||||
grouped ? " chat-message--grouped" : ""
|
||||
}${failed ? " chat-message--failed" : ""}`}
|
||||
>
|
||||
{grouped ? <span className="chat-message-gutter" /> : (
|
||||
<Avatar
|
||||
userId={user.id}
|
||||
username={user.username}
|
||||
hasAvatar={!!user.avatarMime}
|
||||
size={32}
|
||||
/>
|
||||
)}
|
||||
<div className="chat-message-body">
|
||||
{pending.replyTo && (
|
||||
<ReplyRef
|
||||
replyToId={pending.replyTo.id}
|
||||
author={pending.replyTo.author}
|
||||
body={pending.replyTo.body}
|
||||
onJumpTo={onJumpTo}
|
||||
/>
|
||||
)}
|
||||
{!grouped && (
|
||||
<div className="chat-message-meta">
|
||||
<span className="chat-message-author">{user.username}</span>
|
||||
</div>
|
||||
)}
|
||||
<Markdown className={messageTextClass(pending.body)}>
|
||||
{pending.body}
|
||||
</Markdown>
|
||||
<div
|
||||
className="chat-pending-status"
|
||||
role={failed ? "alert" : undefined}
|
||||
>
|
||||
{failed
|
||||
? (
|
||||
<>
|
||||
<span className="chat-pending-error">{pending.error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-text-btn"
|
||||
onClick={() => onRetry(pending.nonce)}
|
||||
>
|
||||
<Trans>Retry</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-text-btn"
|
||||
onClick={() => onRestore(pending)}
|
||||
>
|
||||
<Trans>Edit</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-text-btn"
|
||||
onClick={() => onDiscard(pending.nonce)}
|
||||
>
|
||||
<Trans>Discard</Trans>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: <Trans>Sending…</Trans>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/components/chat/ReplyRef.tsx
Normal file
36
src/components/chat/ReplyRef.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { snippet } from "./message.ts";
|
||||
|
||||
export interface ReplyRefProps {
|
||||
replyToId: string;
|
||||
author?: string;
|
||||
body?: string;
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
// Subtle one-line reference to the replied-to message; clicking jumps to it.
|
||||
// Falls back to a muted note when the target was deleted.
|
||||
export function ReplyRef({ replyToId, author, body, onJumpTo }: ReplyRefProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-reply-ref"
|
||||
onClick={() => onJumpTo(replyToId)}
|
||||
disabled={!author}
|
||||
>
|
||||
<span className="chat-reply-ref-mark">↩</span>
|
||||
{author
|
||||
? (
|
||||
<>
|
||||
<span className="chat-reply-ref-author">{author}</span>
|
||||
<span className="chat-reply-ref-text">{snippet(body ?? "")}</span>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<span className="chat-reply-ref-text">
|
||||
<Trans>deleted message</Trans>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
26
src/components/chat/message.ts
Normal file
26
src/components/chat/message.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { markdownToPlainText } from "../../utils/chatText.ts";
|
||||
import { isEmojiOnly } from "../../utils/editorText.ts";
|
||||
|
||||
// Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts.
|
||||
export const MAX_CHAT_LENGTH = 2000;
|
||||
// The counter only shows once a message is getting near the limit.
|
||||
export const COUNT_FROM = 1800;
|
||||
|
||||
// What the server holds a message to: its length once trimmed.
|
||||
export function sendable(draft: string): boolean {
|
||||
const body = draft.trim();
|
||||
return body.length > 0 && body.length <= MAX_CHAT_LENGTH;
|
||||
}
|
||||
|
||||
// A message of nothing but a few emoji is shown large, as chats do.
|
||||
export function messageTextClass(body: string): string {
|
||||
return isEmojiOnly(body)
|
||||
? "chat-message-text chat-message-text--jumbo"
|
||||
: "chat-message-text";
|
||||
}
|
||||
|
||||
// One line of plain text, for reply previews.
|
||||
export function snippet(body: string): string {
|
||||
return markdownToPlainText(body, t`image`);
|
||||
}
|
||||
39
src/components/chat/time.ts
Normal file
39
src/components/chat/time.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Timestamps in the chat: day separators and the grouped-message gutter.
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { i18n } from "../../i18n.ts";
|
||||
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
// Human label for a day separator: "Today"/"Yesterday" for the two most recent
|
||||
// days, otherwise a localized full date.
|
||||
export function dayLabel(date: Date): string {
|
||||
const now = new Date();
|
||||
if (isSameDay(date, now)) return t`Today`;
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (isSameDay(date, yesterday)) return t`Yesterday`;
|
||||
return date.toLocaleDateString(i18n.locale, {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: now.getFullYear() === date.getFullYear() ? undefined : "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// Hours and minutes only, for the narrow gutter beside a grouped message — the
|
||||
// day period ("PM") is dropped to fit; its tooltip has the full timestamp.
|
||||
export function clockTime(date: Date): string {
|
||||
return new Intl.DateTimeFormat(i18n.locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).formatToParts(date)
|
||||
.filter((part) => part.type !== "dayPeriod")
|
||||
.map((part) => part.value)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
136
src/components/comment/CommentForm.tsx
Normal file
136
src/components/comment/CommentForm.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from "react";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { VALIDATION } from "../../config/api.ts";
|
||||
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
|
||||
import {
|
||||
FormError,
|
||||
FormProvider,
|
||||
SubmitButton,
|
||||
useApiForm,
|
||||
} from "../form/index.ts";
|
||||
|
||||
export interface CommentFormProps {
|
||||
/** Async submit; throw to surface a root error, resolve to clear/close. */
|
||||
onSubmit: (body: string) => Promise<void>;
|
||||
submitLabel: React.ReactNode;
|
||||
pendingLabel: React.ReactNode;
|
||||
errorTitle: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
onCancel?: () => void;
|
||||
/** Top-level form: only show Cancel once there's text to discard. */
|
||||
cancelWhenEmpty?: boolean;
|
||||
editorRef?: React.Ref<TextEditorHandle>;
|
||||
/** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */
|
||||
layout?: "inline" | "top";
|
||||
leading?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-field comment editor (post / reply / edit). Wraps the shared
|
||||
* react-hook-form lifecycle so the three call sites stay identical.
|
||||
*/
|
||||
export function CommentForm({
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
pendingLabel,
|
||||
errorTitle,
|
||||
placeholder,
|
||||
defaultValue = "",
|
||||
onCancel,
|
||||
cancelWhenEmpty,
|
||||
editorRef,
|
||||
layout = "inline",
|
||||
leading,
|
||||
className,
|
||||
}: CommentFormProps) {
|
||||
const form = useApiForm<{ body: string }>({
|
||||
defaultValues: { body: defaultValue },
|
||||
});
|
||||
const body = form.watch("body");
|
||||
const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX;
|
||||
|
||||
const submit = form.submit(async ({ body }) => {
|
||||
if (!body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX) return;
|
||||
await onSubmit(body);
|
||||
form.reset({ body: "" });
|
||||
});
|
||||
|
||||
const showCancel = onCancel && (!cancelWhenEmpty || body.trim().length > 0);
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<Controller
|
||||
name="body"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
ref={editorRef}
|
||||
className="comment-reply-textarea"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSubmit={() => void submit()}
|
||||
// Escape backs out of a reply or an edit; the top-level box has
|
||||
// nothing to back out of.
|
||||
onCancel={onCancel && !cancelWhenEmpty
|
||||
? () => {
|
||||
form.reset({ body: "" });
|
||||
onCancel();
|
||||
}
|
||||
: undefined}
|
||||
placeholder={placeholder}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormError title={errorTitle} />
|
||||
<div className="comment-form-actions">
|
||||
<SubmitButton
|
||||
className="comment-submit-btn"
|
||||
pendingLabel={pendingLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{submitLabel}
|
||||
</SubmitButton>
|
||||
{showCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className="comment-cancel-btn"
|
||||
onClick={() => {
|
||||
form.reset({ body: "" });
|
||||
onCancel?.();
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{layout === "top"
|
||||
? (
|
||||
<form className="comment-top-form" onSubmit={submit}>
|
||||
<div className="comment-top-form-inner">
|
||||
{leading}
|
||||
<div className="comment-top-form-body">{inner}</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
: (
|
||||
<form
|
||||
className={className ? `comment-form ${className}` : "comment-form"}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{inner}
|
||||
</form>
|
||||
)}
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +1,29 @@
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { can } from "../utils/permissions.ts";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { can } from "../../utils/permissions.ts";
|
||||
import type {
|
||||
Comment,
|
||||
CreateCommentRequest,
|
||||
RawComment,
|
||||
UpdateCommentRequest,
|
||||
User,
|
||||
} from "../model.ts";
|
||||
import { deserializeComment } from "../model.ts";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
|
||||
import { LikeButton } from "./LikeButton.tsx";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
import { ConfirmModal } from "./ConfirmModal.tsx";
|
||||
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
FormProvider,
|
||||
SubmitButton,
|
||||
useApiForm,
|
||||
} from "./form/index.ts";
|
||||
|
||||
interface CommentFormProps {
|
||||
/** Async submit; throw to surface a root error, resolve to clear/close. */
|
||||
onSubmit: (body: string) => Promise<void>;
|
||||
submitLabel: React.ReactNode;
|
||||
pendingLabel: React.ReactNode;
|
||||
errorTitle: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
onCancel?: () => void;
|
||||
/** Top-level form: only show Cancel once there's text to discard. */
|
||||
cancelWhenEmpty?: boolean;
|
||||
editorRef?: React.Ref<TextEditorHandle>;
|
||||
/** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */
|
||||
layout?: "inline" | "top";
|
||||
leading?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-field comment editor (post / reply / edit). Wraps the shared
|
||||
* react-hook-form lifecycle so the three call sites stay identical.
|
||||
*/
|
||||
function CommentForm({
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
pendingLabel,
|
||||
errorTitle,
|
||||
placeholder,
|
||||
defaultValue = "",
|
||||
onCancel,
|
||||
cancelWhenEmpty,
|
||||
editorRef,
|
||||
layout = "inline",
|
||||
leading,
|
||||
className,
|
||||
}: CommentFormProps) {
|
||||
const form = useApiForm<{ body: string }>({
|
||||
defaultValues: { body: defaultValue },
|
||||
});
|
||||
const body = form.watch("body");
|
||||
const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX;
|
||||
|
||||
const submit = form.submit(async ({ body }) => {
|
||||
if (!body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX) return;
|
||||
await onSubmit(body);
|
||||
form.reset({ body: "" });
|
||||
});
|
||||
|
||||
const showCancel = onCancel && (!cancelWhenEmpty || body.trim().length > 0);
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<Controller
|
||||
name="body"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
ref={editorRef}
|
||||
className="comment-reply-textarea"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSubmit={() => void submit()}
|
||||
// Escape backs out of a reply or an edit; the top-level box has
|
||||
// nothing to back out of.
|
||||
onCancel={onCancel && !cancelWhenEmpty
|
||||
? () => {
|
||||
form.reset({ body: "" });
|
||||
onCancel();
|
||||
}
|
||||
: undefined}
|
||||
placeholder={placeholder}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormError title={errorTitle} />
|
||||
<div className="comment-form-actions">
|
||||
<SubmitButton
|
||||
className="comment-submit-btn"
|
||||
pendingLabel={pendingLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{submitLabel}
|
||||
</SubmitButton>
|
||||
{showCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className="comment-cancel-btn"
|
||||
onClick={() => {
|
||||
form.reset({ body: "" });
|
||||
onCancel?.();
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{layout === "top"
|
||||
? (
|
||||
<form className="comment-top-form" onSubmit={submit}>
|
||||
<div className="comment-top-form-inner">
|
||||
{leading}
|
||||
<div className="comment-top-form-body">{inner}</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
: (
|
||||
<form
|
||||
className={className ? `comment-form ${className}` : "comment-form"}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{inner}
|
||||
</form>
|
||||
)}
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentThreadProps {
|
||||
dumpId: string;
|
||||
comments: Comment[];
|
||||
currentUser: User | null;
|
||||
token: string | null;
|
||||
onCommentCreated: (comment: Comment) => void;
|
||||
onCommentDeleted: (commentId: string) => void;
|
||||
onCommentUpdated: (comment: Comment) => void;
|
||||
}
|
||||
|
||||
function buildTree(comments: Comment[]): Map<string, Comment[]> {
|
||||
const map = new Map<string, Comment[]>();
|
||||
for (const c of comments) {
|
||||
const key = c.parentId ?? "root";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(c);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
} from "../../model.ts";
|
||||
import { deserializeComment } from "../../model.ts";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import type { TextEditorHandle } from "../editor/TextEditor.tsx";
|
||||
import { LikeButton } from "../social/LikeButton.tsx";
|
||||
import { relativeTime } from "../../utils/relativeTime.ts";
|
||||
import { Tooltip } from "../ui/Tooltip.tsx";
|
||||
import { ConfirmModal } from "../ui/ConfirmModal.tsx";
|
||||
import { useHeightTransition } from "../../hooks/useHeightTransition.ts";
|
||||
import { useWS } from "../../hooks/useWS.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { expectOk } from "../form/index.ts";
|
||||
import { CommentForm } from "./CommentForm.tsx";
|
||||
|
||||
interface CommentNodeProps {
|
||||
comment: Comment;
|
||||
@@ -188,7 +37,7 @@ interface CommentNodeProps {
|
||||
onCommentUpdated: (comment: Comment) => void;
|
||||
}
|
||||
|
||||
function CommentNode({
|
||||
export function CommentNode({
|
||||
comment,
|
||||
tree,
|
||||
depth,
|
||||
@@ -454,77 +303,3 @@ function CommentNode({
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentThread({
|
||||
dumpId,
|
||||
comments,
|
||||
currentUser,
|
||||
token,
|
||||
onCommentCreated,
|
||||
onCommentDeleted,
|
||||
onCommentUpdated,
|
||||
}: CommentThreadProps) {
|
||||
const tree = useMemo(() => buildTree(comments), [comments]);
|
||||
const roots = tree.get("root") ?? [];
|
||||
const { authFetch } = useAuth();
|
||||
|
||||
async function handleTopLevelSubmit(body: string) {
|
||||
const res = await authFetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body } satisfies CreateCommentRequest),
|
||||
});
|
||||
onCommentCreated(deserializeComment(await expectOk<RawComment>(res)));
|
||||
}
|
||||
|
||||
const visibleCount = comments.filter((c) => !c.deleted).length;
|
||||
|
||||
return (
|
||||
<section className="comment-section">
|
||||
<h2 className="comment-section-title">
|
||||
<Plural value={visibleCount} one="# comment" other="# comments" />
|
||||
</h2>
|
||||
|
||||
{currentUser && (
|
||||
<CommentForm
|
||||
layout="top"
|
||||
leading={
|
||||
<div className="comment-avatar">
|
||||
<Avatar
|
||||
userId={currentUser.id}
|
||||
username={currentUser.username}
|
||||
hasAvatar={!!currentUser.avatarMime}
|
||||
size={28}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onSubmit={handleTopLevelSubmit}
|
||||
submitLabel={<Trans>Post comment</Trans>}
|
||||
pendingLabel={<Trans>Posting…</Trans>}
|
||||
errorTitle={t`Failed to post comment`}
|
||||
placeholder={t`Add a comment…`}
|
||||
onCancel={() => {}}
|
||||
cancelWhenEmpty
|
||||
/>
|
||||
)}
|
||||
|
||||
{roots.length > 0 && (
|
||||
<ul className="comment-list">
|
||||
{roots.map((comment) => (
|
||||
<CommentNode
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
tree={tree}
|
||||
depth={0}
|
||||
dumpId={dumpId}
|
||||
currentUser={currentUser}
|
||||
token={token}
|
||||
onCommentCreated={onCommentCreated}
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
onCommentUpdated={onCommentUpdated}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
110
src/components/comment/CommentThread.tsx
Normal file
110
src/components/comment/CommentThread.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import type {
|
||||
Comment,
|
||||
CreateCommentRequest,
|
||||
RawComment,
|
||||
User,
|
||||
} from "../../model.ts";
|
||||
import { deserializeComment } from "../../model.ts";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { expectOk } from "../form/index.ts";
|
||||
import { CommentForm } from "./CommentForm.tsx";
|
||||
import { CommentNode } from "./CommentNode.tsx";
|
||||
|
||||
interface CommentThreadProps {
|
||||
dumpId: string;
|
||||
comments: Comment[];
|
||||
currentUser: User | null;
|
||||
token: string | null;
|
||||
onCommentCreated: (comment: Comment) => void;
|
||||
onCommentDeleted: (commentId: string) => void;
|
||||
onCommentUpdated: (comment: Comment) => void;
|
||||
}
|
||||
|
||||
function buildTree(comments: Comment[]): Map<string, Comment[]> {
|
||||
const map = new Map<string, Comment[]>();
|
||||
for (const c of comments) {
|
||||
const key = c.parentId ?? "root";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(c);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function CommentThread({
|
||||
dumpId,
|
||||
comments,
|
||||
currentUser,
|
||||
token,
|
||||
onCommentCreated,
|
||||
onCommentDeleted,
|
||||
onCommentUpdated,
|
||||
}: CommentThreadProps) {
|
||||
const tree = useMemo(() => buildTree(comments), [comments]);
|
||||
const roots = tree.get("root") ?? [];
|
||||
const { authFetch } = useAuth();
|
||||
|
||||
async function handleTopLevelSubmit(body: string) {
|
||||
const res = await authFetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body } satisfies CreateCommentRequest),
|
||||
});
|
||||
onCommentCreated(deserializeComment(await expectOk<RawComment>(res)));
|
||||
}
|
||||
|
||||
const visibleCount = comments.filter((c) => !c.deleted).length;
|
||||
|
||||
return (
|
||||
<section className="comment-section">
|
||||
<h2 className="comment-section-title">
|
||||
<Plural value={visibleCount} one="# comment" other="# comments" />
|
||||
</h2>
|
||||
|
||||
{currentUser && (
|
||||
<CommentForm
|
||||
layout="top"
|
||||
leading={
|
||||
<div className="comment-avatar">
|
||||
<Avatar
|
||||
userId={currentUser.id}
|
||||
username={currentUser.username}
|
||||
hasAvatar={!!currentUser.avatarMime}
|
||||
size={28}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onSubmit={handleTopLevelSubmit}
|
||||
submitLabel={<Trans>Post comment</Trans>}
|
||||
pendingLabel={<Trans>Posting…</Trans>}
|
||||
errorTitle={t`Failed to post comment`}
|
||||
placeholder={t`Add a comment…`}
|
||||
onCancel={() => {}}
|
||||
cancelWhenEmpty
|
||||
/>
|
||||
)}
|
||||
|
||||
{roots.length > 0 && (
|
||||
<ul className="comment-list">
|
||||
{roots.map((comment) => (
|
||||
<CommentNode
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
tree={tree}
|
||||
depth={0}
|
||||
dumpId={dumpId}
|
||||
currentUser={currentUser}
|
||||
token={token}
|
||||
onCommentCreated={onCommentCreated}
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
onCommentUpdated={onCommentUpdated}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { Dump } from "../model.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useCategories } from "../hooks/useCategories.ts";
|
||||
import { useDumpCardLink } from "../hooks/useDumpCardLink.ts";
|
||||
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||
import FilePreview from "./FilePreview.tsx";
|
||||
import RichContentCard from "./RichContentCard.tsx";
|
||||
import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx";
|
||||
import { VoteButton } from "./VoteButton.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
import type { Dump } from "../../model.ts";
|
||||
import { relativeTime } from "../../utils/relativeTime.ts";
|
||||
import { dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useCategories } from "../../hooks/useCategories.ts";
|
||||
import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
|
||||
import {
|
||||
isDumpVisited,
|
||||
isRecent,
|
||||
markDumpVisited,
|
||||
} from "../../utils/visited.ts";
|
||||
import { FilePreview } from "./FilePreview.tsx";
|
||||
import { RichContentCard } from "./RichContentCard.tsx";
|
||||
import { ThumbnailPlaceholder } from "../ui/ThumbnailPlaceholder.tsx";
|
||||
import { VoteButton } from "../social/VoteButton.tsx";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { Tooltip } from "../ui/Tooltip.tsx";
|
||||
|
||||
interface DumpCardProps {
|
||||
dump: Dump;
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import type { Dump } from "../model.ts";
|
||||
import { formatBytes } from "../utils/format.ts";
|
||||
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx";
|
||||
import type { Dump } from "../../model.ts";
|
||||
import { formatBytes } from "../../utils/format.ts";
|
||||
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { IconPause, IconPlay } from "../player/icons.tsx";
|
||||
import { MediaPlayer } from "../player/MediaPlayer.tsx";
|
||||
import {
|
||||
PlayerContext,
|
||||
type PlayerItem,
|
||||
PlayerTimeContext,
|
||||
} from "../contexts/PlayerContext.ts";
|
||||
} from "../../contexts/PlayerContext.ts";
|
||||
import {
|
||||
BAR_GAP,
|
||||
BAR_W,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
NUM_BARS,
|
||||
VIEWBOX_W,
|
||||
WAVEFORM_H,
|
||||
} from "../utils/waveform.ts";
|
||||
} from "../../utils/waveform.ts";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
@@ -180,7 +181,7 @@ function mimeIcon(mime: string): string {
|
||||
return "📁";
|
||||
}
|
||||
|
||||
export default function FilePreview(
|
||||
export function FilePreview(
|
||||
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps,
|
||||
) {
|
||||
const { current, playing, play, togglePlay } = useContext(PlayerContext);
|
||||
@@ -1,25 +1,29 @@
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { Dump } from "../model.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useDumpCardLink } from "../hooks/useDumpCardLink.ts";
|
||||
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||
import type { Dump } from "../../model.ts";
|
||||
import { relativeTime } from "../../utils/relativeTime.ts";
|
||||
import { dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
|
||||
import {
|
||||
isDumpVisited,
|
||||
isRecent,
|
||||
markDumpVisited,
|
||||
} from "../../utils/visited.ts";
|
||||
import {
|
||||
hasQuote,
|
||||
hasThumbnail,
|
||||
type JournalShape,
|
||||
} from "../utils/journalLayout.ts";
|
||||
import { VoteButton } from "./VoteButton.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
import Thumbnail from "./Thumbnail.tsx";
|
||||
} from "../../utils/journalLayout.ts";
|
||||
import { VoteButton } from "../social/VoteButton.tsx";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { Tooltip } from "../ui/Tooltip.tsx";
|
||||
import { Thumbnail } from "../ui/Thumbnail.tsx";
|
||||
import {
|
||||
canPlayRichContent,
|
||||
usePlayRichContent,
|
||||
} from "../hooks/usePlayRichContent.ts";
|
||||
} from "../../hooks/usePlayRichContent.ts";
|
||||
|
||||
export type { JournalShape };
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import type { RichContent } from "../model.ts";
|
||||
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||
import type { RichContent } from "../../model.ts";
|
||||
import { PlayerContext } from "../../contexts/PlayerContext.ts";
|
||||
import {
|
||||
canPlayRichContent,
|
||||
usePlayRichContent,
|
||||
} from "../hooks/usePlayRichContent.ts";
|
||||
import Thumbnail from "./Thumbnail.tsx";
|
||||
} from "../../hooks/usePlayRichContent.ts";
|
||||
import { Thumbnail } from "../ui/Thumbnail.tsx";
|
||||
|
||||
interface RichContentCardProps {
|
||||
richContent: RichContent;
|
||||
@@ -17,7 +17,7 @@ interface RichContentCardProps {
|
||||
dumpHref?: string;
|
||||
}
|
||||
|
||||
export default function RichContentCard(
|
||||
export function RichContentCard(
|
||||
{ richContent, compact = false, thumbnailOverrideUrl, dumpHref }:
|
||||
RichContentCardProps,
|
||||
) {
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { API_URL, VALIDATION } from "../../../config/api.ts";
|
||||
import type {
|
||||
CreateUrlDumpRequest,
|
||||
Dump,
|
||||
@@ -14,32 +13,30 @@ import type {
|
||||
RawDumpUrlMatch,
|
||||
RawPlaylistMembership,
|
||||
UrlPreviewResponse,
|
||||
} from "../model.ts";
|
||||
} from "../../../model.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
deserializeDumpUrlMatch,
|
||||
deserializePlaylistMembership,
|
||||
} from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { dumpUrl, normalizeUrl } from "../utils/urls.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { titleFromFileName } from "../utils/format.ts";
|
||||
} from "../../../model.ts";
|
||||
import { useAuth } from "../../../hooks/useAuth.ts";
|
||||
import { useWS } from "../../../hooks/useWS.ts";
|
||||
import { dumpUrl, normalizeUrl } from "../../../utils/urls.ts";
|
||||
import { titleFromFileName } from "../../../utils/format.ts";
|
||||
import {
|
||||
clearDumpDraft,
|
||||
type DumpDraft,
|
||||
EMPTY_DUMP_DRAFT,
|
||||
loadDumpDraft,
|
||||
saveDumpDraft,
|
||||
} from "../utils/dumpDraft.ts";
|
||||
import { MAX_FILE_SIZE } from "../config/upload.ts";
|
||||
import RichContentCard from "./RichContentCard.tsx";
|
||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||
import type { RichContent } from "../model.ts";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import type { TextEditorHandle } from "./TextEditor.tsx";
|
||||
import { CategorySelect } from "./CategorySelect.tsx";
|
||||
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
|
||||
} from "../../../utils/dumpDraft.ts";
|
||||
import { MAX_FILE_SIZE } from "../../../config/upload.ts";
|
||||
import { RichContentCard } from "../RichContentCard.tsx";
|
||||
import type { RichContent } from "../../../model.ts";
|
||||
import { Modal } from "../../ui/Modal.tsx";
|
||||
import type { TextEditorHandle } from "../../editor/TextEditor.tsx";
|
||||
import { CategorySelect } from "../../category/CategorySelect.tsx";
|
||||
import { PlaylistMembershipPanel } from "../../playlist/PlaylistMembershipPanel.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FileField,
|
||||
@@ -49,54 +46,15 @@ import {
|
||||
SubmitButton,
|
||||
useApiForm,
|
||||
VisibilityToggle,
|
||||
} from "./form/index.ts";
|
||||
|
||||
/**
|
||||
* Posting a dump walks three panels. The split is deliberate: categories and
|
||||
* visibility used to sit behind a disclosure, where they read as optional and
|
||||
* went untouched — they are neither, so "why & where" is a stop on the way to
|
||||
* posting rather than something to go looking for.
|
||||
*/
|
||||
type Step = "what" | "details" | "playlist";
|
||||
} from "../../form/index.ts";
|
||||
import { DuplicateNotice } from "./DuplicateNotice.tsx";
|
||||
import { LocalFilePreview } from "./LocalFilePreview.tsx";
|
||||
import { type Step, StepIndicator } from "./StepIndicator.tsx";
|
||||
import { TitleField } from "./TitleField.tsx";
|
||||
|
||||
/** Stable empty result, so rendering doesn't churn on a fresh array each pass. */
|
||||
const NO_DUPLICATES: DumpUrlMatch[] = [];
|
||||
|
||||
const STEPS: { id: Step; label: () => string }[] = [
|
||||
{ id: "what", label: () => t`Link or file` },
|
||||
{ id: "details", label: () => t`Why & where` },
|
||||
{ id: "playlist", label: () => t`Playlists` },
|
||||
];
|
||||
|
||||
/** Where you are in the three panels — and, past the first, how far you got. */
|
||||
function StepIndicator({ current }: { current: Step }) {
|
||||
const currentIndex = STEPS.findIndex((s) => s.id === current);
|
||||
|
||||
return (
|
||||
<ol className="dump-steps">
|
||||
{STEPS.map((step, i) => {
|
||||
const state = i < currentIndex
|
||||
? "done"
|
||||
: i === currentIndex
|
||||
? "current"
|
||||
: "todo";
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
className={`dump-step dump-step--${state}`}
|
||||
aria-current={state === "current" ? "step" : undefined}
|
||||
>
|
||||
<span className="dump-step-marker" aria-hidden="true">
|
||||
{state === "done" ? "✓" : i + 1}
|
||||
</span>
|
||||
<span className="dump-step-label">{step.label()}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
type UrlPreview =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
@@ -119,145 +77,6 @@ function prefersNoAutoFocus(): boolean {
|
||||
return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false;
|
||||
}
|
||||
|
||||
function LocalFilePreview({ file }: { file: File }) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
// Blob URL lifecycle requires setState in effect — no synchronous alternative.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSrc(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
if (!src) return null;
|
||||
const mime = file.type;
|
||||
if (mime.startsWith("image/")) {
|
||||
return <img src={src} alt={file.name} className="local-preview-image" />;
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
return <MediaPlayer key={src} src={src} kind="video" mime={mime} />;
|
||||
}
|
||||
if (mime.startsWith("audio/")) {
|
||||
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Title row shared by both kinds of dump: the suggested title (scraped from the
|
||||
* page, or derived from the filename) sits there read-only until the ✎ is used,
|
||||
* which is enough to stop stray typing while keeping a correction one click
|
||||
* away. Once edited, a reset offers the suggestion back.
|
||||
*/
|
||||
function TitleField({
|
||||
registration,
|
||||
editing,
|
||||
onEdit,
|
||||
onReset,
|
||||
disabled,
|
||||
}: {
|
||||
registration: UseFormRegisterReturn<"title">;
|
||||
editing: boolean;
|
||||
onEdit: () => void;
|
||||
onReset?: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { ref, ...titleReg } = registration;
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
<label className="form-label" htmlFor="dc-title">
|
||||
<Trans>Title</Trans>
|
||||
</label>
|
||||
<div className="dump-create-title-row">
|
||||
<input
|
||||
id="dc-title"
|
||||
type="text"
|
||||
{...titleReg}
|
||||
ref={(el) => {
|
||||
ref(el);
|
||||
inputRef.current = el;
|
||||
}}
|
||||
disabled={disabled || !editing}
|
||||
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
||||
/>
|
||||
{!editing && (
|
||||
<button
|
||||
type="button"
|
||||
className="dump-create-title-edit-btn"
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={t`Edit title`}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{editing && onReset && (
|
||||
<button
|
||||
type="button"
|
||||
className="dump-create-title-edit-btn"
|
||||
onClick={onReset}
|
||||
disabled={disabled}
|
||||
aria-label={t`Reset to the suggested title`}
|
||||
title={t`Reset to the suggested title`}
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */
|
||||
function DuplicateNotice(
|
||||
{ matches, onNavigate }: {
|
||||
matches: DumpUrlMatch[];
|
||||
onNavigate: () => void;
|
||||
},
|
||||
) {
|
||||
const [first, ...rest] = matches;
|
||||
|
||||
return (
|
||||
<div className="dump-duplicate" role="status">
|
||||
<span className="dump-duplicate-icon" aria-hidden="true">↩</span>
|
||||
<div className="dump-duplicate-body">
|
||||
<p className="dump-duplicate-lead">
|
||||
<Trans>
|
||||
Already dumped by {first.username} {relativeTime(first.createdAt)}
|
||||
</Trans>
|
||||
</p>
|
||||
<Link
|
||||
to={dumpUrl(first)}
|
||||
className="dump-duplicate-link"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{first.title}
|
||||
</Link>
|
||||
{rest.length > 0 && (
|
||||
<p className="dump-duplicate-more">
|
||||
<Plural
|
||||
value={rest.length}
|
||||
one="and # earlier dump"
|
||||
other="and # earlier dumps"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<p className="dump-duplicate-hint">
|
||||
<Trans>You can post it anyway.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DumpCreateModalProps {
|
||||
onClose: () => void;
|
||||
initialUrl?: string;
|
||||
47
src/components/dump/create/DuplicateNotice.tsx
Normal file
47
src/components/dump/create/DuplicateNotice.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Link } from "react-router";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { DumpUrlMatch } from "../../../model.ts";
|
||||
import { dumpUrl } from "../../../utils/urls.ts";
|
||||
import { relativeTime } from "../../../utils/relativeTime.ts";
|
||||
|
||||
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */
|
||||
export function DuplicateNotice(
|
||||
{ matches, onNavigate }: {
|
||||
matches: DumpUrlMatch[];
|
||||
onNavigate: () => void;
|
||||
},
|
||||
) {
|
||||
const [first, ...rest] = matches;
|
||||
|
||||
return (
|
||||
<div className="dump-duplicate" role="status">
|
||||
<span className="dump-duplicate-icon" aria-hidden="true">↩</span>
|
||||
<div className="dump-duplicate-body">
|
||||
<p className="dump-duplicate-lead">
|
||||
<Trans>
|
||||
Already dumped by {first.username} {relativeTime(first.createdAt)}
|
||||
</Trans>
|
||||
</p>
|
||||
<Link
|
||||
to={dumpUrl(first)}
|
||||
className="dump-duplicate-link"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{first.title}
|
||||
</Link>
|
||||
{rest.length > 0 && (
|
||||
<p className="dump-duplicate-more">
|
||||
<Plural
|
||||
value={rest.length}
|
||||
one="and # earlier dump"
|
||||
other="and # earlier dumps"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<p className="dump-duplicate-hint">
|
||||
<Trans>You can post it anyway.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/components/dump/create/LocalFilePreview.tsx
Normal file
30
src/components/dump/create/LocalFilePreview.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MediaPlayer } from "../../player/MediaPlayer.tsx";
|
||||
|
||||
/** What a picked file will look like, before it's uploaded. */
|
||||
export function LocalFilePreview({ file }: { file: File }) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
// Blob URL lifecycle requires setState in effect — no synchronous alternative.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSrc(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
if (!src) return null;
|
||||
const mime = file.type;
|
||||
if (mime.startsWith("image/")) {
|
||||
return <img src={src} alt={file.name} className="local-preview-image" />;
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
return <MediaPlayer key={src} src={src} kind="video" mime={mime} />;
|
||||
}
|
||||
if (mime.startsWith("audio/")) {
|
||||
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
44
src/components/dump/create/StepIndicator.tsx
Normal file
44
src/components/dump/create/StepIndicator.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
/**
|
||||
* Posting a dump walks three panels. The split is deliberate: categories and
|
||||
* visibility used to sit behind a disclosure, where they read as optional and
|
||||
* went untouched — they are neither, so "why & where" is a stop on the way to
|
||||
* posting rather than something to go looking for.
|
||||
*/
|
||||
export type Step = "what" | "details" | "playlist";
|
||||
|
||||
const STEPS: { id: Step; label: () => string }[] = [
|
||||
{ id: "what", label: () => t`Link or file` },
|
||||
{ id: "details", label: () => t`Why & where` },
|
||||
{ id: "playlist", label: () => t`Playlists` },
|
||||
];
|
||||
|
||||
/** Where you are in the three panels — and, past the first, how far you got. */
|
||||
export function StepIndicator({ current }: { current: Step }) {
|
||||
const currentIndex = STEPS.findIndex((s) => s.id === current);
|
||||
|
||||
return (
|
||||
<ol className="dump-steps">
|
||||
{STEPS.map((step, i) => {
|
||||
const state = i < currentIndex
|
||||
? "done"
|
||||
: i === currentIndex
|
||||
? "current"
|
||||
: "todo";
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
className={`dump-step dump-step--${state}`}
|
||||
aria-current={state === "current" ? "step" : undefined}
|
||||
>
|
||||
<span className="dump-step-marker" aria-hidden="true">
|
||||
{state === "done" ? "✓" : i + 1}
|
||||
</span>
|
||||
<span className="dump-step-label">{step.label()}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
75
src/components/dump/create/TitleField.tsx
Normal file
75
src/components/dump/create/TitleField.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useRef } from "react";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { VALIDATION } from "../../../config/api.ts";
|
||||
|
||||
/**
|
||||
* Title row shared by both kinds of dump: the suggested title (scraped from the
|
||||
* page, or derived from the filename) sits there read-only until the ✎ is used,
|
||||
* which is enough to stop stray typing while keeping a correction one click
|
||||
* away. Once edited, a reset offers the suggestion back.
|
||||
*/
|
||||
export function TitleField({
|
||||
registration,
|
||||
editing,
|
||||
onEdit,
|
||||
onReset,
|
||||
disabled,
|
||||
}: {
|
||||
registration: UseFormRegisterReturn<"title">;
|
||||
editing: boolean;
|
||||
onEdit: () => void;
|
||||
onReset?: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { ref, ...titleReg } = registration;
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
<label className="form-label" htmlFor="dc-title">
|
||||
<Trans>Title</Trans>
|
||||
</label>
|
||||
<div className="dump-create-title-row">
|
||||
<input
|
||||
id="dc-title"
|
||||
type="text"
|
||||
{...titleReg}
|
||||
ref={(el) => {
|
||||
ref(el);
|
||||
inputRef.current = el;
|
||||
}}
|
||||
disabled={disabled || !editing}
|
||||
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
||||
/>
|
||||
{!editing && (
|
||||
<button
|
||||
type="button"
|
||||
className="dump-create-title-edit-btn"
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={t`Edit title`}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{editing && onReset && (
|
||||
<button
|
||||
type="button"
|
||||
className="dump-create-title-edit-btn"
|
||||
onClick={onReset}
|
||||
disabled={disabled}
|
||||
aria-label={t`Reset to the suggested title`}
|
||||
title={t`Reset to the suggested title`}
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/components/editor/EmojiPickerPopup.tsx
Normal file
112
src/components/editor/EmojiPickerPopup.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
type Ref,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import {
|
||||
EmojiPicker,
|
||||
type Locale as EmojiLocale,
|
||||
useSkinTone,
|
||||
} from "frimousse";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { readSkinTone, SKIN_TONE_KEY } from "./skinTone.ts";
|
||||
|
||||
/** Remembers the skin tone picked in the selector for the next picker. */
|
||||
function SkinTonePersist() {
|
||||
const [skinTone] = useSkinTone();
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(SKIN_TONE_KEY, skinTone);
|
||||
} catch {
|
||||
// Private mode: the tone lasts as long as the picker.
|
||||
}
|
||||
}, [skinTone]);
|
||||
return null;
|
||||
}
|
||||
|
||||
interface EmojiPickerPopupProps {
|
||||
floatRef: Ref<HTMLDivElement>;
|
||||
searchRef: Ref<HTMLInputElement>;
|
||||
viewportRef: Ref<HTMLDivElement>;
|
||||
style: CSSProperties;
|
||||
/** Inline, the query is typed in the textarea and the search only shows it;
|
||||
* browsing, the search field takes it. */
|
||||
inline: boolean;
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
onSelect: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
onKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
/** The floating emoji picker the editor opens from its button or a `:query`. */
|
||||
export function EmojiPickerPopup(
|
||||
{
|
||||
floatRef,
|
||||
searchRef,
|
||||
viewportRef,
|
||||
style,
|
||||
inline,
|
||||
query,
|
||||
onQueryChange,
|
||||
onSelect,
|
||||
onClose,
|
||||
onKeyDownCapture,
|
||||
}: EmojiPickerPopupProps,
|
||||
) {
|
||||
const { i18n } = useLingui();
|
||||
return (
|
||||
<div
|
||||
ref={floatRef}
|
||||
className="emoji-picker-float"
|
||||
style={style}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
>
|
||||
<EmojiPicker.Root
|
||||
onEmojiSelect={(e) => onSelect(e.emoji)}
|
||||
locale={i18n.locale as EmojiLocale}
|
||||
emojibaseUrl={import.meta.env.VITE_EMOJIBASE_URL}
|
||||
skinTone={readSkinTone()}
|
||||
>
|
||||
<SkinTonePersist />
|
||||
<div className="emoji-picker-search-row">
|
||||
<EmojiPicker.Search
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder={t`Search emoji…`}
|
||||
aria-label={t`Search emoji`}
|
||||
readOnly={inline}
|
||||
tabIndex={inline ? -1 : undefined}
|
||||
/>
|
||||
<EmojiPicker.SkinToneSelector aria-label={t`Change skin tone`} />
|
||||
<button
|
||||
type="button"
|
||||
className="emoji-picker-close-btn"
|
||||
onClick={onClose}
|
||||
aria-label={t`Close emoji picker`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<EmojiPicker.Viewport
|
||||
ref={viewportRef}
|
||||
// tabIndex={-1} makes the div programmatically focusable so
|
||||
// frimousse's onFocusCapture can detect it and arm arrow-key nav
|
||||
tabIndex={-1}
|
||||
>
|
||||
<EmojiPicker.Loading>
|
||||
<Trans>Loading…</Trans>
|
||||
</EmojiPicker.Loading>
|
||||
<EmojiPicker.Empty>
|
||||
<Trans>No emoji found.</Trans>
|
||||
</EmojiPicker.Empty>
|
||||
<EmojiPicker.List />
|
||||
</EmojiPicker.Viewport>
|
||||
</EmojiPicker.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type CSSProperties, useLayoutEffect, useRef } from "react";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import type { UserResult } from "../hooks/useMentionAutocomplete.ts";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import type { UserResult } from "../../hooks/useMentionAutocomplete.ts";
|
||||
|
||||
interface MentionDropdownProps {
|
||||
/** For the textarea's `aria-controls` / `aria-activedescendant`. */
|
||||
@@ -9,22 +9,16 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
EmojiPicker,
|
||||
type Locale as EmojiLocale,
|
||||
type SkinTone,
|
||||
useSkinTone,
|
||||
} from "frimousse";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { charCountClass } from "../utils/charCount.ts";
|
||||
import { charCountClass } from "../../utils/charCount.ts";
|
||||
import { EmojiPickerPopup } from "./EmojiPickerPopup.tsx";
|
||||
import { MentionDropdown } from "./MentionDropdown.tsx";
|
||||
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
|
||||
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
|
||||
import { useAnchoredFloat } from "../hooks/useAnchoredFloat.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { useMentionAutocomplete } from "../../hooks/useMentionAutocomplete.ts";
|
||||
import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts";
|
||||
import { useAnchoredFloat } from "../../hooks/useAnchoredFloat.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
|
||||
export interface TextEditorHandle {
|
||||
/** `atEnd` puts the caret after the last character (and scrolls the text
|
||||
@@ -59,29 +53,6 @@ interface TextEditorProps {
|
||||
"aria-describedby"?: string;
|
||||
}
|
||||
|
||||
const SKIN_TONE_KEY = "emoji-skin-tone";
|
||||
|
||||
function readSkinTone(): SkinTone {
|
||||
try {
|
||||
return (localStorage.getItem(SKIN_TONE_KEY) as SkinTone | null) ?? "none";
|
||||
} catch {
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
/** Remembers the skin tone picked in the selector for the next picker. */
|
||||
function SkinTonePersist() {
|
||||
const [skinTone] = useSkinTone();
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(SKIN_TONE_KEY, skinTone);
|
||||
} catch {
|
||||
// Private mode: the tone lasts as long as the picker.
|
||||
}
|
||||
}, [skinTone]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasFiles(dt: DataTransfer): boolean {
|
||||
return Array.from(dt.types).includes("Files");
|
||||
}
|
||||
@@ -114,7 +85,6 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { i18n } = useLingui();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const floatRef = useRef<HTMLDivElement>(null);
|
||||
const emojiButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -577,10 +547,19 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
document.body,
|
||||
)}
|
||||
{emojiOpen && createPortal(
|
||||
<div
|
||||
ref={floatRef}
|
||||
className="emoji-picker-float"
|
||||
<EmojiPickerPopup
|
||||
floatRef={floatRef}
|
||||
searchRef={emojiSearchRef}
|
||||
viewportRef={emojiViewportRef}
|
||||
style={emojiStyle ?? { visibility: "hidden" }}
|
||||
inline={emojiMode === "inline"}
|
||||
query={emojiQuery}
|
||||
onQueryChange={setEmojiQuery}
|
||||
onSelect={selectEmoji}
|
||||
onClose={() => {
|
||||
closeEmoji();
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
@@ -624,57 +603,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<EmojiPicker.Root
|
||||
onEmojiSelect={(e) => selectEmoji(e.emoji)}
|
||||
locale={i18n.locale as EmojiLocale}
|
||||
emojibaseUrl={import.meta.env.VITE_EMOJIBASE_URL}
|
||||
skinTone={readSkinTone()}
|
||||
>
|
||||
<SkinTonePersist />
|
||||
<div className="emoji-picker-search-row">
|
||||
<EmojiPicker.Search
|
||||
ref={emojiSearchRef}
|
||||
value={emojiQuery}
|
||||
onChange={(e) => setEmojiQuery(e.target.value)}
|
||||
placeholder={t`Search emoji…`}
|
||||
aria-label={t`Search emoji`}
|
||||
// Inline, the query is typed in the textarea; the field
|
||||
// only shows it.
|
||||
readOnly={emojiMode === "inline"}
|
||||
tabIndex={emojiMode === "inline" ? -1 : undefined}
|
||||
/>
|
||||
<EmojiPicker.SkinToneSelector
|
||||
aria-label={t`Change skin tone`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="emoji-picker-close-btn"
|
||||
onClick={() => {
|
||||
closeEmoji();
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
aria-label={t`Close emoji picker`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<EmojiPicker.Viewport
|
||||
ref={emojiViewportRef}
|
||||
// tabIndex={-1} makes the div programmatically focusable so
|
||||
// frimousse's onFocusCapture can detect it and arm arrow-key nav
|
||||
tabIndex={-1}
|
||||
>
|
||||
<EmojiPicker.Loading>
|
||||
<Trans>Loading…</Trans>
|
||||
</EmojiPicker.Loading>
|
||||
<EmojiPicker.Empty>
|
||||
<Trans>No emoji found.</Trans>
|
||||
</EmojiPicker.Empty>
|
||||
<EmojiPicker.List />
|
||||
</EmojiPicker.Viewport>
|
||||
</EmojiPicker.Root>
|
||||
</div>,
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
13
src/components/editor/skinTone.ts
Normal file
13
src/components/editor/skinTone.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// The emoji picker's skin tone, remembered across pickers and reloads.
|
||||
|
||||
import type { SkinTone } from "frimousse";
|
||||
|
||||
export const SKIN_TONE_KEY = "emoji-skin-tone";
|
||||
|
||||
export function readSkinTone(): SkinTone {
|
||||
try {
|
||||
return (localStorage.getItem(SKIN_TONE_KEY) as SkinTone | null) ?? "none";
|
||||
} catch {
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { formatBytes } from "../utils/format.ts";
|
||||
import { formatBytes } from "../../utils/format.ts";
|
||||
|
||||
function fileIcon(mime: string): string {
|
||||
if (mime.startsWith("image/")) return "🖼";
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { FileDropZone } from "../FileDropZone.tsx";
|
||||
import { FileDropZone } from "./FileDropZone.tsx";
|
||||
|
||||
interface FileFieldProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useFormState } from "react-hook-form";
|
||||
|
||||
import { ErrorCard } from "../ErrorCard.tsx";
|
||||
import { ErrorCard } from "../ui/ErrorCard.tsx";
|
||||
|
||||
/**
|
||||
* Renders the form's root error (set by {@link useApiForm}'s `submit` on a
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { TextEditor, type TextEditorHandle } from "../TextEditor.tsx";
|
||||
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
|
||||
|
||||
interface RichTextFieldProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
|
||||
@@ -2,16 +2,18 @@ import { lazy, type ReactNode, Suspense, useRef, useState } from "react";
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useWS } from "../../hooks/useWS.ts";
|
||||
import { NotificationBell } from "./NotificationBell.tsx";
|
||||
import { ChatButton } from "./ChatButton.tsx";
|
||||
import { UserMenu } from "./UserMenu.tsx";
|
||||
import { SearchBar } from "./SearchBar.tsx";
|
||||
import { SITE_EMOJI, SITE_NAME } from "../hooks/useDocumentTitle.ts";
|
||||
import { SITE_EMOJI, SITE_NAME } from "../../hooks/useDocumentTitle.ts";
|
||||
|
||||
const DumpCreateModal = lazy(() =>
|
||||
import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal }))
|
||||
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
|
||||
default: m.DumpCreateModal,
|
||||
}))
|
||||
);
|
||||
|
||||
/** Carried in a search entry's history state: the history index of the page
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useWSChat } from "../hooks/useWS.ts";
|
||||
import { useChat } from "../hooks/useChat.ts";
|
||||
import { useWSChat } from "../../hooks/useWS.ts";
|
||||
import { useChat } from "../../hooks/useChat.ts";
|
||||
|
||||
/** Header chat button with an unread-message badge. Opens the app-wide chat. */
|
||||
export function ChatButton() {
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWSChat } from "../hooks/useWS.ts";
|
||||
import { useChat } from "../hooks/useChat.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useWSChat } from "../../hooks/useWS.ts";
|
||||
import { useChat } from "../../hooks/useChat.ts";
|
||||
|
||||
/**
|
||||
* Floating chat button shown once the header has scrolled out of view, mirroring
|
||||
@@ -1,10 +1,12 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
|
||||
const DumpCreateModal = lazy(() =>
|
||||
import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal }))
|
||||
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
|
||||
default: m.DumpCreateModal,
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useWS } from "../../hooks/useWS.ts";
|
||||
|
||||
export function NotificationBell() {
|
||||
const { unreadNotificationCount, lastNotification } = useWS();
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { PageShell } from "./PageShell.tsx";
|
||||
import { ErrorCard } from "./ErrorCard.tsx";
|
||||
import { ErrorCard } from "../ui/ErrorCard.tsx";
|
||||
|
||||
export function PageError(
|
||||
{ title, message, actions }: {
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import type { User } from "../model.ts";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import type { User } from "../../model.ts";
|
||||
|
||||
export function UserMenu({ user }: { user: User }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
PlayerContext,
|
||||
type PlayerItem,
|
||||
playerItemKey,
|
||||
} from "../contexts/PlayerContext.ts";
|
||||
} from "../../contexts/PlayerContext.ts";
|
||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||
import { fmt } from "../utils/duration.ts";
|
||||
import Thumbnail from "./Thumbnail.tsx";
|
||||
import { useYouTubePlaylist } from "../hooks/useYouTubePlaylist.ts";
|
||||
import { embedListId, withJsApi } from "../utils/youtube.ts";
|
||||
import { fmt } from "../../utils/duration.ts";
|
||||
import { Thumbnail } from "../ui/Thumbnail.tsx";
|
||||
import { useYouTubePlaylist } from "../../hooks/useYouTubePlaylist.ts";
|
||||
import { embedListId, withJsApi } from "../../utils/youtube.ts";
|
||||
|
||||
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
|
||||
|
||||
@@ -1,189 +1,10 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
BAR_GAP,
|
||||
BAR_W,
|
||||
extractPeaks,
|
||||
NUM_BARS,
|
||||
VIEWBOX_W,
|
||||
WAVEFORM_H,
|
||||
} from "../utils/waveform.ts";
|
||||
import { fmt } from "../utils/duration.ts";
|
||||
import { fmt } from "../../utils/duration.ts";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
export const IconPlay = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconPause = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
||||
<rect x="5" y="3" width="4" height="18" rx="1" />
|
||||
<rect x="15" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconVolume = ({ muted }: { muted: boolean }) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
{muted
|
||||
? (
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z M19 12l2.5 2.5M21.5 12 19 14.5" />
|
||||
)
|
||||
: (
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconFullscreen = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── Waveform ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function Waveform(
|
||||
{ src, current, duration, onSeek }: {
|
||||
src: string;
|
||||
current: number;
|
||||
duration: number;
|
||||
onSeek: (t: number) => void;
|
||||
},
|
||||
) {
|
||||
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
extractPeaks(src, NUM_BARS)
|
||||
.then((p) => {
|
||||
if (!cancelled) setPeaks(p);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<Element>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
onSeek(ratio * duration);
|
||||
};
|
||||
|
||||
if (!peaks) {
|
||||
return (
|
||||
<div className="waveform-skeleton" onClick={handleClick}>
|
||||
<div
|
||||
className="waveform-skeleton-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEWBOX_W} ${WAVEFORM_H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="waveform-svg"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{Array.from(peaks).map((p, i) => {
|
||||
const barH = Math.max(p * WAVEFORM_H, 2);
|
||||
const x = i * (BAR_W + BAR_GAP);
|
||||
const y = (WAVEFORM_H - barH) / 2;
|
||||
const played = i / NUM_BARS <= progress;
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x={x}
|
||||
y={y}
|
||||
width={BAR_W}
|
||||
height={barH}
|
||||
className={`waveform-bar${played ? " waveform-bar--played" : ""}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Volume ────────────────────────────────────────────────────────────────────
|
||||
// Each queue advance remounts MediaPlayer (the element is keyed on the source),
|
||||
// which would otherwise snap the volume back to 100% on every track. Hold it
|
||||
// outside the component and mirror it to localStorage so it also survives a
|
||||
// reload.
|
||||
|
||||
const VOLUME_KEY = "player-volume";
|
||||
|
||||
let lastVolume = 1;
|
||||
let lastMuted = false;
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(VOLUME_KEY) ?? "null");
|
||||
if (stored && typeof stored.volume === "number") {
|
||||
lastVolume = Math.min(Math.max(stored.volume, 0), 1);
|
||||
lastMuted = stored.muted === true;
|
||||
}
|
||||
} catch {
|
||||
// Malformed or unavailable storage — the defaults above stand.
|
||||
}
|
||||
|
||||
function rememberVolume(volume: number, muted: boolean) {
|
||||
lastVolume = volume;
|
||||
lastMuted = muted;
|
||||
try {
|
||||
localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted }));
|
||||
} catch {
|
||||
// Private mode or blocked storage — volume just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seek bar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Slim progress + scrub bar. Used by video, and by audio sources whose peaks
|
||||
* can't be decoded (cross-origin streams) — where a waveform would be a lie. */
|
||||
function SeekBar(
|
||||
{ current, duration, onSeek, onDragChange, className }: {
|
||||
current: number;
|
||||
duration: number;
|
||||
onSeek: (t: number) => void;
|
||||
onDragChange: (dragging: boolean) => void;
|
||||
className?: string;
|
||||
},
|
||||
) {
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
return (
|
||||
<div className={`audio-player-track${className ? ` ${className}` : ""}`}>
|
||||
<div
|
||||
className="audio-player-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
className="audio-player-range"
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
step={0.01}
|
||||
value={current}
|
||||
// Pointer events, so a finger holds the thumb as a mouse does.
|
||||
onPointerDown={() =>
|
||||
onDragChange(true)}
|
||||
onPointerUp={() => onDragChange(false)}
|
||||
onPointerCancel={() => onDragChange(false)}
|
||||
onChange={(e) => onSeek(Number(e.target.value))}
|
||||
aria-label={t`Seek`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MediaPlayer ───────────────────────────────────────────────────────────────
|
||||
import { IconFullscreen, IconPause, IconPlay, IconVolume } from "./icons.tsx";
|
||||
import { SeekBar } from "./SeekBar.tsx";
|
||||
import { Waveform } from "./Waveform.tsx";
|
||||
import { rememberVolume, storedVolume } from "./volume.ts";
|
||||
|
||||
const HIDE_DELAY = 2500;
|
||||
|
||||
@@ -230,8 +51,8 @@ export function MediaPlayer(
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [volume, setVolume] = useState(lastVolume);
|
||||
const [muted, setMuted] = useState(lastMuted);
|
||||
const [volume, setVolume] = useState(() => storedVolume().volume);
|
||||
const [muted, setMuted] = useState(() => storedVolume().muted);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
38
src/components/player/SeekBar.tsx
Normal file
38
src/components/player/SeekBar.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
/** Slim progress + scrub bar. Used by video, and by audio sources whose peaks
|
||||
* can't be decoded (cross-origin streams) — where a waveform would be a lie. */
|
||||
export function SeekBar(
|
||||
{ current, duration, onSeek, onDragChange, className }: {
|
||||
current: number;
|
||||
duration: number;
|
||||
onSeek: (t: number) => void;
|
||||
onDragChange: (dragging: boolean) => void;
|
||||
className?: string;
|
||||
},
|
||||
) {
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
return (
|
||||
<div className={`audio-player-track${className ? ` ${className}` : ""}`}>
|
||||
<div
|
||||
className="audio-player-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
className="audio-player-range"
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
step={0.01}
|
||||
value={current}
|
||||
// Pointer events, so a finger holds the thumb as a mouse does.
|
||||
onPointerDown={() =>
|
||||
onDragChange(true)}
|
||||
onPointerUp={() => onDragChange(false)}
|
||||
onPointerCancel={() => onDragChange(false)}
|
||||
onChange={(e) => onSeek(Number(e.target.value))}
|
||||
aria-label={t`Seek`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
src/components/player/Waveform.tsx
Normal file
80
src/components/player/Waveform.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
BAR_GAP,
|
||||
BAR_W,
|
||||
extractPeaks,
|
||||
NUM_BARS,
|
||||
VIEWBOX_W,
|
||||
WAVEFORM_H,
|
||||
} from "../../utils/waveform.ts";
|
||||
|
||||
export function Waveform(
|
||||
{ src, current, duration, onSeek }: {
|
||||
src: string;
|
||||
current: number;
|
||||
duration: number;
|
||||
onSeek: (t: number) => void;
|
||||
},
|
||||
) {
|
||||
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
extractPeaks(src, NUM_BARS)
|
||||
.then((p) => {
|
||||
if (!cancelled) setPeaks(p);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<Element>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
onSeek(ratio * duration);
|
||||
};
|
||||
|
||||
if (!peaks) {
|
||||
return (
|
||||
<div className="waveform-skeleton" onClick={handleClick}>
|
||||
<div
|
||||
className="waveform-skeleton-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEWBOX_W} ${WAVEFORM_H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="waveform-svg"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{Array.from(peaks).map((p, i) => {
|
||||
const barH = Math.max(p * WAVEFORM_H, 2);
|
||||
const x = i * (BAR_W + BAR_GAP);
|
||||
const y = (WAVEFORM_H - barH) / 2;
|
||||
const played = i / NUM_BARS <= progress;
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x={x}
|
||||
y={y}
|
||||
width={BAR_W}
|
||||
height={barH}
|
||||
className={`waveform-bar${played ? " waveform-bar--played" : ""}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
32
src/components/player/icons.tsx
Normal file
32
src/components/player/icons.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// Player control icons, shared by MediaPlayer and FilePreview.
|
||||
|
||||
export const IconPlay = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconPause = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
||||
<rect x="5" y="3" width="4" height="18" rx="1" />
|
||||
<rect x="15" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconVolume = ({ muted }: { muted: boolean }) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
{muted
|
||||
? (
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z M19 12l2.5 2.5M21.5 12 19 14.5" />
|
||||
)
|
||||
: (
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconFullscreen = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</svg>
|
||||
);
|
||||
33
src/components/player/volume.ts
Normal file
33
src/components/player/volume.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Each queue advance remounts MediaPlayer (the element is keyed on the source),
|
||||
// which would otherwise snap the volume back to 100% on every track. Hold it
|
||||
// outside the component and mirror it to localStorage so it also survives a
|
||||
// reload.
|
||||
|
||||
const VOLUME_KEY = "player-volume";
|
||||
|
||||
let lastVolume = 1;
|
||||
let lastMuted = false;
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(VOLUME_KEY) ?? "null");
|
||||
if (stored && typeof stored.volume === "number") {
|
||||
lastVolume = Math.min(Math.max(stored.volume, 0), 1);
|
||||
lastMuted = stored.muted === true;
|
||||
}
|
||||
} catch {
|
||||
// Malformed or unavailable storage — the defaults above stand.
|
||||
}
|
||||
|
||||
export function rememberVolume(volume: number, muted: boolean) {
|
||||
lastVolume = volume;
|
||||
lastMuted = muted;
|
||||
try {
|
||||
localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted }));
|
||||
} catch {
|
||||
// Private mode or blocked storage — volume just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
/** The volume and mute state the last player left behind. */
|
||||
export function storedVolume(): { volume: number; muted: boolean } {
|
||||
return { volume: lastVolume, muted: lastMuted };
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import type { PlaylistMembership, RawPlaylistMembership } from "../model.ts";
|
||||
import { deserializePlaylistMembership } from "../model.ts";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import type { PlaylistMembership, RawPlaylistMembership } from "../../model.ts";
|
||||
import { deserializePlaylistMembership } from "../../model.ts";
|
||||
import { Modal } from "../ui/Modal.tsx";
|
||||
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
|
||||
|
||||
interface AddToPlaylistModalProps {
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { Playlist } from "../model.ts";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import type { Playlist } from "../../model.ts";
|
||||
import { Modal } from "../ui/Modal.tsx";
|
||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||
|
||||
interface NewPlaylistFormProps {
|
||||
interface NewPlaylistButtonProps {
|
||||
onCreated: (playlist: Playlist) => void;
|
||||
toggleLabel?: string;
|
||||
toggleClassName?: string;
|
||||
}
|
||||
|
||||
export function NewPlaylistForm(
|
||||
export function NewPlaylistButton(
|
||||
{
|
||||
onCreated,
|
||||
toggleLabel,
|
||||
toggleClassName = "new-item-toggle",
|
||||
}: NewPlaylistFormProps,
|
||||
}: NewPlaylistButtonProps,
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { Playlist } from "../model.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { playlistImageUrl, playlistUrl } from "../utils/urls.ts";
|
||||
import type { Playlist } from "../../model.ts";
|
||||
import { relativeTime } from "../../utils/relativeTime.ts";
|
||||
import { playlistImageUrl, playlistUrl } from "../../utils/urls.ts";
|
||||
import {
|
||||
isPlaylistVisited,
|
||||
isRecent,
|
||||
markPlaylistVisited,
|
||||
} from "../utils/visited.ts";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
} from "../../utils/visited.ts";
|
||||
import { Tooltip } from "../ui/Tooltip.tsx";
|
||||
|
||||
interface PlaylistCardProps {
|
||||
playlist: Playlist;
|
||||
@@ -1,9 +1,13 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
|
||||
import { deserializePlaylist } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { API_URL, VALIDATION } from "../../config/api.ts";
|
||||
import type {
|
||||
CreatePlaylistRequest,
|
||||
Playlist,
|
||||
RawPlaylist,
|
||||
} from "../../model.ts";
|
||||
import { deserializePlaylist } from "../../model.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import {
|
||||
expectOk,
|
||||
FormActions,
|
||||
@@ -14,7 +18,7 @@ import {
|
||||
TextField,
|
||||
useApiForm,
|
||||
VisibilityToggle,
|
||||
} from "./form/index.ts";
|
||||
} from "../form/index.ts";
|
||||
|
||||
interface PlaylistCreateFormProps {
|
||||
/** If provided, the new playlist will have this dump added to it. */
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { PlaylistMembership } from "../model.ts";
|
||||
import type { PlaylistMembership } from "../../model.ts";
|
||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||
|
||||
interface PlaylistMembershipPanelProps {
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import { API_URL, VALIDATION } from "../../config/api.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { Modal } from "../ui/Modal.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FormActions,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
SubmitButton,
|
||||
TextField,
|
||||
useApiForm,
|
||||
} from "./form/index.ts";
|
||||
} from "../form/index.ts";
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
onClose: () => void;
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { PublicUser } from "../model.ts";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import type { PublicUser } from "../../model.ts";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
|
||||
interface ProfileSubpageHeaderProps {
|
||||
username: string;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useFollows } from "../hooks/useFollows.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useFollows } from "../../hooks/useFollows.ts";
|
||||
|
||||
interface FollowUserButtonProps {
|
||||
targetUserId: string;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { UserListPopover } from "./UserListPopover.tsx";
|
||||
|
||||
interface LikeButtonProps {
|
||||
@@ -3,16 +3,16 @@ import { createPortal } from "react-dom";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
||||
import {
|
||||
deserializePublicUser,
|
||||
type PaginatedData,
|
||||
parseAPIResponse,
|
||||
type PublicUser,
|
||||
type RawPublicUser,
|
||||
} from "../model.ts";
|
||||
} from "../../model.ts";
|
||||
|
||||
interface UserListPopoverProps {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { UserListPopover } from "./UserListPopover.tsx";
|
||||
|
||||
interface VoteButtonProps {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { AvatarOverrideContext } from "../contexts/AvatarOverrideContext.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
|
||||
|
||||
interface AvatarProps {
|
||||
userId: string;
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useId } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useScrollLock } from "../hooks/useScrollLock.ts";
|
||||
import { useScrollLock } from "../../hooks/useScrollLock.ts";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
message: string;
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from "react";
|
||||
import { charCountClass } from "../utils/charCount.ts";
|
||||
import { charCountClass } from "../../utils/charCount.ts";
|
||||
|
||||
interface CountedInputProps
|
||||
extends
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type ReactNode, useCallback, useEffect, useId, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useScrollLock } from "../hooks/useScrollLock.ts";
|
||||
import { useScrollLock } from "../../hooks/useScrollLock.ts";
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { canProxyImage, proxyImageUrl } from "../utils/proxyImage.ts";
|
||||
import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx";
|
||||
import { canProxyImage, proxyImageUrl } from "../../utils/proxyImage.ts";
|
||||
import { ThumbnailPlaceholder } from "./ThumbnailPlaceholder.tsx";
|
||||
|
||||
interface ThumbnailProps {
|
||||
/** Raw image URL. Proxying is decided here — don't pre-proxy it. */
|
||||
@@ -28,7 +28,7 @@ interface ThumbnailProps {
|
||||
* Before this existed each of these failures ended at `display: none`, leaving
|
||||
* an empty framed box and no signal anywhere that a load had failed.
|
||||
*/
|
||||
export default function Thumbnail(
|
||||
export function Thumbnail(
|
||||
{
|
||||
src,
|
||||
alt = "",
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { proxyImageUrl } from "../utils/proxyImage.ts";
|
||||
import { initialsFor, tintFor } from "../utils/thumbnailTint.ts";
|
||||
import { proxyImageUrl } from "../../utils/proxyImage.ts";
|
||||
import { initialsFor, tintFor } from "../../utils/thumbnailTint.ts";
|
||||
|
||||
interface ThumbnailPlaceholderProps {
|
||||
/** The dumped URL — seeds the fallback hue and the initial. */
|
||||
@@ -26,7 +26,7 @@ interface ThumbnailPlaceholderProps {
|
||||
* Fills whatever box it's placed in, so the same component serves the 36px
|
||||
* compact thumbnail, the 128×72 feed preview and a 2×2 mosaic tile.
|
||||
*/
|
||||
export default function ThumbnailPlaceholder(
|
||||
export function ThumbnailPlaceholder(
|
||||
{ url, seed, accentColor, faviconUrl, siteName, glyph, className }:
|
||||
ThumbnailPlaceholderProps,
|
||||
) {
|
||||
Reference in New Issue
Block a user