v3: overhauled journal view
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
This commit is contained in:
333
src/App.css
333
src/App.css
@@ -3998,106 +3998,55 @@ body.has-player .fab-new {
|
||||
|
||||
/* ── 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 {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 1rem 1.25rem 0;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-auto-rows: 9rem;
|
||||
grid-auto-flow: dense;
|
||||
gap: 0.875rem;
|
||||
max-width: 1100px;
|
||||
gap: 0.75rem;
|
||||
max-width: 1180px;
|
||||
width: 100%;
|
||||
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 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, opacity 0.15s;
|
||||
min-width: 0;
|
||||
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.journal-card:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.journal-card-title {
|
||||
@@ -4105,66 +4054,191 @@ body.has-player .fab-new {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
line-height: 1.35;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.journal-card-title:hover {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.journal-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-text-secondary);
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Small: single compact row */
|
||||
.journal-card--small {
|
||||
.journal-card-vote {
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.875rem;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 1.4rem 0.8rem 0.65rem;
|
||||
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 {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
.journal-card--image .journal-card-title {
|
||||
color: #fff;
|
||||
}
|
||||
.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;
|
||||
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 */
|
||||
@@ -4176,20 +4250,37 @@ body.has-player .fab-new {
|
||||
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 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
@media (max-width: 460px) {
|
||||
.journal-grid {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0.75rem 1rem 0;
|
||||
grid-auto-rows: auto;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.journal-card--large {
|
||||
grid-column: span 1;
|
||||
/* Collapse the mosaic into a single readable column. */
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,16 +7,17 @@ import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { dumpFileUrl, 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 JournalTier = "large" | "medium" | "small";
|
||||
export type { JournalShape };
|
||||
|
||||
interface JournalCardProps {
|
||||
dump: Dump;
|
||||
tier: JournalTier;
|
||||
shape: JournalShape;
|
||||
voteCount: number;
|
||||
voted: boolean;
|
||||
canVote: boolean;
|
||||
@@ -26,7 +27,7 @@ interface JournalCardProps {
|
||||
}
|
||||
|
||||
export function JournalCard(
|
||||
{ dump, tier, voteCount, voted, canVote, castVote, removeVote, isOwner }:
|
||||
{ dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }:
|
||||
JournalCardProps,
|
||||
) {
|
||||
const navigate = useNavigate();
|
||||
@@ -63,6 +64,15 @@ export function JournalCard(
|
||||
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 ?? "";
|
||||
@@ -72,6 +82,8 @@ export function JournalCard(
|
||||
})()
|
||||
: "🔗";
|
||||
|
||||
const embedUrl = dump.richContent?.embedUrl;
|
||||
|
||||
const titleLink = (
|
||||
<Link
|
||||
to={dumpUrl(dump)}
|
||||
@@ -111,7 +123,7 @@ export function JournalCard(
|
||||
);
|
||||
|
||||
const vote = (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="journal-card-vote" onClick={(e) => e.stopPropagation()}>
|
||||
<VoteButton
|
||||
dumpId={dump.id}
|
||||
count={voteCount}
|
||||
@@ -123,96 +135,85 @@ export function JournalCard(
|
||||
</div>
|
||||
);
|
||||
|
||||
const embedUrl = dump.richContent?.embedUrl;
|
||||
const footer = (
|
||||
<div className="journal-card-footer">
|
||||
{meta}
|
||||
{vote}
|
||||
</div>
|
||||
);
|
||||
|
||||
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({
|
||||
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>
|
||||
);
|
||||
}
|
||||
const className =
|
||||
`journal-card journal-card--${shape} journal-card--${mode}`;
|
||||
|
||||
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 (
|
||||
<li
|
||||
className="journal-card journal-card--medium"
|
||||
onClick={handleNavigate}
|
||||
className={className}
|
||||
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-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 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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<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}
|
||||
{vote}
|
||||
{footer}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ msgstr "[deleted]"
|
||||
|
||||
#. placeholder {0}: dump.commentCount
|
||||
#: src/components/DumpCard.tsx:105
|
||||
#: src/components/JournalCard.tsx:98
|
||||
#: src/components/JournalCard.tsx:110
|
||||
msgid "{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/HotFeed.tsx:36
|
||||
#: src/pages/index/JournalFeed.tsx:48
|
||||
#: src/pages/index/JournalFeed.tsx:33
|
||||
#: src/pages/index/NewFeed.tsx:36
|
||||
#: src/pages/Notifications.tsx:367
|
||||
#: src/pages/UserPublicProfile.tsx:984
|
||||
@@ -566,7 +566,7 @@ msgstr "Loading dump…"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:110
|
||||
#: 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/Search.tsx:242
|
||||
#: src/pages/UserDumps.tsx:93
|
||||
@@ -590,7 +590,7 @@ msgstr "Loading profile…"
|
||||
#: src/components/UserListPopover.tsx:230
|
||||
#: src/pages/index/FollowedFeed.tsx:77
|
||||
#: 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/Notifications.tsx:363
|
||||
#: src/pages/Notifications.tsx:439
|
||||
@@ -667,7 +667,7 @@ msgid "No dumps match \"{q}\"."
|
||||
msgstr "No dumps match \"{q}\"."
|
||||
|
||||
#: src/pages/index/HotFeed.tsx:40
|
||||
#: src/pages/index/JournalFeed.tsx:52
|
||||
#: src/pages/index/JournalFeed.tsx:37
|
||||
#: src/pages/index/NewFeed.tsx:40
|
||||
msgid "No dumps yet. Be the first!"
|
||||
msgstr "No dumps yet. Be the first!"
|
||||
@@ -791,7 +791,7 @@ msgid "Posting…"
|
||||
msgstr "Posting…"
|
||||
|
||||
#: src/components/DumpCard.tsx:114
|
||||
#: src/components/JournalCard.tsx:107
|
||||
#: src/components/JournalCard.tsx:119
|
||||
#: src/components/PlaylistCard.tsx:73
|
||||
#: src/components/PlaylistMembershipPanel.tsx:55
|
||||
#: 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/HotFeed.tsx:69
|
||||
#: src/pages/index/JournalFeed.tsx:82
|
||||
#: src/pages/index/JournalFeed.tsx:67
|
||||
#: src/pages/index/NewFeed.tsx:69
|
||||
#: src/pages/Search.tsx:247
|
||||
#: src/pages/UserDumps.tsx:98
|
||||
|
||||
@@ -19,7 +19,7 @@ msgstr "[supprimé]"
|
||||
|
||||
#. placeholder {0}: dump.commentCount
|
||||
#: src/components/DumpCard.tsx:105
|
||||
#: src/components/JournalCard.tsx:98
|
||||
#: src/components/JournalCard.tsx:110
|
||||
msgid "{0, plural, one {# comment} other {# comments}}"
|
||||
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/HotFeed.tsx:36
|
||||
#: src/pages/index/JournalFeed.tsx:48
|
||||
#: src/pages/index/JournalFeed.tsx:33
|
||||
#: src/pages/index/NewFeed.tsx:36
|
||||
#: src/pages/Notifications.tsx:367
|
||||
#: src/pages/UserPublicProfile.tsx:984
|
||||
@@ -566,7 +566,7 @@ msgstr "Chargement de la reco…"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:110
|
||||
#: 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/Search.tsx:242
|
||||
#: src/pages/UserDumps.tsx:93
|
||||
@@ -590,7 +590,7 @@ msgstr "Chargement du profil…"
|
||||
#: src/components/UserListPopover.tsx:230
|
||||
#: src/pages/index/FollowedFeed.tsx:77
|
||||
#: 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/Notifications.tsx:363
|
||||
#: src/pages/Notifications.tsx:439
|
||||
@@ -667,7 +667,7 @@ msgid "No dumps match \"{q}\"."
|
||||
msgstr "Aucune reco ne correspond à « {q} »."
|
||||
|
||||
#: src/pages/index/HotFeed.tsx:40
|
||||
#: src/pages/index/JournalFeed.tsx:52
|
||||
#: src/pages/index/JournalFeed.tsx:37
|
||||
#: src/pages/index/NewFeed.tsx:40
|
||||
msgid "No dumps yet. Be the first!"
|
||||
msgstr "Pas encore de recos. Soyez le premier !"
|
||||
@@ -791,7 +791,7 @@ msgid "Posting…"
|
||||
msgstr "Publication…"
|
||||
|
||||
#: src/components/DumpCard.tsx:114
|
||||
#: src/components/JournalCard.tsx:107
|
||||
#: src/components/JournalCard.tsx:119
|
||||
#: src/components/PlaylistCard.tsx:73
|
||||
#: src/components/PlaylistMembershipPanel.tsx:55
|
||||
#: 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/HotFeed.tsx:69
|
||||
#: src/pages/index/JournalFeed.tsx:82
|
||||
#: src/pages/index/JournalFeed.tsx:67
|
||||
#: src/pages/index/NewFeed.tsx:69
|
||||
#: src/pages/Search.tsx:247
|
||||
#: src/pages/UserDumps.tsx:98
|
||||
|
||||
@@ -2,11 +2,8 @@ import { useMemo } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
||||
import {
|
||||
JournalCard,
|
||||
type JournalTier,
|
||||
} from "../../components/JournalCard.tsx";
|
||||
import { hotScore } from "../../utils/hotScore.ts";
|
||||
import { JournalCard } from "../../components/JournalCard.tsx";
|
||||
import { composeJournal } from "../../utils/journalLayout.ts";
|
||||
import type { MainFeedProps } from "./types.ts";
|
||||
|
||||
export function JournalFeed(
|
||||
@@ -24,19 +21,7 @@ export function JournalFeed(
|
||||
removeVote,
|
||||
}: MainFeedProps,
|
||||
) {
|
||||
const tiered = useMemo(() => {
|
||||
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]);
|
||||
const entries = useMemo(() => composeJournal(dumps), [dumps]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -46,7 +31,7 @@ export function JournalFeed(
|
||||
);
|
||||
}
|
||||
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
|
||||
if (tiered.length === 0) {
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<p className="index-status">
|
||||
<Trans>No dumps yet. Be the first!</Trans>
|
||||
@@ -57,11 +42,11 @@ export function JournalFeed(
|
||||
return (
|
||||
<>
|
||||
<ul className="journal-grid">
|
||||
{tiered.map(({ dump, tier }) => (
|
||||
{entries.map(({ dump, shape }) => (
|
||||
<JournalCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
tier={tier}
|
||||
shape={shape}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
@@ -77,7 +62,7 @@ export function JournalFeed(
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{!hasMore && tiered.length > 0 && (
|
||||
{!hasMore && entries.length > 0 && (
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
|
||||
61
src/utils/journalLayout.ts
Normal file
61
src/utils/journalLayout.ts
Normal 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) }));
|
||||
}
|
||||
Reference in New Issue
Block a user