v3: overhauled journal view
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s

This commit is contained in:
khannurien
2026-06-27 10:57:12 +00:00
parent 4cb537f4d9
commit 01940e14be
6 changed files with 377 additions and 239 deletions

View File

@@ -3998,106 +3998,55 @@ body.has-player .fab-new {
/* ── Journal masonry grid ── */ /* ── Journal masonry grid ── */
/* ══ Journal — editorial mosaic ══════════════════════════════════════
A composed scrapbook: one hero feature plus an asymmetric mix of
banners, portraits, squares and pull-quotes. Footprints are set by the
layout composer; how a card paints (image / quote / text) follows its
content. Dense auto-flow backfills the gaps the varied spans leave. */
.journal-grid { .journal-grid {
list-style: none; list-style: none;
margin: 0; margin: 0 auto;
padding: 1rem 1.25rem 0; padding: 1.25rem;
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 9rem;
grid-auto-flow: dense; grid-auto-flow: dense;
gap: 0.875rem; gap: 0.75rem;
max-width: 1100px; max-width: 1180px;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
align-self: center;
} }
/* ── Footprints ── */
.journal-card--feature {
grid-column: span 2;
grid-row: span 2;
}
.journal-card--wide {
grid-column: span 2;
}
.journal-card--tall {
grid-row: span 2;
}
/* ── Shared shell ── */
.journal-card { .journal-card {
position: relative;
display: flex;
flex-direction: column;
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
border-radius: 10px; border-radius: 12px;
background: var(--color-surface); background: var(--color-surface);
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
transition: border-color 0.15s, opacity 0.15s;
min-width: 0; min-width: 0;
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
} }
.journal-card:hover { .journal-card:hover {
border-color: var(--color-accent); border-color: var(--color-accent);
} transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
/* Large: spans 2 columns, media header + body */
.journal-card--large {
grid-column: span 2;
}
.journal-card--large .journal-card-media {
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.journal-card--large .journal-card-media .rich-content-play-overlay {
font-size: 2rem;
}
.journal-card--large .journal-card-media:hover .rich-content-play-overlay {
background: rgba(0, 0, 0, 0.5);
}
.journal-card--large .journal-card-media img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Medium: icon left + content right */
.journal-card--medium .journal-card-inner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
}
.journal-card-icon {
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: 6px;
overflow: hidden;
background: var(--color-bg);
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
}
.journal-card-icon img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Shared body */
.journal-card-body {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
flex: 1;
}
.journal-card--medium .journal-card-body {
padding: 0;
} }
.journal-card-title { .journal-card-title {
@@ -4105,66 +4054,191 @@ body.has-player .fab-new {
font-size: 0.95rem; font-size: 0.95rem;
color: var(--color-text); color: var(--color-text);
text-decoration: none; text-decoration: none;
line-height: 1.35; line-height: 1.3;
word-break: break-word; word-break: break-word;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
} }
.journal-card-title:hover { .journal-card-title:hover {
color: var(--color-accent); color: var(--color-accent);
} }
.journal-card-comment {
font-size: 0.82rem;
opacity: 0.65;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
margin: 0;
line-height: 1.4;
}
.journal-card--large .journal-card-comment {
-webkit-line-clamp: 3;
line-clamp: 3;
}
.journal-card--medium .journal-card-comment {
-webkit-line-clamp: 1;
line-clamp: 1;
}
.journal-card-footer { .journal-card-footer {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.2rem;
} }
.journal-card-meta { .journal-card-meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.78rem; font-size: 0.74rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
flex-wrap: wrap; flex-wrap: wrap;
min-width: 0;
} }
/* Small: single compact row */ .journal-card-vote {
.journal-card--small { flex-shrink: 0;
}
/* ── Image cards — full-bleed thumbnail, text laid over a scrim ── */
.journal-card--image .journal-card-image {
position: absolute;
inset: 0;
background: var(--color-bg);
}
.journal-card--image .journal-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* The play affordance is decorative — clicks fall through to the card, which
plays the embed. This keeps the hit target from being covered by the text
overlay on smaller card sizes. */
.journal-card--image .rich-content-play-overlay {
pointer-events: none;
}
.journal-card--image:hover .rich-content-play-overlay {
background: rgba(0, 0, 0, 0.5);
}
.journal-card--image .journal-card-overlay {
position: relative;
margin-top: auto;
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: space-between; gap: 0.3rem;
gap: 0.5rem; padding: 1.4rem 0.8rem 0.65rem;
padding: 0.55rem 0.875rem; background: linear-gradient(
to top,
rgba(0, 0, 0, 0.85) 0%,
rgba(0, 0, 0, 0.55) 55%,
rgba(0, 0, 0, 0) 100%
);
} }
.journal-card--small .journal-card-title { .journal-card--image .journal-card-title {
flex: 1; color: #fff;
white-space: nowrap; }
.journal-card--image .journal-card-title:hover {
color: var(--color-accent);
}
.journal-card--image .journal-card-meta {
color: rgba(255, 255, 255, 0.78);
}
.journal-card--image .journal-card-comment {
font-size: 0.82rem;
line-height: 1.4;
margin: 0;
color: rgba(255, 255, 255, 0.85);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; }
font-size: 0.87rem;
.journal-card--feature .journal-card-title {
font-size: 1.25rem;
-webkit-line-clamp: 3;
line-clamp: 3;
}
.journal-card--square.journal-card--image .journal-card-title {
font-size: 0.85rem;
}
/* ── Quote cards — the note is the subject, on an accent wash ── */
.journal-card--quote {
padding: 0.9rem;
gap: 0.4rem;
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-surface));
}
.journal-card-quotemark {
position: absolute;
top: -0.5rem;
right: 0.4rem;
font-family: Georgia, "Times New Roman", serif;
font-size: 4.5rem;
line-height: 1;
color: var(--color-accent);
opacity: 0.22;
pointer-events: none;
}
.journal-card-quote-body {
position: relative;
z-index: 1;
margin: 0;
font-size: 0.92rem;
line-height: 1.45;
color: var(--color-text);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
}
.journal-card--wide.journal-card--quote .journal-card-quote-body,
.journal-card--feature.journal-card--quote .journal-card-quote-body {
font-size: 1rem;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.journal-card-attribution {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 0.3rem;
padding-top: 0.5rem;
}
.journal-card--quote .journal-card-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-secondary);
-webkit-line-clamp: 1;
line-clamp: 1;
}
.journal-card--quote .journal-card-title::before {
content: "— ";
}
/* ── Text cards — typographic fallback for note-less, image-less dumps ── */
.journal-card--text {
padding: 0.85rem;
gap: 0.4rem;
}
.journal-card-glyph {
font-size: 1.5rem;
line-height: 1;
}
.journal-card--text .journal-card-title {
font-size: 0.92rem;
-webkit-line-clamp: 3;
line-clamp: 3;
}
.journal-card--text .journal-card-footer {
margin-top: auto;
} }
/* feed-end message */ /* feed-end message */
@@ -4176,20 +4250,37 @@ body.has-player .fab-new {
opacity: 0.55; opacity: 0.55;
} }
@media (max-width: 860px) { @media (max-width: 980px) {
.journal-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 680px) {
.journal-grid { .journal-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
} }
@media (max-width: 540px) { @media (max-width: 460px) {
.journal-grid { .journal-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
padding: 0.75rem 1rem 0; grid-auto-rows: auto;
padding: 0.85rem 1rem;
} }
.journal-card--large { /* Collapse the mosaic into a single readable column. */
grid-column: span 1; .journal-card {
grid-column: auto;
grid-row: auto;
}
.journal-card--image {
aspect-ratio: 16 / 10;
}
.journal-card--feature.journal-card--image {
aspect-ratio: 4 / 3;
} }
} }

View File

@@ -7,16 +7,17 @@ import { relativeTime } from "../utils/relativeTime.ts";
import { dumpFileUrl, dumpUrl } from "../utils/urls.ts"; import { dumpFileUrl, dumpUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts"; import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
import { hasQuote, hasThumbnail, type JournalShape } from "../utils/journalLayout.ts";
import { VoteButton } from "./VoteButton.tsx"; import { VoteButton } from "./VoteButton.tsx";
import { Markdown } from "./Markdown.tsx"; import { Markdown } from "./Markdown.tsx";
import { Tooltip } from "./Tooltip.tsx"; import { Tooltip } from "./Tooltip.tsx";
import { PlayerContext } from "../contexts/PlayerContext.ts"; import { PlayerContext } from "../contexts/PlayerContext.ts";
export type JournalTier = "large" | "medium" | "small"; export type { JournalShape };
interface JournalCardProps { interface JournalCardProps {
dump: Dump; dump: Dump;
tier: JournalTier; shape: JournalShape;
voteCount: number; voteCount: number;
voted: boolean; voted: boolean;
canVote: boolean; canVote: boolean;
@@ -26,7 +27,7 @@ interface JournalCardProps {
} }
export function JournalCard( export function JournalCard(
{ dump, tier, voteCount, voted, canVote, castVote, removeVote, isOwner }: { dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }:
JournalCardProps, JournalCardProps,
) { ) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -63,6 +64,15 @@ export function JournalCard(
return rawThumbnail; 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 fallbackIcon = dump.kind === "file"
? (() => { ? (() => {
const m = dump.fileMime ?? ""; const m = dump.fileMime ?? "";
@@ -72,6 +82,8 @@ export function JournalCard(
})() })()
: "🔗"; : "🔗";
const embedUrl = dump.richContent?.embedUrl;
const titleLink = ( const titleLink = (
<Link <Link
to={dumpUrl(dump)} to={dumpUrl(dump)}
@@ -111,7 +123,7 @@ export function JournalCard(
); );
const vote = ( const vote = (
<div onClick={(e) => e.stopPropagation()}> <div className="journal-card-vote" onClick={(e) => e.stopPropagation()}>
<VoteButton <VoteButton
dumpId={dump.id} dumpId={dump.id}
count={voteCount} count={voteCount}
@@ -123,96 +135,85 @@ export function JournalCard(
</div> </div>
); );
const embedUrl = dump.richContent?.embedUrl; const footer = (
<div className="journal-card-footer">
{meta}
{vote}
</div>
);
if (tier === "large") { const className =
return ( `journal-card journal-card--${shape} journal-card--${mode}`;
<li className="journal-card journal-card--large" onClick={handleNavigate}>
{thumbnailUrl && (
<div
className="journal-card-media"
onClick={embedUrl
? (e) => {
e.stopPropagation();
play({
kind: "embed",
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") { 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 ( return (
<li <li
className="journal-card journal-card--medium" className={className}
onClick={handleNavigate} onClick={embedUrl
? () =>
play({
kind: "embed",
embedUrl,
title: dump.richContent?.title,
type: dump.richContent?.type ?? "unknown",
})
: handleNavigate}
> >
<div className="journal-card-inner"> <div className="journal-card-image">
<div className="journal-card-icon"> <img
{thumbnailUrl src={thumbnailUrl ?? undefined}
? ( alt=""
<img loading="lazy"
src={thumbnailUrl} onError={(e) => {
alt="" (e.target as HTMLImageElement).style.visibility = "hidden";
onError={(e) => { }}
(e.target as HTMLImageElement).style.display = "none"; />
}} {embedUrl && (
/> <span className="rich-content-play-overlay" aria-hidden="true">
)
: <span>{fallbackIcon}</span>} </span>
</div> )}
<div className="journal-card-body"> </div>
{titleLink} <div className="journal-card-overlay">
{dump.comment && ( {titleLink}
<Markdown className="journal-card-comment"> {showComment && (
{dump.comment} <Markdown className="journal-card-comment" inline>
</Markdown> {dump.comment!}
)} </Markdown>
<div className="journal-card-footer"> )}
{meta} {footer}
{vote}
</div>
</div>
</div> </div>
</li> </li>
); );
} }
// small 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 ( return (
<li className="journal-card journal-card--small" onClick={handleNavigate}> <li className={className} onClick={handleNavigate}>
<span className="journal-card-glyph" aria-hidden="true">
{fallbackIcon}
</span>
{titleLink} {titleLink}
{vote} {footer}
</li> </li>
); );
} }

View File

@@ -19,7 +19,7 @@ msgstr "[deleted]"
#. placeholder {0}: dump.commentCount #. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:105 #: src/components/DumpCard.tsx:105
#: src/components/JournalCard.tsx:98 #: src/components/JournalCard.tsx:110
msgid "{0, plural, one {# comment} other {# comments}}" msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}"
@@ -395,7 +395,7 @@ msgstr "Failed to generate invite"
#: src/pages/index/FollowedFeed.tsx:82 #: src/pages/index/FollowedFeed.tsx:82
#: src/pages/index/HotFeed.tsx:36 #: src/pages/index/HotFeed.tsx:36
#: src/pages/index/JournalFeed.tsx:48 #: src/pages/index/JournalFeed.tsx:33
#: src/pages/index/NewFeed.tsx:36 #: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:367 #: src/pages/Notifications.tsx:367
#: src/pages/UserPublicProfile.tsx:984 #: src/pages/UserPublicProfile.tsx:984
@@ -566,7 +566,7 @@ msgstr "Loading dump…"
#: src/pages/index/FollowedFeed.tsx:110 #: src/pages/index/FollowedFeed.tsx:110
#: src/pages/index/HotFeed.tsx:64 #: src/pages/index/HotFeed.tsx:64
#: src/pages/index/JournalFeed.tsx:77 #: src/pages/index/JournalFeed.tsx:62
#: src/pages/index/NewFeed.tsx:64 #: src/pages/index/NewFeed.tsx:64
#: src/pages/Search.tsx:242 #: src/pages/Search.tsx:242
#: src/pages/UserDumps.tsx:93 #: src/pages/UserDumps.tsx:93
@@ -590,7 +590,7 @@ msgstr "Loading profile…"
#: src/components/UserListPopover.tsx:230 #: src/components/UserListPopover.tsx:230
#: src/pages/index/FollowedFeed.tsx:77 #: src/pages/index/FollowedFeed.tsx:77
#: src/pages/index/HotFeed.tsx:32 #: src/pages/index/HotFeed.tsx:32
#: src/pages/index/JournalFeed.tsx:44 #: src/pages/index/JournalFeed.tsx:29
#: src/pages/index/NewFeed.tsx:32 #: src/pages/index/NewFeed.tsx:32
#: src/pages/Notifications.tsx:363 #: src/pages/Notifications.tsx:363
#: src/pages/Notifications.tsx:439 #: src/pages/Notifications.tsx:439
@@ -667,7 +667,7 @@ msgid "No dumps match \"{q}\"."
msgstr "No dumps match \"{q}\"." msgstr "No dumps match \"{q}\"."
#: src/pages/index/HotFeed.tsx:40 #: src/pages/index/HotFeed.tsx:40
#: src/pages/index/JournalFeed.tsx:52 #: src/pages/index/JournalFeed.tsx:37
#: src/pages/index/NewFeed.tsx:40 #: src/pages/index/NewFeed.tsx:40
msgid "No dumps yet. Be the first!" msgid "No dumps yet. Be the first!"
msgstr "No dumps yet. Be the first!" msgstr "No dumps yet. Be the first!"
@@ -791,7 +791,7 @@ msgid "Posting…"
msgstr "Posting…" msgstr "Posting…"
#: src/components/DumpCard.tsx:114 #: src/components/DumpCard.tsx:114
#: src/components/JournalCard.tsx:107 #: src/components/JournalCard.tsx:119
#: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55 #: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:416 #: src/pages/Dump.tsx:416
@@ -1082,7 +1082,7 @@ msgstr "You'll be notified when someone follows your playlists, upvotes your dum
#: src/pages/index/FollowedFeed.tsx:115 #: src/pages/index/FollowedFeed.tsx:115
#: src/pages/index/HotFeed.tsx:69 #: src/pages/index/HotFeed.tsx:69
#: src/pages/index/JournalFeed.tsx:82 #: src/pages/index/JournalFeed.tsx:67
#: src/pages/index/NewFeed.tsx:69 #: src/pages/index/NewFeed.tsx:69
#: src/pages/Search.tsx:247 #: src/pages/Search.tsx:247
#: src/pages/UserDumps.tsx:98 #: src/pages/UserDumps.tsx:98

View File

@@ -19,7 +19,7 @@ msgstr "[supprimé]"
#. placeholder {0}: dump.commentCount #. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:105 #: src/components/DumpCard.tsx:105
#: src/components/JournalCard.tsx:98 #: src/components/JournalCard.tsx:110
msgid "{0, plural, one {# comment} other {# comments}}" msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
@@ -395,7 +395,7 @@ msgstr "Impossible de générer une invitation"
#: src/pages/index/FollowedFeed.tsx:82 #: src/pages/index/FollowedFeed.tsx:82
#: src/pages/index/HotFeed.tsx:36 #: src/pages/index/HotFeed.tsx:36
#: src/pages/index/JournalFeed.tsx:48 #: src/pages/index/JournalFeed.tsx:33
#: src/pages/index/NewFeed.tsx:36 #: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:367 #: src/pages/Notifications.tsx:367
#: src/pages/UserPublicProfile.tsx:984 #: src/pages/UserPublicProfile.tsx:984
@@ -566,7 +566,7 @@ msgstr "Chargement de la reco…"
#: src/pages/index/FollowedFeed.tsx:110 #: src/pages/index/FollowedFeed.tsx:110
#: src/pages/index/HotFeed.tsx:64 #: src/pages/index/HotFeed.tsx:64
#: src/pages/index/JournalFeed.tsx:77 #: src/pages/index/JournalFeed.tsx:62
#: src/pages/index/NewFeed.tsx:64 #: src/pages/index/NewFeed.tsx:64
#: src/pages/Search.tsx:242 #: src/pages/Search.tsx:242
#: src/pages/UserDumps.tsx:93 #: src/pages/UserDumps.tsx:93
@@ -590,7 +590,7 @@ msgstr "Chargement du profil…"
#: src/components/UserListPopover.tsx:230 #: src/components/UserListPopover.tsx:230
#: src/pages/index/FollowedFeed.tsx:77 #: src/pages/index/FollowedFeed.tsx:77
#: src/pages/index/HotFeed.tsx:32 #: src/pages/index/HotFeed.tsx:32
#: src/pages/index/JournalFeed.tsx:44 #: src/pages/index/JournalFeed.tsx:29
#: src/pages/index/NewFeed.tsx:32 #: src/pages/index/NewFeed.tsx:32
#: src/pages/Notifications.tsx:363 #: src/pages/Notifications.tsx:363
#: src/pages/Notifications.tsx:439 #: src/pages/Notifications.tsx:439
@@ -667,7 +667,7 @@ msgid "No dumps match \"{q}\"."
msgstr "Aucune reco ne correspond à « {q} »." msgstr "Aucune reco ne correspond à « {q} »."
#: src/pages/index/HotFeed.tsx:40 #: src/pages/index/HotFeed.tsx:40
#: src/pages/index/JournalFeed.tsx:52 #: src/pages/index/JournalFeed.tsx:37
#: src/pages/index/NewFeed.tsx:40 #: src/pages/index/NewFeed.tsx:40
msgid "No dumps yet. Be the first!" msgid "No dumps yet. Be the first!"
msgstr "Pas encore de recos. Soyez le premier !" msgstr "Pas encore de recos. Soyez le premier !"
@@ -791,7 +791,7 @@ msgid "Posting…"
msgstr "Publication…" msgstr "Publication…"
#: src/components/DumpCard.tsx:114 #: src/components/DumpCard.tsx:114
#: src/components/JournalCard.tsx:107 #: src/components/JournalCard.tsx:119
#: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55 #: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:416 #: src/pages/Dump.tsx:416
@@ -1082,7 +1082,7 @@ msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vo
#: src/pages/index/FollowedFeed.tsx:115 #: src/pages/index/FollowedFeed.tsx:115
#: src/pages/index/HotFeed.tsx:69 #: src/pages/index/HotFeed.tsx:69
#: src/pages/index/JournalFeed.tsx:82 #: src/pages/index/JournalFeed.tsx:67
#: src/pages/index/NewFeed.tsx:69 #: src/pages/index/NewFeed.tsx:69
#: src/pages/Search.tsx:247 #: src/pages/Search.tsx:247
#: src/pages/UserDumps.tsx:98 #: src/pages/UserDumps.tsx:98

View File

@@ -2,11 +2,8 @@ import { useMemo } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { ErrorCard } from "../../components/ErrorCard.tsx"; import { ErrorCard } from "../../components/ErrorCard.tsx";
import { import { JournalCard } from "../../components/JournalCard.tsx";
JournalCard, import { composeJournal } from "../../utils/journalLayout.ts";
type JournalTier,
} from "../../components/JournalCard.tsx";
import { hotScore } from "../../utils/hotScore.ts";
import type { MainFeedProps } from "./types.ts"; import type { MainFeedProps } from "./types.ts";
export function JournalFeed( export function JournalFeed(
@@ -24,19 +21,7 @@ export function JournalFeed(
removeVote, removeVote,
}: MainFeedProps, }: MainFeedProps,
) { ) {
const tiered = useMemo(() => { const entries = useMemo(() => composeJournal(dumps), [dumps]);
const sorted = [...dumps].sort((a, b) => hotScore(b) - hotScore(a));
const n = sorted.length;
return sorted.map((dump, i) => {
const rank = i / n;
const tier: JournalTier = rank < 0.2
? "large"
: rank < 0.5
? "medium"
: "small";
return { dump, tier };
});
}, [dumps]);
if (loading) { if (loading) {
return ( return (
@@ -46,7 +31,7 @@ export function JournalFeed(
); );
} }
if (error) return <ErrorCard title={t`Failed to load`} message={error} />; if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (tiered.length === 0) { if (entries.length === 0) {
return ( return (
<p className="index-status"> <p className="index-status">
<Trans>No dumps yet. Be the first!</Trans> <Trans>No dumps yet. Be the first!</Trans>
@@ -57,11 +42,11 @@ export function JournalFeed(
return ( return (
<> <>
<ul className="journal-grid"> <ul className="journal-grid">
{tiered.map(({ dump, tier }) => ( {entries.map(({ dump, shape }) => (
<JournalCard <JournalCard
key={dump.id} key={dump.id}
dump={dump} dump={dump}
tier={tier} shape={shape}
voteCount={voteCounts[dump.id] ?? dump.voteCount} voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)} voted={myVotes.has(dump.id)}
canVote={!!user} canVote={!!user}
@@ -77,7 +62,7 @@ export function JournalFeed(
<Trans>Loading more</Trans> <Trans>Loading more</Trans>
</p> </p>
)} )}
{!hasMore && tiered.length > 0 && ( {!hasMore && entries.length > 0 && (
<p className="feed-end"> <p className="feed-end">
<Trans>You've reached the end.</Trans> <Trans>You've reached the end.</Trans>
</p> </p>

View File

@@ -0,0 +1,61 @@
import type { Dump } from "../model.ts";
import { hotScore } from "./hotScore.ts";
/**
* Grid footprint of a journal entry in the editorial mosaic.
* feature — 2×2 hero tall — 1×2 portrait
* wide — 2×1 banner square — 1×1 unit
* The footprint only describes the grid span; how a card is *rendered*
* (image / quote / text) is derived separately from its content.
*/
export type JournalShape = "feature" | "wide" | "tall" | "square";
export interface JournalEntry {
dump: Dump;
shape: JournalShape;
}
/** A dump that can carry a real preview image (file image or rich thumbnail). */
export function hasThumbnail(dump: Dump): boolean {
if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) return true;
return !!dump.richContent?.thumbnailUrl;
}
/** A dump whose comment is substantial enough to feature as a pull-quote. */
export function hasQuote(dump: Dump): boolean {
return !!dump.comment && dump.comment.trim().length > 0;
}
/**
* Pick a grid footprint for a single entry.
*
* Variety is composed, not tiered: a repeating 11-step rhythm spreads tall and
* wide accents through the feed instead of bucketing every card by score. The
* rhythm is content-aware — only dumps with a thumbnail are allowed to claim the
* image-forward `tall`/`wide`/`feature` footprints; everything else stays a
* compact `square` so quote and text cards never stretch awkwardly.
*/
function pickShape(dump: Dump, index: number): JournalShape {
const thumb = hasThumbnail(dump);
if (index === 0) return thumb ? "feature" : "wide";
const beat = index % 11;
if (thumb) {
if (beat === 2 || beat === 7) return "tall";
if (beat === 0 || beat === 5) return "wide";
return "square";
}
// No thumbnail: an occasional wide quote keeps the rhythm from flattening.
if (beat === 3) return "wide";
return "square";
}
/**
* Sort dumps by hotness, then lay them out as an editorial mosaic: one hero
* feature followed by an asymmetric mix of banners, portraits, squares and
* pull-quotes. Dense grid auto-flow backfills the gaps these spans create.
*/
export function composeJournal(dumps: Dump[]): JournalEntry[] {
const sorted = [...dumps].sort((a, b) => hotScore(b) - hotScore(a));
return sorted.map((dump, index) => ({ dump, shape: pickShape(dump, index) }));
}