All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 39s
223 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|