v3: added journal view to the index, code organization pass

This commit is contained in:
khannurien
2026-03-25 21:07:17 +00:00
parent c293f3e706
commit 0cb5a798c7
16 changed files with 1025 additions and 420 deletions

View File

@@ -16,7 +16,7 @@ import invitesRouter from "./routes/invites.ts";
import { BASE_URL, HOSTNAME, PORT } from "./config.ts"; import { BASE_URL, HOSTNAME, PORT } from "./config.ts";
import { errorMiddleware } from "./middleware/error.ts"; import { errorMiddleware } from "./middleware/error.ts";
import routeStaticFilesFrom from "./lib/static.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"; import { UUID_RE } from "./lib/slugify.ts";
const app = new Application(); const app = new Application();

View File

@@ -7,7 +7,7 @@ import {
AVATARS_DIR, AVATARS_DIR,
serveUploadedFile, serveUploadedFile,
validateImageUpload, validateImageUpload,
} from "../utils/upload.ts"; } from "../lib/upload.ts";
const router = new Router(); const router = new Router();

View File

@@ -1,7 +1,7 @@
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { getDump } from "../services/dump-service.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" }); const router = new Router({ prefix: "/api/files" });

View File

@@ -24,7 +24,7 @@ import {
PLAYLIST_IMAGES_DIR, PLAYLIST_IMAGES_DIR,
serveUploadedFile, serveUploadedFile,
validateImageUpload, validateImageUpload,
} from "../utils/upload.ts"; } from "../lib/upload.ts";
const router = new Router<AuthState>({ prefix: "/api/playlists" }); const router = new Router<AuthState>({ prefix: "/api/playlists" });

View File

@@ -17,7 +17,7 @@ import {
notifyUserFollowersNewDump, notifyUserFollowersNewDump,
} from "./notification-service.ts"; } from "./notification-service.ts";
import { makeSlug, UUID_RE } from "../lib/slugify.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 const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB

View File

@@ -2963,6 +2963,201 @@ body.has-player .fab-new {
padding-bottom: 0.75rem; 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 ── */ /* ── Notification bell ── */
@keyframes bell-ring { @keyframes bell-ring {
0% { 0% {

View File

@@ -16,7 +16,7 @@ export function AppHeader(
<header <header
className={`app-header${centerSlot ? " app-header--has-center" : ""}`} className={`app-header${centerSlot ? " app-header--has-center" : ""}`}
> >
<Link to="/" state={{ tab: "hot" }} className="app-header-brand"> <Link to="/?tab=hot" className="app-header-brand">
🚚 gerbeur 🚚 gerbeur
</Link> </Link>

View File

@@ -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 = (
<Link
to={dumpUrl(dump)}
className="journal-card-title"
onClick={(e) => {
e.stopPropagation();
markDumpVisited(dump.id);
}}
>
{unread && <span className="unread-dot" aria-hidden="true" />}
{dump.title}
</Link>
);
const meta = (
<div className="journal-card-meta">
<Tooltip text={dump.createdAt.toLocaleString()}>
<time dateTime={dump.createdAt.toISOString()}>
{relativeTime(dump.createdAt)}
</time>
</Tooltip>
{dump.commentCount > 0 && (
<span>
{dump.commentCount}{" "}
{dump.commentCount === 1 ? "comment" : "comments"}
</span>
)}
{dump.isPrivate && isOwner && (
<span className="dump-card-private-badge">private</span>
)}
</div>
);
const vote = (
<div onClick={(e) => e.stopPropagation()}>
<VoteButton
dumpId={dump.id}
count={voteCount}
voted={voted}
disabled={!canVote}
onCast={castVote}
onRemove={removeVote}
/>
</div>
);
const embedUrl = dump.richContent?.embedUrl;
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({
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") {
return (
<li className="journal-card journal-card--medium" onClick={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>
</li>
);
}
// small
return (
<li className="journal-card journal-card--small" onClick={handleNavigate}>
{titleLink}
{vote}
</li>
);
}

View File

@@ -6,10 +6,9 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { Link, useLocation } from "react-router"; import { Link, useLocation, useNavigate } from "react-router";
import { Avatar } from "../components/Avatar.tsx"; import { Avatar } from "../components/Avatar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
import { AppHeader } from "../components/AppHeader.tsx"; import { AppHeader } from "../components/AppHeader.tsx";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
@@ -20,10 +19,8 @@ import {
hydrateDump, hydrateDump,
type PaginatedData, type PaginatedData,
type RawDump, type RawDump,
type User,
} from "../model.ts"; } from "../model.ts";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts"; import { friendlyFetchError } from "../utils/apiError.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts";
import { useScrollSave } from "../hooks/useScrollSave.ts"; import { useScrollSave } from "../hooks/useScrollSave.ts";
@@ -32,6 +29,12 @@ import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { HotFeed } from "./index/HotFeed.tsx";
import { NewFeed } from "./index/NewFeed.tsx";
import { JournalFeed } from "./index/JournalFeed.tsx";
import { FollowedFeed } from "./index/FollowedFeed.tsx";
import type { MainFeedProps } from "./index/types.ts";
type DumpsState = type DumpsState =
| { status: "loading" } | { status: "loading" }
| { status: "error"; error: string } | { status: "error"; error: string }
@@ -43,82 +46,13 @@ type DumpsState =
loadingMore: boolean; loadingMore: boolean;
}; };
type FeedTab = "hot" | "new" | "followed"; type FeedTab = "hot" | "new" | "journal" | "followed";
type FollowedSection = "users" | "playlists";
function hotScore(dump: Dump): number { const VALID_TABS = new Set<string>(["hot", "new", "journal", "followed"]);
const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000;
return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5);
}
// ── FollowedSubFeed ──────────────────────────────────────────────────────────
interface FollowedSubFeedProps {
state: DumpsState;
voteCounts: Record<string, number>;
myVotes: Set<string>;
user: User | null;
castVote: (id: string) => void;
removeVote: (id: string) => void;
deletedDumpIds: Set<string>;
emptyMessage: string;
onLoadMore: () => void;
}
function FollowedSubFeed({
state,
voteCounts,
myVotes,
user,
castVote,
removeVote,
deletedDumpIds,
emptyMessage,
onLoadMore,
}: FollowedSubFeedProps) {
const hasMore = state.status === "loaded" && state.hasMore &&
!state.loadingMore;
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore);
if (state.status === "loading") {
return <p className="index-status">Loading</p>;
}
if (state.status === "error") {
return <ErrorCard title="Failed to load" message={state.error} />;
}
const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
if (visible.length === 0) {
return <p className="index-status">{emptyMessage}</p>;
}
return (
<>
<ul className="dump-feed">
{visible.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
<div ref={sentinelRef} />
{state.loadingMore && <p className="feed-loading-more">Loading more</p>}
</>
);
}
// ── Index ────────────────────────────────────────────────────────────────────
export function Index() { export function Index() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const justDeletedId = (location.state as { deletedDumpId?: string } | null) const justDeletedId = (location.state as { deletedDumpId?: string } | null)
?.deletedDumpId; ?.deletedDumpId;
@@ -133,7 +67,8 @@ export function Index() {
removeVote, removeVote,
} = useWS(); } = useWS();
// Main feed // ── Main feed state ──
const { cached, saveState } = useFeedCache<Dump>( const { cached, saveState } = useFeedCache<Dump>(
`feed:index:${user?.id ?? "guest"}`, `feed:index:${user?.id ?? "guest"}`,
hydrateDump, hydrateDump,
@@ -151,56 +86,12 @@ export function Index() {
); );
const mainFetchDone = useRef(false); const mainFetchDone = useRef(false);
// Followed feeds const rawTab = new URLSearchParams(location.search).get("tab") ?? "hot";
const { cached: cachedFollowedUsers, saveState: saveFollowedUsers } = const tab: FeedTab = VALID_TABS.has(rawTab) ? rawTab as FeedTab : "hot";
useFeedCache<Dump>(
`feed:followed-users:${user?.id ?? "guest"}`,
hydrateDump,
);
const { cached: cachedFollowedPlaylists, saveState: saveFollowedPlaylists } =
useFeedCache<Dump>(
`feed:followed-playlists:${user?.id ?? "guest"}`,
hydrateDump,
);
const [followedUsersDumps, setFollowedUsersDumps] = useState<DumpsState>({ function setTab(t: FeedTab) {
status: "loading", navigate(`/?tab=${t}`, { replace: true });
});
const [followedPlaylistsDumps, setFollowedPlaylistsDumps] = useState<
DumpsState
>({ status: "loading" });
const setFollowedUsersDumpsItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setFollowedUsersDumps((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setFollowedUsersDumpsItems);
const setFollowedPlaylistsDumpsItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setFollowedPlaylistsDumps((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setFollowedPlaylistsDumpsItems);
const [tab, setTab] = useState<FeedTab>("hot");
const [followedSection, setFollowedSection] = useState<FollowedSection>(
"users",
);
// When the logo is clicked it navigates to / with state { tab: "hot" }, producing
// a new location.key even if already on /. React to that to reset the active tab.
useEffect(() => {
const st = location.state as { tab?: string } | null;
if (st?.tab === "hot" || st?.tab === "new" || st?.tab === "followed") {
setTab(st.tab as FeedTab);
} }
}, [location]);
// ── Main feed fetch ── // ── Main feed fetch ──
@@ -229,10 +120,7 @@ export function Index() {
}); });
} catch (err) { } catch (err) {
if ((err as Error).name === "AbortError") return; if ((err as Error).name === "AbortError") return;
setDumpsState({ setDumpsState({ status: "error", error: friendlyFetchError(err) });
status: "error",
error: friendlyFetchError(err),
});
} }
})(); })();
return () => { return () => {
@@ -241,93 +129,18 @@ export function Index() {
}; };
}, [cached, token]); }, [cached, token]);
// ── Followed feeds fetch (lazy, on first tab open) ── // ── WS sync for main feed ──
useEffect(() => { const setDumpsItems = useCallback(
if (tab !== "followed" || !user || !token) return; (fn: (prev: Dump[]) => Dump[]) =>
setDumpsState((s) =>
if (followedUsersDumps.status === "loading") { s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
if (cachedFollowedUsers) { ),
setFollowedUsersDumps({ [],
status: "loaded",
dumps: cachedFollowedUsers.items,
hasMore: cachedFollowedUsers.hasMore,
page: cachedFollowedUsers.page,
loadingMore: false,
});
} else {
fetch(
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{
headers: { Authorization: `Bearer ${token}` },
},
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setFollowedUsersDumps({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setFollowedUsersDumps({
status: "error",
error: friendlyFetchError(err),
})
); );
} useDumpListSync(setDumpsItems);
}
if (followedPlaylistsDumps.status === "loading") { // ── Load more ──
if (cachedFollowedPlaylists) {
setFollowedPlaylistsDumps({
status: "loaded",
dumps: cachedFollowedPlaylists.items,
hasMore: cachedFollowedPlaylists.hasMore,
page: cachedFollowedPlaylists.page,
loadingMore: false,
});
} else {
fetch(
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{
headers: { Authorization: `Bearer ${token}` },
},
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setFollowedPlaylistsDumps({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setFollowedPlaylistsDumps({
status: "error",
error: friendlyFetchError(err),
})
);
}
}
}, [
tab,
user,
token,
cachedFollowedUsers,
cachedFollowedPlaylists,
followedUsersDumps.status,
followedPlaylistsDumps.status,
]);
// ── Load-more callbacks ──
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if ( if (
@@ -363,85 +176,7 @@ export function Index() {
); );
}, [dumpsState, token]); }, [dumpsState, token]);
const loadMoreFollowedUsers = useCallback(() => { // ── Scroll save / restore ──
if (
followedUsersDumps.status !== "loaded" ||
!followedUsersDumps.hasMore ||
followedUsersDumps.loadingMore ||
!token
) return;
const nextPage = followedUsersDumps.page + 1;
setFollowedUsersDumps((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{
headers: { Authorization: `Bearer ${token}` },
},
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setFollowedUsersDumps((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setFollowedUsersDumps((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [followedUsersDumps, token]);
const loadMoreFollowedPlaylists = useCallback(() => {
if (
followedPlaylistsDumps.status !== "loaded" ||
!followedPlaylistsDumps.hasMore ||
followedPlaylistsDumps.loadingMore ||
!token
) return;
const nextPage = followedPlaylistsDumps.page + 1;
setFollowedPlaylistsDumps((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{
headers: { Authorization: `Bearer ${token}` },
},
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setFollowedPlaylistsDumps((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setFollowedPlaylistsDumps((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [followedPlaylistsDumps, token]);
// ── Scroll save effects ──
const sentinelRef = useInfiniteScroll( const sentinelRef = useInfiniteScroll(
loadMore, loadMore,
@@ -457,34 +192,6 @@ export function Index() {
}, [dumpsState, saveState]), }, [dumpsState, saveState]),
); );
useScrollSave(
followedUsersDumps.status === "loaded",
useCallback((y) => {
if (followedUsersDumps.status !== "loaded") return;
saveFollowedUsers(
followedUsersDumps.dumps,
followedUsersDumps.page,
followedUsersDumps.hasMore,
y,
);
}, [followedUsersDumps, saveFollowedUsers]),
);
useScrollSave(
followedPlaylistsDumps.status === "loaded",
useCallback((y) => {
if (followedPlaylistsDumps.status !== "loaded") return;
saveFollowedPlaylists(
followedPlaylistsDumps.dumps,
followedPlaylistsDumps.page,
followedPlaylistsDumps.hasMore,
y,
);
}, [followedPlaylistsDumps, saveFollowedPlaylists]),
);
// ── Scroll restoration ──
const scrollRestored = useRef(false); const scrollRestored = useRef(false);
useLayoutEffect(() => { useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return; if (cached?.scrollY == null || scrollRestored.current) return;
@@ -498,19 +205,36 @@ export function Index() {
const loading = dumpsState.status === "loading"; const loading = dumpsState.status === "loading";
const error = dumpsState.status === "error" ? dumpsState.error : null; const error = dumpsState.status === "error" ? dumpsState.error : null;
const dumps = dumpsState.status === "loaded" ? dumpsState.dumps : []; const dumps = useMemo(
() => dumpsState.status === "loaded" ? dumpsState.dumps : [],
[dumpsState],
);
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore; const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
const hasMore = dumpsState.status === "loaded" ? dumpsState.hasMore : false;
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]); const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
const combined = [...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps] const combined = useMemo(
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId); () =>
[...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps].filter(
const sortedDumps = [...combined].sort( (d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId,
tab === "new" ),
? (a, b) => b.createdAt.getTime() - a.createdAt.getTime() [recentDumps, restIds, dumps, deletedDumpIds, justDeletedId],
: (a, b) => hotScore(b) - hotScore(a),
); );
const mainFeedProps: MainFeedProps = {
dumps: combined,
loading,
error,
hasMore,
loadingMore,
sentinelRef,
voteCounts,
myVotes,
user,
castVote,
removeVote,
};
// ── Render ── // ── Render ──
const presenceRow = ( const presenceRow = (
@@ -550,6 +274,13 @@ export function Index() {
> >
New New
</button> </button>
<button
type="button"
className={`feed-sort-btn${tab === "journal" ? " active" : ""}`}
onClick={() => setTab("journal")}
>
Journal
</button>
{user && ( {user && (
<button <button
type="button" type="button"
@@ -574,97 +305,24 @@ export function Index() {
disableNew={dumpsState.status === "error"} disableNew={dumpsState.status === "error"}
/> />
{/* Shown only on narrow viewports */}
<div className="index-below-header"> <div className="index-below-header">
{tabBar} {tabBar}
{presenceRow} {presenceRow}
</div> </div>
{/* Hot / New feed */} {tab === "hot" && <HotFeed {...mainFeedProps} />}
{tab !== "followed" && ( {tab === "new" && <NewFeed {...mainFeedProps} />}
<> {tab === "journal" && <JournalFeed {...mainFeedProps} />}
{loading && <p className="index-status">Loading</p>}
{error && <ErrorCard title="Failed to load" message={error} />}
{!loading && !error && combined.length === 0 && (
<p className="index-status">No dumps yet. Be the first!</p>
)}
{!loading && !error && combined.length > 0 && (
<ul className="dump-feed">
{sortedDumps.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
</>
)}
{/* Followed feed */}
{tab === "followed" && user && ( {tab === "followed" && user && (
<div className="followed-feed"> <FollowedFeed
<div className="feed-sort followed-sub-nav">
<button
type="button"
className={`feed-sort-btn${
followedSection === "users" ? " active" : ""
}`}
onClick={() => setFollowedSection("users")}
>
From people
</button>
<button
type="button"
className={`feed-sort-btn${
followedSection === "playlists" ? " active" : ""
}`}
onClick={() => setFollowedSection("playlists")}
>
From playlists
</button>
</div>
{followedSection === "users" && (
<FollowedSubFeed
state={followedUsersDumps}
voteCounts={voteCounts} voteCounts={voteCounts}
myVotes={myVotes} myVotes={myVotes}
user={user} user={user}
castVote={castVote} castVote={castVote}
removeVote={removeVote} removeVote={removeVote}
deletedDumpIds={deletedDumpIds} deletedDumpIds={deletedDumpIds}
emptyMessage="Follow some users to see their dumps here."
onLoadMore={loadMoreFollowedUsers}
/> />
)} )}
{followedSection === "playlists" && (
<FollowedSubFeed
state={followedPlaylistsDumps}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage="Follow some public playlists to see their dumps here."
onLoadMore={loadMoreFollowedPlaylists}
/>
)}
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,365 @@
import { useCallback, useEffect, useState } from "react";
import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
import { useAuth } from "../../hooks/useAuth.ts";
import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
import { useFeedCache } from "../../hooks/useFeedCache.ts";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts";
import { useScrollSave } from "../../hooks/useScrollSave.ts";
import {
deserializeDump,
type Dump,
hydrateDump,
type PaginatedData,
type RawDump,
type User,
} from "../../model.ts";
import { friendlyFetchError } from "../../utils/apiError.ts";
type FeedState =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
dumps: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
type FollowedSection = "users" | "playlists";
interface FollowedFeedProps {
voteCounts: Record<string, number>;
myVotes: Set<string>;
user: User;
castVote: (id: string) => void;
removeVote: (id: string) => void;
deletedDumpIds: Set<string>;
}
// ── FollowedSubFeed ───────────────────────────────────────────────────────────
interface FollowedSubFeedProps {
state: FeedState;
voteCounts: Record<string, number>;
myVotes: Set<string>;
user: User;
castVote: (id: string) => void;
removeVote: (id: string) => void;
deletedDumpIds: Set<string>;
emptyMessage: string;
onLoadMore: () => void;
}
function FollowedSubFeed({
state,
voteCounts,
myVotes,
user,
castVote,
removeVote,
deletedDumpIds,
emptyMessage,
onLoadMore,
}: FollowedSubFeedProps) {
const enabled = state.status === "loaded" && state.hasMore &&
!state.loadingMore;
const sentinelRef = useInfiniteScroll(onLoadMore, enabled);
if (state.status === "loading") {
return <p className="index-status">Loading</p>;
}
if (state.status === "error") {
return <ErrorCard title="Failed to load" message={state.error} />;
}
const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
if (visible.length === 0) {
return <p className="index-status">{emptyMessage}</p>;
}
return (
<>
<ul className="dump-feed">
{visible.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={true}
castVote={castVote}
removeVote={removeVote}
isOwner={user.id === dump.userId}
/>
))}
</ul>
<div ref={sentinelRef} />
{state.loadingMore && <p className="feed-loading-more">Loading more</p>}
</>
);
}
// ── FollowedFeed ──────────────────────────────────────────────────────────────
export function FollowedFeed({
voteCounts,
myVotes,
user,
castVote,
removeVote,
deletedDumpIds,
}: FollowedFeedProps) {
const { token } = useAuth();
const { cached: cachedUsers, saveState: saveUsers } = useFeedCache<Dump>(
`feed:followed-users:${user.id}`,
hydrateDump,
);
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
Dump
>(
`feed:followed-playlists:${user.id}`,
hydrateDump,
);
const [usersState, setUsersState] = useState<FeedState>(() =>
cachedUsers
? {
status: "loaded",
dumps: cachedUsers.items,
hasMore: cachedUsers.hasMore,
page: cachedUsers.page,
loadingMore: false,
}
: { status: "loading" }
);
const [playlistsState, setPlaylistsState] = useState<FeedState>(() =>
cachedPlaylists
? {
status: "loaded",
dumps: cachedPlaylists.items,
hasMore: cachedPlaylists.hasMore,
page: cachedPlaylists.page,
loadingMore: false,
}
: { status: "loading" }
);
const [section, setSection] = useState<FollowedSection>("users");
// WS sync
const setUsersItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setUsersState((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setUsersItems);
const setPlaylistsItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setPlaylistsState((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setPlaylistsItems);
// Fetch on mount if not cached
useEffect(() => {
if (!token) return;
if (usersState.status === "loading") {
fetch(
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setUsersState({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setUsersState({ status: "error", error: friendlyFetchError(err) })
);
}
if (playlistsState.status === "loading") {
fetch(
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setPlaylistsState({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setPlaylistsState({
status: "error",
error: friendlyFetchError(err),
})
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
// Scroll save
useScrollSave(
usersState.status === "loaded",
useCallback((y) => {
if (usersState.status !== "loaded") return;
saveUsers(usersState.dumps, usersState.page, usersState.hasMore, y);
}, [usersState, saveUsers]),
);
useScrollSave(
playlistsState.status === "loaded",
useCallback((y) => {
if (playlistsState.status !== "loaded") return;
savePlaylists(
playlistsState.dumps,
playlistsState.page,
playlistsState.hasMore,
y,
);
}, [playlistsState, savePlaylists]),
);
// Load-more callbacks
const loadMoreUsers = useCallback(() => {
if (
usersState.status !== "loaded" || !usersState.hasMore ||
usersState.loadingMore || !token
) return;
const nextPage = usersState.page + 1;
setUsersState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setUsersState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setUsersState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [usersState, token]);
const loadMorePlaylists = useCallback(() => {
if (
playlistsState.status !== "loaded" || !playlistsState.hasMore ||
playlistsState.loadingMore || !token
) return;
const nextPage = playlistsState.page + 1;
setPlaylistsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setPlaylistsState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setPlaylistsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [playlistsState, token]);
return (
<div className="followed-feed">
<div className="feed-sort followed-sub-nav">
<button
type="button"
className={`feed-sort-btn${section === "users" ? " active" : ""}`}
onClick={() => setSection("users")}
>
From people
</button>
<button
type="button"
className={`feed-sort-btn${
section === "playlists" ? " active" : ""
}`}
onClick={() => setSection("playlists")}
>
From playlists
</button>
</div>
{section === "users" && (
<FollowedSubFeed
state={usersState}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage="Follow some users to see their dumps here."
onLoadMore={loadMoreUsers}
/>
)}
{section === "playlists" && (
<FollowedSubFeed
state={playlistsState}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage="Follow some public playlists to see their dumps here."
onLoadMore={loadMorePlaylists}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { useMemo } from "react";
import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import { hotScore } from "../../utils/hotScore.ts";
import type { MainFeedProps } from "./types.ts";
export function HotFeed(
{
dumps,
loading,
error,
hasMore,
loadingMore,
sentinelRef,
voteCounts,
myVotes,
user,
castVote,
removeVote,
}: MainFeedProps,
) {
const sorted = useMemo(
() => [...dumps].sort((a, b) => hotScore(b) - hotScore(a)),
[dumps],
);
if (loading) return <p className="index-status">Loading</p>;
if (error) return <ErrorCard title="Failed to load" message={error} />;
if (sorted.length === 0) {
return <p className="index-status">No dumps yet. Be the first!</p>;
}
return (
<>
<ul className="dump-feed">
{sorted.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{!hasMore && sorted.length > 0 && (
<p className="feed-end">You've reached the end.</p>
)}
</>
);
}

View File

@@ -0,0 +1,66 @@
import { useMemo } from "react";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import { JournalCard, type JournalTier } from "../../components/JournalCard.tsx";
import { hotScore } from "../../utils/hotScore.ts";
import type { MainFeedProps } from "./types.ts";
export function JournalFeed(
{
dumps,
loading,
error,
hasMore,
loadingMore,
sentinelRef,
voteCounts,
myVotes,
user,
castVote,
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]);
if (loading) return <p className="index-status">Loading</p>;
if (error) return <ErrorCard title="Failed to load" message={error} />;
if (tiered.length === 0) {
return <p className="index-status">No dumps yet. Be the first!</p>;
}
return (
<>
<ul className="journal-grid">
{tiered.map(({ dump, tier }) => (
<JournalCard
key={dump.id}
dump={dump}
tier={tier}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{!hasMore && tiered.length > 0 && (
<p className="feed-end">You've reached the end.</p>
)}
</>
);
}

View File

@@ -0,0 +1,56 @@
import { useMemo } from "react";
import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import type { MainFeedProps } from "./types.ts";
export function NewFeed(
{
dumps,
loading,
error,
hasMore,
loadingMore,
sentinelRef,
voteCounts,
myVotes,
user,
castVote,
removeVote,
}: MainFeedProps,
) {
const sorted = useMemo(
() =>
[...dumps].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
[dumps],
);
if (loading) return <p className="index-status">Loading</p>;
if (error) return <ErrorCard title="Failed to load" message={error} />;
if (sorted.length === 0) {
return <p className="index-status">No dumps yet. Be the first!</p>;
}
return (
<>
<ul className="dump-feed">
{sorted.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{!hasMore && sorted.length > 0 && (
<p className="feed-end">You've reached the end.</p>
)}
</>
);
}

16
src/pages/index/types.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { RefObject } from "react";
import type { Dump, User } from "../../model.ts";
export interface MainFeedProps {
dumps: Dump[];
loading: boolean;
error: string | null;
hasMore: boolean;
loadingMore: boolean;
sentinelRef: RefObject<HTMLDivElement | null>;
voteCounts: Record<string, number>;
myVotes: Set<string>;
user: User | null;
castVote: (id: string) => void;
removeVote: (id: string) => void;
}

6
src/utils/hotScore.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { Dump } from "../model.ts";
export function hotScore(dump: Dump): number {
const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000;
return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5);
}