diff --git a/src/App.css b/src/App.css index 41a67ac..b4e1c36 100644 --- a/src/App.css +++ b/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; } } diff --git a/src/components/JournalCard.tsx b/src/components/JournalCard.tsx index 02bdea1..c4c10a8 100644 --- a/src/components/JournalCard.tsx +++ b/src/components/JournalCard.tsx @@ -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 = ( e.stopPropagation()}> +
e.stopPropagation()}> ); - const embedUrl = dump.richContent?.embedUrl; + const footer = ( +
+ {meta} + {vote} +
+ ); - if (tier === "large") { - return ( -
  • - {thumbnailUrl && ( -
    { - e.stopPropagation(); - play({ - kind: "embed", - embedUrl, - title: dump.richContent?.title, - type: dump.richContent?.type ?? "unknown", - }); - } - : undefined} - > - {dump.title} { - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - {embedUrl && ( - - )} -
    - )} -
    - {titleLink} - {dump.comment && ( - {dump.comment} - )} -
    - {meta} - {vote} -
    -
    -
  • - ); - } + 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
  • means the text overlay can't cover it. return (
  • + play({ + kind: "embed", + embedUrl, + title: dump.richContent?.title, + type: dump.richContent?.type ?? "unknown", + }) + : handleNavigate} > -
    -
    - {thumbnailUrl - ? ( - { - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - ) - : {fallbackIcon}} -
    -
    - {titleLink} - {dump.comment && ( - - {dump.comment} - - )} -
    - {meta} - {vote} -
    -
    +
    + { + (e.target as HTMLImageElement).style.visibility = "hidden"; + }} + /> + {embedUrl && ( + + )} +
    +
    + {titleLink} + {showComment && ( + + {dump.comment!} + + )} + {footer}
  • ); } - // small + if (mode === "quote") { + return ( +
  • + + + {dump.comment!} + +
    + {titleLink} + {footer} +
    +
  • + ); + } + + // text return ( -
  • +
  • + {titleLink} - {vote} + {footer}
  • ); } diff --git a/src/locales/en.po b/src/locales/en.po index 338db72..5816c5b 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -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 diff --git a/src/locales/fr.po b/src/locales/fr.po index 53ec6df..75b5f81 100644 --- a/src/locales/fr.po +++ b/src/locales/fr.po @@ -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 diff --git a/src/pages/index/JournalFeed.tsx b/src/pages/index/JournalFeed.tsx index 95af50d..db648af 100644 --- a/src/pages/index/JournalFeed.tsx +++ b/src/pages/index/JournalFeed.tsx @@ -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 ; - if (tiered.length === 0) { + if (entries.length === 0) { return (

    No dumps yet. Be the first! @@ -57,11 +42,11 @@ export function JournalFeed( return ( <>

      - {tiered.map(({ dump, tier }) => ( + {entries.map(({ dump, shape }) => ( Loading more…

      )} - {!hasMore && tiered.length > 0 && ( + {!hasMore && entries.length > 0 && (

      You've reached the end.

      diff --git a/src/utils/journalLayout.ts b/src/utils/journalLayout.ts new file mode 100644 index 0000000..3016e86 --- /dev/null +++ b/src/utils/journalLayout.ts @@ -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) })); +}