v3: added journal view to the index, code organization pass
This commit is contained in:
@@ -16,7 +16,7 @@ export function AppHeader(
|
||||
<header
|
||||
className={`app-header${centerSlot ? " app-header--has-center" : ""}`}
|
||||
>
|
||||
<Link to="/" state={{ tab: "hot" }} className="app-header-brand">
|
||||
<Link to="/?tab=hot" className="app-header-brand">
|
||||
🚚 gerbeur
|
||||
</Link>
|
||||
|
||||
|
||||
187
src/components/JournalCard.tsx
Normal file
187
src/components/JournalCard.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useContext } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import type { Dump } from "../model.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { dumpUrl } from "../utils/urls.ts";
|
||||
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||
import { VoteButton } from "./VoteButton.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||
|
||||
export type JournalTier = "large" | "medium" | "small";
|
||||
|
||||
interface JournalCardProps {
|
||||
dump: Dump;
|
||||
tier: JournalTier;
|
||||
voteCount: number;
|
||||
voted: boolean;
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
export function JournalCard(
|
||||
{ dump, tier, voteCount, voted, canVote, castVote, removeVote, isOwner }:
|
||||
JournalCardProps,
|
||||
) {
|
||||
const navigate = useNavigate();
|
||||
const { play } = useContext(PlayerContext);
|
||||
const unread = !isOwner && isRecent(dump.createdAt) && !isDumpVisited(dump.id);
|
||||
|
||||
function handleNavigate() {
|
||||
markDumpVisited(dump.id);
|
||||
navigate(dumpUrl(dump));
|
||||
}
|
||||
|
||||
const thumbnailUrl = dump.kind === "file" &&
|
||||
dump.fileMime?.startsWith("image/")
|
||||
? `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`
|
||||
: (dump.richContent?.thumbnailUrl ?? null);
|
||||
|
||||
const fallbackIcon = dump.kind === "file"
|
||||
? (() => {
|
||||
const m = dump.fileMime ?? "";
|
||||
if (m.startsWith("video/")) return "🎬";
|
||||
if (m.startsWith("audio/")) return "🎵";
|
||||
return "📄";
|
||||
})()
|
||||
: "🔗";
|
||||
|
||||
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>
|
||||
{dump.commentCount}{" "}
|
||||
{dump.commentCount === 1 ? "comment" : "comments"}
|
||||
</span>
|
||||
)}
|
||||
{dump.isPrivate && isOwner && (
|
||||
<span className="dump-card-private-badge">private</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const vote = (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<VoteButton
|
||||
dumpId={dump.id}
|
||||
count={voteCount}
|
||||
voted={voted}
|
||||
disabled={!canVote}
|
||||
onCast={castVote}
|
||||
onRemove={removeVote}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const embedUrl = dump.richContent?.embedUrl;
|
||||
|
||||
if (tier === "large") {
|
||||
return (
|
||||
<li className="journal-card journal-card--large" onClick={handleNavigate}>
|
||||
{thumbnailUrl && (
|
||||
<div
|
||||
className="journal-card-media"
|
||||
onClick={embedUrl
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
play({
|
||||
embedUrl,
|
||||
title: dump.richContent?.title,
|
||||
type: dump.richContent?.type ?? "unknown",
|
||||
});
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={dump.title}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
{embedUrl && (
|
||||
<span className="rich-content-play-overlay" aria-hidden="true">
|
||||
▶
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="journal-card-body">
|
||||
{titleLink}
|
||||
{dump.comment && (
|
||||
<Markdown className="journal-card-comment">{dump.comment}</Markdown>
|
||||
)}
|
||||
<div className="journal-card-footer">
|
||||
{meta}
|
||||
{vote}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
if (tier === "medium") {
|
||||
return (
|
||||
<li className="journal-card journal-card--medium" onClick={handleNavigate}>
|
||||
<div className="journal-card-inner">
|
||||
<div className="journal-card-icon">
|
||||
{thumbnailUrl
|
||||
? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: <span>{fallbackIcon}</span>}
|
||||
</div>
|
||||
<div className="journal-card-body">
|
||||
{titleLink}
|
||||
{dump.comment && (
|
||||
<Markdown className="journal-card-comment">
|
||||
{dump.comment}
|
||||
</Markdown>
|
||||
)}
|
||||
<div className="journal-card-footer">
|
||||
{meta}
|
||||
{vote}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// small
|
||||
return (
|
||||
<li className="journal-card journal-card--small" onClick={handleNavigate}>
|
||||
{titleLink}
|
||||
{vote}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user