feat: add feed

This commit is contained in:
gosangam
2026-06-14 00:18:31 +05:30
parent eb0c981d3c
commit 61581b67a7
9 changed files with 395 additions and 47 deletions
+31 -30
View File
@@ -1,29 +1,40 @@
"use client";
import Link from "next/link";
import { Compass, LogIn, Sparkles } from "lucide-react";
import { Compass, Loader2, LogIn, Sparkles } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta";
import { HomeFeed } from "@/components/feed/home-feed";
/**
* / — the Home (Feed) gate.
*
* The Instagram-inspired infinite-scroll feed of posts from people you
* follow will live here. Until that ships we show two paths:
*
* • Sign in — for visitors who don't have an account yet; the eventual
* feed is personalised, so an account is the prerequisite.
* • Explore the Knowledgebase — Śrīla Prabhupāda's full library is
* ready today, regardless of feed status.
*
* The "Sign in" CTA hides once the user is authenticated — at that point
* there's nothing to gate, just a "feed coming soon" note above the
* always-on Knowledgebase entry.
* • Signed in → the daily sādhana streak on top, then the global,
* personalised infinite-scroll feed of community posts.
* • Anonymous → a welcome screen: sign in (the feed is personalised, so an
* account is the prerequisite) or jump into the Knowledgebase.
*/
export default function HomePage() {
const { user, loading } = useAuth();
const authed = !loading && !!user;
if (loading) {
return (
<main className="flex-1 flex items-center justify-center py-24">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</main>
);
}
if (user) {
return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6">
<div className="mb-6">
<SadhnaStreakCTA />
</div>
<HomeFeed />
</main>
);
}
return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-16 sm:py-24 select-none">
@@ -36,19 +47,16 @@ export default function HomePage() {
</span>
<h1 className="font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-3xl text-foreground leading-tight text-balance">
{authed
? "Your feed is coming soon"
: "Welcome to Prabhupāda Says"}
Welcome to Prabhupāda Says
</h1>
<p className="text-sm text-muted-foreground leading-relaxed max-w-md">
{authed
? "We're building a feed of quotes shared by the community. Until it lands, dive into the Knowledgebase — Śrīla Prabhupāda's books, letters, lectures, and teachings."
: "Sign in to follow seekers and see their quotes here once the feed launches, or jump straight into the Knowledgebase — the original, unedited writings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda."}
Sign in to follow seekers and see their quotes in your feed, or jump
straight into the Knowledgebase — the original, unedited writings of
His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda.
</p>
<div className="flex flex-col sm:flex-row items-center gap-3 mt-2">
{!authed ? (
<Link
href="/login"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground px-5 h-11 text-sm font-medium hover:bg-primary/90 transition-colors min-w-[12rem]"
@@ -56,21 +64,15 @@ export default function HomePage() {
<LogIn className="size-4" />
Sign in to view feed
</Link>
) : null}
<Link
href="/search"
className={
authed
? "inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground px-5 h-11 text-sm font-medium hover:bg-primary/90 transition-colors min-w-[12rem]"
: "inline-flex items-center justify-center gap-2 rounded-lg border border-border text-foreground px-5 h-11 text-sm font-medium hover:bg-muted transition-colors min-w-[12rem]"
}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-border text-foreground px-5 h-11 text-sm font-medium hover:bg-muted transition-colors min-w-[12rem]"
>
<Compass className="size-4" />
Explore Knowledgebase
</Link>
</div>
{!authed ? (
<p className="text-xs text-muted-foreground mt-1">
No account?{" "}
<Link href="/signup" className="text-foreground hover:underline">
@@ -78,9 +80,8 @@ export default function HomePage() {
</Link>{" "}
— it&apos;s free.
</p>
) : null}
{/* Daily sādhana streak — a simple entry point until the feed lands. */}
{/* Daily sādhana streak — a simple entry point for visitors. */}
<div className="mt-6 w-full max-w-md">
<SadhnaStreakCTA />
</div>
+5
View File
@@ -9,6 +9,7 @@ import {
useState,
} from "react";
import { auth, type User } from "@/lib/auth";
import { feed } from "@/lib/social";
/**
* AuthProvider holds the current-user state and exposes the auth actions
@@ -89,6 +90,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
);
const logOut = useCallback(async () => {
// Drop the home-feed seen-set first, while the auth cookie is still set
// (the reset endpoint is auth-gated). Best-effort — a stale set would
// otherwise expire on its own TTL.
await feed.reset().catch(() => {});
await auth.logOut();
setUser(null);
}, []);
+154
View File
@@ -0,0 +1,154 @@
"use client";
/**
* HomeFeed — the global, personalised feed of community posts.
*
* Architecture:
* • Window-virtualised list (dynamic row heights via measureElement) so a
* long feed stays cheap to render.
* • Infinite scroll: 10 posts per page, fetched as a bottom sentinel nears
* the viewport. The server orders not-yet-liked posts from people you
* follow first, then everyone else — seamless, no visible boundary.
* • When the feed is exhausted, a "new on the platform" strip (≤5 accounts
* that joined in the last 24h) caps it off — hidden if there are none.
*
* The card's Follow / Follow back affordance is enabled here (enableFollow);
* the server hydrates each post's viewer↔author relationship.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { Loader2 } from "lucide-react";
import { feed, type Post, type UserSummary } from "@/lib/social";
import { PostCard } from "@/components/social/post/post-card";
import { NewUsersStrip } from "./new-users-strip";
const PAGE = 10;
export function HomeFeed() {
const [items, setItems] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [newUsers, setNewUsers] = useState<UserSummary[]>([]);
const fetchingRef = useRef(false);
const offsetRef = useRef(0);
const parentRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const [offsetTop, setOffsetTop] = useState(0);
const loadPage = useCallback(async () => {
if (fetchingRef.current) return;
fetchingRef.current = true;
try {
const rows = (await feed.home(PAGE, offsetRef.current)) ?? [];
offsetRef.current += rows.length;
setItems((prev) => {
const seen = new Set(prev.map((p) => p.id));
return [...prev, ...rows.filter((p) => !seen.has(p.id))];
});
if (rows.length < PAGE) {
setHasMore(false);
// Feed exhausted → load the discovery strip once.
feed
.newUsers()
.then((u) => setNewUsers(u ?? []))
.catch(() => setNewUsers([]));
}
} catch {
setHasMore(false);
} finally {
fetchingRef.current = false;
setLoading(false);
}
}, []);
// Initial page + measure where the list sits in the document (so the window
// virtualiser offsets correctly below the streak component).
useEffect(() => {
void loadPage();
}, [loadPage]);
useEffect(() => {
if (parentRef.current) setOffsetTop(parentRef.current.offsetTop);
}, [loading]);
const virtualizer = useWindowVirtualizer({
count: items.length,
estimateSize: () => 460,
overscan: 4,
scrollMargin: offsetTop,
});
// Infinite scroll — fetch the next page as the sentinel approaches.
useEffect(() => {
const node = sentinelRef.current;
if (!node || !hasMore) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !fetchingRef.current) {
void loadPage();
}
},
{ rootMargin: "600px 0px" },
);
io.observe(node);
return () => io.disconnect();
}, [hasMore, loadPage, items.length]);
if (loading && items.length === 0) {
return (
<div className="flex justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!loading && items.length === 0) {
return (
<div className="space-y-4">
<p className="rounded-xl bg-card ring-1 ring-foreground/10 px-5 py-10 text-center text-sm text-muted-foreground">
Your feed is quiet for now. Follow a few devotees, or share a verse to
get things started.
</p>
<NewUsersStrip users={newUsers} />
</div>
);
}
const virtualItems = virtualizer.getVirtualItems();
return (
<div>
<div
ref={parentRef}
className="relative w-full"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualItems.map((vi) => {
const post = items[vi.index];
return (
<div
key={post.id}
data-index={vi.index}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${vi.start - offsetTop}px)` }}
>
<div className="pb-4">
<PostCard post={post} enableFollow />
</div>
</div>
);
})}
</div>
{hasMore ? (
<div ref={sentinelRef} className="flex justify-center py-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : (
<NewUsersStrip users={newUsers} />
)}
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
/**
* NewUsersStrip — the "new on the platform" discovery row shown at the very
* end of the home feed. Up to 5 accounts that joined in the last 24h; each
* avatar links to the user's profile. Renders nothing when the list is empty
* (the feed simply ends).
*/
import Link from "next/link";
import { Sparkles } from "lucide-react";
import type { UserSummary } from "@/lib/social";
import { UserAvatar } from "@/components/ui/user-avatar";
export function NewUsersStrip({ users }: { users: UserSummary[] }) {
if (users.length === 0) return null;
return (
<section className="mt-2 rounded-xl bg-card ring-1 ring-foreground/10 p-5">
<div className="mb-4 flex items-center gap-2.5">
<span
aria-hidden
className="inline-flex size-8 items-center justify-center rounded-full bg-primary/10 text-primary shrink-0"
>
<Sparkles className="size-4" />
</span>
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground">
New to Prabhupāda Says
</h2>
<p className="text-xs text-muted-foreground">
Welcome them — follow the devotees who just joined.
</p>
</div>
</div>
<ul className="flex flex-wrap gap-4">
{users.map((u) => (
<li key={u.handle}>
<Link
href={`/u/${u.handle}`}
className="group flex w-20 flex-col items-center gap-1.5 text-center"
>
<UserAvatar
src={u.avatar_url}
name={u.name}
className="size-14 group-hover:ring-foreground/30 transition"
/>
<span className="block w-full truncate text-xs font-medium text-foreground">
{u.name}
</span>
<span className="-mt-1 block w-full truncate text-[11px] text-muted-foreground">
@{u.handle}
</span>
</Link>
</li>
))}
</ul>
</section>
);
}
@@ -26,7 +26,7 @@ import {
} from "react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { notifications } from "@/lib/social";
import { feed, notifications } from "@/lib/social";
import { sadhna } from "@/lib/sadhna";
type NotificationsContextValue = {
@@ -113,8 +113,24 @@ export function NotificationsProvider({
}
}, [user?.id]);
// Open the realtime connection while signed in.
const userId = user?.id;
// When the session ends — the page unloads / the realtime socket drops as
// the user navigates away or closes the tab — clear the home-feed seen-set
// so it doesn't linger in Redis. `keepalive` lets the request outlive the
// page. (Genuine network drops with no unload are caught by the set's TTL;
// we deliberately don't clear on transient socket blips, which would re-show
// posts mid-scroll.)
useEffect(() => {
if (!userId) return;
const onLeave = () => {
void feed.reset().catch(() => {});
};
window.addEventListener("pagehide", onLeave);
return () => window.removeEventListener("pagehide", onLeave);
}, [userId]);
// Open the realtime connection while signed in.
useEffect(() => {
if (!userId) return;
const key = process.env.NEXT_PUBLIC_SOKETI_KEY;
@@ -206,6 +206,10 @@ export function CollectionsDrawer({
open={open}
onOpenChange={(o) => !o && onClose()}
direction={direction}
// Let the browser handle the on-screen keyboard. vaul's default
// input-repositioning resizes/translates the sheet and gets stuck on
// iOS after the keyboard hides (matches the other input-bearing drawers).
repositionInputs={false}
>
<DrawerContent
direction={direction}
@@ -316,7 +320,9 @@ export function CollectionsDrawer({
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Collection title"
maxLength={120}
className="w-full bg-white/5 text-white text-sm rounded-md px-3 py-2 placeholder:text-white/30 outline-none focus:bg-white/10"
// 16px on mobile (text-base) so iOS Safari doesn't auto-zoom
// on focus; back to 14px from sm: up.
className="w-full bg-white/5 text-white text-base sm:text-sm rounded-md px-3 py-2 placeholder:text-white/30 outline-none focus:bg-white/10"
/>
<div className="flex items-center justify-between text-xs text-white/60">
<label className="flex items-center gap-2 cursor-pointer">
@@ -0,0 +1,67 @@
"use client";
/**
* FeedFollowButton — the inline Follow / Follow back affordance shown in a
* home-feed PostCard header for authors the viewer doesn't already follow.
*
* Follows are request-based, so a tap sends a request and the control settles
* into a non-interactive "Requested" pill (matching the profile action row).
* Anonymous taps route through the auth gate first.
*/
import { useState } from "react";
import { Clock, UserPlus } from "lucide-react";
import { toast } from "sonner";
import { relationships, SocialError } from "@/lib/social";
import { useAuthGate } from "@/components/auth/auth-gate";
export function FeedFollowButton({
handle,
followsYou,
}: {
handle: string;
/** The author already follows the viewer → label becomes "Follow back". */
followsYou: boolean;
}) {
const { requireAuth } = useAuthGate();
const [requested, setRequested] = useState(false);
const [busy, setBusy] = useState(false);
async function follow() {
if (busy || requested) return;
if (!(await requireAuth())) return;
setBusy(true);
try {
await relationships.follow(handle);
setRequested(true);
} catch (err) {
toast.error(
err instanceof SocialError ? err.message : "Could not follow",
{ duration: 3000 },
);
} finally {
setBusy(false);
}
}
if (requested) {
return (
<span className="shrink-0 inline-flex items-center gap-1 rounded-full border border-border px-3 h-8 text-xs font-medium text-muted-foreground">
<Clock className="size-3.5" />
Requested
</span>
);
}
return (
<button
type="button"
onClick={follow}
disabled={busy}
className="shrink-0 inline-flex items-center gap-1 rounded-full bg-primary px-3 h-8 text-xs font-medium text-primary-foreground dark:text-white hover:bg-primary/90 transition-colors disabled:opacity-60"
>
<UserPlus className="size-3.5" />
{followsYou ? "Follow back" : "Follow"}
</button>
);
}
+11
View File
@@ -30,13 +30,18 @@ import { UserAvatar } from "@/components/ui/user-avatar";
import { timeAgo } from "@/lib/time";
import { PostActionsFooter } from "./post-actions-footer";
import { PostCardMenu } from "./post-card-menu";
import { FeedFollowButton } from "./feed-follow-button";
export function PostCard({
post,
onDelete,
enableFollow = false,
}: {
post: Post;
onDelete?: (id: string) => void;
/** Home-feed only: show a Follow / Follow back button in the header for
* authors the viewer doesn't already follow. */
enableFollow?: boolean;
}) {
const sourceHref =
hasHighlight(post.source_url) || !post.caption
@@ -94,6 +99,12 @@ export function PostCard({
{timeAgo(post.created_at)}
</p>
</div>
{enableFollow && authorHandle && !post.viewer_follows_author ? (
<FeedFollowButton
handle={authorHandle}
followsYou={!!post.author_follows_viewer}
/>
) : null}
<PostCardMenu post={post} onDelete={onDelete} />
</header>
+29
View File
@@ -90,6 +90,10 @@ export type Post = {
pinned_comment?: Comment | null;
/** Total comments + replies on the post (drives the footer comment count). */
comments_count?: number;
/** Home-feed only: does the viewer follow this post's author (accepted)? */
viewer_follows_author?: boolean;
/** Home-feed only: does the author follow the viewer (drives "Follow back")? */
author_follows_viewer?: boolean;
};
/** A comment or reply on a post. Threading is one level deep: `parent_id` is
@@ -541,6 +545,31 @@ export const profile = {
},
};
// ─── Home feed (global, personalised) ──────────────────────────────────────
export const feed = {
/** One page of the global home feed (auth required). Returns at most
* `limit` posts; callers infer "more" from a full page. */
home(limit = 10, offset = 0) {
const qs = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
return call<Post[]>(`/feed?${qs}`);
},
/** Up to 5 accounts that joined in the last 24h — the feed's discovery
* strip. Empty array when there are none. */
newUsers() {
return call<UserSummary[]>(`/feed/new-users`);
},
/** Clear the server-side "seen" set so the feed starts fresh. Called on
* logout and on page unload; `keepalive` lets the request outlive the
* unloading page (like a beacon). */
reset() {
return call<void>(`/feed/reset`, { method: "POST", keepalive: true });
},
};
// ─── Relationships (follow / block / report) ───────────────────────────────
/** Allowed user-report categories — mirrors the BFF allow-list. */