import { useContext } from "react"; import { Link, useNavigate } from "react-router"; import { Plural, Trans } from "@lingui/react/macro"; import type { Dump } from "../model.ts"; import { API_URL } from "../config/api.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 { PlayerContext } from "../contexts/PlayerContext.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 { play } = useContext(PlayerContext); const unread = !isOwner && isRecent(dump.createdAt) && !isDumpVisited(dump.id); function handleNavigate() { markDumpVisited(dump.id); navigate(dumpUrl(dump)); } const rawThumbnail = dump.kind === "file" && dump.fileMime?.startsWith("image/") ? dumpFileUrl(dump, token) : dump.thumbnailMime ? dumpThumbnailUrl(dump, token) : (dump.richContent?.thumbnailUrl ?? null); // Route external HTTP thumbnails through the server proxy to avoid // mixed-content blocks when the frontend is served over HTTPS. const thumbnailUrl = (() => { if (!rawThumbnail) return null; try { const u = new URL(rawThumbnail); if ( u.protocol === "http:" && u.hostname !== "localhost" && u.hostname !== "127.0.0.1" ) { return `${API_URL}/api/proxy-image?url=${ encodeURIComponent(rawThumbnail) }`; } } catch { /* relative URL */ } return rawThumbnail; })(); // 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 embedUrl = dump.richContent?.embedUrl; const titleLink = ( { e.stopPropagation(); markDumpVisited(dump.id); }} > {unread &&