From 0cb5a798c7736a80854b6a5d8558f6376965568b Mon Sep 17 00:00:00 2001 From: khannurien Date: Wed, 25 Mar 2026 21:07:17 +0000 Subject: [PATCH] v3: added journal view to the index, code organization pass --- api/{utils => lib}/upload.ts | 0 api/main.ts | 2 +- api/routes/avatars.ts | 2 +- api/routes/files.ts | 2 +- api/routes/playlists.ts | 2 +- api/services/dump-service.ts | 2 +- src/App.css | 195 +++++++++++++ src/components/AppHeader.tsx | 2 +- src/components/JournalCard.tsx | 187 ++++++++++++ src/pages/Index.tsx | 486 +++++-------------------------- src/pages/index/FollowedFeed.tsx | 365 +++++++++++++++++++++++ src/pages/index/HotFeed.tsx | 56 ++++ src/pages/index/JournalFeed.tsx | 66 +++++ src/pages/index/NewFeed.tsx | 56 ++++ src/pages/index/types.ts | 16 + src/utils/hotScore.ts | 6 + 16 files changed, 1025 insertions(+), 420 deletions(-) rename api/{utils => lib}/upload.ts (100%) create mode 100644 src/components/JournalCard.tsx create mode 100644 src/pages/index/FollowedFeed.tsx create mode 100644 src/pages/index/HotFeed.tsx create mode 100644 src/pages/index/JournalFeed.tsx create mode 100644 src/pages/index/NewFeed.tsx create mode 100644 src/pages/index/types.ts create mode 100644 src/utils/hotScore.ts diff --git a/api/utils/upload.ts b/api/lib/upload.ts similarity index 100% rename from api/utils/upload.ts rename to api/lib/upload.ts diff --git a/api/main.ts b/api/main.ts index 18c95b0..2c6d126 100644 --- a/api/main.ts +++ b/api/main.ts @@ -16,7 +16,7 @@ import invitesRouter from "./routes/invites.ts"; import { BASE_URL, HOSTNAME, PORT } from "./config.ts"; import { errorMiddleware } from "./middleware/error.ts"; import routeStaticFilesFrom from "./lib/static.ts"; -import { DUMPS_DIR, UPLOADS_DIR } from "./utils/upload.ts"; +import { DUMPS_DIR, UPLOADS_DIR } from "./lib/upload.ts"; import { UUID_RE } from "./lib/slugify.ts"; const app = new Application(); diff --git a/api/routes/avatars.ts b/api/routes/avatars.ts index 50e20bb..470bf11 100644 --- a/api/routes/avatars.ts +++ b/api/routes/avatars.ts @@ -7,7 +7,7 @@ import { AVATARS_DIR, serveUploadedFile, validateImageUpload, -} from "../utils/upload.ts"; +} from "../lib/upload.ts"; const router = new Router(); diff --git a/api/routes/files.ts b/api/routes/files.ts index 1fb50c9..d703822 100644 --- a/api/routes/files.ts +++ b/api/routes/files.ts @@ -1,7 +1,7 @@ import { Router } from "@oak/oak"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { getDump } from "../services/dump-service.ts"; -import { DUMPS_DIR } from "../utils/upload.ts"; +import { DUMPS_DIR } from "../lib/upload.ts"; const router = new Router({ prefix: "/api/files" }); diff --git a/api/routes/playlists.ts b/api/routes/playlists.ts index e5b6320..644f0f4 100644 --- a/api/routes/playlists.ts +++ b/api/routes/playlists.ts @@ -24,7 +24,7 @@ import { PLAYLIST_IMAGES_DIR, serveUploadedFile, validateImageUpload, -} from "../utils/upload.ts"; +} from "../lib/upload.ts"; const router = new Router({ prefix: "/api/playlists" }); diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index f2519ad..229a261 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -17,7 +17,7 @@ import { notifyUserFollowersNewDump, } from "./notification-service.ts"; import { makeSlug, UUID_RE } from "../lib/slugify.ts"; -import { DUMPS_DIR } from "../utils/upload.ts"; +import { DUMPS_DIR } from "../lib/upload.ts"; const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB diff --git a/src/App.css b/src/App.css index 79428ed..4fb644c 100644 --- a/src/App.css +++ b/src/App.css @@ -2963,6 +2963,201 @@ body.has-player .fab-new { padding-bottom: 0.75rem; } +/* ── Journal masonry grid ── */ + +.journal-grid { + list-style: none; + margin: 0; + padding: 1rem 1.25rem 0; + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-auto-flow: dense; + gap: 0.875rem; + max-width: 1100px; + width: 100%; + box-sizing: border-box; + align-self: center; +} + +.journal-card { + border: 2px solid var(--color-border); + border-radius: 10px; + background: var(--color-surface); + overflow: hidden; + cursor: pointer; + transition: border-color 0.15s, opacity 0.15s; + min-width: 0; +} + +.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; +} + +.journal-card-title { + font-weight: 600; + font-size: 0.95rem; + color: var(--color-text); + text-decoration: none; + line-height: 1.35; + word-break: break-word; +} + +.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; +} + +.journal-card--medium .journal-card-comment { + -webkit-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; + color: var(--color-text-secondary); + flex-wrap: wrap; +} + +/* Small: single compact row */ +.journal-card--small { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.55rem 0.875rem; +} + +.journal-card--small .journal-card-title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.87rem; +} + +/* feed-end message */ +.feed-end { + text-align: center; + padding: 1.5rem 1rem; + font-size: 0.82rem; + color: var(--color-text-secondary); + opacity: 0.55; +} + +@media (max-width: 860px) { + .journal-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 540px) { + .journal-grid { + grid-template-columns: 1fr; + padding: 0.75rem 1rem 0; + } + + .journal-card--large { + grid-column: span 1; + } +} + /* ── Notification bell ── */ @keyframes bell-ring { 0% { diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 31e8d5a..f2fee43 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -16,7 +16,7 @@ export function AppHeader(
- + 🚚 gerbeur diff --git a/src/components/JournalCard.tsx b/src/components/JournalCard.tsx new file mode 100644 index 0000000..21ffe8b --- /dev/null +++ b/src/components/JournalCard.tsx @@ -0,0 +1,187 @@ +import { useContext } from "react"; +import { Link, useNavigate } from "react-router"; +import type { Dump } from "../model.ts"; +import { API_URL } from "../config/api.ts"; +import { relativeTime } from "../utils/relativeTime.ts"; +import { dumpUrl } from "../utils/urls.ts"; +import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts"; +import { VoteButton } from "./VoteButton.tsx"; +import { Markdown } from "./Markdown.tsx"; +import { Tooltip } from "./Tooltip.tsx"; +import { PlayerContext } from "../contexts/PlayerContext.ts"; + +export type JournalTier = "large" | "medium" | "small"; + +interface JournalCardProps { + dump: Dump; + tier: JournalTier; + voteCount: number; + voted: boolean; + canVote: boolean; + castVote: (id: string) => void; + removeVote: (id: string) => void; + isOwner?: boolean; +} + +export function JournalCard( + { dump, tier, voteCount, voted, canVote, castVote, removeVote, isOwner }: + JournalCardProps, +) { + const navigate = useNavigate(); + const { play } = useContext(PlayerContext); + const unread = !isOwner && isRecent(dump.createdAt) && !isDumpVisited(dump.id); + + function handleNavigate() { + markDumpVisited(dump.id); + navigate(dumpUrl(dump)); + } + + const thumbnailUrl = dump.kind === "file" && + dump.fileMime?.startsWith("image/") + ? `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}` + : (dump.richContent?.thumbnailUrl ?? null); + + const fallbackIcon = dump.kind === "file" + ? (() => { + const m = dump.fileMime ?? ""; + if (m.startsWith("video/")) return "🎬"; + if (m.startsWith("audio/")) return "🎵"; + return "📄"; + })() + : "🔗"; + + const titleLink = ( + { + e.stopPropagation(); + markDumpVisited(dump.id); + }} + > + {unread &&