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 { 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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
195
src/App.css
195
src/App.css
@@ -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% {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
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,
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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