Files
gerbeur/src/components/dump/JournalCard.tsx
khannurien 2b6e68391b v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:44:14 +00:00

234 lines
7.1 KiB
TypeScript

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/shared/relativeTime.ts";
import { dumpThumbnailUrl, dumpUrl } from "../../utils/shared/urls.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts";
import {
isDumpVisited,
isRecent,
markDumpVisited,
} from "../../utils/shared/visited.ts";
import {
hasQuote,
hasThumbnail,
type JournalShape,
} from "../../utils/feed/journalLayout.ts";
import { VoteButton } from "../social/VoteButton.tsx";
import { Markdown } from "../editor/Markdown.tsx";
import { Tooltip } from "../shared/Tooltip.tsx";
import { Thumbnail } from "../shared/Thumbnail.tsx";
import {
canPlayRichContent,
usePlayRichContent,
} from "../../hooks/player/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>
);
});