feat: social graph features
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* /notifications — the signed-in user's notification feed.
|
||||
*
|
||||
* Lists everything newest-first and marks the whole feed read on open
|
||||
* (fire-and-forget), so the header bell's unread dot clears after a
|
||||
* visit. Each row links to the actor's profile, where follow requests can
|
||||
* be accepted/rejected.
|
||||
*
|
||||
* Anonymous visitors are bounced through the auth gate (then home if they
|
||||
* dismiss it), mirroring /u/me.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||
import { notifications, type AppNotification } from "@/lib/social";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const { requireAuth } = useAuthGate();
|
||||
const router = useRouter();
|
||||
|
||||
const [items, setItems] = useState<AppNotification[]>([]);
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
|
||||
// Auth gate — same pattern as /u/me.
|
||||
useEffect(() => {
|
||||
if (loading || user) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const ok = await requireAuth();
|
||||
if (!ok && !cancelled) router.replace("/");
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loading, user, requireAuth, router]);
|
||||
|
||||
// Load the feed + mark everything read once we have a user.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
let cancelled = false;
|
||||
notifications
|
||||
.list()
|
||||
.then((rows) => {
|
||||
if (!cancelled) {
|
||||
setItems(rows ?? []);
|
||||
setState("ready");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setState("error");
|
||||
});
|
||||
notifications.markAllRead().catch(() => {
|
||||
/* non-critical */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<main className="flex-1 flex items-center justify-center px-4 py-12">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{loading ? "Loading…" : "Sign in to view your notifications."}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
|
||||
<h1 className="font-[family-name:var(--font-title)] text-3xl font-semibold text-foreground">
|
||||
Notifications
|
||||
</h1>
|
||||
|
||||
{state === "error" ? (
|
||||
<p className="text-sm text-destructive">
|
||||
Could not load notifications. Please try again.
|
||||
</p>
|
||||
) : state === "loading" ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : items.length === 0 ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
|
||||
{items.map((n) => (
|
||||
<NotificationRow key={n.id} n={n} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationRow({ n }: { n: AppNotification }) {
|
||||
const display = n.actor_name || (n.actor_handle ? `@${n.actor_handle}` : "Someone");
|
||||
const message = messageFor(n.kind);
|
||||
const unread = !n.read_at;
|
||||
|
||||
const body = (
|
||||
<div
|
||||
className={cnRow(unread)}
|
||||
>
|
||||
<Avatar src={n.actor_avatar_url ?? null} name={display} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-foreground leading-snug">
|
||||
<span className="font-semibold">{display}</span> {message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{timeAgo(n.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{unread ? (
|
||||
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Link to the actor's profile when we know their handle.
|
||||
return n.actor_handle ? (
|
||||
<li>
|
||||
<Link href={`/u/${n.actor_handle}`} className="block">
|
||||
{body}
|
||||
</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li>{body}</li>
|
||||
);
|
||||
}
|
||||
|
||||
function cnRow(unread: boolean) {
|
||||
return [
|
||||
"flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/50",
|
||||
unread ? "bg-primary/5" : "",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/** Human-readable tail for each notification kind. Add cases as new
|
||||
* notification kinds are introduced on the backend. */
|
||||
function messageFor(kind: string): string {
|
||||
switch (kind) {
|
||||
case "follow_request":
|
||||
return "requested to follow you.";
|
||||
case "follow_accepted":
|
||||
return "accepted your follow request.";
|
||||
default:
|
||||
return "sent you a notification.";
|
||||
}
|
||||
}
|
||||
|
||||
function Avatar({ src, name }: { src: string | null; name: string }) {
|
||||
if (src) {
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="size-10 rounded-full object-cover ring-1 ring-foreground/10 shrink-0"
|
||||
unoptimized
|
||||
/>
|
||||
);
|
||||
}
|
||||
const initials =
|
||||
name
|
||||
.replace(/^@/, "")
|
||||
.split(/\s+/)
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase() || "?";
|
||||
return (
|
||||
<div className="size-10 shrink-0 rounded-full bg-primary/15 text-primary flex items-center justify-center text-sm font-semibold ring-1 ring-foreground/10">
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center gap-3 py-16 text-muted-foreground">
|
||||
<Bell className="size-8" />
|
||||
<p className="text-sm">No notifications yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact relative time ("just now", "5m", "3h", "2d"), falling back to
|
||||
* a date for anything older than a week. */
|
||||
function timeAgo(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return "";
|
||||
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
||||
if (secs < 45) return "just now";
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
@@ -120,11 +120,17 @@ export default function PublicProfileLayout(props: {
|
||||
return (
|
||||
<main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6">
|
||||
<ProfileHeader
|
||||
key={data.handle}
|
||||
avatarURL={data.avatar_url}
|
||||
name={data.name}
|
||||
handle={data.handle}
|
||||
postCount={data.post_count}
|
||||
followerCount={data.follower_count}
|
||||
followingCount={data.following_count}
|
||||
relationship={data.relationship}
|
||||
enableActions
|
||||
joinedAt={data.joined_at}
|
||||
country={data.country}
|
||||
/>
|
||||
<ProfileTabs tabs={tabs} />
|
||||
<PublicProfileContext.Provider value={data}>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* via a query param (kept simple — no new route for the form).
|
||||
*/
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
AtSign,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||
import { profile } from "@/lib/social";
|
||||
import { ProfileHeader } from "@/components/profile/profile-header";
|
||||
import { ProfileTabs, type ProfileTab } from "@/components/profile/profile-tabs";
|
||||
|
||||
@@ -43,6 +44,37 @@ export default function MeLayout({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
}, [loading, user, requireAuth, router]);
|
||||
|
||||
// Own profile counts (posts / followers / following) for the header
|
||||
// stat line. /auth/me doesn't carry these, so we read them from the
|
||||
// public profile endpoint (which returns no relationship for self).
|
||||
const [counts, setCounts] = useState<{
|
||||
posts: number;
|
||||
followers: number;
|
||||
following: number;
|
||||
} | null>(null);
|
||||
const handle = user?.handle;
|
||||
useEffect(() => {
|
||||
if (!handle) return;
|
||||
let cancelled = false;
|
||||
profile
|
||||
.get(handle)
|
||||
.then((p) => {
|
||||
if (!cancelled) {
|
||||
setCounts({
|
||||
posts: p.post_count,
|
||||
followers: p.follower_count,
|
||||
following: p.following_count,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* counts are non-critical; leave the stat line minimal on error */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [handle]);
|
||||
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<main className="flex-1 flex items-center justify-center px-4 py-12">
|
||||
@@ -80,11 +112,16 @@ export default function MeLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6">
|
||||
<ProfileHeader
|
||||
// Re-seed the header's count state once the async counts land.
|
||||
key={counts ? "me-ready" : "me-pending"}
|
||||
avatarURL={user.avatar_url}
|
||||
name={user.name}
|
||||
handle={user.handle}
|
||||
joinedAt={user.created_at}
|
||||
editHref="/u/me?edit=1"
|
||||
postCount={counts?.posts}
|
||||
followerCount={counts?.followers}
|
||||
followingCount={counts?.following}
|
||||
/>
|
||||
{/* Tab strip hides on /u/me?edit=1 (see ConditionalTabs). The
|
||||
Suspense boundary is required by Next.js because useSearchParams
|
||||
|
||||
Reference in New Issue
Block a user