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:
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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user