q12xo172i: Render post cards in the signed-in home feed
This commit is contained in:
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { loadSession, signOut, type Person } from "@/lib/auth-client";
|
||||
import { SignInForm } from "./SignInForm";
|
||||
import { Logo } from "./Logo";
|
||||
import { Feed } from "./Feed";
|
||||
|
||||
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
|
||||
|
||||
@@ -39,7 +40,6 @@ export function App() {
|
||||
if (state.kind === "out") {
|
||||
return <SignInForm onSignedIn={(person) => setState({ kind: "in", person })} />;
|
||||
}
|
||||
// Placeholder shell. The home feed is a later task; sign-out is wired here.
|
||||
return (
|
||||
<main className="shell">
|
||||
<header>
|
||||
@@ -52,6 +52,7 @@ export function App() {
|
||||
{signingOut ? "Signing out..." : "Sign out"}
|
||||
</button>
|
||||
</header>
|
||||
<Feed onSignedOut={() => setState({ kind: "out" })} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
|
||||
import { mergePosts, toCardPosts, type CardPost } from "@/lib/feed-model";
|
||||
import { FeedView, type FeedStatus } from "./FeedView";
|
||||
|
||||
const POLL_MS = 45_000;
|
||||
|
||||
export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
const [posts, setPosts] = useState<CardPost[]>([]);
|
||||
const [status, setStatus] = useState<FeedStatus>("loading");
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [loadMoreFailed, setLoadMoreFailed] = useState(false);
|
||||
const [pending, setPending] = useState<CardPost[]>([]);
|
||||
const sentinel = useRef<HTMLDivElement | null>(null);
|
||||
const busy = useRef(false);
|
||||
const shown = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
shown.current = new Set(posts.map((p) => p.key));
|
||||
}, [posts]);
|
||||
|
||||
const fail = useCallback(
|
||||
(e: unknown) => {
|
||||
if (e instanceof FeedLoadError && e.signedOut) onSignedOut();
|
||||
},
|
||||
[onSignedOut],
|
||||
);
|
||||
|
||||
const loadFirst = useCallback(async () => {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
setStatus("loading");
|
||||
setLoadMoreFailed(false);
|
||||
try {
|
||||
const page = await fetchFeedPage(null);
|
||||
setPosts(mergePosts(toCardPosts(page.items)));
|
||||
setNextCursor(page.nextCursor);
|
||||
setPending([]);
|
||||
setStatus("ready");
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
setStatus("error");
|
||||
} finally {
|
||||
busy.current = false;
|
||||
}
|
||||
}, [fail]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (busy.current || !nextCursor) return;
|
||||
busy.current = true;
|
||||
setLoadingMore(true);
|
||||
setLoadMoreFailed(false);
|
||||
try {
|
||||
const page = await fetchFeedPage(nextCursor);
|
||||
setPosts((cur) => mergePosts(cur, toCardPosts(page.items)));
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
setLoadMoreFailed(true);
|
||||
} finally {
|
||||
busy.current = false;
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [nextCursor, fail]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFirst();
|
||||
}, [loadFirst]);
|
||||
|
||||
// Infinite scroll. A fresh observer per change re-checks a sentinel that is
|
||||
// still in view after a page lands.
|
||||
useEffect(() => {
|
||||
const el = sentinel.current;
|
||||
if (!el || !nextCursor || status !== "ready" || loadMoreFailed) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) void loadMore();
|
||||
},
|
||||
{ rootMargin: "400px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length]);
|
||||
|
||||
// Real-time: poll the first page while the tab is visible.
|
||||
useEffect(() => {
|
||||
if (status !== "ready") return;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const page = await fetchFeedPage(null);
|
||||
const fresh = toCardPosts(page.items).filter((p) => !shown.current.has(p.key));
|
||||
if (fresh.length) setPending((cur) => mergePosts(cur, fresh));
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
};
|
||||
const start = () => {
|
||||
if (timer === null) timer = setInterval(() => void poll(), POLL_MS);
|
||||
};
|
||||
const stop = () => {
|
||||
if (timer !== null) clearInterval(timer);
|
||||
timer = null;
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void poll();
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
if (document.visibilityState === "visible") start();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [status, fail]);
|
||||
|
||||
function showNew() {
|
||||
setPosts((cur) => mergePosts(cur, pending));
|
||||
setPending([]);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<FeedView
|
||||
posts={posts}
|
||||
status={status}
|
||||
loadingMore={loadingMore}
|
||||
loadMoreFailed={loadMoreFailed}
|
||||
newCount={pending.length}
|
||||
onRetry={() => (status === "error" ? void loadFirst() : void loadMore())}
|
||||
onShowNew={showNew}
|
||||
sentinelRef={sentinel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Ref } from "react";
|
||||
import type { CardPost } from "@/lib/feed-model";
|
||||
import { PostCard } from "./PostCard";
|
||||
|
||||
export type FeedStatus = "loading" | "ready" | "error";
|
||||
|
||||
export interface FeedViewProps {
|
||||
posts: CardPost[];
|
||||
status: FeedStatus;
|
||||
loadingMore: boolean;
|
||||
loadMoreFailed: boolean;
|
||||
newCount: number;
|
||||
onRetry: () => void;
|
||||
onShowNew: () => void;
|
||||
sentinelRef?: Ref<HTMLDivElement>;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return <span className="spinner" role="status" aria-label="Loading" />;
|
||||
}
|
||||
|
||||
export function FeedView(p: FeedViewProps) {
|
||||
if (p.status === "loading") {
|
||||
return (
|
||||
<section className="feed">
|
||||
<div className="feed-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (p.status === "error") {
|
||||
return (
|
||||
<section className="feed">
|
||||
<div className="feed-message" role="alert">
|
||||
<p>The feed did not load.</p>
|
||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section className="feed">
|
||||
{p.newCount > 0 ? (
|
||||
<button type="button" className="new-posts" onClick={p.onShowNew}>
|
||||
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
|
||||
</button>
|
||||
) : null}
|
||||
{p.posts.length === 0 ? (
|
||||
<p className="feed-message">No posts yet.</p>
|
||||
) : (
|
||||
p.posts.map((post) => <PostCard key={post.key} post={post} now={p.now} />)
|
||||
)}
|
||||
{p.loadMoreFailed ? (
|
||||
<div className="feed-message" role="alert">
|
||||
<p>The feed did not load.</p>
|
||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||
</div>
|
||||
) : null}
|
||||
{p.loadingMore ? (
|
||||
<div className="feed-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { initials, relativeTime, type CardPost } from "@/lib/feed-model";
|
||||
|
||||
export function PostCard({ post, now }: { post: CardPost; now?: number }) {
|
||||
return (
|
||||
<article className="post" data-testid="post-card">
|
||||
<header className="post-head">
|
||||
{post.authorPictureUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="avatar" src={post.authorPictureUrl} alt="" width={40} height={40} />
|
||||
) : (
|
||||
<span className="avatar avatar-initials" aria-hidden="true">
|
||||
{initials(post.authorName)}
|
||||
</span>
|
||||
)}
|
||||
<div className="post-who">
|
||||
<strong>{post.authorName}</strong>
|
||||
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
|
||||
</div>
|
||||
</header>
|
||||
{post.text ? <p className="post-text">{post.text}</p> : null}
|
||||
{post.media.map((m, i) =>
|
||||
m.src ? (
|
||||
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={m.src} alt={m.kind === "video" ? "Video thumbnail" : ""} loading="lazy" />
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
<footer className="post-stats">
|
||||
<span aria-label={`${post.likesCount} likes`}>
|
||||
<span className="heart" aria-hidden="true">♥</span> {post.likesCount}
|
||||
</span>
|
||||
<span aria-label={`${post.commentsCount} comments`}>{post.commentsCount} comments</span>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user