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 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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