Files
gerbeur/src/components/JournalCard.tsx
khannurien 324bff3133
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 39s
v3: various minor visual fixes
2026-06-28 17:00:44 +00:00

223 lines
6.2 KiB
TypeScript

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 = (
<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={embedUrl
? () =>
play({
kind: "embed",
embedUrl,
title: dump.richContent?.title,
type: dump.richContent?.type ?? "unknown",
dumpHref: dumpUrl(dump),
})
: handleNavigate}
>
<div className="journal-card-image">
<img
src={thumbnailUrl ?? undefined}
alt=""
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.visibility = "hidden";
}}
/>
{embedUrl && (
<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>
);
}