v3: added journal view to the index, code organization pass
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
AVATARS_DIR,
|
||||
serveUploadedFile,
|
||||
validateImageUpload,
|
||||
} from "../utils/upload.ts";
|
||||
} from "../lib/upload.ts";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
PLAYLIST_IMAGES_DIR,
|
||||
serveUploadedFile,
|
||||
validateImageUpload,
|
||||
} from "../utils/upload.ts";
|
||||
} from "../lib/upload.ts";
|
||||
|
||||
const router = new Router<AuthState>({ prefix: "/api/playlists" });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
195
src/App.css
195
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% {
|
||||
|
||||
@@ -16,7 +16,7 @@ export function AppHeader(
|
||||
<header
|
||||
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
|
||||
</Link>
|
||||
|
||||
|
||||
187
src/components/JournalCard.tsx
Normal file
187
src/components/JournalCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,9 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { Link, useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { AppHeader } from "../components/AppHeader.tsx";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
@@ -20,10 +19,8 @@ import {
|
||||
hydrateDump,
|
||||
type PaginatedData,
|
||||
type RawDump,
|
||||
type User,
|
||||
} from "../model.ts";
|
||||
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { useScrollSave } from "../hooks/useScrollSave.ts";
|
||||
@@ -32,6 +29,12 @@ import { useWS } from "../hooks/useWS.ts";
|
||||
import { useDumpListSync } from "../hooks/useDumpListSync.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 =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -43,82 +46,13 @@ type DumpsState =
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
type FeedTab = "hot" | "new" | "followed";
|
||||
type FollowedSection = "users" | "playlists";
|
||||
type FeedTab = "hot" | "new" | "journal" | "followed";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ── 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 ────────────────────────────────────────────────────────────────────
|
||||
const VALID_TABS = new Set<string>(["hot", "new", "journal", "followed"]);
|
||||
|
||||
export function Index() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
|
||||
?.deletedDumpId;
|
||||
|
||||
@@ -133,7 +67,8 @@ export function Index() {
|
||||
removeVote,
|
||||
} = useWS();
|
||||
|
||||
// Main feed
|
||||
// ── Main feed state ──
|
||||
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:index:${user?.id ?? "guest"}`,
|
||||
hydrateDump,
|
||||
@@ -151,56 +86,12 @@ export function Index() {
|
||||
);
|
||||
const mainFetchDone = useRef(false);
|
||||
|
||||
// Followed feeds
|
||||
const { cached: cachedFollowedUsers, saveState: saveFollowedUsers } =
|
||||
useFeedCache<Dump>(
|
||||
`feed:followed-users:${user?.id ?? "guest"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const { cached: cachedFollowedPlaylists, saveState: saveFollowedPlaylists } =
|
||||
useFeedCache<Dump>(
|
||||
`feed:followed-playlists:${user?.id ?? "guest"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const rawTab = new URLSearchParams(location.search).get("tab") ?? "hot";
|
||||
const tab: FeedTab = VALID_TABS.has(rawTab) ? rawTab as FeedTab : "hot";
|
||||
|
||||
const [followedUsersDumps, setFollowedUsersDumps] = useState<DumpsState>({
|
||||
status: "loading",
|
||||
});
|
||||
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]);
|
||||
function setTab(t: FeedTab) {
|
||||
navigate(`/?tab=${t}`, { replace: true });
|
||||
}
|
||||
|
||||
// ── Main feed fetch ──
|
||||
|
||||
@@ -229,10 +120,7 @@ export function Index() {
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setDumpsState({
|
||||
status: "error",
|
||||
error: friendlyFetchError(err),
|
||||
});
|
||||
setDumpsState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -241,93 +129,18 @@ export function Index() {
|
||||
};
|
||||
}, [cached, token]);
|
||||
|
||||
// ── Followed feeds fetch (lazy, on first tab open) ──
|
||||
// ── WS sync for main feed ──
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== "followed" || !user || !token) return;
|
||||
const setDumpsItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) =>
|
||||
setDumpsState((s) =>
|
||||
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
|
||||
),
|
||||
[],
|
||||
);
|
||||
useDumpListSync(setDumpsItems);
|
||||
|
||||
if (followedUsersDumps.status === "loading") {
|
||||
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),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (followedPlaylistsDumps.status === "loading") {
|
||||
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 ──
|
||||
// ── Load more ──
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (
|
||||
@@ -363,85 +176,7 @@ export function Index() {
|
||||
);
|
||||
}, [dumpsState, token]);
|
||||
|
||||
const loadMoreFollowedUsers = useCallback(() => {
|
||||
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 ──
|
||||
// ── Scroll save / restore ──
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
@@ -457,34 +192,6 @@ export function Index() {
|
||||
}, [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);
|
||||
useLayoutEffect(() => {
|
||||
if (cached?.scrollY == null || scrollRestored.current) return;
|
||||
@@ -498,19 +205,36 @@ export function Index() {
|
||||
|
||||
const loading = dumpsState.status === "loading";
|
||||
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 hasMore = dumpsState.status === "loaded" ? dumpsState.hasMore : false;
|
||||
|
||||
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
|
||||
const combined = [...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps]
|
||||
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);
|
||||
|
||||
const sortedDumps = [...combined].sort(
|
||||
tab === "new"
|
||||
? (a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
||||
: (a, b) => hotScore(b) - hotScore(a),
|
||||
const combined = useMemo(
|
||||
() =>
|
||||
[...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps].filter(
|
||||
(d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId,
|
||||
),
|
||||
[recentDumps, restIds, dumps, deletedDumpIds, justDeletedId],
|
||||
);
|
||||
|
||||
const mainFeedProps: MainFeedProps = {
|
||||
dumps: combined,
|
||||
loading,
|
||||
error,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
sentinelRef,
|
||||
voteCounts,
|
||||
myVotes,
|
||||
user,
|
||||
castVote,
|
||||
removeVote,
|
||||
};
|
||||
|
||||
// ── Render ──
|
||||
|
||||
const presenceRow = (
|
||||
@@ -550,6 +274,13 @@ export function Index() {
|
||||
>
|
||||
New
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`feed-sort-btn${tab === "journal" ? " active" : ""}`}
|
||||
onClick={() => setTab("journal")}
|
||||
>
|
||||
Journal
|
||||
</button>
|
||||
{user && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -574,96 +305,23 @@ export function Index() {
|
||||
disableNew={dumpsState.status === "error"}
|
||||
/>
|
||||
|
||||
{/* Shown only on narrow viewports */}
|
||||
<div className="index-below-header">
|
||||
{tabBar}
|
||||
{presenceRow}
|
||||
</div>
|
||||
|
||||
{/* Hot / New feed */}
|
||||
{tab !== "followed" && (
|
||||
<>
|
||||
{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 === "hot" && <HotFeed {...mainFeedProps} />}
|
||||
{tab === "new" && <NewFeed {...mainFeedProps} />}
|
||||
{tab === "journal" && <JournalFeed {...mainFeedProps} />}
|
||||
{tab === "followed" && user && (
|
||||
<div className="followed-feed">
|
||||
<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}
|
||||
myVotes={myVotes}
|
||||
user={user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
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>
|
||||
<FollowedFeed
|
||||
voteCounts={voteCounts}
|
||||
myVotes={myVotes}
|
||||
user={user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
deletedDumpIds={deletedDumpIds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
365
src/pages/index/FollowedFeed.tsx
Normal file
365
src/pages/index/FollowedFeed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/pages/index/HotFeed.tsx
Normal file
56
src/pages/index/HotFeed.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
src/pages/index/JournalFeed.tsx
Normal file
66
src/pages/index/JournalFeed.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
src/pages/index/NewFeed.tsx
Normal file
56
src/pages/index/NewFeed.tsx
Normal 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
16
src/pages/index/types.ts
Normal 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
6
src/utils/hotScore.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user