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) { 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 = ( { e.stopPropagation(); markDumpVisited(dump.id); }} > {unread &&