v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 19:30:28 +00:00
parent de5abeceb2
commit 07d0344be7
131 changed files with 3893 additions and 3596 deletions

View File

@@ -0,0 +1,149 @@
import { memo } from "react";
import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../../model.ts";
import { relativeTime } from "../../utils/relativeTime.ts";
import { dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts";
import { useAuth } from "../../hooks/useAuth.ts";
import { useCategories } from "../../hooks/useCategories.ts";
import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
import {
isDumpVisited,
isRecent,
markDumpVisited,
} from "../../utils/visited.ts";
import { FilePreview } from "./FilePreview.tsx";
import { RichContentCard } from "./RichContentCard.tsx";
import { ThumbnailPlaceholder } from "../ui/ThumbnailPlaceholder.tsx";
import { VoteButton } from "../social/VoteButton.tsx";
import { Markdown } from "../editor/Markdown.tsx";
import { Tooltip } from "../ui/Tooltip.tsx";
interface DumpCardProps {
dump: Dump;
voteCount: number;
voted: boolean;
canVote: boolean;
castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string, shownCount?: number) => void;
className?: string;
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(
{ dump, voteCount, voted, canVote, castVote, removeVote, className, isOwner }:
DumpCardProps,
) {
const { cardProps, handledAsNewTab } = useDumpCardLink(dump);
const { mediaToken } = useAuth();
const { byId } = useCategories();
const dumpCategories = (dump.categoryIds ?? [])
.map((id) => byId.get(id))
.filter((c) => c !== undefined);
const unread = !isOwner && isRecent(dump.createdAt) &&
!isDumpVisited(dump.id);
return (
<li className={`dump-card${className ? ` ${className}` : ""}`}>
<div
className="dump-card-inner"
{...cardProps}
>
<div className="dump-card-left">
<div
className="dump-card-preview"
onClick={dump.richContent ? (e) => e.stopPropagation() : undefined}
onClickCapture={handledAsNewTab}
>
{dump.kind === "file"
? <FilePreview dump={dump} compact />
: dump.richContent
? (
<RichContentCard
richContent={dump.richContent}
compact
dumpHref={dumpUrl(dump)}
thumbnailOverrideUrl={dump.thumbnailMime
? dumpThumbnailUrl(dump, mediaToken)
: undefined}
/>
)
: <ThumbnailPlaceholder url={dump.url} />}
</div>
<div className="dump-card-vote" onClick={(e) => e.stopPropagation()}>
<VoteButton
dumpId={dump.id}
count={voteCount}
voted={voted}
disabled={!canVote}
onCast={castVote}
onRemove={removeVote}
/>
</div>
</div>
<div className="dump-card-body">
<Link
to={dumpUrl(dump)}
className="dump-card-title"
onClick={(e) => {
e.stopPropagation();
markDumpVisited(dump.id);
}}
>
{unread && <span className="unread-dot" aria-hidden="true" />}
{dump.title}
</Link>
{dump.comment && (
<Markdown className="dump-card-comment" inline>
{dump.comment}
</Markdown>
)}
<div className="dump-card-meta">
<Tooltip text={dump.createdAt.toLocaleString()}>
<time
className="dump-card-date"
dateTime={dump.createdAt.toISOString()}
>
{relativeTime(dump.createdAt)}
</time>
</Tooltip>
{dump.commentCount > 0 && (
<span className="dump-card-comment-count">
<Plural
value={dump.commentCount}
one="# comment"
other="# comments"
/>
</span>
)}
{dump.isPrivate && isOwner && (
<span className="dump-card-private-badge">
<Trans>private</Trans>
</span>
)}
</div>
{dumpCategories.length > 0 && (
<div
className="dump-card-categories"
onClick={(e) => e.stopPropagation()}
>
{dumpCategories.map((category) => (
<Link
key={category.id}
to={`/${category.slug}`}
className="dump-card-category-chip"
>
{category.name}
</Link>
))}
</div>
)}
</div>
</div>
</li>
);
});

View File

@@ -0,0 +1,334 @@
import { useContext, useEffect, useState } from "react";
import type { Dump } from "../../model.ts";
import { formatBytes } from "../../utils/format.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts";
import { useAuth } from "../../hooks/useAuth.ts";
import { IconPause, IconPlay } from "../player/icons.tsx";
import { MediaPlayer } from "../player/MediaPlayer.tsx";
import {
PlayerContext,
type PlayerItem,
PlayerTimeContext,
} from "../../contexts/PlayerContext.ts";
import {
BAR_GAP,
BAR_W,
extractPeaks,
NUM_BARS,
VIEWBOX_W,
WAVEFORM_H,
} from "../../utils/waveform.ts";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
interface FilePreviewProps {
dump: Dump;
compact?: boolean;
global?: boolean;
}
/**
* Build 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.
*
* Artwork is only claimed when one actually exists: videos get their generated
* still and any dump can carry an uploaded thumbnail, but a bare audio file has
* neither, and pointing at the endpoint anyway would just render a placeholder.
*/
function filePlayerItem(
dump: Dump,
fileUrl: string,
mime: string,
token?: string | null,
): PlayerItem {
const hasArtwork = !!dump.thumbnailMime || mime.startsWith("video/");
return {
kind: "file",
fileUrl,
mimeType: mime,
title: dump.title,
dumpHref: dumpUrl(dump),
artworkUrl: hasArtwork ? dumpThumbnailUrl(dump, token) : undefined,
subtitle: dump.fileName,
};
}
// Waveform preview for the dump detail page — routes to global player,
// reflects live play state and position from PlayerContext/PlayerTimeContext.
function AudioFilePreview(
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
) {
const { mediaToken: token } = useAuth();
const { current, playing, play, togglePlay, seekTo } = useContext(
PlayerContext,
);
const { currentTime, duration } = useContext(PlayerTimeContext);
const [peaks, setPeaks] = useState<Float32Array | null>(null);
const isActive = current?.kind === "file" && current.fileUrl === fileUrl;
const progress = isActive && duration > 0 ? currentTime / duration : 0;
useEffect(() => {
let cancelled = false;
extractPeaks(fileUrl, NUM_BARS)
.then((p) => {
if (!cancelled) setPeaks(p);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [fileUrl]);
const handlePlayBtn = () => {
if (isActive) togglePlay();
else play(filePlayerItem(dump, fileUrl, mime, token));
};
const handleWaveformClick = (e: React.MouseEvent<Element>) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.max(
0,
Math.min(1, (e.clientX - rect.left) / rect.width),
);
if (isActive) {
seekTo(ratio * duration);
} else {
// Start playing and seek once it loads — seekTo after play() is a no-op
// until MediaPlayer mounts; the fraction is best-effort on first click
play(filePlayerItem(dump, fileUrl, mime, token));
}
};
const isPlaying = isActive && playing;
return (
<div
className={`audio-file-preview${
isActive ? " audio-file-preview--active" : ""
}`}
>
<button
type="button"
className="audio-player-btn"
onClick={handlePlayBtn}
aria-label={isPlaying ? t`Pause` : t`Play`}
>
{isPlaying ? <IconPause /> : <IconPlay />}
</button>
{peaks
? (
<svg
viewBox={`0 0 ${VIEWBOX_W} ${WAVEFORM_H}`}
preserveAspectRatio="none"
className="waveform-svg"
onClick={handleWaveformClick}
>
{Array.from(peaks).map((p, i) => {
const barH = Math.max(p * WAVEFORM_H, 2);
const x = i * (BAR_W + BAR_GAP);
const y = (WAVEFORM_H - barH) / 2;
const played = i / NUM_BARS <= progress;
return (
<rect
key={i}
x={x}
y={y}
width={BAR_W}
height={barH}
className={`waveform-bar${
played ? " waveform-bar--played" : ""
}`}
/>
);
})}
</svg>
)
: (
<div className="waveform-skeleton" onClick={handleWaveformClick}>
<div
className="waveform-skeleton-fill"
style={{ width: `${progress * 100}%` }}
/>
</div>
)}
</div>
);
}
function VideoThumb({ src, fallback }: { src: string; fallback: string }) {
const [failed, setFailed] = useState(false);
if (failed) {
return <span className="rich-content-compact-icon">{fallback}</span>;
}
return (
<img
src={src}
alt=""
className="rich-content-compact-thumbnail"
loading="lazy"
decoding="async"
onError={() => setFailed(true)}
/>
);
}
function mimeIcon(mime: string): string {
if (mime.startsWith("video/")) return "🎬";
if (mime.startsWith("audio/")) return "🎵";
if (mime === "application/pdf") return "📄";
if (mime.startsWith("text/")) return "📝";
return "📁";
}
export function FilePreview(
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps,
) {
const { current, playing, play, togglePlay } = useContext(PlayerContext);
const { mediaToken: token } = useAuth();
const fileUrl = dumpFileUrl(dump, token);
const mime = dump.fileMime ?? "";
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
const thumbOverride = dump.thumbnailMime
? dumpThumbnailUrl(dump, token)
: null;
if (compact) {
if (mime.startsWith("image/")) {
// The server's preview (or the custom thumbnail): a card doesn't need
// the full upload.
return (
<img
src={dumpThumbnailUrl(dump, token)}
alt={dump.fileName}
className="rich-content-compact-thumbnail"
loading="lazy"
decoding="async"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
);
}
if (mime.startsWith("video/")) {
const thumbUrl = dumpThumbnailUrl(dump, token);
return (
<button
type="button"
className={`rich-content-thumbnail-btn${
isPlaying ? " is-playing" : ""
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
play(filePlayerItem(dump, fileUrl, mime, token));
}}
aria-label={t`Play`}
>
<VideoThumb src={thumbUrl} fallback={mimeIcon(mime)} />
<span className="rich-content-play-overlay" aria-hidden="true">
▶
</span>
</button>
);
}
if (mime.startsWith("audio/")) {
return (
<button
type="button"
className={`rich-content-thumbnail-btn${
isPlaying ? " is-playing" : ""
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
play(filePlayerItem(dump, fileUrl, mime, token));
}}
aria-label={t`Play`}
>
{thumbOverride
? <VideoThumb src={thumbOverride} fallback={mimeIcon(mime)} />
: (
<span className="rich-content-compact-icon">
{mimeIcon(mime)}
</span>
)}
<span className="rich-content-play-overlay" aria-hidden="true">
▶
</span>
</button>
);
}
if (thumbOverride) {
return <VideoThumb src={thumbOverride} fallback={mimeIcon(mime)} />;
}
return <span className="rich-content-compact-icon">{mimeIcon(mime)}</span>;
}
if (mime.startsWith("image/")) {
return (
<img src={fileUrl} alt={dump.fileName} className="file-preview-image" />
);
}
if (mime.startsWith("video/")) {
if (useGlobal) {
const videoActive = isPlaying;
const videoPlaying = videoActive && playing;
return (
<button
type="button"
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
aria-label={videoPlaying ? t`Pause` : t`Play`}
onClick={() =>
videoActive
? togglePlay()
: play(filePlayerItem(dump, fileUrl, mime, token))}
>
<video
src={fileUrl}
poster={thumbOverride ?? undefined}
preload="none"
className="file-preview-video-thumb"
muted
/>
<span className="rich-content-play-overlay">
{videoPlaying ? "⏸" : "▶"}
</span>
</button>
);
}
return (
<MediaPlayer
src={fileUrl}
kind="video"
mime={mime}
poster={thumbOverride ?? undefined}
/>
);
}
if (mime.startsWith("audio/")) {
if (useGlobal) {
return <AudioFilePreview fileUrl={fileUrl} mime={mime} dump={dump} />;
}
return <MediaPlayer src={fileUrl} kind="audio" mime={mime} />;
}
if (mime === "application/pdf") {
return (
<embed
src={fileUrl}
type="application/pdf"
className="file-preview-pdf"
/>
);
}
return (
<a href={fileUrl} download={dump.fileName} className="file-download-link">
{mimeIcon(mime)} <Trans>Download {dump.fileName}</Trans>
{dump.fileSize != null && ` (${formatBytes(dump.fileSize)})`}
</a>
);
}

View File

@@ -0,0 +1,233 @@
import { memo } from "react";
import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../../model.ts";
import { relativeTime } from "../../utils/relativeTime.ts";
import { dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts";
import { useAuth } from "../../hooks/useAuth.ts";
import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
import {
isDumpVisited,
isRecent,
markDumpVisited,
} from "../../utils/visited.ts";
import {
hasQuote,
hasThumbnail,
type JournalShape,
} from "../../utils/journalLayout.ts";
import { VoteButton } from "../social/VoteButton.tsx";
import { Markdown } from "../editor/Markdown.tsx";
import { Tooltip } from "../ui/Tooltip.tsx";
import { Thumbnail } from "../ui/Thumbnail.tsx";
import {
canPlayRichContent,
usePlayRichContent,
} from "../../hooks/usePlayRichContent.ts";
export type { JournalShape };
interface JournalCardProps {
dump: Dump;
shape: JournalShape;
voteCount: number;
voted: boolean;
canVote: boolean;
castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string, shownCount?: number) => void;
isOwner?: boolean;
}
// Memoized, like DumpCard: live events re-render the feed, not every card.
export const JournalCard = memo(function JournalCard(
{ dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }:
JournalCardProps,
) {
const { go, cardProps, handledAsNewTab } = useDumpCardLink(dump);
const { mediaToken } = useAuth();
const { playRichContent } = usePlayRichContent();
const unread = !isOwner && isRecent(dump.createdAt) &&
!isDumpVisited(dump.id);
// A playable card plays. If playback turns out to be impossible — a Bandcamp
// page with no streams and no embed to fall back to — the card must still do
// what an unplayable one does rather than swallowing the click.
async function handlePlayOrNavigate(rc: NonNullable<typeof playable>) {
if (!await playRichContent(rc, dumpUrl(dump))) go();
}
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
// still here too, rather than degrading to a text card with a 🎬. An image
// shows the server's scaled-down preview, not the full upload.
const thumbnailUrl = dump.thumbnailMime
? dumpThumbnailUrl(dump, mediaToken)
: dump.kind === "file"
? (dump.fileMime?.startsWith("image/") ||
dump.fileMime?.startsWith("video/")
? dumpThumbnailUrl(dump, mediaToken)
: null)
: (dump.richContent?.thumbnailUrl ?? null);
// Content mode is independent of grid footprint: a thumbnailed dump reads as
// an image card, a thumbnail-less dump with a note becomes a pull-quote, and
// everything else falls back to a typographic text card.
const mode: "image" | "quote" | "text" = hasThumbnail(dump) && thumbnailUrl
? "image"
: hasQuote(dump)
? "quote"
: "text";
const fallbackIcon = dump.kind === "file"
? (() => {
const m = dump.fileMime ?? "";
if (m.startsWith("video/")) return "🎬";
if (m.startsWith("audio/")) return "🎵";
return "📄";
})()
: "🔗";
const richContent = dump.richContent;
// In native mode a Bandcamp page is playable even with no stored embedUrl.
// The card's own thumbnail (dump upload or provider) becomes the player's
// header artwork.
const playable = richContent && canPlayRichContent(richContent)
? { ...richContent, thumbnailUrl: thumbnailUrl ?? undefined }
: null;
const titleLink = (
<Link
to={dumpUrl(dump)}
className="journal-card-title"
onClick={(e) => {
e.stopPropagation();
markDumpVisited(dump.id);
}}
>
{unread && <span className="unread-dot" aria-hidden="true" />}
{dump.title}
</Link>
);
const meta = (
<div className="journal-card-meta">
<Tooltip text={dump.createdAt.toLocaleString()}>
<time dateTime={dump.createdAt.toISOString()}>
{relativeTime(dump.createdAt)}
</time>
</Tooltip>
{dump.commentCount > 0 && (
<span className="dump-card-comment-count">
<Plural
value={dump.commentCount}
one="# comment"
other="# comments"
/>
</span>
)}
{dump.isPrivate && isOwner && (
<span className="dump-card-private-badge">
<Trans>private</Trans>
</span>
)}
</div>
);
const vote = (
<div
className="journal-card-vote"
onClick={(e) => e.stopPropagation()}
>
<VoteButton
dumpId={dump.id}
count={voteCount}
voted={voted}
disabled={!canVote}
onCast={castVote}
onRemove={removeVote}
/>
</div>
);
const footer = (
<div className="journal-card-footer">
{meta}
{vote}
</div>
);
const className = `journal-card journal-card--${shape} journal-card--${mode}`;
if (mode === "image") {
// feature shows its note; tall/wide/square keep the image as the subject.
const showComment = shape === "feature" && !!dump.comment;
// For playable media the whole card plays; the title link still navigates.
// Keeping the handler on the <li> means the text overlay can't cover it.
return (
<li
className={className}
{...cardProps}
onClick={playable
? (e) => {
if (!handledAsNewTab(e)) void handlePlayOrNavigate(playable);
}
: cardProps.onClick}
>
<div className="journal-card-image">
<Thumbnail
src={thumbnailUrl ?? undefined}
placeholder={dump.kind === "file"
// No ffmpeg on the host means no still — the mime glyph says more
// about the dump than an initial taken from its filename.
? { seed: dump.fileName ?? dump.id, glyph: fallbackIcon }
: {
url: dump.url,
accentColor: dump.richContent?.accentColor,
faviconUrl: dump.richContent?.faviconUrl,
siteName: dump.richContent?.siteName,
}}
/>
{playable && (
<span className="rich-content-play-overlay" aria-hidden="true">
▶
</span>
)}
</div>
<div className="journal-card-overlay">
{titleLink}
{showComment && (
<Markdown className="journal-card-comment" inline>
{dump.comment!}
</Markdown>
)}
{footer}
</div>
</li>
);
}
if (mode === "quote") {
return (
<li className={className} {...cardProps}>
<span className="journal-card-quotemark" aria-hidden="true">“</span>
<Markdown className="journal-card-quote-body" inline>
{dump.comment!}
</Markdown>
<div className="journal-card-attribution">
{titleLink}
{footer}
</div>
</li>
);
}
// text
return (
<li className={className} {...cardProps}>
<span className="journal-card-glyph" aria-hidden="true">
{fallbackIcon}
</span>
{titleLink}
{footer}
</li>
);
});

View File

@@ -0,0 +1,161 @@
import { useContext } from "react";
import { t } from "@lingui/core/macro";
import type { RichContent } from "../../model.ts";
import { PlayerContext } from "../../contexts/PlayerContext.ts";
import {
canPlayRichContent,
usePlayRichContent,
} from "../../hooks/usePlayRichContent.ts";
import { Thumbnail } from "../ui/Thumbnail.tsx";
interface RichContentCardProps {
richContent: RichContent;
compact?: boolean;
/** Overrides richContent.thumbnailUrl with a custom dump thumbnail, if set. */
thumbnailOverrideUrl?: string;
/** Link back to the owning dump, surfaced as the global player's title. */
dumpHref?: string;
}
export function RichContentCard(
{ richContent, compact = false, thumbnailOverrideUrl, dumpHref }:
RichContentCardProps,
) {
const { current, playing, togglePlay } = useContext(PlayerContext);
const { playRichContent, pending } = usePlayRichContent();
const canPlay = canPlayRichContent(richContent);
const thumbnailSrc = thumbnailOverrideUrl ?? richContent.thumbnailUrl;
// The dump's own thumbnail overrides the provider's, in the player header too.
const playable = { ...richContent, thumbnailUrl: thumbnailSrc };
// Nothing to play and no embed to fall back to: the card body is a link to
// the source, so the thumbnail behaves like one rather than going dead.
const playOrOpen = async () => {
if (!await playRichContent(playable, dumpHref)) {
globalThis.open(richContent.url, "_blank", "noopener,noreferrer");
}
};
const placeholder = {
url: richContent.url,
accentColor: richContent.accentColor,
faviconUrl: richContent.faviconUrl,
siteName: richContent.siteName,
};
if (compact) {
const thumbnail = (
<Thumbnail
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-compact-thumbnail"
placeholder={placeholder}
placeholderClassName="rich-content-compact-thumbnail"
/>
);
if (canPlay) {
// A native Bandcamp queue is the same card as its embed, matched on the
// source page rather than a stream URL that changes on every resolve.
const isActive = current != null &&
((current.kind === "embed" &&
current.embedUrl === richContent.embedUrl) ||
(current.kind === "stream" &&
current.resolveUrl === richContent.url));
const isPlaying = isActive && playing;
return (
<button
type="button"
className={`rich-content-thumbnail-btn${
isActive ? " is-playing" : ""
}${pending ? " is-pending" : ""}`}
disabled={pending}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// The button says "Pause" once this card is the one playing, so it
// has to pause. Re-playing would re-resolve the album and lose the
// queue position. Embeds have no transport of their own, so they
// keep restarting, as before.
if (isActive && current?.kind === "stream") togglePlay();
else void playOrOpen();
}}
aria-label={isPlaying ? t`Pause` : t`Play`}
>
{thumbnail}
<span className="rich-content-play-overlay">
{isPlaying ? "⏸" : "▶"}
</span>
</button>
);
}
return (
<a
href={richContent.url}
target="_blank"
rel="noopener noreferrer"
className="rich-content-compact"
onClick={(e) => e.stopPropagation()}
aria-label={richContent.siteName
? t`Open on ${richContent.siteName}`
: t`Open source`}
>
{thumbnail}
<span className="rich-content-open-overlay" aria-hidden="true">
↗
</span>
</a>
);
}
const thumbnailImg = (
<Thumbnail
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-thumbnail"
placeholder={placeholder}
placeholderClassName="rich-content-thumbnail"
/>
);
const thumbnail = canPlay
? (
<button
type="button"
className={`rich-content-thumbnail-btn${pending ? " is-pending" : ""}`}
disabled={pending}
onClick={() => void playOrOpen()}
aria-label={t`Play`}
>
{thumbnailImg}
<span className="rich-content-play-overlay">▶</span>
</button>
)
: thumbnailImg;
return (
<div className={`rich-content-card rich-content-card--${richContent.type}`}>
{thumbnail}
<a
href={richContent.url}
target="_blank"
rel="noopener noreferrer"
className="rich-content-body"
>
{richContent.siteName && (
<span className="rich-content-badge">{richContent.siteName}</span>
)}
{richContent.title && (
<p className="rich-content-title">{richContent.title}</p>
)}
{richContent.description && (
<p className="rich-content-description">
{richContent.description}
</p>
)}
<span className="rich-content-url">{richContent.url}</span>
</a>
</div>
);
}

View File

@@ -0,0 +1,802 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../../../config/api.ts";
import type {
CreateUrlDumpRequest,
Dump,
DumpUrlMatch,
PlaylistMembership,
RawDump,
RawDumpUrlMatch,
RawPlaylistMembership,
UrlPreviewResponse,
} from "../../../model.ts";
import {
deserializeDump,
deserializeDumpUrlMatch,
deserializePlaylistMembership,
} from "../../../model.ts";
import { useAuth } from "../../../hooks/useAuth.ts";
import { useWS } from "../../../hooks/useWS.ts";
import { dumpUrl, normalizeUrl } from "../../../utils/urls.ts";
import { titleFromFileName } from "../../../utils/format.ts";
import {
clearDumpDraft,
type DumpDraft,
EMPTY_DUMP_DRAFT,
loadDumpDraft,
saveDumpDraft,
} from "../../../utils/dumpDraft.ts";
import { MAX_FILE_SIZE } from "../../../config/upload.ts";
import { RichContentCard } from "../RichContentCard.tsx";
import type { RichContent } from "../../../model.ts";
import { Modal } from "../../ui/Modal.tsx";
import type { TextEditorHandle } from "../../editor/TextEditor.tsx";
import { CategorySelect } from "../../category/CategorySelect.tsx";
import { PlaylistMembershipPanel } from "../../playlist/PlaylistMembershipPanel.tsx";
import {
expectOk,
FileField,
FormError,
FormProvider,
RichTextField,
SubmitButton,
useApiForm,
VisibilityToggle,
} from "../../form/index.ts";
import { DuplicateNotice } from "./DuplicateNotice.tsx";
import { LocalFilePreview } from "./LocalFilePreview.tsx";
import { type Step, StepIndicator } from "./StepIndicator.tsx";
import { TitleField } from "./TitleField.tsx";
/** Stable empty result, so rendering doesn't churn on a fresh array each pass. */
const NO_DUPLICATES: DumpUrlMatch[] = [];
type UrlPreview =
| { status: "idle" }
| { status: "loading" }
| { status: "done"; reached: boolean; richContent: RichContent | null };
interface CreateValues {
url: string;
file: File | null;
title: string;
comment: string;
isPublic: boolean;
}
/**
* 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.
*/
function prefersNoAutoFocus(): boolean {
return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false;
}
interface DumpCreateModalProps {
onClose: () => void;
initialUrl?: string;
}
export function DumpCreateModal(
{ onClose, initialUrl = "" }: DumpCreateModalProps,
) {
const { authFetch, authUpload } = useAuth();
const { injectDump } = useWS();
const [step, setStep] = useState<Step>("what");
// Which way the next panel should come in from. Purely presentational.
const [direction, setDirection] = useState<"forward" | "back">("forward");
const [createdDump, setCreatedDump] = useState<Dump | null>(null);
const goToStep = (next: Step, dir: "forward" | "back") => {
setDirection(dir);
setStep(next);
};
// Arriving with a URL (the Android share sheet) is a request to post *that*,
// so an abandoned draft is dropped whole rather than half-merged: pairing
// someone's old "why" with a link they just shared from another app would be
// more confusing than starting them clean.
const [restoredDraft] = useState<DumpDraft | null>(() =>
initialUrl ? null : loadDumpDraft()
);
const draft = restoredDraft ?? EMPTY_DUMP_DRAFT;
const [draftNoticeVisible, setDraftNoticeVisible] = useState(!!restoredDraft);
// Composition state
const [titleEditing, setTitleEditing] = useState(false);
const [titleFromUser, setTitleFromUser] = useState(draft.titleFromUser);
const [urlFetchState, setUrlFetchState] = useState<UrlPreview>({
status: "idle",
});
// Tagged with the URL they describe, so a result never lingers over the next
// link during the debounce (and so clearing them needs no effect).
const [duplicateState, setDuplicateState] = useState<
{ url: string; matches: DumpUrlMatch[] }
>({ url: "", matches: [] });
const [uploadProgress, setUploadProgress] = useState<number | null>();
const [confirmClose, setConfirmClose] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const commentEditorRef = useRef<TextEditorHandle | null>(null);
const form = useApiForm<CreateValues>({
defaultValues: {
url: initialUrl || draft.url,
file: null,
title: draft.title,
comment: draft.comment,
isPublic: draft.isPublic,
},
});
const { register, setValue, watch, clearErrors, reset } = form;
const submitting = form.formState.isSubmitting;
const url = watch("url");
const file = watch("file");
const title = watch("title");
const comment = watch("comment");
const isPublic = watch("isPublic");
// The dump's kind is whatever the user actually provided, so there is no mode
// to pick: attaching a file makes it a file dump, typing a link makes it a
// link dump, and clearing either offers both again.
const mode: "url" | "file" = file ? "file" : "url";
const [selectedCategoryIds, setSelectedCategoryIds] = useState<Set<string>>(
() => new Set(draft.categoryIds),
);
const toggleCategory = (id: string) =>
setSelectedCategoryIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
// Playlist step state
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
const [playlistsLoading, setPlaylistsLoading] = useState(false);
const selectFile = useCallback((f: File | null) => {
setValue("file", f);
if (f) {
setValue("title", titleFromFileName(f.name));
setTitleFromUser(false);
}
setTitleEditing(false);
}, [setValue]);
// Canonical form of `url`, used (rather than the raw input text) to decide
// whether the preview actually needs to re-fetch — e.g. "example.com" and
// "https://example.com" (set later by onBlur's normalizeUrl) canonicalize
// to the same value, so they shouldn't trigger two separate fetches.
let canonicalUrl: string | null;
try {
const u = new URL(normalizeUrl(url));
canonicalUrl = (u.protocol === "http:" || u.protocol === "https:")
? u.toString()
: null;
} catch {
canonicalUrl = null;
}
// Idle state is derived below from canonicalUrl rather than set here, so
// this effect only ever needs to set loading/done (inside the timeout).
const urlPreview: UrlPreview = canonicalUrl
? urlFetchState
: { status: "idle" };
const duplicates = canonicalUrl && duplicateState.url === canonicalUrl
? duplicateState.matches
: NO_DUPLICATES;
// Debounced preview + "already dumped?" lookup. Both answer questions about
// the same link and settle at the same time, so they share one debounce.
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!canonicalUrl) return;
// Deferred to a microtask (rather than called directly) so the immediate
// "loading" feedback doesn't fire synchronously within the effect body —
// same timing for the user, just not in the same tick.
queueMicrotask(() => setUrlFetchState({ status: "loading" }));
// A preview can take seconds (the server fetches the page), so the link
// may have changed by the time one lands: the answer for the old link must
// not show, or suggest its title, under the new one.
const controller = new AbortController();
const { signal } = controller;
debounceRef.current = setTimeout(() => {
const encoded = encodeURIComponent(canonicalUrl);
authFetch(`${API_URL}/api/preview?url=${encoded}`, { signal })
.then((res) => res.json())
.then((body) => {
if (signal.aborted) return;
const data = body.success
? body.data as UrlPreviewResponse
: undefined;
setUrlFetchState({
status: "done",
reached: data?.reached ?? false,
richContent: data?.richContent ?? null,
});
})
.catch(() => {
if (signal.aborted) return;
setUrlFetchState({
status: "done",
reached: false,
richContent: null,
});
});
// Signed in, so that a private dump of the poster's own counts as a
// duplicate for them and for nobody else.
authFetch(`${API_URL}/api/dumps/by-url?url=${encoded}`, { signal })
.then((res) => res.json())
.then((body) => {
if (signal.aborted) return;
setDuplicateState({
url: canonicalUrl,
matches: body.success
? (body.data as RawDumpUrlMatch[]).map(deserializeDumpUrlMatch)
: [],
});
})
.catch(() => {
if (!signal.aborted) {
setDuplicateState({ url: canonicalUrl, matches: [] });
}
});
}, 600);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
controller.abort();
};
}, [canonicalUrl, authFetch]);
// Until the poster takes the field over, the title *is* the current page's
// title — including when the new link suggests nothing, which is why this
// writes an empty suggestion too rather than leaving the previous link's.
const suggestedTitle = urlPreview.status === "done"
? urlPreview.richContent?.title ?? ""
: "";
useEffect(() => {
if (mode !== "url" || titleFromUser) return;
setValue("title", suggestedTitle);
}, [mode, titleFromUser, suggestedTitle, setValue]);
// Mirror the draft on every change so a dismissal — deliberate or not — never
// costs more than the attached file, which cannot be stored.
useEffect(() => {
if (step === "playlist") return;
saveDumpDraft({
url,
title,
titleFromUser,
comment,
isPublic,
categoryIds: [...selectedCategoryIds],
});
}, [
step,
url,
title,
titleFromUser,
comment,
isPublic,
selectedCategoryIds,
]);
const discardDraft = () => {
clearDumpDraft();
reset({
url: "",
file: null,
title: "",
comment: "",
isPublic: true,
});
setSelectedCategoryIds(new Set());
setTitleFromUser(false);
setTitleEditing(false);
setDraftNoticeVisible(false);
};
// Land the caret in the "why" box on arrival, so the second panel is
// immediately typeable — except on touch, where it would raise a keyboard
// over the categories this panel exists to show.
useEffect(() => {
if (step !== "details" || prefersNoAutoFocus()) return;
commentEditorRef.current?.focus();
}, [step]);
// Only the attached file is unrecoverable, so it is the only thing worth
// interrupting a dismissal for — the text comes back on its own.
const handleBeforeClose = useCallback(() => {
if (step === "playlist" || submitting || !file || confirmClose) return true;
setConfirmClose(true);
return false;
}, [step, submitting, file, confirmClose]);
/** Dismissal from inside the form (Cancel), guarded like Escape and the ✕. */
const requestClose = () => {
if (handleBeforeClose()) onClose();
};
// Paste handler
useEffect(() => {
const handler = (e: ClipboardEvent) => {
// Fields own their paste: the comment editor uploads pasted images as
// inline attachments, and the URL input has its own file handler below.
// Without this guard, pasting an image into the description would flip
// the whole modal over to a file dump.
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
const pastedFile = e.clipboardData?.files[0];
if (pastedFile) {
setValue("url", "");
selectFile(pastedFile);
clearErrors("root");
return;
}
const text = e.clipboardData?.getData("text") ?? "";
try {
const u = new URL(normalizeUrl(text));
if (u.protocol === "http:" || u.protocol === "https:") {
selectFile(null);
setValue("url", u.toString());
clearErrors("root");
}
} catch { /* not a URL */ }
};
globalThis.addEventListener("paste", handler);
return () => globalThis.removeEventListener("paste", handler);
}, [selectFile, setValue, clearErrors]);
const [dragging, setDragging] = useState(false);
// Dropping is accepted over the whole modal, not just the drop zone: once a
// link is being typed the zone is hidden, and aiming at it was never the
// point of dragging a file into a "new dump" dialog.
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
if (submitting) return;
const dropped = e.dataTransfer.files[0];
if (dropped) {
setValue("url", "");
selectFile(dropped);
clearErrors("root");
}
};
const onSubmit = form.submit(async (
{ url, file, title, comment, isPublic },
) => {
const isPrivate = !isPublic;
let res: Response;
if (!file) {
const normalizedUrl = normalizeUrl(url);
if (!normalizedUrl) throw new Error(t`URL is required.`);
setValue("url", normalizedUrl);
const body: CreateUrlDumpRequest = {
url: normalizedUrl,
// Only when the poster typed it: otherwise the server would be pinned
// to whatever the preview happened to scrape a moment ago.
title: titleFromUser && title.trim() ? title.trim() : undefined,
comment: comment.trim() || undefined,
isPrivate,
categoryIds: [...selectedCategoryIds],
};
res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST",
body: JSON.stringify(body),
});
} else {
if (!title.trim()) throw new Error(t`Title is required.`);
if (file.size > MAX_FILE_SIZE) {
throw new Error(t`File too large (max 50 MB).`);
}
const formData = new FormData();
formData.append("file", file);
if (comment.trim()) formData.append("comment", comment.trim());
if (title.trim()) formData.append("title", title.trim());
formData.append("isPrivate", String(isPrivate));
formData.append("categoryIds", JSON.stringify([...selectedCategoryIds]));
setUploadProgress(0);
try {
res = await authUpload(
`${API_URL}/api/dumps`,
formData,
setUploadProgress,
);
} finally {
setUploadProgress(undefined);
}
}
const dump = deserializeDump(await expectOk<RawDump>(res));
clearDumpDraft();
injectDump(dump);
setCreatedDump(dump);
goToStep("playlist", "forward");
setPlaylistsLoading(true);
authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`)
.then((r) => r.json())
.then((body) => {
if (body.success) {
setMemberships(
(body.data as RawPlaylistMembership[]).map(
deserializePlaylistMembership,
),
);
}
})
.catch(() => {})
.finally(() => setPlaylistsLoading(false));
});
const toggleMembership = async (membership: PlaylistMembership) => {
if (!createdDump) return;
const { playlist, hasDump } = membership;
// The box only changes once the server agrees; a refusal or a dropped
// connection leaves it showing the truth.
try {
const res = await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
{ method: hasDump ? "DELETE" : "POST" },
);
if (!res.ok) return;
} catch {
return;
}
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m
)
);
};
const canProceed = !!file || !!canonicalUrl;
// What the second panel's choices apply to, in one line.
const recap = title.trim() || file?.name || normalizeUrl(url);
const uploadPercentLabel = t`Uploading ${
Math.round((uploadProgress ?? 0) * 100)
}%`;
// Enter in the link field advances rather than posts, so the panels behave
// like a form the whole way through.
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
if (step === "what") {
e.preventDefault();
if (canProceed) goToStep("details", "forward");
return;
}
void onSubmit(e);
};
return (
<Modal
title={step === "playlist" ? t`Add to playlist` : t`New dump`}
onClose={onClose}
onBeforeClose={handleBeforeClose}
wide
>
<div
className={`dump-create${dragging ? " dump-create--drag" : ""}`}
onDragOver={(e) => {
e.preventDefault();
if (!submitting && step !== "playlist") setDragging(true);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setDragging(false);
}
}}
onDrop={handleDrop}
>
{confirmClose && (
<div className="dump-create-confirm" role="alert">
<p>
<Trans>
Close without posting? The attached file won't be kept —
everything you typed will.
</Trans>
</p>
<div className="dump-create-confirm-actions">
<button type="button" onClick={() => setConfirmClose(false)}>
<Trans>Keep editing</Trans>
</button>
<button type="button" className="btn-danger" onClick={onClose}>
<Trans>Close anyway</Trans>
</button>
</div>
</div>
)}
{draftNoticeVisible && step === "what" && (
<div className="dump-create-draft" role="status">
<span>
<Trans>Picked up where you left off.</Trans>
</span>
<button
type="button"
className="dump-create-draft-discard"
onClick={discardDraft}
>
<Trans>Start fresh</Trans>
</button>
</div>
)}
<StepIndicator current={step} />
{step === "playlist"
? (
<div className="dump-panel dump-panel--forward">
{createdDump && (
<p className="dump-create-success">
<Trans>Dumped!</Trans>{" "}
<Link to={dumpUrl(createdDump)} onClick={onClose}>
<Trans>View dump →</Trans>
</Link>
</p>
)}
<PlaylistMembershipPanel
dumpId={createdDump?.id ?? ""}
memberships={memberships}
loading={playlistsLoading}
onToggle={toggleMembership}
onPlaylistCreated={(membership) =>
setMemberships((prev) => [membership, ...prev])}
/>
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="btn-primary"
onClick={onClose}
>
<Trans>Done</Trans>
</button>
</div>
</div>
</div>
)
: (
<FormProvider {...form}>
<form onSubmit={onFormSubmit} className="form">
<FormError title={t`Failed to post`} />
{/* Keyed so each panel re-enters from the side it came from. */}
<div
key={step}
className={`dump-panel dump-panel--${direction}`}
>
{step === "what"
? (
<>
{mode === "url" && (
<>
<div className="form-field">
<label className="form-label" htmlFor="dc-url">
<Trans>Link</Trans>
</label>
<input
id="dc-url"
type="url"
{...register("url")}
onBlur={(e) =>
setValue("url", normalizeUrl(e.target.value))}
onPaste={(e) => {
const pastedFile = e.clipboardData.files[0];
if (pastedFile) {
e.preventDefault();
setValue("url", "");
selectFile(pastedFile);
clearErrors("root");
}
}}
disabled={submitting}
placeholder={t`Paste a link…`}
autoFocus={!prefersNoAutoFocus()}
/>
</div>
{urlPreview.status === "loading" && (
<p className="preview-loading">
<Trans>Fetching preview…</Trans>
</p>
)}
{urlPreview.status === "done" && (
<>
{urlPreview.richContent && (
<RichContentCard
richContent={urlPreview.richContent}
/>
)}
{!urlPreview.reached && (
<p className="preview-note">
<Trans>
Couldn't load a preview for this link —
check it, or post it as-is and refresh the
preview later.
</Trans>
</p>
)}
</>
)}
{duplicates.length > 0 && (
<DuplicateNotice
matches={duplicates}
onNavigate={onClose}
/>
)}
{urlPreview.status === "done" && (
<TitleField
registration={register("title")}
editing={titleEditing}
onEdit={() => {
setTitleEditing(true);
setTitleFromUser(true);
}}
onReset={titleFromUser && suggestedTitle
? () => {
setTitleFromUser(false);
setTitleEditing(false);
setValue("title", suggestedTitle);
}
: undefined}
disabled={submitting}
/>
)}
</>
)}
{
/* Hidden only while a link is being written: an empty
panel offers both ways in at once, no mode to pick. */
}
{!(mode === "url" && url.trim()) && (
<FileField<CreateValues>
name="file"
onValueChange={selectFile}
disabled={submitting}
hint={t`Drop a file here, or paste one`}
/>
)}
{file && (
<>
<LocalFilePreview file={file} />
<TitleField
registration={register("title")}
editing={titleEditing}
onEdit={() => {
setTitleEditing(true);
setTitleFromUser(true);
}}
disabled={submitting}
/>
</>
)}
</>
)
: (
<>
{
/* The link itself is a panel back, so name the thing
these choices are about. */
}
<p className="dump-panel-recap" title={recap}>
<span aria-hidden="true">
{mode === "url" ? "🔗" : "📎"}
</span>{" "}
{recap}
</p>
{uploadProgress !== undefined && (
<div
className={`dump-upload-progress${
typeof uploadProgress === "number"
? ""
: " dump-upload-progress--indeterminate"
}`}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={typeof uploadProgress === "number"
? Math.round(uploadProgress * 100)
: undefined}
>
<div
className="dump-upload-progress-fill"
style={typeof uploadProgress === "number"
? { width: `${uploadProgress * 100}%` }
: undefined}
/>
</div>
)}
<RichTextField<CreateValues>
name="comment"
label={t`Why?`}
placeholder={t`What makes it worth it?`}
rows={3}
maxLength={VALIDATION.DUMP_COMMENT_MAX}
disabled={submitting}
editorRef={commentEditorRef}
/>
<CategorySelect
selected={selectedCategoryIds}
onToggle={toggleCategory}
disabled={submitting}
/>
<VisibilityToggle<CreateValues>
name="isPublic"
label={t`Who can see it`}
disabled={submitting}
/>
</>
)}
</div>
<div className="form-actions">
{step === "details" && (
<button
type="button"
className="form-cancel"
onClick={() => goToStep("what", "back")}
disabled={submitting}
>
<Trans>← Back</Trans>
</button>
)}
<div className="form-actions-right">
{step === "what" && (
<button
type="button"
className="form-cancel"
onClick={requestClose}
>
<Trans>Cancel</Trans>
</button>
)}
<SubmitButton
disabled={step === "what" && !canProceed}
pendingLabel={mode === "url"
? <Trans>Posting…</Trans>
: typeof uploadProgress === "number"
? uploadPercentLabel
: <Trans>Uploading…</Trans>}
>
{step === "what"
? <Trans>Next →</Trans>
: <Trans>Dump it</Trans>}
</SubmitButton>
</div>
</div>
</form>
</FormProvider>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,47 @@
import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
import type { DumpUrlMatch } from "../../../model.ts";
import { dumpUrl } from "../../../utils/urls.ts";
import { relativeTime } from "../../../utils/relativeTime.ts";
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */
export function DuplicateNotice(
{ matches, onNavigate }: {
matches: DumpUrlMatch[];
onNavigate: () => void;
},
) {
const [first, ...rest] = matches;
return (
<div className="dump-duplicate" role="status">
<span className="dump-duplicate-icon" aria-hidden="true">↩</span>
<div className="dump-duplicate-body">
<p className="dump-duplicate-lead">
<Trans>
Already dumped by {first.username} {relativeTime(first.createdAt)}
</Trans>
</p>
<Link
to={dumpUrl(first)}
className="dump-duplicate-link"
onClick={onNavigate}
>
{first.title}
</Link>
{rest.length > 0 && (
<p className="dump-duplicate-more">
<Plural
value={rest.length}
one="and # earlier dump"
other="and # earlier dumps"
/>
</p>
)}
<p className="dump-duplicate-hint">
<Trans>You can post it anyway.</Trans>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import { MediaPlayer } from "../../player/MediaPlayer.tsx";
/** What a picked file will look like, before it's uploaded. */
export function LocalFilePreview({ file }: { file: File }) {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
const url = URL.createObjectURL(file);
// Blob URL lifecycle requires setState in effect — no synchronous alternative.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSrc(url);
return () => {
URL.revokeObjectURL(url);
};
}, [file]);
if (!src) return null;
const mime = file.type;
if (mime.startsWith("image/")) {
return <img src={src} alt={file.name} className="local-preview-image" />;
}
if (mime.startsWith("video/")) {
return <MediaPlayer key={src} src={src} kind="video" mime={mime} />;
}
if (mime.startsWith("audio/")) {
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
}
return null;
}

View File

@@ -0,0 +1,44 @@
import { t } from "@lingui/core/macro";
/**
* Posting a dump walks three panels. The split is deliberate: categories and
* visibility used to sit behind a disclosure, where they read as optional and
* went untouched — they are neither, so "why & where" is a stop on the way to
* posting rather than something to go looking for.
*/
export type Step = "what" | "details" | "playlist";
const STEPS: { id: Step; label: () => string }[] = [
{ id: "what", label: () => t`Link or file` },
{ id: "details", label: () => t`Why & where` },
{ id: "playlist", label: () => t`Playlists` },
];
/** Where you are in the three panels — and, past the first, how far you got. */
export function StepIndicator({ current }: { current: Step }) {
const currentIndex = STEPS.findIndex((s) => s.id === current);
return (
<ol className="dump-steps">
{STEPS.map((step, i) => {
const state = i < currentIndex
? "done"
: i === currentIndex
? "current"
: "todo";
return (
<li
key={step.id}
className={`dump-step dump-step--${state}`}
aria-current={state === "current" ? "step" : undefined}
>
<span className="dump-step-marker" aria-hidden="true">
{state === "done" ? "✓" : i + 1}
</span>
<span className="dump-step-label">{step.label()}</span>
</li>
);
})}
</ol>
);
}

View File

@@ -0,0 +1,75 @@
import { useRef } from "react";
import type { UseFormRegisterReturn } from "react-hook-form";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { VALIDATION } from "../../../config/api.ts";
/**
* Title row shared by both kinds of dump: the suggested title (scraped from the
* page, or derived from the filename) sits there read-only until the ✎ is used,
* which is enough to stop stray typing while keeping a correction one click
* away. Once edited, a reset offers the suggestion back.
*/
export function TitleField({
registration,
editing,
onEdit,
onReset,
disabled,
}: {
registration: UseFormRegisterReturn<"title">;
editing: boolean;
onEdit: () => void;
onReset?: () => void;
disabled: boolean;
}) {
const inputRef = useRef<HTMLInputElement | null>(null);
const { ref, ...titleReg } = registration;
return (
<div className="form-field">
<label className="form-label" htmlFor="dc-title">
<Trans>Title</Trans>
</label>
<div className="dump-create-title-row">
<input
id="dc-title"
type="text"
{...titleReg}
ref={(el) => {
ref(el);
inputRef.current = el;
}}
disabled={disabled || !editing}
maxLength={VALIDATION.DUMP_TITLE_MAX}
/>
{!editing && (
<button
type="button"
className="dump-create-title-edit-btn"
onClick={() => {
onEdit();
setTimeout(() => inputRef.current?.focus(), 0);
}}
disabled={disabled}
aria-label={t`Edit title`}
>
✎
</button>
)}
{editing && onReset && (
<button
type="button"
className="dump-create-title-edit-btn"
onClick={onReset}
disabled={disabled}
aria-label={t`Reset to the suggested title`}
title={t`Reset to the suggested title`}
>
↺
</button>
)}
</div>
</div>
);
}