253 lines
7.8 KiB
TypeScript
253 lines
7.8 KiB
TypeScript
/**
|
|
* A dump, as a tile of the journal's mosaic.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { memo } from "react";
|
|
import { Link } from "react-router";
|
|
import { Plural, Trans } from "@lingui/react/macro";
|
|
import type { Dump } from "../../../shared/dump.ts";
|
|
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
|
import { dumpThumbnailUrl, dumpUrl } from "../../utils/shared/urls.ts";
|
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
|
import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts";
|
|
import {
|
|
isDumpVisited,
|
|
isRecent,
|
|
markDumpVisited,
|
|
} from "../../utils/shared/visited.ts";
|
|
import {
|
|
hasQuote,
|
|
hasThumbnail,
|
|
type JournalShape,
|
|
} from "../../utils/feed/journalLayout.ts";
|
|
import { VoteButton } from "../social/VoteButton.tsx";
|
|
import { Markdown } from "../editor/Markdown.tsx";
|
|
import { Tooltip } from "../shared/Tooltip.tsx";
|
|
import { Thumbnail } from "../shared/Thumbnail.tsx";
|
|
import {
|
|
canPlayRichContent,
|
|
usePlayRichContent,
|
|
} from "../../hooks/player/usePlayRichContent.ts";
|
|
|
|
/** The props of {@link JournalCard}. */
|
|
export interface JournalCardProps {
|
|
/** The dump. */
|
|
dump: Dump;
|
|
/** The tile's footprint in the mosaic. */
|
|
shape: JournalShape;
|
|
/** Its live vote count. */
|
|
voteCount: number;
|
|
/** Whether the user upvoted it. */
|
|
voted: boolean;
|
|
/** Whether the user may vote (signed in). */
|
|
canVote: boolean;
|
|
/** Upvotes dump `id`; `shownCount` is the count on screen. */
|
|
castVote: (id: string, shownCount?: number) => void;
|
|
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
|
|
removeVote: (id: string, shownCount?: number) => void;
|
|
/** Whether the viewer posted it: no unread dot. */
|
|
isOwner?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Renders a dump as a mosaic tile — an image card, a pull-quote or a text card,
|
|
* by its content. A playable card plays; one that can't opens the dump.
|
|
* Memoized, like DumpCard: live events re-render the feed, not every card.
|
|
*/
|
|
export const JournalCard: React.MemoExoticComponent<
|
|
(props: JournalCardProps) => React.JSX.Element
|
|
> = memo(function JournalCard(
|
|
{ dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }:
|
|
JournalCardProps,
|
|
) {
|
|
const { go, cardProps, handledAsNewTab } = useDumpCardLink(dump);
|
|
const { mediaToken } = useAuth();
|
|
const { playRichContent } = usePlayRichContent();
|
|
const unread = !isOwner && isRecent(dump.createdAt) &&
|
|
!isDumpVisited(dump.id);
|
|
|
|
// 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))) go();
|
|
}
|
|
|
|
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
|
|
// still here too, rather than degrading to a text card with a 🎬. An image
|
|
// shows the server's scaled-down preview, not the full upload.
|
|
const thumbnailUrl = dump.thumbnailMime
|
|
? dumpThumbnailUrl(dump, mediaToken)
|
|
: dump.kind === "file"
|
|
? (dump.fileMime?.startsWith("image/") ||
|
|
dump.fileMime?.startsWith("video/")
|
|
? dumpThumbnailUrl(dump, mediaToken)
|
|
: 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}
|
|
{...cardProps}
|
|
onClick={playable
|
|
? (e) => {
|
|
if (!handledAsNewTab(e)) void handlePlayOrNavigate(playable);
|
|
}
|
|
: cardProps.onClick}
|
|
>
|
|
<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} {...cardProps}>
|
|
<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} {...cardProps}>
|
|
<span className="journal-card-glyph" aria-hidden="true">
|
|
{fallbackIcon}
|
|
</span>
|
|
{titleLink}
|
|
{footer}
|
|
</li>
|
|
);
|
|
});
|