Files
gerbeur/src/components/JournalCard.tsx
khannurien 79b7adce8f
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m59s
v3: fix the queue and re-resolve edge cases in native bandcamp playback
Re-resolving a stream is a network round-trip, and nothing checked that the
player still belonged to that item once it landed. Picking another dump —
or closing the player — while a resolve was in flight let the stale result
swap the queue back; from onError, which resolves with autoplay, it would
also start playing over whatever was chosen instead. A generation counter,
bumped by playQueue/advanceTo/stop, now drops any result that no longer owns
the player, with a separate counter owning the resolving flag so a
superseded resolve can't clear a newer one's spinner.

A track that stays dead after a re-resolve is one track, not one album: the
cooldown path now steps to the next entry and only falls back to the iframe
on the last one. A failed resolve still goes straight to the embed, since
that's an album-wide failure rather than a single bad URL. And a track that
has vanished from the release, or lost its streamable flag to Bandcamp's
free-play cap, no longer resumes track 1 at the dead track's offset — the
findIndex miss resets the offset instead of seeking past the end of another
track.

Clicking the playing row rewound it but never resumed: seekTo only moves
currentTime, so re-selecting a finished or paused track looked like a
no-op. It now resumes as well. Same idea in the compact rich-content card,
whose button advertises "Pause" while active but re-played on click — in
native mode that meant a fresh /api/bandcamp/tracks and a restart from
track 1. It pauses for streams now; embeds have no transport of their own
and keep restarting, as before.

playRichContent reports whether playback actually started, so a native-mode
Bandcamp page with no streams and no stored embedUrl — an artist root, a
/music index, a preorder — is no longer a dead click: the journal card
navigates to the dump as it used to, and the rich-content card opens the
source page.

Also: the tralbum fetch releases the response body on its two error paths.
The endpoint is unauthenticated, so random /track/<slug> URLs would leak a
connection per request while also defeating the cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E56F55N7m5GGKKEwoYFJtH
2026-08-30 17:29:14 +00:00

223 lines
6.7 KiB
TypeScript

import { Link, useNavigate } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
import { hasQuote, hasThumbnail, type JournalShape } from "../utils/journalLayout.ts";
import { VoteButton } from "./VoteButton.tsx";
import { Markdown } from "./Markdown.tsx";
import { Tooltip } from "./Tooltip.tsx";
import Thumbnail from "./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) => void;
removeVote: (id: string) => void;
isOwner?: boolean;
}
export function JournalCard(
{ dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }:
JournalCardProps,
) {
const navigate = useNavigate();
const { token } = useAuth();
const { playRichContent } = usePlayRichContent();
const unread = !isOwner && isRecent(dump.createdAt) &&
!isDumpVisited(dump.id);
function handleNavigate() {
markDumpVisited(dump.id);
navigate(dumpUrl(dump));
}
// 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))) handleNavigate();
}
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
// still here too, rather than degrading to a text card with a 🎬.
const thumbnailUrl = dump.thumbnailMime
? dumpThumbnailUrl(dump, token)
: dump.kind === "file"
? (dump.fileMime?.startsWith("image/")
? dumpFileUrl(dump, token)
: dump.fileMime?.startsWith("video/")
? dumpThumbnailUrl(dump, token)
: 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}
onClick={playable
? () => void handlePlayOrNavigate(playable)
: handleNavigate}
>
<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} onClick={handleNavigate}>
<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} onClick={handleNavigate}>
<span className="journal-card-glyph" aria-hidden="true">
{fallbackIcon}
</span>
{titleLink}
{footer}
</li>
);
}