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:
149
src/components/dump/DumpCard.tsx
Normal file
149
src/components/dump/DumpCard.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
334
src/components/dump/FilePreview.tsx
Normal file
334
src/components/dump/FilePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
src/components/dump/JournalCard.tsx
Normal file
233
src/components/dump/JournalCard.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
161
src/components/dump/RichContentCard.tsx
Normal file
161
src/components/dump/RichContentCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
802
src/components/dump/create/DumpCreateModal.tsx
Normal file
802
src/components/dump/create/DumpCreateModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/dump/create/DuplicateNotice.tsx
Normal file
47
src/components/dump/create/DuplicateNotice.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Link } from "react-router";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { DumpUrlMatch } from "../../../model.ts";
|
||||
import { dumpUrl } from "../../../utils/urls.ts";
|
||||
import { relativeTime } from "../../../utils/relativeTime.ts";
|
||||
|
||||
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */
|
||||
export function DuplicateNotice(
|
||||
{ matches, onNavigate }: {
|
||||
matches: DumpUrlMatch[];
|
||||
onNavigate: () => void;
|
||||
},
|
||||
) {
|
||||
const [first, ...rest] = matches;
|
||||
|
||||
return (
|
||||
<div className="dump-duplicate" role="status">
|
||||
<span className="dump-duplicate-icon" aria-hidden="true">↩</span>
|
||||
<div className="dump-duplicate-body">
|
||||
<p className="dump-duplicate-lead">
|
||||
<Trans>
|
||||
Already dumped by {first.username} {relativeTime(first.createdAt)}
|
||||
</Trans>
|
||||
</p>
|
||||
<Link
|
||||
to={dumpUrl(first)}
|
||||
className="dump-duplicate-link"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{first.title}
|
||||
</Link>
|
||||
{rest.length > 0 && (
|
||||
<p className="dump-duplicate-more">
|
||||
<Plural
|
||||
value={rest.length}
|
||||
one="and # earlier dump"
|
||||
other="and # earlier dumps"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<p className="dump-duplicate-hint">
|
||||
<Trans>You can post it anyway.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/components/dump/create/LocalFilePreview.tsx
Normal file
30
src/components/dump/create/LocalFilePreview.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MediaPlayer } from "../../player/MediaPlayer.tsx";
|
||||
|
||||
/** What a picked file will look like, before it's uploaded. */
|
||||
export function LocalFilePreview({ file }: { file: File }) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
// Blob URL lifecycle requires setState in effect — no synchronous alternative.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSrc(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
if (!src) return null;
|
||||
const mime = file.type;
|
||||
if (mime.startsWith("image/")) {
|
||||
return <img src={src} alt={file.name} className="local-preview-image" />;
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
return <MediaPlayer key={src} src={src} kind="video" mime={mime} />;
|
||||
}
|
||||
if (mime.startsWith("audio/")) {
|
||||
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
44
src/components/dump/create/StepIndicator.tsx
Normal file
44
src/components/dump/create/StepIndicator.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
/**
|
||||
* Posting a dump walks three panels. The split is deliberate: categories and
|
||||
* visibility used to sit behind a disclosure, where they read as optional and
|
||||
* went untouched — they are neither, so "why & where" is a stop on the way to
|
||||
* posting rather than something to go looking for.
|
||||
*/
|
||||
export type Step = "what" | "details" | "playlist";
|
||||
|
||||
const STEPS: { id: Step; label: () => string }[] = [
|
||||
{ id: "what", label: () => t`Link or file` },
|
||||
{ id: "details", label: () => t`Why & where` },
|
||||
{ id: "playlist", label: () => t`Playlists` },
|
||||
];
|
||||
|
||||
/** Where you are in the three panels — and, past the first, how far you got. */
|
||||
export function StepIndicator({ current }: { current: Step }) {
|
||||
const currentIndex = STEPS.findIndex((s) => s.id === current);
|
||||
|
||||
return (
|
||||
<ol className="dump-steps">
|
||||
{STEPS.map((step, i) => {
|
||||
const state = i < currentIndex
|
||||
? "done"
|
||||
: i === currentIndex
|
||||
? "current"
|
||||
: "todo";
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
className={`dump-step dump-step--${state}`}
|
||||
aria-current={state === "current" ? "step" : undefined}
|
||||
>
|
||||
<span className="dump-step-marker" aria-hidden="true">
|
||||
{state === "done" ? "✓" : i + 1}
|
||||
</span>
|
||||
<span className="dump-step-label">{step.label()}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
75
src/components/dump/create/TitleField.tsx
Normal file
75
src/components/dump/create/TitleField.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useRef } from "react";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { VALIDATION } from "../../../config/api.ts";
|
||||
|
||||
/**
|
||||
* Title row shared by both kinds of dump: the suggested title (scraped from the
|
||||
* page, or derived from the filename) sits there read-only until the ✎ is used,
|
||||
* which is enough to stop stray typing while keeping a correction one click
|
||||
* away. Once edited, a reset offers the suggestion back.
|
||||
*/
|
||||
export function TitleField({
|
||||
registration,
|
||||
editing,
|
||||
onEdit,
|
||||
onReset,
|
||||
disabled,
|
||||
}: {
|
||||
registration: UseFormRegisterReturn<"title">;
|
||||
editing: boolean;
|
||||
onEdit: () => void;
|
||||
onReset?: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { ref, ...titleReg } = registration;
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
<label className="form-label" htmlFor="dc-title">
|
||||
<Trans>Title</Trans>
|
||||
</label>
|
||||
<div className="dump-create-title-row">
|
||||
<input
|
||||
id="dc-title"
|
||||
type="text"
|
||||
{...titleReg}
|
||||
ref={(el) => {
|
||||
ref(el);
|
||||
inputRef.current = el;
|
||||
}}
|
||||
disabled={disabled || !editing}
|
||||
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
||||
/>
|
||||
{!editing && (
|
||||
<button
|
||||
type="button"
|
||||
className="dump-create-title-edit-btn"
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={t`Edit title`}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{editing && onReset && (
|
||||
<button
|
||||
type="button"
|
||||
className="dump-create-title-edit-btn"
|
||||
onClick={onReset}
|
||||
disabled={disabled}
|
||||
aria-label={t`Reset to the suggested title`}
|
||||
title={t`Reset to the suggested title`}
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user