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:
khannurien
2026-09-27 19:30:28 +00:00
parent de5abeceb2
commit 07d0344be7
131 changed files with 3893 additions and 3596 deletions

View File

@@ -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 />}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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,

View 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>
);
}

View 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>
);
}

View File

@@ -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 }

View 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>
);
}

View 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>
);
}

View 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`);
}

View 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();
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 };

View File

@@ -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,
) {

View File

@@ -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;

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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`. */

View File

@@ -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>

View 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";
}
}

View File

@@ -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 "🖼";

View File

@@ -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>;

View File

@@ -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

View File

@@ -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>;

View File

@@ -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

View File

@@ -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() {

View File

@@ -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

View File

@@ -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,
}))
);
/**

View File

@@ -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();

View File

@@ -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 }: {

View File

@@ -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);

View File

@@ -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" }>;

View File

@@ -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);

View 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>
);
}

View 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>
);
}

View 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>
);

View 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 };
}

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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. */

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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>;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,5 +1,5 @@
import type React from "react";
import { charCountClass } from "../utils/charCount.ts";
import { charCountClass } from "../../utils/charCount.ts";
interface CountedInputProps
extends

View File

@@ -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;

View File

@@ -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 = "",

View File

@@ -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,
) {