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

253 lines
7.8 KiB
TypeScript

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