v3: src/ is documented like api/ and shared/, and the reference covers the whole repository — the frontend had no module docs, JSDoc on some components and hooks and not on others, props typed inline in half the components and in private interfaces in the rest, // comments on declarations, and no return types on 147 exported functions, so deno doc --lint over src/ found 637 errors. The rule api/ follows is applied to every non-test module of src/: a /** … @module */ block, JSDoc on every export and every field of an exported interface, /** */ for declarations and // for notes, verbs for functions ("Renders…" for a component), parameters named in the prose, {@link} only for symbols in scope, and a page's JSDoc names its route and who may see it (App.tsx's module doc lists the route table). For deno doc to show them, every exported function states its return type: components React.JSX.Element (or | null, or a portal), memo and forwardRef components their exotic types, contexts React.Context<…>, and a hook returning several things a documented Use<Name>Result interface (useAuth, useRequiredAuth, useFading, useEmojiTrigger, useMentionAutocomplete, useDumpCardLink, usePlayRichContent; FeedCacheResult, YouTubePlaylistControls and UseApiFormReturn are renamed to match). Every component that takes props takes an exported, documented <Name>Props interface — the 64 inline prop types are lifted into one, DumpFeed gets DumpFeedProps extending MainFeedProps. React types go through the global React namespace: the four default imports of react are gone, since deno doc can't follow them. Two duplicates go too: useMentionAutocomplete's UserResult was shared/'s UserSuggestion, and JournalCard re-exported JournalShape for no one; i18n.ts no longer re-exports Lingui's i18n, which its three users now import from @lingui/core. deno task doc and doc:lint now take src/ (but its tests and the generated locales), the index page gains a Frontend section linking every module by layer and domain, mod.test.ts walks src/ as well, and the docs workflow runs on changes to src/. deno doc doesn't document npm packages, so it takes a type imported from one — react-hook-form's FieldValues in the form fields, frimousse's SkinTone — for a private one; doc:lint now goes through doc-lint.ts, which fails on every diagnostic but those, 25 of them. The README states the frontend's part of the rule. No behaviour changes; 205 backend and 628 frontend tests pass, and tsc, deno check and deno lint are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-28 07:23:03 +00:00
parent b85a71d86e
commit 563daa720c
171 changed files with 3921 additions and 851 deletions

View File

@@ -1,18 +1,30 @@
/**
* A dump form's category picker.
*
* @module
*/
import { Trans } from "@lingui/react/macro";
import { useCategories } from "../../hooks/category/useCategories.ts";
/** The props of {@link CategorySelect}. */
export interface CategorySelectProps {
/** The ids of the categories chosen. */
selected: Set<string>;
/** Called with the id of a category toggled. */
onToggle: (id: string) => void;
/** Whether the chips are disabled. */
disabled?: boolean;
}
/**
* Multi-select of categories rendered as toggle chips. Renders nothing when no
* categories are configured, so dump forms stay unchanged until an admin adds
* the first category.
* Renders a multi-select of categories as toggle chips. Renders nothing when
* no categories are configured, so dump forms stay unchanged until an admin
* adds the first category.
*/
export function CategorySelect(
{ selected, onToggle, disabled }: {
selected: Set<string>;
onToggle: (id: string) => void;
disabled?: boolean;
},
) {
{ selected, onToggle, disabled }: CategorySelectProps,
): React.JSX.Element | null {
const { categories } = useCategories();
if (categories.length === 0) return null;

View File

@@ -1,3 +1,9 @@
/**
* The feed's category switcher.
*
* @module
*/
import {
type CSSProperties,
useEffect,
@@ -12,15 +18,16 @@ import { useCategories } from "../../hooks/category/useCategories.ts";
import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts";
/**
* Header dropdown that switches the feed scope between "All" and a category,
* preserving the active feed tab (e.g. /music/new → /cinema/new). Rendered as
* the leading item of the feed-sort tab row so it reads as part of the bar.
* Renders the header dropdown that switches the feed scope between "All" and a
* category, preserving the active feed tab (e.g. /music/new → /cinema/new).
* Rendered as the leading item of the feed-sort tab row so it reads as part of
* the bar.
*
* A custom popover (not a native <select>) keeps it on-theme; the menu uses
* fixed positioning so the row's `overflow-x: auto` can't clip it. `~` is the
* sentinel value for the all-categories feed. Hidden when no categories exist.
*/
export function CategorySwitcher() {
export function CategorySwitcher(): React.JSX.Element | null {
const { categories } = useCategories();
const navigate = useNavigate();
const { categorySlug, feedTab } = useParams();

View File

@@ -1,16 +1,32 @@
/**
* The inline editor of a chat message.
*
* @module
*/
import { useEffect, useRef, useState } from "react";
import { Trans } from "@lingui/react/macro";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
import { COUNT_FROM, sendable } from "../../utils/chat/message.ts";
import { VALIDATION } from "../../../shared/validation.ts";
/** The props of {@link ChatEditBox}. */
export interface ChatEditBoxProps {
/** The message's current text. */
initial: string;
/** Called with the trimmed new text. */
onSave: (body: string) => void;
/** Called to leave without saving. */
onCancel: () => void;
}
export function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
/**
* Renders an editor over a chat message's text, with Save and Cancel; it takes
* focus and scrolls itself into view once its box has settled.
*/
export function ChatEditBox(
{ initial, onSave, onCancel }: ChatEditBoxProps,
): React.JSX.Element {
const [draft, setDraft] = useState(initial);
const editorRef = useRef<TextEditorHandle>(null);
const boxRef = useRef<HTMLDivElement>(null);

View File

@@ -1,3 +1,9 @@
/**
* A message in the chat.
*
* @module
*/
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -14,25 +20,42 @@ import { ReplyRef } from "./ReplyRef.tsx";
import { messageTextClass } from "../../utils/chat/message.ts";
import { clockTime } from "../../utils/chat/time.ts";
/** The props of {@link ChatMessageItem}. */
export interface ChatMessageItemProps {
/** The message. */
message: ChatMessage;
/** Continues the previous message's run: no avatar or author line. */
grouped: boolean;
/** Whether it @mentions the user, which highlights it. */
mentionsMe: boolean;
/** The replied-to message has been deleted since this one loaded. */
replyDeleted: boolean;
/** Whether the user may edit it. */
canEdit: boolean;
/** Whether the user may delete it. */
canDelete: boolean;
/** Whether it is flashed, having just been jumped to. */
flashed: boolean;
/** Whether it is being edited. */
editing: boolean;
/** Starts editing the message `id`. */
onStartEdit: (id: string) => void;
/** Ends the editing. */
onEndEdit: () => void;
/** Saves `body` as the text of message `id`. */
onEdit: (id: string, body: string) => void;
/** Deletes message `id`. */
onDelete: (id: string) => void;
/** Replies to `message`. */
onReply: (message: ChatMessage) => void;
/** Scrolls to message `id`, for the reply reference. */
onJumpTo: (id: string) => void;
}
/**
* Renders a chat message: its author and time (unless grouped), the reply it
* answers, its text or its editor, and its reply, edit and delete actions.
*/
export function ChatMessageItem(
{
message,
@@ -50,7 +73,7 @@ export function ChatMessageItem(
onReply,
onJumpTo,
}: ChatMessageItemProps,
) {
): React.JSX.Element {
const bodyRef = useHeightTransition<HTMLDivElement>(editing);
const [confirmDelete, setConfirmDelete] = useState(false);

View File

@@ -1,3 +1,9 @@
/**
* The live chat, in a modal.
*
* @module
*/
import {
type KeyboardEvent,
useCallback,
@@ -31,28 +37,44 @@ import { COUNT_FROM, sendable, snippet } from "../../utils/chat/message.ts";
import { dayLabel, isSameDay } from "../../utils/chat/time.ts";
import { VALIDATION } from "../../../shared/validation.ts";
// Matches the server's default page size; a full page means there may be more.
/**
* 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
// pages of this size; past the cap it gives up and reloads the latest page.
/**
* Resync after a reconnect walks forward from the oldest loaded message in
* pages of this size; past the cap it gives up and reloads the latest page.
*/
const RESYNC_PAGE = 100;
/** How many pages a resync walks at most. */
const RESYNC_MAX_PAGES = 5;
// Consecutive messages from one author within this window share a header.
/** Consecutive messages from one author within this window share a header. */
const GROUP_WINDOW = 5 * 60_000;
// Relative timestamps ("2 minutes ago") are refreshed this often.
/** Relative timestamps ("2 minutes ago") are refreshed this often. */
const TICK = 30_000;
// How long a failed edit/delete notice stays up.
/** How long a failed edit/delete notice stays up. */
const ERROR_TTL = 6_000;
// How close to the bottom still counts as "at the bottom" (px).
/** How close to the bottom still counts as "at the bottom" (px). */
const BOTTOM_SLACK = 80;
// How long the composer stays "typing" after the last keystroke, and how often
// we refresh that signal while typing continuously. The refresh interval must
// stay below the server's TYPING_TTL so a steady typist never flickers out.
/**
* How long the composer stays "typing" after the last keystroke. The server
* drops a typer it hasn't heard from in its TYPING_TTL.
*/
const TYPING_STOP_DELAY = 3_500;
/**
* How often the "typing" signal is refreshed while typing continuously; below
* the server's TYPING_TTL, so a steady typist never flickers out.
*/
const TYPING_REFRESH = 3_000;
/** The session's `authFetch`. */
type AuthFetch = (input: RequestInfo, init?: RequestInit) => Promise<Response>;
/**
* Returns the chat messages `query` selects from `GET /api/chat`.
*
* @throws {Error} When the API answers a failure.
*/
async function fetchChat(
authFetch: AuthFetch,
query: string,
@@ -63,12 +85,19 @@ async function fetchChat(
return (json.data as Wire<ChatMessage>[]).map(deserializeChatMessage);
}
// Stream order, matching the server's: by time, then id to break ties.
/**
* Orders two messages as the stream does, matching the server's: by time, then
* id to break ties.
*/
function compareMessages(a: ChatMessage, b: ChatMessage): number {
return a.createdAt.getTime() - b.createdAt.getTime() ||
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
}
/**
* A row of the message list: a day separator, the "new" divider, a message, or
* a pending message.
*/
type Row =
| { kind: "sep"; key: string; label: string }
| { kind: "new"; key: string }
@@ -80,7 +109,23 @@ type Row =
grouped: boolean;
};
export function ChatModal({ onClose }: { onClose: () => void }) {
/** The props of {@link ChatModal}. */
export interface ChatModalProps {
/** Called to close the chat. */
onClose: () => void;
}
/**
* Renders the live chat in a modal: the history, loaded a page at a time as it
* is scrolled up and resynced after a reconnect, merged with the live stream
* and the messages still on their way, grouped by author and split by day, a
* "new" divider at the read marker, who is typing, and the composer, which
* keeps its draft and reply target. The read marker follows what is scrolled
* into view.
*/
export function ChatModal(
{ onClose }: ChatModalProps,
): React.JSX.Element {
const { wsStatus, connectionEpoch } = useWS();
const {
chatMessages,

View File

@@ -1,3 +1,9 @@
/**
* A chat message on its way.
*
* @module
*/
import { Trans } from "@lingui/react/macro";
import { Markdown } from "../editor/Markdown.tsx";
import { Avatar } from "../shared/Avatar.tsx";
@@ -6,22 +12,32 @@ import type { PublicUser } from "../../../shared/user.ts";
import { ReplyRef } from "./ReplyRef.tsx";
import { messageTextClass } from "../../utils/chat/message.ts";
/** The props of {@link PendingItem}. */
export interface PendingItemProps {
/** The message. */
pending: PendingChatMessage;
/** Continues the previous message's run: no avatar or author line. */
grouped: boolean;
/** The sender: the user. */
user: PublicUser;
/** Resends message `nonce`. */
onRetry: (nonce: string) => void;
/** Drops message `nonce`. */
onDiscard: (nonce: string) => void;
/** Takes the message back into the composer. */
onRestore: (pending: PendingChatMessage) => void;
/** Scrolls to message `id`, for the reply reference. */
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.
/**
* Renders 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,
) {
): React.JSX.Element {
const failed = pending.status === "failed";
return (
<div

View File

@@ -1,16 +1,31 @@
/**
* A chat message's reference to the message it replies to.
*
* @module
*/
import { Trans } from "@lingui/react/macro";
import { snippet } from "../../utils/chat/message.ts";
/** The props of {@link ReplyRef}. */
export interface ReplyRefProps {
/** The replied-to message's id. */
replyToId: string;
/** Its author; undefined when it was deleted. */
author?: string;
/** Its text. */
body?: string;
/** Scrolls to message `id`. */
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) {
/**
* Renders a 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,
): React.JSX.Element {
return (
<button
type="button"

View File

@@ -1,4 +1,9 @@
import React from "react";
/**
* The comment editor, for posting, replying and editing.
*
* @module
*/
import { Controller, FormProvider } from "react-hook-form";
import { Trans } from "@lingui/react/macro";
import { VALIDATION } from "../../../shared/validation.ts";
@@ -7,26 +12,36 @@ import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
/** The props of {@link CommentForm}. */
export interface CommentFormProps {
/** Async submit; throw to surface a root error, resolve to clear/close. */
onSubmit: (body: string) => Promise<void>;
/** The submit button's label. */
submitLabel: React.ReactNode;
/** The submit button's label while submitting. */
pendingLabel: React.ReactNode;
/** The title of the error shown when the submit fails. */
errorTitle: string;
/** The editor's placeholder. */
placeholder?: string;
/** The initial text; empty by default. */
defaultValue?: string;
/** Called by the Cancel button; no Cancel button without it. */
onCancel?: () => void;
/** Top-level form: only show Cancel once there's text to discard. */
cancelWhenEmpty?: boolean;
/** Forwarded to the editor so callers can focus it. */
editorRef?: React.Ref<TextEditorHandle>;
/** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */
layout?: "inline" | "top";
/** Shown before the editor in the `top` layout (the user's avatar). */
leading?: React.ReactNode;
/** An extra class for the form. */
className?: string;
}
/**
* Single-field comment editor (post / reply / edit). Wraps the shared
* Renders a single-field comment editor (post / reply / edit). Wraps the shared
* react-hook-form lifecycle so the three call sites stay identical.
*/
export function CommentForm({
@@ -42,7 +57,7 @@ export function CommentForm({
layout = "inline",
leading,
className,
}: CommentFormProps) {
}: CommentFormProps): React.JSX.Element {
const form = useApiForm<{ body: string }>({
defaultValues: { body: defaultValue },
});

View File

@@ -1,4 +1,10 @@
import React, { useRef, useState } from "react";
/**
* A comment in the thread, with its replies.
*
* @module
*/
import { useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -25,18 +31,32 @@ import { useAuth } from "../../hooks/auth/useAuth.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { CommentForm } from "./CommentForm.tsx";
interface CommentNodeProps {
/** The props of {@link CommentNode}. */
export interface CommentNodeProps {
/** The comment. */
comment: Comment;
/** Every comment's replies, by parent id. */
tree: Map<string, Comment[]>;
/** How deep it is nested. */
depth: number;
/** The id of the dump it is under. */
dumpId: string;
/** The signed-in user, null when signed out. */
currentUser: SelfUser | null;
/** The session token. */
token: string | null;
/** Called with a reply posted. */
onCommentCreated: (comment: Comment) => void;
/** Called with the id of a comment deleted. */
onCommentDeleted: (commentId: string) => void;
/** Called with a comment edited. */
onCommentUpdated: (comment: Comment) => void;
}
/**
* Renders a comment — its author, time, text and like button, with reply, edit
* and delete for whoever may — and under it its replies, recursively.
*/
export function CommentNode({
comment,
tree,
@@ -47,7 +67,7 @@ export function CommentNode({
onCommentCreated,
onCommentDeleted,
onCommentUpdated,
}: CommentNodeProps) {
}: CommentNodeProps): React.JSX.Element {
const [replyOpen, setReplyOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);

View File

@@ -1,3 +1,9 @@
/**
* A dump's comment thread.
*
* @module
*/
import { useMemo } from "react";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
@@ -12,16 +18,28 @@ import { expectOk } from "../../utils/shared/api.ts";
import { CommentForm } from "./CommentForm.tsx";
import { CommentNode } from "./CommentNode.tsx";
interface CommentThreadProps {
/** The props of {@link CommentThread}. */
export interface CommentThreadProps {
/** The dump's id. */
dumpId: string;
/** Every comment on the dump. */
comments: Comment[];
/** The signed-in user, null when signed out. */
currentUser: SelfUser | null;
/** The session token. */
token: string | null;
/** Called with a comment posted. */
onCommentCreated: (comment: Comment) => void;
/** Called with the id of a comment deleted. */
onCommentDeleted: (commentId: string) => void;
/** Called with a comment edited. */
onCommentUpdated: (comment: Comment) => void;
}
/**
* Returns the replies of each comment of `comments`, by parent id (`root` for
* the top level).
*/
function buildTree(comments: Comment[]): Map<string, Comment[]> {
const map = new Map<string, Comment[]>();
for (const c of comments) {
@@ -32,6 +50,10 @@ function buildTree(comments: Comment[]): Map<string, Comment[]> {
return map;
}
/**
* Renders the form that posts a comment (for a signed-in user), and the thread
* under it.
*/
export function CommentThread({
dumpId,
comments,
@@ -40,7 +62,7 @@ export function CommentThread({
onCommentCreated,
onCommentDeleted,
onCommentUpdated,
}: CommentThreadProps) {
}: CommentThreadProps): React.JSX.Element {
const tree = useMemo(() => buildTree(comments), [comments]);
const roots = tree.get("root") ?? [];
const { authFetch } = useAuth();

View File

@@ -1,3 +1,9 @@
/**
* A dump, as a card in the hot and new feeds.
*
* @module
*/
import { memo } from "react";
import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
@@ -19,20 +25,36 @@ import { VoteButton } from "../social/VoteButton.tsx";
import { Markdown } from "../editor/Markdown.tsx";
import { Tooltip } from "../shared/Tooltip.tsx";
interface DumpCardProps {
/** The props of {@link DumpCard}. */
export interface DumpCardProps {
/** The dump. */
dump: Dump;
/** Its live vote count. */
voteCount: number;
/** Whether the user upvoted it. */
voted: boolean;
/** Whether the user may vote (signed in). */
canVote: boolean;
/** Upvotes dump `id`; `shownCount` is the count on screen. */
castVote: (id: string, shownCount?: number) => void;
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
removeVote: (id: string, shownCount?: number) => void;
/** An extra class. */
className?: string;
/** Whether the viewer posted it: no unread dot. */
isOwner?: boolean;
}
// Memoized: a feed re-renders on every live event (a vote anywhere, a new
// dump), and a card whose props haven't changed has nothing to redraw.
export const DumpCard = memo(function DumpCard(
/**
* Renders a dump as a card: its preview (playable where it can be), title,
* note, categories, poster, age, comment count and vote button, with an unread
* dot until it is opened. The card opens the dump. Memoized: a feed re-renders
* on every live event (a vote anywhere, a new dump), and a card whose props
* haven't changed has nothing to redraw.
*/
export const DumpCard: React.MemoExoticComponent<
(props: DumpCardProps) => React.JSX.Element
> = memo(function DumpCard(
{ dump, voteCount, voted, canVote, castVote, removeVote, className, isOwner }:
DumpCardProps,
) {

View File

@@ -1,3 +1,10 @@
/**
* The preview of an uploaded file: an image, a player, a PDF or a download
* link.
*
* @module
*/
import { useContext, useEffect, useState } from "react";
import type { Dump } from "../../../shared/dump.ts";
import { formatBytes } from "../../utils/shared/format.ts";
@@ -25,14 +32,18 @@ import {
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
interface FilePreviewProps {
/** The props of {@link FilePreview}. */
export interface FilePreviewProps {
/** The file dump. */
dump: Dump;
/** Whether to render the small thumbnail of a card. */
compact?: boolean;
/** Whether audio and video play in the global player rather than inline. */
global?: boolean;
}
/**
* Build the global-player item for an uploaded file, so every entry point
* Returns the global-player item for an uploaded file, so every entry point
* (compact cards, the detail page, the waveform) puts the same title, artwork
* and subtitle in the player header.
*
@@ -58,8 +69,11 @@ function filePlayerItem(
};
}
// Waveform preview for the dump detail page — routes to global player,
// reflects live play state and position from PlayerContext/PlayerTimeContext.
/**
* Renders the waveform preview for the dump detail page — routes to the global
* player, reflects live play state and position from PlayerContext and
* PlayerTimeContext.
*/
function AudioFilePreview(
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
) {
@@ -160,6 +174,7 @@ function AudioFilePreview(
);
}
/** Renders a video's still, or `fallback` when it fails to load. */
function VideoThumb({ src, fallback }: { src: string; fallback: string }) {
const [failed, setFailed] = useState(false);
if (failed) {
@@ -177,6 +192,7 @@ function VideoThumb({ src, fallback }: { src: string; fallback: string }) {
);
}
/** Returns the icon for a file of type `mime`. */
function mimeIcon(mime: string): string {
if (mime.startsWith("video/")) return "🎬";
if (mime.startsWith("audio/")) return "🎵";
@@ -185,9 +201,14 @@ function mimeIcon(mime: string): string {
return "📁";
}
/**
* Renders an uploaded file by its type: compact, a thumbnail or icon that plays
* it; full size, the image, a player (or a play button for the global player),
* the PDF, or a download link.
*/
export function FilePreview(
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps,
) {
): React.JSX.Element {
const { current, playing, play, togglePlay } = useContext(PlayerContext);
const { mediaToken: token } = useAuth();
const fileUrl = dumpFileUrl(dump, token);

View File

@@ -1,3 +1,9 @@
/**
* A dump, as a tile of the journal's mosaic.
*
* @module
*/
import { memo } from "react";
import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
@@ -25,21 +31,34 @@ import {
usePlayRichContent,
} from "../../hooks/player/usePlayRichContent.ts";
export type { JournalShape };
interface JournalCardProps {
/** The props of {@link JournalCard}. */
export interface JournalCardProps {
/** The dump. */
dump: Dump;
/** The tile's footprint in the mosaic. */
shape: JournalShape;
/** Its live vote count. */
voteCount: number;
/** Whether the user upvoted it. */
voted: boolean;
/** Whether the user may vote (signed in). */
canVote: boolean;
/** Upvotes dump `id`; `shownCount` is the count on screen. */
castVote: (id: string, shownCount?: number) => void;
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
removeVote: (id: string, shownCount?: number) => void;
/** Whether the viewer posted it: no unread dot. */
isOwner?: boolean;
}
// Memoized, like DumpCard: live events re-render the feed, not every card.
export const JournalCard = memo(function JournalCard(
/**
* Renders a dump as a mosaic tile — an image card, a pull-quote or a text card,
* by its content. A playable card plays; one that can't opens the dump.
* Memoized, like DumpCard: live events re-render the feed, not every card.
*/
export const JournalCard: React.MemoExoticComponent<
(props: JournalCardProps) => React.JSX.Element
> = memo(function JournalCard(
{ dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }:
JournalCardProps,
) {

View File

@@ -1,3 +1,9 @@
/**
* A link's preview card.
*
* @module
*/
import { useContext } from "react";
import { t } from "@lingui/core/macro";
import type { RichContent } from "../../../shared/dump.ts";
@@ -8,8 +14,11 @@ import {
} from "../../hooks/player/usePlayRichContent.ts";
import { Thumbnail } from "../shared/Thumbnail.tsx";
interface RichContentCardProps {
/** The props of {@link RichContentCard}. */
export interface RichContentCardProps {
/** The link's preview. */
richContent: RichContent;
/** Whether to render the small thumbnail of a card. */
compact?: boolean;
/** Overrides richContent.thumbnailUrl with a custom dump thumbnail, if set. */
thumbnailOverrideUrl?: string;
@@ -17,10 +26,15 @@ interface RichContentCardProps {
dumpHref?: string;
}
/**
* Renders a link's preview: its thumbnail (or a placeholder), which plays it in
* the global player when it can be played and otherwise opens the link, and
* full size its site, title and description.
*/
export function RichContentCard(
{ richContent, compact = false, thumbnailOverrideUrl, dumpHref }:
RichContentCardProps,
) {
): React.JSX.Element {
const { current, playing, togglePlay } = useContext(PlayerContext);
const { playRichContent, pending } = usePlayRichContent();

View File

@@ -1,3 +1,9 @@
/**
* The dump creation modal.
*
* @module
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
@@ -54,11 +60,13 @@ import { TitleField } from "./TitleField.tsx";
/** Stable empty result, so rendering doesn't churn on a fresh array each pass. */
const NO_DUPLICATES: DumpUrlMatch[] = [];
/** The link's preview, as fetched for the form. */
type UrlPreview =
| { status: "idle" }
| { status: "loading" }
| { status: "done"; reached: boolean; richContent: RichContent | null };
/** The form's fields. */
interface CreateValues {
url: string;
file: File | null;
@@ -68,7 +76,7 @@ interface CreateValues {
}
/**
* Whether the pointer is coarse — a touch device, where autofocusing the URL
* Checks whether the pointer is coarse — a touch device, where autofocusing the URL
* input raises a keyboard that covers most of the modal before the user has
* seen it.
*/
@@ -76,14 +84,27 @@ function prefersNoAutoFocus(): boolean {
return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false;
}
interface DumpCreateModalProps {
/** The props of {@link DumpCreateModal}. */
export interface DumpCreateModalProps {
/** Called to close it. */
onClose: () => void;
/**
* A link to start from (the Android share sheet); it replaces any saved
* draft.
*/
initialUrl?: string;
}
/**
* Renders the modal that posts a dump, in three panels: the link (previewed as
* it is typed, with a hint when it was already dumped) or the file, and the
* title; the note, categories and visibility; then, once posted, the playlists
* to add it to. Everything but the file is kept as a draft until it is posted,
* and closing with unsaved work asks first.
*/
export function DumpCreateModal(
{ onClose, initialUrl = "" }: DumpCreateModalProps,
) {
): React.JSX.Element {
const { authFetch, authUpload } = useAuth();
const { injectDump } = useWS();

View File

@@ -1,16 +1,30 @@
/**
* The create form's "already dumped" hint.
*
* @module
*/
import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
import type { DumpUrlMatch } from "../../../../shared/dump.ts";
import { dumpUrl } from "../../../utils/shared/urls.ts";
import { relativeTime } from "../../../utils/shared/relativeTime.ts";
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */
/** The props of {@link DuplicateNotice}. */
export interface DuplicateNoticeProps {
/** The dumps already pointing at the link, most recent first. */
matches: DumpUrlMatch[];
/** Called when a link to one of them is followed. */
onNavigate: () => void;
}
/**
* Renders the "Already dumped" hint — never blocking, since reposts are
* sometimes on purpose.
*/
export function DuplicateNotice(
{ matches, onNavigate }: {
matches: DumpUrlMatch[];
onNavigate: () => void;
},
) {
{ matches, onNavigate }: DuplicateNoticeProps,
): React.JSX.Element {
const [first, ...rest] = matches;
return (

View File

@@ -1,8 +1,22 @@
/**
* The preview of a picked file, before it is uploaded.
*
* @module
*/
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 }) {
/** The props of {@link LocalFilePreview}. */
export interface LocalFilePreviewProps {
/** The file picked. */
file: File;
}
/** Renders what a picked file will look like, before it's uploaded. */
export function LocalFilePreview(
{ file }: LocalFilePreviewProps,
): React.JSX.Element | null {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {

View File

@@ -1,21 +1,39 @@
/**
* The create modal's steps, and where the user is in them.
*
* @module
*/
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.
* A panel of the create modal. 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";
/** The panels, in order, with their labels. */
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 }) {
/** The props of {@link StepIndicator}. */
export interface StepIndicatorProps {
/** The panel shown. */
current: Step;
}
/**
* Renders where you are in the three panels — and, past the first, how far you
* got.
*/
export function StepIndicator(
{ current }: StepIndicatorProps,
): React.JSX.Element {
const currentIndex = STEPS.findIndex((s) => s.id === current);
return (

View File

@@ -1,14 +1,34 @@
/**
* The create form's title row.
*
* @module
*/
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 "../../../../shared/validation.ts";
/** The props of {@link TitleField}. */
export interface TitleFieldProps {
/** The title field's react-hook-form registration. */
registration: UseFormRegisterReturn<"title">;
/** Whether the title is being edited. */
editing: boolean;
/** Starts editing it. */
onEdit: () => void;
/** Puts the suggestion back; no reset button without it. */
onReset?: () => void;
/** Whether the row is disabled. */
disabled: boolean;
}
/**
* 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.
* Renders the 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,
@@ -16,13 +36,7 @@ export function TitleField({
onEdit,
onReset,
disabled,
}: {
registration: UseFormRegisterReturn<"title">;
editing: boolean;
onEdit: () => void;
onReset?: () => void;
disabled: boolean;
}) {
}: TitleFieldProps): React.JSX.Element {
const inputRef = useRef<HTMLInputElement | null>(null);
const { ref, ...titleReg } = registration;

View File

@@ -1,3 +1,9 @@
/**
* The editor's floating emoji picker.
*
* @module
*/
import {
type CSSProperties,
type KeyboardEvent,
@@ -27,22 +33,35 @@ function SkinTonePersist() {
return null;
}
interface EmojiPickerPopupProps {
/** The props of {@link EmojiPickerPopup}. */
export interface EmojiPickerPopupProps {
/** The ref of the floating panel, for outside-click checks. */
floatRef: Ref<HTMLDivElement>;
/** The ref of the picker's search field, to focus it. */
searchRef: Ref<HTMLInputElement>;
/** The ref of the picker's scrolling grid. */
viewportRef: Ref<HTMLDivElement>;
/** Fixed-position placement from `useAnchoredFloat`; see TextEditor. */
style: CSSProperties;
/** Inline, the query is typed in the textarea and the search only shows it;
* browsing, the search field takes it. */
inline: boolean;
/** What the picker searches for. */
query: string;
/** Called as the search field is typed in. */
onQueryChange: (query: string) => void;
/** Called with the emoji picked. */
onSelect: (emoji: string) => void;
/** Called to close the picker. */
onClose: () => void;
/** The editor's key handling while focus is in the picker. */
onKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void;
}
/** The floating emoji picker the editor opens from its button or a `:query`. */
/**
* Renders the floating emoji picker the editor opens from its button or a
* `:query`, in the current locale, remembering the skin tone.
*/
export function EmojiPickerPopup(
{
floatRef,
@@ -56,7 +75,7 @@ export function EmojiPickerPopup(
onClose,
onKeyDownCapture,
}: EmojiPickerPopupProps,
) {
): React.JSX.Element {
const { i18n } = useLingui();
return (
<div

View File

@@ -1,18 +1,33 @@
/**
* Markdown, rendered: GFM, @mentions as profile links, line breaks kept, and
* links into the app kept in the app.
*
* @module
*/
import { memo } from "react";
import { Link } from "react-router";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface MarkdownProps {
/** The props of {@link Markdown}. */
export interface MarkdownProps {
/** The Markdown source. */
children: string;
/** An extra class. */
className?: string;
/** Whether it is a line-clamped teaser inside a card: images are dropped. */
inline?: boolean;
}
// The mention rule the server notifies by (see utils/chatText.ts): an `@` not
// preceded by a word character, so an email address isn't a mention.
/**
* The mention rule the server notifies by (see shared/mention.ts), on parsed
* text: an `@` not preceded by a word character, so an email address isn't a
* mention. Link syntax is already parsed away here.
*/
const MENTION_RE = /(?<!\w)@(\w+)/g;
/** A node of the Markdown syntax tree, the part the plugins read. */
interface MdNode {
type: string;
value?: string;
@@ -59,7 +74,7 @@ function remarkMentions() {
}
/**
* A single newline is a line break, as it is in chat everywhere: markdown's
* Turns a single newline into a line break, as in chat everywhere: markdown's
* own rule folds it into a space, so a message typed on three lines with
* Shift+Enter came out as one. (Code keeps its newlines; it has no text
* nodes.)
@@ -82,10 +97,13 @@ function remarkBreaks() {
return (tree: MdNode) => walk(tree);
}
/** The remark plugins, in order. */
const REMARK_PLUGINS = [remarkGfm, remarkMentions, remarkBreaks];
/** The path of a link into this app — relative, or absolute to this origin —
* or null for anywhere else. */
/**
* Returns the path of a link into this app — relative, or absolute to this
* origin — or null for anywhere else (the API's files included).
*/
function internalPath(href: string | undefined): string | null {
if (!href) return null;
if (href.startsWith("/") && !href.startsWith("//")) {
@@ -102,9 +120,14 @@ function internalPath(href: string | undefined): string | null {
}
}
/** react-markdown's element overrides. */
type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
// Static components objects — defined once at module scope to avoid recreation on every render
/**
* The element overrides, defined once at module scope to avoid recreation on
* every render. Mentions and links to other pages here stay in the app; the
* rest open in a new tab; a wide GFM table scrolls inside its own box.
*/
const MARKDOWN_COMPONENTS: Components = {
// Mentions and links to other pages here stay in the app; the rest open
// in a new tab.
@@ -128,17 +151,24 @@ const MARKDOWN_COMPONENTS: Components = {
),
};
// Inline renderings are line-clamped teasers inside cards, where an embedded
// image blows the layout apart. Drop images there — the full description is
// one click away on the detail page.
/**
* The element overrides of an inline rendering. Inline renderings are
* line-clamped teasers inside cards, where an embedded image blows the layout
* apart; images are dropped there — the full description is one click away on
* the detail page.
*/
const INLINE_MARKDOWN_COMPONENTS: Components = {
...MARKDOWN_COMPONENTS,
img: () => null,
};
// Memoized: parsing runs the whole remark pipeline, and the text rarely
// changes when the component around it re-renders.
export const Markdown = memo(function Markdown(
/**
* Renders Markdown. Memoized: parsing runs the whole remark pipeline, and the
* text rarely changes when the component around it re-renders.
*/
export const Markdown: React.MemoExoticComponent<
(props: MarkdownProps) => React.JSX.Element
> = memo(function Markdown(
{ children, className, inline = false }: MarkdownProps,
) {
return (

View File

@@ -1,20 +1,34 @@
/**
* The editor's @mention suggestions.
*
* @module
*/
import { type CSSProperties, useLayoutEffect, useRef } from "react";
import { Avatar } from "../shared/Avatar.tsx";
import type { UserResult } from "../../hooks/editor/useMentionAutocomplete.ts";
import type { UserSuggestion } from "../../../shared/user.ts";
interface MentionDropdownProps {
/** The props of {@link MentionDropdown}. */
export interface MentionDropdownProps {
/** For the textarea's `aria-controls` / `aria-activedescendant`. */
id: string;
results: UserResult[];
/** The matching users. */
results: UserSuggestion[];
/** The highlighted match. */
selectedIndex: number;
/** Called with the name picked. */
onSelect: (username: string) => void;
/** Fixed-position placement from `useAnchoredFloat`; see TextEditor. */
style?: CSSProperties;
}
/**
* Renders the users matching the `@query` as a listbox, keeping the highlighted
* one in view; nothing when none match.
*/
export function MentionDropdown(
{ id, results, selectedIndex, onSelect, style }: MentionDropdownProps,
) {
): React.JSX.Element | null {
const listRef = useRef<HTMLUListElement>(null);
// Arrowing past the visible rows keeps the selection in view.

View File

@@ -1,3 +1,10 @@
/**
* The site's text editor: a Markdown textarea with @mention and emoji
* autocompletes, image upload by paste or drop, and a character counter.
*
* @module
*/
import {
forwardRef,
useCallback,
@@ -20,20 +27,30 @@ import { useAnchoredFloat } from "../../hooks/shared/useAnchoredFloat.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { API_URL } from "../../config/api.ts";
/** What a ref to {@link TextEditor} can do. */
export interface TextEditorHandle {
/** `atEnd` puts the caret after the last character (and scrolls the text
* there); `preventScroll` leaves the page and its scrollers where they are. */
focus(options?: { atEnd?: boolean; preventScroll?: boolean }): void;
}
interface TextEditorProps {
/** The props of {@link TextEditor}. */
export interface TextEditorProps {
/** The text. */
value: string;
/** Called with the new text. */
onChange: (value: string) => void;
/** The textarea's placeholder. */
placeholder?: string;
/** Whether the textarea is disabled. */
disabled?: boolean;
/** The textarea's height, in rows. */
rows?: number;
/** The textarea's id. */
id?: string;
/** An extra class. */
className?: string;
/** Whether the textarea grows with its text. */
autoResize?: boolean;
/** Ctrl/Cmd+Enter. Not called while an image is still uploading. */
onSubmit?: () => void;
@@ -44,19 +61,24 @@ interface TextEditorProps {
* while an IME is composing, so an Enter that confirms a candidate isn't
* taken for "send". */
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
/** The most characters it takes, counted and warned about. */
maxLength?: number;
/** Show the character counter only from this length. Defaults to 80% of
* `maxLength`: a standing "0 / 5000" is just noise, but the counter turns
* up well before the warning colour does. */
countFrom?: number;
/** Marks the textarea invalid. */
"aria-invalid"?: boolean;
/** The id of what describes it (its error). */
"aria-describedby"?: string;
}
/** Checks whether `dt` carries files. */
function hasFiles(dt: DataTransfer): boolean {
return Array.from(dt.types).includes("Files");
}
/** Text to put in once the textarea can take it. */
interface PendingInsert {
start: number;
end: number;
@@ -64,7 +86,16 @@ interface PendingInsert {
focus: boolean;
}
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
/**
* Renders the editor: a textarea that autocompletes @mentions and `:emoji`,
* opens an emoji picker from its button, uploads pasted or dropped images as
* attachments and inserts them as Markdown, and counts characters near the
* limit. Both popups are portaled to <body> and anchored to the textarea, so
* the cards, modals and scrollers it sits in can't clip them.
*/
export const TextEditor: React.ForwardRefExoticComponent<
TextEditorProps & React.RefAttributes<TextEditorHandle>
> = forwardRef<TextEditorHandle, TextEditorProps>(
function TextEditor(
{
value,

View File

@@ -1,3 +1,9 @@
/**
* The hot and new feeds, and the props every main feed of the index takes.
*
* @module
*/
import { type Ref, useMemo } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -7,22 +13,44 @@ import type { Dump } from "../../../shared/dump.ts";
import type { SelfUser } from "../../../shared/user.ts";
import { hotScore } from "../../utils/feed/hotScore.ts";
// What the index hands whichever main feed its tab shows.
/** What the index hands whichever main feed its tab shows. */
export interface MainFeedProps {
/** The dumps loaded so far. */
dumps: Dump[];
/** Whether the first page is loading. */
loading: boolean;
/** Why loading failed, null when it didn't. */
error: string | null;
/** Whether another page follows. */
hasMore: boolean;
/** Whether the next page is loading. */
loadingMore: boolean;
/**
* The ref of the element that loads the next page when scrolled into view.
*/
sentinelRef: Ref<HTMLDivElement>;
/** Live vote counts, by dump id. */
voteCounts: Record<string, number>;
/** The ids of the dumps the user upvoted. */
myVotes: Set<string>;
/** The signed-in user, null when signed out. */
user: SelfUser | null;
/** Upvotes dump `id`; `shownCount` is the count on screen. */
castVote: (id: string, shownCount?: number) => void;
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
removeVote: (id: string, shownCount?: number) => void;
}
// The hot and new tabs: the same list, ordered by hot score or by date.
/** The props of {@link DumpFeed}. */
export interface DumpFeedProps extends MainFeedProps {
/** Whether the list is ordered by hot score or by date. */
order: "hot" | "new";
}
/**
* Renders the hot and new tabs: the same list of dump cards, ordered by hot
* score or by date, loading more at its end.
*/
export function DumpFeed(
{
order,
@@ -37,8 +65,8 @@ export function DumpFeed(
user,
castVote,
removeVote,
}: MainFeedProps & { order: "hot" | "new" },
) {
}: DumpFeedProps,
): React.JSX.Element {
const sorted = useMemo(
() =>
[...dumps].sort((a, b) =>

View File

@@ -1,3 +1,9 @@
/**
* The index's feed tabs.
*
* @module
*/
import { useNavigate, useParams } from "react-router";
import { Trans } from "@lingui/react/macro";
import { useAuth } from "../../hooks/auth/useAuth.ts";
@@ -11,11 +17,11 @@ import { TabBar } from "../shared/TabBar.tsx";
import { CategorySwitcher } from "../category/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.
* Renders the 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() {
export function FeedTabBar(): React.JSX.Element {
const { user } = useAuth();
const navigate = useNavigate();
const { categorySlug, feedTab } = useParams();

View File

@@ -1,3 +1,9 @@
/**
* The followed feed: dumps from the users and the playlists the user follows.
*
* @module
*/
import { useCallback, useEffect, useState } from "react";
import { TabBar } from "../shared/TabBar.tsx";
import { t } from "@lingui/core/macro";
@@ -19,6 +25,7 @@ import type { PaginatedData, Wire } from "../../../shared/api.ts";
import type { SelfUser } from "../../../shared/user.ts";
import { friendlyFetchError } from "../../utils/shared/api.ts";
/** A section's state: loading, failed, or loaded with a page of dumps. */
type FeedState =
| { status: "loading" }
| { status: "error"; error: string }
@@ -30,8 +37,12 @@ type FeedState =
loadingMore: boolean;
};
/** Page of a followed feed. Throws the server's own message on an error
* envelope, so it isn't mistaken for a lost connection. */
/**
* Returns a page of a followed feed from `res`.
*
* @throws {Error} The server's own message on an error envelope, so it isn't
* mistaken for a lost connection.
*/
async function readPage(res: Response): Promise<PaginatedData<Wire<Dump>>> {
const body = await res.json();
if (!body.success) {
@@ -40,20 +51,30 @@ async function readPage(res: Response): Promise<PaginatedData<Wire<Dump>>> {
return body.data as PaginatedData<Wire<Dump>>;
}
/** A section of the followed feed. */
type FollowedSection = "users" | "playlists";
interface FollowedFeedProps {
/** The props of {@link FollowedFeed}. */
export interface FollowedFeedProps {
/** The category the feed is scoped to; all when undefined. */
categorySlug?: string;
/** Live vote counts, by dump id. */
voteCounts: Record<string, number>;
/** The ids of the dumps the user upvoted. */
myVotes: Set<string>;
/** The signed-in user. */
user: SelfUser;
/** Upvotes dump `id`; `shownCount` is the count on screen. */
castVote: (id: string, shownCount?: number) => void;
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
removeVote: (id: string, shownCount?: number) => void;
/** Ids of dumps deleted this session, filtered out. */
deletedDumpIds: Set<string>;
}
// ── FollowedSubFeed ───────────────────────────────────────────────────────────
/** The props of {@link FollowedSubFeed}. */
interface FollowedSubFeedProps {
state: FeedState;
voteCounts: Record<string, number>;
@@ -66,6 +87,7 @@ interface FollowedSubFeedProps {
onLoadMore: () => void;
}
/** Renders a section of the followed feed, loading more on demand. */
function FollowedSubFeed({
state,
voteCounts,
@@ -131,6 +153,10 @@ function FollowedSubFeed({
// ── FollowedFeed ──────────────────────────────────────────────────────────────
/**
* Renders the followed feed in two sections, from people and from playlists,
* each paginated and scoped to `categorySlug`.
*/
export function FollowedFeed({
categorySlug,
voteCounts,
@@ -139,7 +165,7 @@ export function FollowedFeed({
castVote,
removeVote,
deletedDumpIds,
}: FollowedFeedProps) {
}: FollowedFeedProps): React.JSX.Element {
const { token } = useAuth();
// Scope key + query suffix applied to every followed-feed request.

View File

@@ -1,3 +1,9 @@
/**
* The journal feed: the editorial mosaic.
*
* @module
*/
import { Fragment, useMemo } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -6,6 +12,10 @@ import { JournalCard } from "../dump/JournalCard.tsx";
import { composeJournal } from "../../utils/feed/journalLayout.ts";
import type { MainFeedProps } from "./DumpFeed.tsx";
/**
* Renders the dumps as the journal's mosaic, block by block, loading more at
* its end.
*/
export function JournalFeed(
{
dumps,
@@ -20,7 +30,7 @@ export function JournalFeed(
castVote,
removeVote,
}: MainFeedProps,
) {
): React.JSX.Element {
const blocks = useMemo(() => composeJournal(dumps), [dumps]);
if (loading) {

View File

@@ -1,3 +1,9 @@
/**
* The row of who's online, in the feed's header.
*
* @module
*/
import { useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
@@ -5,25 +11,40 @@ import { Avatar } from "../shared/Avatar.tsx";
import { useWSPresence } from "../../hooks/ws/useWS.ts";
import { useRoomBeside } from "../../hooks/shared/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.
/**
* 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.
/**
* 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.
/** Each seat (avatar or counter) is 32px wide… */
const SEAT = 32;
/**
* …and overlaps the one before it by 10px (.index-presence-avatar's negative
* margin), so adds 22px.
*/
const SEAT_STEP = 22;
/** The props of {@link PresenceRow}. */
export interface PresenceRowProps {
/** An ancestor selector bounding the row it shares. */
fitWithin?: string;
/** A selector for what comes first in that row: the tabs. */
fitBeside?: string;
}
/**
* Who's online, as a row of overlapping avatars. Given `fitWithin` and
* Renders 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 },
) {
{ fitWithin, fitBeside }: PresenceRowProps,
): React.JSX.Element | null {
const { onlineUsers } = useWSPresence();
const [el, setEl] = useState<HTMLDivElement | null>(null);
const room = useRoomBeside(el, { within: fitWithin, beside: fitBeside });

View File

@@ -1,8 +1,15 @@
/**
* A file drop zone, which is also a file picker.
*
* @module
*/
import { useCallback, useRef, useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { formatBytes } from "../../utils/shared/format.ts";
/** Returns the icon for a file of type `mime`. */
function fileIcon(mime: string): string {
if (mime.startsWith("image/")) return "🖼";
if (mime.startsWith("video/")) return "🎬";
@@ -11,21 +18,31 @@ function fileIcon(mime: string): string {
return "📎";
}
interface FileDropZoneProps {
/** The props of {@link FileDropZone}. */
export interface FileDropZoneProps {
/** The chosen file, null without one. */
file: File | null;
/** Called with the file dropped or picked, or null when it is removed. */
onChange: (file: File | null) => void;
/** Whether it takes no file. */
disabled?: boolean;
/** The prompt shown while empty; "Drop a file here" by default. */
hint?: string;
/** Whether to state the size limit; true by default. */
showLimit?: boolean;
}
/**
* Renders a zone that takes a dropped file, or opens a file picker when
* clicked, and shows the file once chosen, with a button to remove it.
*/
export function FileDropZone({
file,
onChange,
disabled,
hint,
showLimit = true,
}: FileDropZoneProps) {
}: FileDropZoneProps): React.JSX.Element {
const resolvedHint = hint ?? t`Drop a file here`;
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);

View File

@@ -1,3 +1,9 @@
/**
* A form's file field.
*
* @module
*/
import {
Controller,
type FieldValues,
@@ -8,18 +14,28 @@ import {
import { FileDropZone } from "./FileDropZone.tsx";
interface FileFieldProps<T extends FieldValues> {
/** The props of {@link FileField}. */
export interface FileFieldProps<T extends FieldValues> {
/** The field's name in the form. */
name: Path<T>;
/** The field's label. */
label?: string;
/** The drop zone's prompt. */
hint?: string;
/** Whether to state the size limit. */
showLimit?: boolean;
/** Whether the field takes no file. */
disabled?: boolean;
/** The field's validation rules. */
rules?: RegisterOptions<T, Path<T>>;
/** Side effect run after the field value changes (e.g. derive a title). */
onValueChange?: (file: File | null) => void;
}
/** `Controller`-wrapped {@link FileDropZone} bound to react-hook-form. */
/**
* Renders a `Controller`-wrapped {@link FileDropZone} bound to react-hook-form,
* with the field's error under it.
*/
export function FileField<T extends FieldValues>({
name,
label,
@@ -28,7 +44,7 @@ export function FileField<T extends FieldValues>({
disabled,
rules,
onValueChange,
}: FileFieldProps<T>) {
}: FileFieldProps<T>): React.JSX.Element {
const { control, formState: { errors } } = useFormContext<T>();
const error = errors[name];

View File

@@ -1,24 +1,35 @@
/**
* A form's row of actions.
*
* @module
*/
import type { ReactNode } from "react";
import { Trans } from "@lingui/react/macro";
interface FormActionsProps {
/** The props of {@link FormActions}. */
export interface FormActionsProps {
/** The submit control(s). */
children: ReactNode;
/** Called by the Cancel button; no Cancel button without it. */
onCancel?: () => void;
/** The Cancel button's label; "Cancel" by default. */
cancelLabel?: ReactNode;
/** Whether the Cancel button is disabled. */
disabled?: boolean;
}
/**
* Right-aligned form action row: an optional Cancel button followed by the
* submit control(s). Standardizes the `.form-actions > .form-actions-right`
* markup duplicated across forms.
* Renders a right-aligned form action row: an optional Cancel button followed
* by the submit control(s). Standardizes the `.form-actions >
* .form-actions-right` markup duplicated across forms.
*/
export function FormActions({
children,
onCancel,
cancelLabel,
disabled,
}: FormActionsProps) {
}: FormActionsProps): React.JSX.Element {
return (
<div className="form-actions">
<div className="form-actions-right">

View File

@@ -1,14 +1,28 @@
/**
* A form's submit error.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { useFormState } from "react-hook-form";
import { ErrorCard } from "../shared/ErrorCard.tsx";
/** The props of {@link FormError}. */
export interface FormErrorProps {
/** The card's title; "Something went wrong" by default. */
title?: string;
}
/**
* Renders the form's root error (set by {@link useApiForm}'s `submit` on a
* thrown/failed request) through the shared {@link ErrorCard}. One consistent
* place for server/submit errors across every form.
* Renders the form's root error (set by `useApiForm`'s `submit` on a
* thrown/failed request) through the shared {@link ErrorCard}, and nothing
* without one. One consistent place for server/submit errors across every form.
*/
export function FormError({ title }: { title?: string }) {
export function FormError(
{ title }: FormErrorProps,
): React.JSX.Element | null {
const { errors } = useFormState();
const message = errors.root?.message;
if (!message) return null;

View File

@@ -1,3 +1,9 @@
/**
* A form's rich-text field.
*
* @module
*/
import { useId } from "react";
import { t } from "@lingui/core/macro";
import {
@@ -10,15 +16,23 @@ import {
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
interface RichTextFieldProps<T extends FieldValues> {
/** The props of {@link RichTextField}. */
export interface RichTextFieldProps<T extends FieldValues> {
/** The field's name in the form. */
name: Path<T>;
/** The field's label. */
label?: string;
/** The editor's placeholder. */
placeholder?: string;
/** The editor's height, in rows. */
rows?: number;
/** Counted in the editor and enforced on submit, with a message saying so. */
maxLength?: number;
/** Whether the editor grows with its text. */
autoResize?: boolean;
/** Whether the editor is disabled. */
disabled?: boolean;
/** The field's validation rules. */
rules?: RegisterOptions<T, Path<T>>;
/** Forwarded to the underlying TextEditor so callers can focus it. */
editorRef?: React.Ref<TextEditorHandle>;
@@ -27,9 +41,9 @@ interface RichTextFieldProps<T extends FieldValues> {
}
/**
* Rich-text field: a `Controller`-wrapped {@link TextEditor} (mentions, emoji,
* image upload, char count) bound to react-hook-form via the surrounding
* `FormProvider`.
* Renders a rich-text field: a `Controller`-wrapped {@link TextEditor}
* (mentions, emoji, image upload, char count) bound to react-hook-form via the
* surrounding `FormProvider`, with the field's error under it.
*/
export function RichTextField<T extends FieldValues>({
name,
@@ -42,7 +56,7 @@ export function RichTextField<T extends FieldValues>({
rules,
editorRef,
onSubmit,
}: RichTextFieldProps<T>) {
}: RichTextFieldProps<T>): React.JSX.Element {
const id = useId();
const hintId = useId();
const { control, formState: { errors } } = useFormContext<T>();

View File

@@ -1,3 +1,9 @@
/**
* A form's segmented control, and the public/private toggle built on it.
*
* @module
*/
import type { ReactNode } from "react";
import { Trans } from "@lingui/react/macro";
import {
@@ -7,28 +13,37 @@ import {
useFormContext,
} from "react-hook-form";
interface SegmentedOption<V> {
/** A choice of a {@link SegmentedField}. */
export interface SegmentedOption<V> {
/** The value it sets. */
value: V;
/** What it shows. */
label: ReactNode;
}
interface SegmentedFieldProps<T extends FieldValues, V> {
/** The props of {@link SegmentedField}. */
export interface SegmentedFieldProps<T extends FieldValues, V> {
/** The field's name in the form. */
name: Path<T>;
/** The choices, in order. */
options: SegmentedOption<V>[];
/** The field's label. */
label?: string;
/** Whether the control is disabled. */
disabled?: boolean;
}
/**
* Segmented control (the `.visibility-toggle` style) bound to react-hook-form.
* Generic over the field value, so it works for booleans, string unions, etc.
* Renders a segmented control (the `.visibility-toggle` style) bound to
* react-hook-form. Generic over the field value, so it works for booleans,
* string unions, etc.
*/
export function SegmentedField<T extends FieldValues, V>({
name,
options,
label,
disabled,
}: SegmentedFieldProps<T, V>) {
}: SegmentedFieldProps<T, V>): React.JSX.Element {
const { control } = useFormContext<T>();
return (
@@ -57,20 +72,26 @@ export function SegmentedField<T extends FieldValues, V>({
);
}
/** The props of {@link VisibilityToggle}. */
export interface VisibilityToggleProps<T extends FieldValues> {
/** The boolean field's name in the form. */
name: Path<T>;
/** The field's label. */
label?: string;
/** Whether the toggle is disabled. */
disabled?: boolean;
}
/**
* Public/Private toggle on a boolean field (default `isPublic`). Forms that
* persist `isPrivate` should still bind this to an `isPublic` field and invert
* at submit time.
* Renders a Public/Private toggle on a boolean field (default `isPublic`).
* Forms that persist `isPrivate` should still bind this to an `isPublic` field
* and invert at submit time.
*/
export function VisibilityToggle<T extends FieldValues>({
name,
label,
disabled,
}: {
name: Path<T>;
label?: string;
disabled?: boolean;
}) {
}: VisibilityToggleProps<T>): React.JSX.Element {
return (
<SegmentedField<T, boolean>
name={name}

View File

@@ -1,20 +1,30 @@
/**
* A form's submit button.
*
* @module
*/
import type { ReactNode } from "react";
import { useFormState } from "react-hook-form";
interface SubmitButtonProps {
/** The props of {@link SubmitButton}. */
export interface SubmitButtonProps {
/** The button's label. */
children: ReactNode;
/** Replaces the label while the form is submitting (e.g. "Saving…"). */
pendingLabel?: ReactNode;
/** Whether it is disabled, besides while submitting. */
disabled?: boolean;
/** Its class; `btn-primary` by default. */
className?: string;
/** For an icon-only button (e.g. "✓"), its accessible name. */
"aria-label"?: string;
}
/**
* Submit button that reflects react-hook-form's `isSubmitting` from the
* surrounding `FormProvider`: it disables itself while submitting and swaps in
* `pendingLabel`. Standardizes the hand-rolled "Save → Saving…" pattern.
* Renders a submit button that reflects react-hook-form's `isSubmitting` from
* the surrounding `FormProvider`: it disables itself while submitting and swaps
* in `pendingLabel`. Standardizes the hand-rolled "Save → Saving…" pattern.
*/
export function SubmitButton({
children,
@@ -22,7 +32,7 @@ export function SubmitButton({
disabled,
className = "btn-primary",
"aria-label": ariaLabel,
}: SubmitButtonProps) {
}: SubmitButtonProps): React.JSX.Element {
const { isSubmitting } = useFormState();
return (

View File

@@ -1,3 +1,9 @@
/**
* A form's text field.
*
* @module
*/
import { type ReactNode, useId } from "react";
import {
type FieldValues,
@@ -9,16 +15,27 @@ import {
import { charCountClass } from "../../utils/shared/charCount.ts";
interface TextFieldProps<T extends FieldValues> {
/** The props of {@link TextField}. */
export interface TextFieldProps<T extends FieldValues> {
/** The field's name in the form. */
name: Path<T>;
/** The field's label. */
label?: string;
/** Optional control rendered to the right of the label (e.g. a reset button). */
/**
* Optional control rendered to the right of the label (e.g. a reset button).
*/
labelAction?: ReactNode;
/** The input's type; `text` by default. */
type?: "text" | "email" | "password" | "url" | "search";
/** The input's placeholder. */
placeholder?: string;
/** The input's autocomplete hint. */
autoComplete?: string;
/** Whether the input takes focus on mount. */
autoFocus?: boolean;
/** Whether the input is disabled. */
disabled?: boolean;
/** The field's validation rules. */
rules?: RegisterOptions<T, Path<T>>;
/** Cap input length at `max` (native `maxLength`). */
maxLength?: number;
@@ -26,7 +43,10 @@ interface TextFieldProps<T extends FieldValues> {
showCount?: boolean;
}
/** Live `n / max` counter; isolated so only counted fields subscribe to value. */
/**
* Renders a live `n / max` counter of field `name`; isolated so only counted
* fields subscribe to the value.
*/
function CharCounter<T extends FieldValues>(
{ name, max }: { name: Path<T>; max: number },
) {
@@ -40,9 +60,9 @@ function CharCounter<T extends FieldValues>(
}
/**
* Native text input wired to react-hook-form via the surrounding `FormProvider`.
* Renders a label, the input (with an optional character counter), and a
* validation hint sourced from `formState.errors[name]`.
* Renders a native text input wired to react-hook-form via the surrounding
* `FormProvider`: a label, the input (with an optional character counter), and
* a validation hint sourced from `formState.errors[name]`.
*/
export function TextField<T extends FieldValues>({
name,
@@ -56,7 +76,7 @@ export function TextField<T extends FieldValues>({
rules,
maxLength,
showCount = false,
}: TextFieldProps<T>) {
}: TextFieldProps<T>): React.JSX.Element {
const id = useId();
const { register, formState: { errors } } = useFormContext<T>();
const error = errors[name];

View File

@@ -1,3 +1,10 @@
/**
* The site's header: the brand, the search, the user's links and buttons, and
* the socket's status.
*
* @module
*/
import { lazy, type ReactNode, Suspense, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
@@ -10,32 +17,51 @@ import { UserMenu } from "./UserMenu.tsx";
import { SearchBar } from "./SearchBar.tsx";
import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
/** The dump creation modal, loaded on first open. */
const DumpCreateModal = lazy(() =>
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
default: m.DumpCreateModal,
}))
);
/** Carried in a search entry's history state: the history index of the page
* the search started from, so leaving search returns there in one step
* however many searches (or result tabs) came in between. */
/** Carried in a search entry's history state. */
interface SearchEntryState {
/**
* The history index of the page the search started from, so leaving search
* returns there in one step however many searches (or result tabs) came in
* between.
*/
searchExit?: number;
}
/** React Router's index of the current history entry — 0 for the first page
* of the session, which has nothing in the app to go back to. */
/**
* Returns React Router's index of the current history entry — 0 for the first
* page of the session, which has nothing in the app to go back to.
*/
function historyIndex(): number {
return (globalThis.history.state as { idx?: number } | null)?.idx ?? 0;
}
/** The props of {@link AppHeader}. */
export interface AppHeaderProps {
/** What the page shows in the header's centre, beside the search. */
centerSlot?: ReactNode;
/** Whether the new-dump button is disabled (the server is unreachable). */
disableNew?: boolean;
/** A link to open the dump creation modal with, at mount. */
initialDumpUrl?: string;
}
/**
* Renders the site's header: the brand; the search, which stays open on the
* results page and slides open under the header on small viewports; for a
* signed-in user their profile and playlists links (an avatar menu on small
* viewports), the chat and notification buttons and the new-dump button, else a
* login button; and a banner while live updates are down.
*/
export function AppHeader(
{ centerSlot, disableNew, initialDumpUrl }: {
centerSlot?: ReactNode;
disableNew?: boolean;
initialDumpUrl?: string;
},
) {
{ centerSlot, disableNew, initialDumpUrl }: AppHeaderProps,
): React.JSX.Element {
const { user } = useAuth();
const { wsStatus, wsErrorMessage } = useWS();
const navigate = useNavigate();

View File

@@ -1,9 +1,18 @@
/**
* The header's chat button.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { useWSChat } from "../../hooks/ws/useWS.ts";
import { useChat } from "../../hooks/chat/useChat.ts";
/** Header chat button with an unread-message badge. Opens the app-wide chat. */
export function ChatButton() {
/**
* Renders the header chat button with an unread-message badge. Opens the
* app-wide chat.
*/
export function ChatButton(): React.JSX.Element {
const { unreadChatCount } = useWSChat();
const { openChat } = useChat();

View File

@@ -1,3 +1,9 @@
/**
* The floating chat button.
*
* @module
*/
import { useEffect, useState } from "react";
import { useLocation } from "react-router";
import { t } from "@lingui/core/macro";
@@ -6,11 +12,11 @@ import { useWSChat } from "../../hooks/ws/useWS.ts";
import { useChat } from "../../hooks/chat/useChat.ts";
/**
* Floating chat button shown once the header has scrolled out of view, mirroring
* {@link DumpFab}. Opens the single app-wide chat modal (owned by ChatProvider).
* Only rendered for signed-in users.
* Renders the floating chat button shown once the header has scrolled out of
* view, mirroring `DumpFab`, with an unread badge. Opens the single app-wide
* chat modal (owned by ChatProvider). Only rendered for signed-in users.
*/
export function ChatFab() {
export function ChatFab(): React.JSX.Element | null {
const { user } = useAuth();
const { unreadChatCount } = useWSChat();
const { openChat } = useChat();

View File

@@ -1,8 +1,15 @@
/**
* The floating new-dump button.
*
* @module
*/
import { lazy, Suspense, useEffect, useState } from "react";
import { useLocation } from "react-router";
import { t } from "@lingui/core/macro";
import { useAuth } from "../../hooks/auth/useAuth.ts";
/** The dump creation modal, loaded on first open. */
const DumpCreateModal = lazy(() =>
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
default: m.DumpCreateModal,
@@ -10,17 +17,17 @@ const DumpCreateModal = lazy(() =>
);
/**
* Floating "new dump" button shown once the header has scrolled out of view.
* Sits in the bottom-right corner and lifts above the global player (which is
* full-width on mobile) so the two never overlap. Only rendered for signed-in
* users — dump creation requires an account.
* Renders the floating "new dump" button shown once the header has scrolled out
* of view. Sits in the bottom-right corner and lifts above the global player
* (which is full-width on mobile) so the two never overlap. Only rendered for
* signed-in users — dump creation requires an account.
*
* Mounted once at the app root (not per-page) since some pages render their own
* header instead of going through PageShell. Visibility is derived from a live
* read of the current page's `.app-header`, so it survives client-side
* navigation and lazily-mounted route headers.
*/
export function DumpFab() {
export function DumpFab(): React.JSX.Element | null {
const { user } = useAuth();
const location = useLocation();
const [headerHidden, setHeaderHidden] = useState(false);

View File

@@ -1,9 +1,19 @@
/**
* The header's notification bell.
*
* @module
*/
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
import { useWS } from "../../hooks/ws/useWS.ts";
export function NotificationBell() {
/**
* Renders the header's notification bell with an unread badge; it rings when a
* notification arrives, and opens the notifications page.
*/
export function NotificationBell(): React.JSX.Element {
const { unreadNotificationCount, lastNotification } = useWS();
const navigate = useNavigate();
const [ringing, setRinging] = useState(false);

View File

@@ -1,15 +1,28 @@
/**
* A page that failed to load.
*
* @module
*/
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { PageShell } from "./PageShell.tsx";
import { ErrorCard } from "../shared/ErrorCard.tsx";
/** The props of {@link PageError}. */
export interface PageErrorProps {
/** What failed; "Something went wrong" by default. */
title?: string;
/** Why. */
message: string;
/** Buttons offered under it. */
actions?: ReactNode;
}
/** Renders an error card as the whole page, under the header. */
export function PageError(
{ title, message, actions }: {
title?: string;
message: string;
actions?: ReactNode;
},
) {
{ title, message, actions }: PageErrorProps,
): React.JSX.Element {
const resolvedTitle = title ?? t`Something went wrong`;
return (
<PageShell>

View File

@@ -1,15 +1,26 @@
/**
* A page's frame: the header, and the content under it.
*
* @module
*/
import { type ReactNode } from "react";
import { AppHeader } from "./AppHeader.tsx";
interface PageShellProps {
/** The props of {@link PageShell}. */
export interface PageShellProps {
/** The page's content. */
children: ReactNode;
/** Whether the content is centred. */
centered?: boolean;
/** What the page shows in the header's centre. */
centerSlot?: ReactNode;
}
/** Renders the header and, under it, the page's content. */
export function PageShell(
{ children, centered = false, centerSlot }: PageShellProps,
) {
): React.JSX.Element {
return (
<div className="page-shell">
<AppHeader centerSlot={centerSlot} />

View File

@@ -1,24 +1,34 @@
/**
* The header's search form.
*
* @module
*/
import { useEffect, useRef } from "react";
import { t } from "@lingui/core/macro";
interface SearchBarProps {
/** The props of {@link SearchBar}. */
export interface SearchBarProps {
/** Whether it is open. */
expanded: boolean;
/** The query as typed. */
value: string;
/** Called as the query is typed. */
onValueChange: (v: string) => void;
/** Called with the trimmed query on submit. */
onSubmit: (q: string) => void;
/** Escape pressed in the input. */
onCancel: () => void;
}
/**
* The header's search form. The header renders two of these (one in its
* centre column, one in the row that slides open under it on small
* viewports) over one shared state, so the draft survives crossing the
* breakpoint.
* Renders the header's search form. The header renders two of these (one in its
* centre column, one in the row that slides open under it on small viewports)
* over one shared state, so the draft survives crossing the breakpoint.
*/
export function SearchBar(
{ expanded, value, onValueChange, onSubmit, onCancel }: SearchBarProps,
) {
): React.JSX.Element {
const inputRef = useRef<HTMLInputElement>(null);
const wasExpanded = useRef(expanded);

View File

@@ -1,3 +1,9 @@
/**
* The header's avatar menu, on small viewports.
*
* @module
*/
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
@@ -5,7 +11,17 @@ import { Trans } from "@lingui/react/macro";
import { Avatar } from "../shared/Avatar.tsx";
import type { SelfUser } from "../../../shared/user.ts";
export function UserMenu({ user }: { user: SelfUser }) {
/** The props of {@link UserMenu}. */
export interface UserMenuProps {
/** The signed-in user. */
user: SelfUser;
}
/**
* Renders the signed-in user's avatar, which opens a menu with their profile
* and playlists links.
*/
export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);

View File

@@ -1,3 +1,9 @@
/**
* The global player, docked at the bottom of every page.
*
* @module
*/
import { useContext, useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { Trans } from "@lingui/react/macro";
@@ -13,10 +19,13 @@ import { Thumbnail } from "../shared/Thumbnail.tsx";
import { useYouTubePlaylist } from "../../hooks/player/useYouTubePlaylist.ts";
import { embedListId, withJsApi } from "../../utils/player/youtube.ts";
/** An embed item of the player. */
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
/** One row of the tracklist, whether it comes from the queue or from a
* YouTube playlist the embed is holding. */
/**
* Renders one row of the tracklist, whether it comes from the queue or from a
* YouTube playlist the embed is holding.
*/
function TrackRow(
{ num, title, duration, isCurrent, disabled, onPlay, rowRef }: {
num: number | string;
@@ -44,13 +53,16 @@ function TrackRow(
);
}
// The stored embedUrl is the canonical, non-playing form — it is also rendered
// outside the player, so the autoplay parameter is added here at playback time
// rather than baked into rich_content. Only a fresh play() sets autoplay: a
// session restored from localStorage has no user gesture behind it, so it stays
// paused, matching how MediaPlayer treats file items.
// Bandcamp's EmbeddedPlayer has no autoplay parameter — that is what the native
// playback path (GERBEUR_BANDCAMP_PLAYER=native) exists to solve.
/**
* Returns the URL the iframe of `item` loads. The stored embedUrl is the
* canonical, non-playing form — it is also rendered outside the player, so the
* autoplay parameter is added here at playback time rather than baked into
* rich_content. Only a fresh play() sets `autoplay`: a session restored from
* localStorage has no user gesture behind it, so it stays paused, matching how
* MediaPlayer treats file items. Bandcamp's EmbeddedPlayer has no autoplay
* parameter — that is what the native playback path
* (GERBEUR_BANDCAMP_PLAYER=native) exists to solve.
*/
function playbackUrl(item: EmbedItem, autoplay: boolean) {
// A playlist embed is also driven from the outside — see useYouTubePlaylist.
const base = item.type === "youtube" && embedListId(item.embedUrl)
@@ -69,7 +81,13 @@ function playbackUrl(item: EmbedItem, autoplay: boolean) {
}
}
export function GlobalPlayer() {
/**
* Renders what the player plays — a media player for files and streams, the
* provider's iframe for embeds — with its artwork, title, link to the dump,
* transport, and the tracklist of the queue or of a YouTube playlist; nothing
* while nothing plays.
*/
export function GlobalPlayer(): React.JSX.Element | null {
const {
current,
queue,

View File

@@ -1,3 +1,9 @@
/**
* The site's audio and video player.
*
* @module
*/
import { useEffect, useRef, useState } from "react";
import { fmt } from "../../utils/player/duration.ts";
import { t } from "@lingui/core/macro";
@@ -11,16 +17,26 @@ import { SeekBar } from "./SeekBar.tsx";
import { Waveform } from "./Waveform.tsx";
import { rememberVolume, storedVolume } from "../../utils/player/volume.ts";
/** How long a playing video's controls stay after the pointer stops, in ms. */
const HIDE_DELAY = 2500;
interface MediaPlayerProps {
/** The props of {@link MediaPlayer}. */
export interface MediaPlayerProps {
/** The media's URL. */
src: string;
/** Whether it is audio or video. */
kind: "audio" | "video";
/** The media's type. */
mime?: string;
/** A video's still before it plays. */
poster?: string;
/** Whether it starts playing on mount. */
autoplay?: boolean;
/** Where it starts, in seconds. */
startTime?: number;
/** Called when it starts or stops playing. */
onPlayStateChange?: (playing: boolean) => void;
/** Called as it plays, with the playhead and the duration. */
onTimeUpdate?: (time: number, duration: number) => void;
/** Fired when the media reaches its end — the queue's advance hook. */
onEnded?: () => void;
@@ -30,10 +46,17 @@ interface MediaPlayerProps {
/** "waveform" (default) decodes peaks from the source; "progress" draws a
* slim seek bar and never fetches the media a second time. */
trackStyle?: "waveform" | "progress";
/** Receives its seek handle, for the global player's controls. */
seekRef?: { current: ((t: number) => void) | null };
/** Receives its play/pause handle, for the global player's controls. */
toggleRef?: { current: (() => void) | null };
}
/**
* Renders a media element with the site's own controls: play/pause, a waveform
* or seek bar, time, volume (remembered across players) and, for video,
* fullscreen.
*/
export function MediaPlayer(
{
src,
@@ -50,7 +73,7 @@ export function MediaPlayer(
seekRef,
toggleRef,
}: MediaPlayerProps,
) {
): React.JSX.Element {
const mediaRef = useRef<HTMLMediaElement>(null);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);

View File

@@ -1,19 +1,34 @@
// Player control icons, shared by MediaPlayer and FilePreview.
/**
* Player control icons, shared by MediaPlayer and FilePreview.
*
* @module
*/
export const IconPlay = () => (
/** Renders the play icon. */
export const IconPlay = (): React.JSX.Element => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
<polygon points="6,3 20,12 6,21" />
</svg>
);
export const IconPause = () => (
/** Renders the pause icon. */
export const IconPause = (): React.JSX.Element => (
<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 }) => (
/** The props of {@link IconVolume}. */
export interface IconVolumeProps {
/** Whether the sound is off. */
muted: boolean;
}
/** Renders the volume icon, struck through when muted. */
export const IconVolume = (
{ muted }: IconVolumeProps,
): React.JSX.Element => (
<svg viewBox="0 0 24 24" fill="currentColor">
{muted
? (
@@ -25,7 +40,8 @@ export const IconVolume = ({ muted }: { muted: boolean }) => (
</svg>
);
export const IconFullscreen = () => (
/** Renders the fullscreen icon. */
export const IconFullscreen = (): React.JSX.Element => (
<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

@@ -1,16 +1,33 @@
/**
* A slim seek bar.
*
* @module
*/
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. */
/** The props of {@link SeekBar}. */
export interface SeekBarProps {
/** The playhead, in seconds. */
current: number;
/** The media's length, in seconds. */
duration: number;
/** Called with the time sought to. */
onSeek: (t: number) => void;
/** Called when a drag starts and ends. */
onDragChange: (dragging: boolean) => void;
/** An extra class. */
className?: string;
}
/**
* Renders a 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;
},
) {
{ current, duration, onSeek, onDragChange, className }: SeekBarProps,
): React.JSX.Element {
const progress = duration > 0 ? current / duration : 0;
return (
<div className={`audio-player-track${className ? ` ${className}` : ""}`}>

View File

@@ -1,3 +1,9 @@
/**
* An audio file's waveform, which is also its seek bar.
*
* @module
*/
import { useEffect, useState } from "react";
import {
BAR_GAP,
@@ -8,14 +14,25 @@ import {
WAVEFORM_H,
} from "../../utils/player/waveform.ts";
/** The props of {@link Waveform}. */
export interface WaveformProps {
/** The audio's URL. */
src: string;
/** The playhead, in seconds. */
current: number;
/** The audio's length, in seconds. */
duration: number;
/** Called with the time clicked. */
onSeek: (t: number) => void;
}
/**
* Renders the audio's peaks as bars, played ones highlighted, seeking where
* clicked.
*/
export function Waveform(
{ src, current, duration, onSeek }: {
src: string;
current: number;
duration: number;
onSeek: (t: number) => void;
},
) {
{ src, current, duration, onSeek }: WaveformProps,
): React.JSX.Element {
const [peaks, setPeaks] = useState<Float32Array | null>(null);
useEffect(() => {

View File

@@ -1,3 +1,9 @@
/**
* The modal that adds a dump to playlists.
*
* @module
*/
import { useEffect, useState } from "react";
import { t } from "@lingui/core/macro";
import { API_URL } from "../../config/api.ts";
@@ -8,14 +14,22 @@ import { deserializePlaylistMembership } from "../../utils/shared/deserialize.ts
import { Modal } from "../shared/Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
interface AddToPlaylistModalProps {
/** The props of {@link AddToPlaylistModal}. */
export interface AddToPlaylistModalProps {
/** The dump's id. */
dumpId: string;
/** Called to close it. */
onClose: () => void;
}
/**
* Renders a modal listing the user's playlists, each toggling whether it holds
* the dump, with a form to make a new one. A toggle shows once the server
* agrees.
*/
export function AddToPlaylistModal(
{ dumpId, onClose }: AddToPlaylistModalProps,
) {
): React.JSX.Element {
const { authFetch } = useAuth();
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
const [loading, setLoading] = useState(true);

View File

@@ -1,3 +1,9 @@
/**
* The new-playlist button.
*
* @module
*/
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -5,19 +11,24 @@ import type { Playlist } from "../../../shared/playlist.ts";
import { Modal } from "../shared/Modal.tsx";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
interface NewPlaylistButtonProps {
/** The props of {@link NewPlaylistButton}. */
export interface NewPlaylistButtonProps {
/** Called with the playlist created. */
onCreated: (playlist: Playlist) => void;
/** The button's label; "+ New playlist" by default. */
toggleLabel?: string;
/** The button's class. */
toggleClassName?: string;
}
/** Renders a button that opens the playlist creation form in a modal. */
export function NewPlaylistButton(
{
onCreated,
toggleLabel,
toggleClassName = "new-item-toggle",
}: NewPlaylistButtonProps,
) {
): React.JSX.Element {
const [open, setOpen] = useState(false);
return (

View File

@@ -1,3 +1,9 @@
/**
* A playlist, as a card in a list.
*
* @module
*/
import { Link, useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
@@ -12,15 +18,24 @@ import {
} from "../../utils/shared/visited.ts";
import { Tooltip } from "../shared/Tooltip.tsx";
interface PlaylistCardProps {
/** The props of {@link PlaylistCard}. */
export interface PlaylistCardProps {
/** The playlist. */
playlist: Playlist;
/** Deletes it; no delete button without it. */
onDelete?: () => void;
/** Whether the viewer owns it: no owner link, no unread dot. */
isOwner?: boolean;
}
/**
* Renders a playlist as a card: its cover, title, description, visibility,
* owner, size and age, with an unread dot until it is opened. The card opens
* the playlist.
*/
export function PlaylistCard(
{ playlist, onDelete, isOwner }: PlaylistCardProps,
) {
): React.JSX.Element {
const navigate = useNavigate();
const { mediaToken } = useAuth();
const unread = !isOwner && isRecent(playlist.createdAt) &&

View File

@@ -1,3 +1,9 @@
/**
* The playlist creation form.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
@@ -19,23 +25,32 @@ import { TextField } from "../form/TextField.tsx";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { VisibilityToggle } from "../form/SegmentedField.tsx";
interface PlaylistCreateFormProps {
/** The props of {@link PlaylistCreateForm}. */
export interface PlaylistCreateFormProps {
/** If provided, the new playlist will have this dump added to it. */
dumpId?: string;
/** Called with the playlist created. */
onCreated: (playlist: Playlist) => void;
/** Called by the Cancel button. */
onCancel: () => void;
/** An extra class for the form. */
className?: string;
}
/** The form's fields. */
interface Values {
title: string;
description: string;
isPublic: boolean;
}
/**
* Renders the form that creates a playlist — title, description, visibility —
* and adds `dumpId` to it when given.
*/
export function PlaylistCreateForm(
{ dumpId, onCreated, onCancel, className }: PlaylistCreateFormProps,
) {
): React.JSX.Element {
const { authFetch } = useAuth();
const form = useApiForm<Values>({
defaultValues: { title: "", description: "", isPublic: true },

View File

@@ -1,23 +1,39 @@
/**
* The list of a user's playlists, each toggling whether it holds a dump.
*
* @module
*/
import { useState } from "react";
import { Trans } from "@lingui/react/macro";
import type { PlaylistMembership } from "../../../shared/playlist.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
interface PlaylistMembershipPanelProps {
/** The props of {@link PlaylistMembershipPanel}. */
export interface PlaylistMembershipPanelProps {
/** The dump's id. */
dumpId: string;
/** The user's playlists, and whether each holds the dump. */
memberships: PlaylistMembership[];
/** Whether the list is loading. */
loading: boolean;
/** Called with the playlist toggled. */
onToggle: (membership: PlaylistMembership) => void;
/** Called with a playlist created from the panel, which holds the dump. */
onPlaylistCreated: (membership: PlaylistMembership) => void;
}
/**
* Renders the user's playlists as checkboxes of whether each holds the dump,
* and a form to make a new one with it.
*/
export function PlaylistMembershipPanel({
dumpId,
memberships,
loading,
onToggle,
onPlaylistCreated,
}: PlaylistMembershipPanelProps) {
}: PlaylistMembershipPanelProps): React.JSX.Element {
const [showNewForm, setShowNewForm] = useState(false);
return (

View File

@@ -1,3 +1,9 @@
/**
* The password change modal.
*
* @module
*/
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -13,17 +19,27 @@ import { SubmitButton } from "../form/SubmitButton.tsx";
import { TextField } from "../form/TextField.tsx";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
interface ChangePasswordModalProps {
/** The props of {@link ChangePasswordModal}. */
export interface ChangePasswordModalProps {
/** Called to close it. */
onClose: () => void;
}
/** The form's fields. */
interface Values {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
/**
* Renders the form that changes the user's password, given the current one. The
* change signs out every other session, and this one keeps the fresh tokens it
* is handed.
*/
export function ChangePasswordModal(
{ onClose }: ChangePasswordModalProps,
): React.JSX.Element {
const { authFetch, updateSession } = useAuth();
const [done, setDone] = useState(false);
const form = useApiForm<Values>({

View File

@@ -1,18 +1,33 @@
/**
* The header of a profile's subpage.
*
* @module
*/
import type { ReactNode } from "react";
import { Link } from "react-router";
import type { PublicUser } from "../../../shared/user.ts";
import { Avatar } from "../shared/Avatar.tsx";
interface ProfileSubpageHeaderProps {
/** The props of {@link ProfileSubpageHeader}. */
export interface ProfileSubpageHeaderProps {
/** The name in the route, for the back link. */
username: string;
/** The profile's user. */
profileUser: PublicUser;
/** The subpage's title. */
title: string;
/** Buttons on the title row. */
actions?: ReactNode;
}
/**
* Renders a link back to the profile, and the subpage's title beside the user's
* avatar.
*/
export function ProfileSubpageHeader(
{ username, profileUser, title, actions }: ProfileSubpageHeaderProps,
) {
): React.JSX.Element {
return (
<div className="profile-subpage-header">
<Link to={`/users/${username}`} className="profile-subpage-back">

View File

@@ -1,18 +1,35 @@
/**
* A user's avatar, or their initial.
*
* @module
*/
import { useContext, useState } from "react";
import { API_URL } from "../../config/api.ts";
import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts";
interface AvatarProps {
/** The props of {@link Avatar}. */
export interface AvatarProps {
/** The user's id. */
userId: string;
/** The user's name, for the alt text and the initial. */
username: string;
/** Whether they have an avatar, as the surrounding data says. */
hasAvatar: boolean;
/** The side, in px; 36 by default. */
size?: number;
/** Cache-busts the avatar URL. */
version?: number;
}
/**
* Renders a user's avatar, or their initial when they have none or it fails to
* load. A live `user_updated` override (avatar add/remove/change) wins over the
* possibly-stale props the surrounding list was rendered with.
*/
export function Avatar(
{ userId, username, hasAvatar, size = 36, version }: AvatarProps,
) {
): React.JSX.Element {
const [imgFailed, setImgFailed] = useState(false);
const sizeStyle = { width: size, height: size };

View File

@@ -1,19 +1,34 @@
/**
* A confirmation dialog, for destructive actions.
*
* @module
*/
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/shared/useScrollLock.ts";
interface ConfirmModalProps {
/** The props of {@link ConfirmModal}. */
export interface ConfirmModalProps {
/** The question asked. */
message: string;
/** The confirm button's label; "Delete" by default. */
confirmLabel?: string;
/** Called on confirm. */
onConfirm: () => void;
/** Called on cancel, Escape or a backdrop click. */
onCancel: () => void;
}
/**
* Renders a confirmation dialog over the page. It takes Escape before a {@link
* Modal} it sits on, so only the confirm goes.
*/
export function ConfirmModal(
{ message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps,
) {
): React.ReactPortal {
const label = confirmLabel ?? t`Delete`;
const messageId = useId();
useScrollLock();

View File

@@ -1,14 +1,30 @@
import type React from "react";
/**
* A text input with a character counter.
*
* @module
*/
import { charCountClass } from "../../utils/shared/charCount.ts";
interface CountedInputProps
/**
* The props of {@link CountedInput}: an input's, with a required value and
* limit.
*/
export interface CountedInputProps
extends
Omit<React.InputHTMLAttributes<HTMLInputElement>, "maxLength" | "value"> {
/** The input's value. */
value: string;
/** The most characters it takes. */
maxLength: number;
}
export function CountedInput({ value, maxLength, ...rest }: CountedInputProps) {
/**
* Renders a text input with a counter that warns as the value nears its limit.
*/
export function CountedInput(
{ value, maxLength, ...rest }: CountedInputProps,
): React.JSX.Element {
const countClass = charCountClass(value.length, maxLength);
return (

View File

@@ -1,10 +1,25 @@
/**
* An error, as a card.
*
* @module
*/
import type { ReactNode } from "react";
export function ErrorCard({ title, message, actions }: {
/** The props of {@link ErrorCard}. */
export interface ErrorCardProps {
/** What failed. */
title: string;
/** Why. */
message: string;
/** Buttons offered under it (retry, go back…). */
actions?: ReactNode;
}) {
}
/** Renders an error as a card, with optional actions under it. */
export function ErrorCard(
{ title, message, actions }: ErrorCardProps,
): React.JSX.Element {
return (
<div className="error-card-wrap">
<div className="error-card" role="alert">

View File

@@ -1,16 +1,34 @@
/**
* An image that is also its own upload button.
*
* @module
*/
import { useRef } from "react";
interface ImagePickerProps {
/** The props of {@link ImagePicker}. */
export interface ImagePickerProps {
/** The current image, null without one. */
src: string | null;
/** Its alt text. */
alt?: string;
/** The side, in px; 80 by default. */
size?: number;
/** The corner radius, in px; 8 by default. */
borderRadius?: number;
/** Called with the file picked. */
onChange: (file: File) => void;
/** Whether an upload is under way: the picker is disabled meanwhile. */
uploading?: boolean;
/** The file types offered; JPEG, PNG, GIF and WebP by default. */
accept?: string;
/** An extra class. */
className?: string;
}
/**
* Renders an image (or a placeholder) that opens a file picker when clicked.
*/
export function ImagePicker({
src,
alt = "",
@@ -20,7 +38,7 @@ export function ImagePicker({
uploading = false,
accept = "image/jpeg,image/png,image/gif,image/webp",
className,
}: ImagePickerProps) {
}: ImagePickerProps): React.JSX.Element {
const inputRef = useRef<HTMLInputElement>(null);
const sizeStyle = { width: size, height: size, borderRadius };

View File

@@ -1,12 +1,23 @@
/**
* A modal dialog.
*
* @module
*/
import { type ReactNode, useCallback, useEffect, useId, useRef } from "react";
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro";
import { useScrollLock } from "../../hooks/shared/useScrollLock.ts";
interface ModalProps {
/** The props of {@link Modal}. */
export interface ModalProps {
/** The dialog's title. */
title: string;
/** Called to close it. */
onClose: () => void;
/** The dialog's body. */
children: ReactNode;
/** Whether it takes the wide layout. */
wide?: boolean;
/**
* Runs before every dismissal the user did not aim at the content — Escape,
@@ -16,9 +27,13 @@ interface ModalProps {
onBeforeClose?: () => boolean;
}
/**
* Renders a modal dialog over the page, closed by Escape, the backdrop or its
* ✕, and locks the page's scroll while it is open.
*/
export function Modal(
{ title, onClose, children, wide = false, onBeforeClose }: ModalProps,
) {
): React.ReactPortal {
const backdropRef = useRef<HTMLDivElement>(null);
const titleId = useId();

View File

@@ -1,25 +1,46 @@
/**
* A row of tabs.
*
* @module
*/
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
interface Tab<T extends string> {
/** A tab. */
export interface Tab<T extends string> {
/** What {@link TabBar} reports when it is chosen. */
key: T;
/** What it shows. */
label: ReactNode;
}
interface TabBarProps<T extends string> {
/** The props of {@link TabBar}. */
export interface TabBarProps<T extends string> {
/** The tabs, in order. */
tabs: Tab<T>[];
/** The active tab's key. */
activeTab: T;
/** Called with the key of the tab chosen. */
onChange: (key: T) => void;
/** An extra class for the scroller. */
className?: string;
/** An extra class for the row. */
innerClassName?: string;
// Optional content rendered inside the row, before the tabs (e.g. a scope
// selector). Sits in the same flex line so it scrolls and aligns with them.
/**
* Optional content rendered inside the row, before the tabs (e.g. a scope
* selector). Sits in the same flex line so it scrolls and aligns with them.
*/
leading?: ReactNode;
}
/**
* Renders a row of tabs that scrolls when they don't all fit, keeping the
* active one in view.
*/
export function TabBar<T extends string>(
{ tabs, activeTab, onChange, className, innerClassName, leading }:
TabBarProps<T>,
) {
): React.JSX.Element {
const rowRef = useRef<HTMLDivElement>(null);
const [overflowing, setOverflowing] = useState(false);

View File

@@ -1,29 +1,43 @@
import { type ComponentProps, useState } from "react";
import { canProxyImage, proxyImageUrl } from "../../utils/shared/proxyImage.ts";
import { ThumbnailPlaceholder } from "./ThumbnailPlaceholder.tsx";
/**
* An external thumbnail, with fallbacks down to a placeholder.
*
* @module
*/
interface ThumbnailProps {
import { useState } from "react";
import { canProxyImage, proxyImageUrl } from "../../utils/shared/proxyImage.ts";
import {
ThumbnailPlaceholder,
type ThumbnailPlaceholderProps,
} from "./ThumbnailPlaceholder.tsx";
/** The props of {@link Thumbnail}. */
export interface ThumbnailProps {
/** Raw image URL. Proxying is decided here — don't pre-proxy it. */
src?: string;
/** Its alt text. */
alt?: string;
/** The image's class. */
className?: string;
/** When to load it; lazily by default. */
loading?: "lazy" | "eager";
/** Passed through to the placeholder when there's nothing to show. */
placeholder: Omit<ComponentProps<typeof ThumbnailPlaceholder>, "className">;
placeholder: Omit<ThumbnailPlaceholderProps, "className">;
/** Extra class for the placeholder element only. */
placeholderClassName?: string;
}
/**
* An external thumbnail that always renders *something*.
* Renders an external thumbnail, and always renders *something*.
*
* Three attempts, in order:
* 1. load it directly with no `Referer` — hotlink protection (Cloudflare's is
* one click) answers a cross-site referrer with 403, and an empty one with
* 200;
* 2. retry through `/api/proxy-image`, which fetches server-side, for hosts
* that reject an empty referrer too;
* 3. fall back to the generated placeholder.
*
* 1. load it directly with no `Referer` — hotlink protection (Cloudflare's is
* one click) answers a cross-site referrer with 403, and an empty one with
* 200;
* 2. retry through `/api/proxy-image`, which fetches server-side, for hosts
* that reject an empty referrer too;
* 3. fall back to the generated placeholder.
*
* 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.
@@ -37,7 +51,7 @@ export function Thumbnail(
placeholder,
placeholderClassName,
}: ThumbnailProps,
) {
): React.JSX.Element {
const [attempt, setAttempt] = useState<"direct" | "proxied" | "failed">(
"direct",
);

View File

@@ -1,8 +1,15 @@
/**
* Generated artwork for a page with no preview image.
*
* @module
*/
import { useState } from "react";
import { proxyImageUrl } from "../../utils/shared/proxyImage.ts";
import { initialsFor, tintFor } from "../../utils/shared/thumbnailTint.ts";
interface ThumbnailPlaceholderProps {
/** The props of {@link ThumbnailPlaceholder}. */
export interface ThumbnailPlaceholderProps {
/** The dumped URL — seeds the fallback hue and the initial. */
url?: string;
/** Explicit hue seed for dumps with no URL to hash (file dumps). */
@@ -15,13 +22,14 @@ interface ThumbnailPlaceholderProps {
siteName?: string;
/** Emoji shown instead of an initial — file dumps say more with 🎬 than "T". */
glyph?: string;
/** An extra class. */
className?: string;
}
/**
* Stand-in artwork for pages that offer no preview image: the site's own color
* washed over the theme surface, with its favicon centered on top — or its
* initial when the favicon is missing or fails to load.
* Renders stand-in artwork for pages that offer no preview image: the site's
* own color washed over the theme surface, with its favicon centered on top —
* or its initial when the favicon is missing or fails to load.
*
* 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.
@@ -29,7 +37,7 @@ interface ThumbnailPlaceholderProps {
export function ThumbnailPlaceholder(
{ url, seed, accentColor, faviconUrl, siteName, glyph, className }:
ThumbnailPlaceholderProps,
) {
): React.JSX.Element {
const [iconFailed, setIconFailed] = useState(false);
// A different icon is a fresh thing to try — clear the prior failure during

View File

@@ -1,12 +1,22 @@
/**
* A tooltip.
*
* @module
*/
import { useCallback, useRef, useState } from "react";
import { createPortal } from "react-dom";
interface TooltipProps {
/** The props of {@link Tooltip}. */
export interface TooltipProps {
/** The tooltip's text. */
text: string;
/** What it is shown over. */
children: React.ReactNode;
}
export function Tooltip({ text, children }: TooltipProps) {
/** Renders `children` with a tooltip above them on hover. */
export function Tooltip({ text, children }: TooltipProps): React.JSX.Element {
const [rect, setRect] = useState<DOMRect | null>(null);
const ref = useRef<HTMLSpanElement>(null);

View File

@@ -1,21 +1,37 @@
/**
* The follow buttons, for a user and for a playlist.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useFollows } from "../../hooks/social/useFollows.ts";
interface FollowUserButtonProps {
/** The props of {@link FollowUserButton}. */
export interface FollowUserButtonProps {
/** The user's id. */
targetUserId: string;
/** The user's name, for the button's label. */
targetUsername: string;
}
interface FollowPlaylistButtonProps {
/** The props of {@link FollowPlaylistButton}. */
export interface FollowPlaylistButtonProps {
/** The playlist's id. */
targetPlaylistId: string;
/** Whether it is public; only a public playlist can be followed. */
isPublic: boolean;
}
/**
* Renders the button that follows or unfollows a user; nothing when signed out
* or on one's own profile.
*/
export function FollowUserButton(
{ targetUserId, targetUsername }: FollowUserButtonProps,
) {
): React.JSX.Element | null {
const { user } = useAuth();
const { followedUserIds, followUser, unfollowUser, isLoaded } = useFollows();
@@ -39,9 +55,13 @@ export function FollowUserButton(
);
}
/**
* Renders the button that follows or unfollows a playlist; nothing when signed
* out or for a private playlist.
*/
export function FollowPlaylistButton(
{ targetPlaylistId, isPublic }: FollowPlaylistButtonProps,
) {
): React.JSX.Element | null {
const { user } = useAuth();
const { followedPlaylistIds, followPlaylist, unfollowPlaylist, isLoaded } =
useFollows();

View File

@@ -1,20 +1,37 @@
/**
* A comment's like button.
*
* @module
*/
import { useRef, useState } from "react";
import { t } from "@lingui/core/macro";
import { API_URL } from "../../config/api.ts";
import { UserListPopover } from "./UserListPopover.tsx";
interface LikeButtonProps {
/** The props of {@link LikeButton}. */
export interface LikeButtonProps {
/** The comment's id. */
commentId: string;
/** Its like count. */
count: number;
/** Whether the user likes it. */
liked: boolean;
/** Whether it is disabled (signed out). */
disabled?: boolean;
/** Likes the comment; `shownCount` is the count on screen. */
onLike: (commentId: string, shownCount?: number) => void;
/** Withdraws the like; `shownCount` is the count on screen. */
onUnlike: (commentId: string, shownCount?: number) => void;
}
/**
* Renders a comment's like button with its count, which opens the list of who
* likes it.
*/
export function LikeButton(
{ commentId, count, liked, disabled, onLike, onUnlike }: LikeButtonProps,
) {
): React.JSX.Element {
const [popoverOpen, setPopoverOpen] = useState(false);
const countRef = useRef<HTMLSpanElement>(null);

View File

@@ -1,3 +1,9 @@
/**
* A popover listing users: who voted for a dump, who likes a comment.
*
* @module
*/
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Link } from "react-router";
@@ -11,12 +17,17 @@ import type { PaginatedData, Wire } from "../../../shared/api.ts";
import { parseAPIResponse } from "../../utils/shared/api.ts";
import type { PublicUser } from "../../../shared/user.ts";
interface UserListPopoverProps {
/** The props of {@link UserListPopover}. */
export interface UserListPopoverProps {
/** The element it opens against. */
anchorRef: React.RefObject<HTMLElement | null>;
/** Called to close it: an outside click, Escape, or a scroll. */
onClose: () => void;
/** The paginated endpoint listing the users. */
fetchUrl: string;
}
/** The list's state: loading, failed, or loaded with a page of users. */
type State =
| { status: "loading" }
| { status: "error"; error: string }
@@ -28,6 +39,7 @@ type State =
loadingMore: boolean;
};
/** Where the popover sits, in viewport coordinates. */
interface Placement {
left: number;
top?: number;
@@ -35,13 +47,21 @@ interface Placement {
maxHeight: number;
}
/** The popover's preferred largest height, in px. */
const PREFERRED_MAX_HEIGHT = 280;
/** The space kept clear of the viewport's edges, in px. */
const VIEWPORT_MARGIN = 8;
/** The least height worth opening below the anchor, in px. */
const MIN_USABLE_HEIGHT = 100;
/**
* Renders the users from `fetchUrl`, loading more on demand, in a popover
* against `anchorRef`. It is fixed and portaled to <body> so a card's overflow
* can't clip it, and flips above the anchor when there isn't enough room below.
*/
export function UserListPopover(
{ anchorRef, onClose, fetchUrl }: UserListPopoverProps,
) {
): React.ReactPortal | null {
const { token } = useAuth();
const [state, setState] = useState<State>({ status: "loading" });
const [placement, setPlacement] = useState<Placement | null>(null);

View File

@@ -1,20 +1,36 @@
/**
* A dump's vote button.
*
* @module
*/
import { useRef, useState } from "react";
import { t } from "@lingui/core/macro";
import { API_URL } from "../../config/api.ts";
import { UserListPopover } from "./UserListPopover.tsx";
interface VoteButtonProps {
/** The props of {@link VoteButton}. */
export interface VoteButtonProps {
/** The dump's id. */
dumpId: string;
/** Its vote count. */
count: number;
/** Whether the user upvoted it. */
voted: boolean;
/** Whether it is disabled (signed out). */
disabled?: boolean;
/** Upvotes the dump; `shownCount` is the count on screen. */
onCast: (dumpId: string, shownCount?: number) => void;
/** Withdraws the vote; `shownCount` is the count on screen. */
onRemove: (dumpId: string, shownCount?: number) => void;
}
/**
* Renders a dump's vote button with its count, which opens the list of voters.
*/
export function VoteButton(
{ dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps,
) {
): React.JSX.Element {
const [popoverOpen, setPopoverOpen] = useState(false);
const countRef = useRef<HTMLSpanElement>(null);