feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
// app/(auth)/layout.tsx
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
openGraph: { type: "website" },
|
||||
};
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
|
||||
10
thoughts-frontend/app/(auth)/login/layout.tsx
Normal file
10
thoughts-frontend/app/(auth)/login/layout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign in",
|
||||
description: "Sign in to your Thoughts account",
|
||||
};
|
||||
|
||||
export default function LoginLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export default function LoginPage() {
|
||||
|
||||
const form = useForm<z.infer<typeof LoginSchema>>({
|
||||
resolver: zodResolver(LoginSchema),
|
||||
defaultValues: { username: "", password: "" },
|
||||
defaultValues: { email: "", password: "" },
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof LoginSchema>) {
|
||||
@@ -43,7 +43,7 @@ export default function LoginPage() {
|
||||
setToken(token);
|
||||
router.push("/"); // Redirect to homepage on successful login
|
||||
} catch {
|
||||
setError("Invalid username or password.");
|
||||
setError("Invalid email or password.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,12 +61,12 @@ export default function LoginPage() {
|
||||
{/* ... Form fields for username and password ... */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="frutiger" {...field} />
|
||||
<Input type="email" placeholder="you@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
10
thoughts-frontend/app/(auth)/register/layout.tsx
Normal file
10
thoughts-frontend/app/(auth)/register/layout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Join Thoughts",
|
||||
description: "Create an account on Thoughts and connect across the Fediverse",
|
||||
};
|
||||
|
||||
export default function RegisterLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RegisterSchema, registerUser } from "@/lib/api";
|
||||
import Cookies from "js-cookie";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function RegisterPage() {
|
||||
@@ -37,9 +38,9 @@ export default function RegisterPage() {
|
||||
async function onSubmit(values: z.infer<typeof RegisterSchema>) {
|
||||
try {
|
||||
setError(null);
|
||||
await registerUser(values);
|
||||
// You can automatically log the user in here or just redirect them
|
||||
router.push("/login");
|
||||
const { token } = await registerUser(values);
|
||||
Cookies.set("auth_token", token, { expires: 7, secure: true });
|
||||
router.push("/");
|
||||
} catch {
|
||||
setError("Username or email may already be taken.");
|
||||
}
|
||||
|
||||
23
thoughts-frontend/app/actions/profile.ts
Normal file
23
thoughts-frontend/app/actions/profile.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
import { updateProfile as apiUpdateProfile, UpdateProfileSchema } from "@/lib/api";
|
||||
import { z } from "zod";
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
const token = (await cookies()).get("auth_token")?.value;
|
||||
if (!token) throw new Error("Not authenticated");
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
username: string,
|
||||
data: z.infer<typeof UpdateProfileSchema>
|
||||
) {
|
||||
const token = await getToken();
|
||||
const updated = await apiUpdateProfile(data, token);
|
||||
revalidateTag(`profile:${username}`);
|
||||
revalidateTag("me");
|
||||
return updated;
|
||||
}
|
||||
28
thoughts-frontend/app/actions/social.ts
Normal file
28
thoughts-frontend/app/actions/social.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
followUser as apiFollowUser,
|
||||
unfollowUser as apiUnfollowUser,
|
||||
} from "@/lib/api";
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
const token = (await cookies()).get("auth_token")?.value;
|
||||
if (!token) throw new Error("Not authenticated");
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function followUser(username: string) {
|
||||
const token = await getToken();
|
||||
await apiFollowUser(username, token);
|
||||
revalidateTag(`profile:${username}`);
|
||||
revalidateTag("feed");
|
||||
}
|
||||
|
||||
export async function unfollowUser(username: string) {
|
||||
const token = await getToken();
|
||||
await apiUnfollowUser(username, token);
|
||||
revalidateTag(`profile:${username}`);
|
||||
revalidateTag("feed");
|
||||
}
|
||||
30
thoughts-frontend/app/actions/thoughts.ts
Normal file
30
thoughts-frontend/app/actions/thoughts.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
createThought as apiCreateThought,
|
||||
deleteThought as apiDeleteThought,
|
||||
CreateThoughtSchema,
|
||||
} from "@/lib/api";
|
||||
import { z } from "zod";
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
const token = (await cookies()).get("auth_token")?.value;
|
||||
if (!token) throw new Error("Not authenticated");
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function createThought(data: z.infer<typeof CreateThoughtSchema>) {
|
||||
const token = await getToken();
|
||||
const thought = await apiCreateThought(data, token);
|
||||
revalidateTag("feed");
|
||||
return thought;
|
||||
}
|
||||
|
||||
export async function deleteThought(thoughtId: string) {
|
||||
const token = await getToken();
|
||||
await apiDeleteThought(thoughtId, token);
|
||||
revalidateTag("feed");
|
||||
revalidateTag(`thought:${thoughtId}`);
|
||||
}
|
||||
@@ -7,8 +7,25 @@ import localFont from "next/font/local";
|
||||
import InstallPrompt from "@/components/install-prompt";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Thoughts",
|
||||
description: "A social network for sharing thoughts",
|
||||
title: {
|
||||
default: "Thoughts",
|
||||
template: "%s · Thoughts",
|
||||
},
|
||||
description:
|
||||
"A federated social network for short-form thoughts. Follow people across Mastodon, Pixelfed, and the wider Fediverse.",
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: "Thoughts",
|
||||
title: "Thoughts",
|
||||
description:
|
||||
"A federated social network for short-form thoughts. Follow people across the Fediverse.",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: "Thoughts",
|
||||
description:
|
||||
"A federated social network for short-form thoughts. Follow people across the Fediverse.",
|
||||
},
|
||||
};
|
||||
|
||||
const frutiger = localFont({
|
||||
|
||||
20
thoughts-frontend/app/loading.tsx
Normal file
20
thoughts-frontend/app/loading.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ThoughtSkeleton } from "@/components/loading-skeleton";
|
||||
|
||||
export default function FeedLoading() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-6xl p-4 sm:p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
<aside className="hidden lg:block lg:col-span-1" />
|
||||
<main className="col-span-1 lg:col-span-2 space-y-6">
|
||||
<div className="h-10 w-32 bg-muted rounded animate-pulse mb-6" />
|
||||
<div className="space-y-4">
|
||||
<ThoughtSkeleton />
|
||||
<ThoughtSkeleton />
|
||||
<ThoughtSkeleton />
|
||||
</div>
|
||||
</main>
|
||||
<aside className="hidden lg:block lg:col-span-1" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
getFeed,
|
||||
getFriends,
|
||||
getMe,
|
||||
getUserProfile,
|
||||
Me,
|
||||
User,
|
||||
} from "@/lib/api";
|
||||
import { PostThoughtForm } from "@/components/post-thought-form";
|
||||
import { getFeed, getMe, Me } from "@/lib/api";
|
||||
import { ThoughtForm } from "@/components/thought-form";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { PopularTags } from "@/components/popular-tags";
|
||||
@@ -15,25 +10,26 @@ import { ThoughtThread } from "@/components/thought-thread";
|
||||
import { buildThoughtThreads } from "@/lib/utils";
|
||||
import { TopFriends } from "@/components/top-friends";
|
||||
import { UsersCount } from "@/components/users-count";
|
||||
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { PaginationNav } from "@/components/pagination-nav";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { ProfileSkeleton, TagsSkeleton, CountSkeleton } from "@/components/loading-skeleton";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Home",
|
||||
description: "Your home timeline — thoughts from people you follow",
|
||||
};
|
||||
|
||||
export default async function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { page?: string };
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
|
||||
if (token) {
|
||||
return <FeedPage token={token} searchParams={searchParams} />;
|
||||
return <FeedPage token={token} searchParams={resolvedSearchParams} />;
|
||||
} else {
|
||||
return <LandingPage />;
|
||||
}
|
||||
@@ -60,29 +56,26 @@ async function FeedPage({
|
||||
const { items: allThoughts, totalPages } = feedData!;
|
||||
const thoughtThreads = buildThoughtThreads(allThoughts);
|
||||
|
||||
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))];
|
||||
const userProfiles = await Promise.all(
|
||||
authors.map((username) => getUserProfile(username, token).catch(() => null))
|
||||
const sidebar = (
|
||||
<>
|
||||
<Suspense fallback={<ProfileSkeleton />}>
|
||||
<TopFriends username={me.username} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<TagsSkeleton />}>
|
||||
<PopularTags />
|
||||
</Suspense>
|
||||
<Suspense fallback={<CountSkeleton />}>
|
||||
<UsersCount />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
|
||||
const authorDetails = new Map<string, { avatarUrl?: string | null }>(
|
||||
userProfiles
|
||||
.filter((u): u is User => !!u)
|
||||
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
|
||||
);
|
||||
|
||||
const friends = (await getFriends(token)).users.map((user) => user.username);
|
||||
const shouldDisplayTopFriends =
|
||||
token && me?.topFriends && me.topFriends.length > 8;
|
||||
|
||||
console.log("Should display top friends:", shouldDisplayTopFriends);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-6xl p-4 sm:p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
<aside className="hidden lg:block lg:col-span-1">
|
||||
<div className="sticky top-20 space-y-6 glass-effect glossy-effect bottom rounded-md p-4">
|
||||
<h2 className="text-lg font-semibold">Filters & Sorting</h2>
|
||||
<h2 className="text-lg font-semibold">Filters & Sorting</h2>
|
||||
<p className="text-sm text-muted-foreground">Coming soon...</p>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -91,17 +84,10 @@ async function FeedPage({
|
||||
<header className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-shadow-sm">Your Feed</h1>
|
||||
</header>
|
||||
<PostThoughtForm />
|
||||
<ThoughtForm />
|
||||
|
||||
<div className="block lg:hidden space-y-6">
|
||||
<PopularTags />
|
||||
{shouldDisplayTopFriends && (
|
||||
<TopFriends mode="top-friends" usernames={me.topFriends} />
|
||||
)}
|
||||
{!shouldDisplayTopFriends && token && friends.length > 0 && (
|
||||
<TopFriends mode="friends" usernames={friends || []} />
|
||||
)}
|
||||
<UsersCount />
|
||||
{sidebar}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
@@ -109,44 +95,23 @@ async function FeedPage({
|
||||
<ThoughtThread
|
||||
key={thought.id}
|
||||
thought={thought}
|
||||
authorDetails={authorDetails}
|
||||
currentUser={me}
|
||||
/>
|
||||
))}
|
||||
{thoughtThreads.length === 0 && (
|
||||
<p className="text-center text-muted-foreground pt-8">
|
||||
Your feed is empty. Follow some users to see their thoughts!
|
||||
</p>
|
||||
<EmptyState message="Your feed is empty. Follow some users to see their thoughts!" />
|
||||
)}
|
||||
</div>
|
||||
<Pagination className="mt-8">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={page > 1 ? `/?page=${page - 1}` : "#"}
|
||||
aria-disabled={page <= 1}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={page < totalPages ? `/?page=${page + 1}` : "#"}
|
||||
aria-disabled={page >= totalPages}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
<PaginationNav
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
buildHref={(p) => `/?page=${p}`}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<aside className="hidden lg:block lg:col-span-1">
|
||||
<div className="sticky top-20 space-y-6">
|
||||
<PopularTags />
|
||||
{shouldDisplayTopFriends && (
|
||||
<TopFriends mode="top-friends" usernames={me.topFriends} />
|
||||
)}
|
||||
{!shouldDisplayTopFriends && token && friends.length > 0 && (
|
||||
<TopFriends mode="friends" usernames={friends || []} />
|
||||
)}
|
||||
<UsersCount />
|
||||
{sidebar}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
85
thoughts-frontend/app/remote-actor/page.tsx
Normal file
85
thoughts-frontend/app/remote-actor/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { getMe, getRemoteFollowing, lookupRemoteActor, getRemoteActorPosts, Me } from "@/lib/api";
|
||||
import { RemoteUserProfile } from "@/components/remote-user-profile";
|
||||
|
||||
interface RemoteActorPageProps {
|
||||
searchParams: Promise<{ handle?: string }>;
|
||||
}
|
||||
|
||||
function stripHtml(html: string) {
|
||||
return html.replace(/<[^>]*>/g, "").trim();
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: RemoteActorPageProps): Promise<Metadata> {
|
||||
const { handle } = await searchParams;
|
||||
if (!handle) return { title: "Profile" };
|
||||
|
||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||
const actor = await lookupRemoteActor(handle, token).catch(() => null);
|
||||
if (!actor) return { title: handle };
|
||||
|
||||
const name = actor.displayName || actor.handle;
|
||||
const description = actor.bio
|
||||
? stripHtml(actor.bio).slice(0, 160)
|
||||
: `${name} on the Fediverse. Follow from Thoughts.`;
|
||||
|
||||
return {
|
||||
title: `${name} (${actor.handle})`,
|
||||
description,
|
||||
openGraph: {
|
||||
type: "profile",
|
||||
title: `${name} (${actor.handle})`,
|
||||
description,
|
||||
images: actor.avatarUrl ? [{ url: actor.avatarUrl }] : [],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: `${name} · Thoughts`,
|
||||
description,
|
||||
images: actor.avatarUrl ? [actor.avatarUrl] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RemoteActorPage({
|
||||
searchParams,
|
||||
}: RemoteActorPageProps) {
|
||||
const { handle } = await searchParams;
|
||||
if (!handle) notFound();
|
||||
|
||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||
|
||||
const [actorResult, postsResult, meResult, followingResult] = await Promise.allSettled([
|
||||
lookupRemoteActor(handle, token),
|
||||
getRemoteActorPosts(handle, 1, token),
|
||||
token ? getMe(token) : Promise.resolve(null),
|
||||
token ? getRemoteFollowing(token) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
if (actorResult.status === "rejected") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const actor = actorResult.value;
|
||||
const posts =
|
||||
postsResult.status === "fulfilled" ? postsResult.value.items : [];
|
||||
const me =
|
||||
meResult.status === "fulfilled" ? (meResult.value as Me | null) : null;
|
||||
const following =
|
||||
followingResult.status === "fulfilled" ? followingResult.value : [];
|
||||
const initialFollowed = following.some((f) => f.url === actor.url);
|
||||
|
||||
return (
|
||||
<RemoteUserProfile
|
||||
key={actor.url}
|
||||
actor={actor}
|
||||
initialPosts={posts}
|
||||
me={me}
|
||||
initialFollowed={initialFollowed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
12
thoughts-frontend/app/search/loading.tsx
Normal file
12
thoughts-frontend/app/search/loading.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ThoughtSkeleton } from "@/components/loading-skeleton";
|
||||
|
||||
export default function SearchLoading() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6 space-y-4">
|
||||
<div className="h-8 w-48 bg-muted rounded animate-pulse" />
|
||||
<ThoughtSkeleton />
|
||||
<ThoughtSkeleton />
|
||||
<ThoughtSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { getMe, search, User } from "@/lib/api";
|
||||
import { getMe, search, lookupRemoteActor } from "@/lib/api";
|
||||
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { q } = await searchParams;
|
||||
const title = q ? `Search: "${q}"` : "Search";
|
||||
return {
|
||||
title,
|
||||
description: q
|
||||
? `Search results for "${q}" on Thoughts`
|
||||
: "Search for people and thoughts on Thoughts",
|
||||
};
|
||||
}
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { UserListCard } from "@/components/user-list-card";
|
||||
import { RemoteUserCard } from "@/components/remote-user-card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ThoughtList } from "@/components/thought-list";
|
||||
|
||||
const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/;
|
||||
|
||||
interface SearchPageProps {
|
||||
searchParams: { q?: string };
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}
|
||||
|
||||
export default async function SearchPage({ searchParams }: SearchPageProps) {
|
||||
const query = searchParams.q || "";
|
||||
const { q } = await searchParams;
|
||||
const query = q || "";
|
||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||
|
||||
if (!query) {
|
||||
@@ -23,18 +44,14 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const [results, me] = await Promise.all([
|
||||
search(query, token).catch(() => null),
|
||||
const isHandle = HANDLE_RE.test(query);
|
||||
|
||||
const [results, remoteActor, me] = await Promise.all([
|
||||
isHandle ? null : search(query, token).catch(() => null),
|
||||
isHandle ? lookupRemoteActor(query, token).catch(() => null) : null,
|
||||
token ? getMe(token).catch(() => null) : null,
|
||||
]);
|
||||
|
||||
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
||||
if (results) {
|
||||
results.users.users.forEach((user: User) => {
|
||||
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||
<header className="my-6">
|
||||
@@ -44,31 +61,37 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
|
||||
</p>
|
||||
</header>
|
||||
<main>
|
||||
{results ? (
|
||||
{isHandle ? (
|
||||
remoteActor ? (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Remote user</h2>
|
||||
<RemoteUserCard actor={remoteActor} />
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState message={`No user found at ${query}`} />
|
||||
)
|
||||
) : results ? (
|
||||
<Tabs defaultValue="thoughts" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="thoughts">
|
||||
Thoughts ({results.thoughts.thoughts.length})
|
||||
Thoughts ({results.thoughts.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users">
|
||||
Users ({results.users.users.length})
|
||||
Users ({results.users.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="thoughts">
|
||||
<ThoughtList
|
||||
thoughts={results.thoughts.thoughts}
|
||||
authorDetails={authorDetails}
|
||||
thoughts={results.thoughts}
|
||||
currentUser={me}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="users">
|
||||
<UserListCard users={results.users.users} />
|
||||
<UserListCard users={results.users} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground pt-8">
|
||||
No results found or an error occurred.
|
||||
</p>
|
||||
<EmptyState message="No results found or an error occurred." />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default async function ApiKeysPage() {
|
||||
}
|
||||
|
||||
const initialApiKeys = await getApiKeys(token).catch(() => ({
|
||||
apiKeys: [],
|
||||
keys: [],
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -21,7 +21,7 @@ export default async function ApiKeysPage() {
|
||||
Manage API keys for third-party applications.
|
||||
</p>
|
||||
</div>
|
||||
<ApiKeyList initialApiKeys={initialApiKeys.apiKeys} />
|
||||
<ApiKeyList initialApiKeys={initialApiKeys.keys} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
23
thoughts-frontend/app/settings/federation/page.tsx
Normal file
23
thoughts-frontend/app/settings/federation/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { FederationPanel } from "@/components/federation/federation-panel";
|
||||
|
||||
export default async function FederationSettingsPage() {
|
||||
const token = (await cookies()).get("auth_token")?.value;
|
||||
if (!token) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="glass-effect glossy-effect bottom rounded-md shadow-fa-lg p-4">
|
||||
<h3 className="text-lg font-medium">Federation</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage remote follow requests, followers, and accounts you follow on
|
||||
other instances.
|
||||
</p>
|
||||
</div>
|
||||
<FederationPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,10 @@ const sidebarNavItems = [
|
||||
title: "API Keys",
|
||||
href: "/settings/api-keys",
|
||||
},
|
||||
{
|
||||
title: "Federation",
|
||||
href: "/settings/federation",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsLayout({
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// app/settings/profile/page.tsx
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Edit profile",
|
||||
description: "Update your Thoughts profile",
|
||||
};
|
||||
import { redirect } from "next/navigation";
|
||||
import { getMe } from "@/lib/api";
|
||||
import { EditProfileForm } from "@/components/edit-profile-form";
|
||||
|
||||
12
thoughts-frontend/app/tags/[tagName]/loading.tsx
Normal file
12
thoughts-frontend/app/tags/[tagName]/loading.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ThoughtSkeleton } from "@/components/loading-skeleton";
|
||||
|
||||
export default function TagLoading() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6 space-y-4">
|
||||
<div className="h-8 w-40 bg-muted rounded animate-pulse" />
|
||||
<ThoughtSkeleton />
|
||||
<ThoughtSkeleton />
|
||||
<ThoughtSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,40 @@
|
||||
// app/tags/[tagName]/page.tsx
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { getThoughtsByTag, getUserProfile, getMe, Me, User } from "@/lib/api";
|
||||
import { getThoughtsByTag, getMe, Me } from "@/lib/api";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ tagName: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { tagName } = await params;
|
||||
return {
|
||||
title: `#${tagName}`,
|
||||
description: `Thoughts tagged with #${tagName}`,
|
||||
openGraph: {
|
||||
title: `#${tagName} · Thoughts`,
|
||||
description: `Thoughts tagged with #${tagName}`,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: `#${tagName} · Thoughts`,
|
||||
description: `Thoughts tagged with #${tagName}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { buildThoughtThreads } from "@/lib/utils";
|
||||
import { ThoughtThread } from "@/components/thought-thread";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Hash } from "lucide-react";
|
||||
|
||||
interface TagPageProps {
|
||||
params: { tagName: string };
|
||||
params: Promise<{ tagName: string }>;
|
||||
}
|
||||
|
||||
export default async function TagPage({ params }: TagPageProps) {
|
||||
const { tagName } = params;
|
||||
const { tagName } = await params;
|
||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||
|
||||
const [thoughtsResult, meResult] = await Promise.allSettled([
|
||||
@@ -23,20 +46,10 @@ export default async function TagPage({ params }: TagPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const allThoughts = thoughtsResult.value.thoughts;
|
||||
const allThoughts = thoughtsResult.value.items;
|
||||
const thoughtThreads = buildThoughtThreads(allThoughts);
|
||||
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
|
||||
|
||||
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))];
|
||||
const userProfiles = await Promise.all(
|
||||
authors.map((username) => getUserProfile(username, token).catch(() => null))
|
||||
);
|
||||
const authorDetails = new Map<string, { avatarUrl?: string | null }>(
|
||||
userProfiles
|
||||
.filter((u): u is User => !!u)
|
||||
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||
<header className="my-6">
|
||||
@@ -50,14 +63,11 @@ export default async function TagPage({ params }: TagPageProps) {
|
||||
<ThoughtThread
|
||||
key={thought.id}
|
||||
thought={thought}
|
||||
authorDetails={authorDetails}
|
||||
currentUser={me}
|
||||
/>
|
||||
))}
|
||||
{thoughtThreads.length === 0 && (
|
||||
<p className="text-center text-muted-foreground pt-8">
|
||||
No thoughts found for this tag.
|
||||
</p>
|
||||
<EmptyState message="No thoughts found for this tag." />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
13
thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx
Normal file
13
thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ThoughtSkeleton } from "@/components/loading-skeleton";
|
||||
|
||||
export default function ThoughtLoading() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6 space-y-4">
|
||||
<ThoughtSkeleton />
|
||||
<div className="pl-6 border-l-2 border-primary border-dashed space-y-4">
|
||||
<ThoughtSkeleton />
|
||||
<ThoughtSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,57 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
getThoughtById,
|
||||
getThoughtThread,
|
||||
getUserProfile,
|
||||
getMe,
|
||||
Me,
|
||||
User,
|
||||
ThoughtThread as ThoughtThreadType,
|
||||
} from "@/lib/api";
|
||||
import { ThoughtThread } from "@/components/thought-thread";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
interface ThoughtPageProps {
|
||||
params: { thoughtId: string };
|
||||
params: Promise<{ thoughtId: string }>;
|
||||
}
|
||||
|
||||
function collectAuthors(thread: ThoughtThreadType): string[] {
|
||||
const authors = new Set<string>([thread.authorUsername]);
|
||||
for (const reply of thread.replies) {
|
||||
collectAuthors(reply).forEach((author) => authors.add(author));
|
||||
}
|
||||
return Array.from(authors);
|
||||
function stripHtml(html: string) {
|
||||
return html.replace(/<[^>]*>/g, "").trim();
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: ThoughtPageProps): Promise<Metadata> {
|
||||
const { thoughtId } = await params;
|
||||
const thought = await getThoughtById(thoughtId, null).catch(() => null);
|
||||
if (!thought) return { title: "Thought" };
|
||||
|
||||
const author = thought.author.displayName || thought.author.username;
|
||||
const preview = stripHtml(thought.content).slice(0, 120);
|
||||
const description = preview || `A thought by ${author}`;
|
||||
|
||||
return {
|
||||
title: `${author}: "${preview.slice(0, 60)}${preview.length > 60 ? "…" : ""}"`,
|
||||
description,
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: `${author} on Thoughts`,
|
||||
description,
|
||||
images: thought.author.avatarUrl
|
||||
? [{ url: thought.author.avatarUrl }]
|
||||
: [],
|
||||
publishedTime: thought.createdAt.toISOString(),
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: `${author} on Thoughts`,
|
||||
description,
|
||||
images: thought.author.avatarUrl ? [thought.author.avatarUrl] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ThoughtPage({ params }: ThoughtPageProps) {
|
||||
const { thoughtId } = params;
|
||||
const { thoughtId } = await params;
|
||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||
|
||||
const [threadResult, meResult] = await Promise.allSettled([
|
||||
@@ -38,20 +66,6 @@ export default async function ThoughtPage({ params }: ThoughtPageProps) {
|
||||
const thread = threadResult.value;
|
||||
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
|
||||
|
||||
// Fetch details for all authors in the thread efficiently
|
||||
const authorUsernames = collectAuthors(thread);
|
||||
const userProfiles = await Promise.all(
|
||||
authorUsernames.map((username) =>
|
||||
getUserProfile(username, token).catch(() => null)
|
||||
)
|
||||
);
|
||||
|
||||
const authorDetails = new Map<string, { avatarUrl?: string | null }>(
|
||||
userProfiles
|
||||
.filter((u): u is User => !!u)
|
||||
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||
<header className="my-6">
|
||||
@@ -60,7 +74,6 @@ export default async function ThoughtPage({ params }: ThoughtPageProps) {
|
||||
<main>
|
||||
<ThoughtThread
|
||||
thought={thread}
|
||||
authorDetails={authorDetails}
|
||||
currentUser={me}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -1,32 +1,44 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getFollowersList } from "@/lib/api";
|
||||
import { getFollowersList, getMe } from "@/lib/api";
|
||||
import { UserListCard } from "@/components/user-list-card";
|
||||
import { RemoteFollowers } from "@/components/federation/remote-followers";
|
||||
|
||||
interface FollowersPageProps {
|
||||
params: { username: string };
|
||||
params: Promise<{ username: string }>;
|
||||
}
|
||||
|
||||
export default async function FollowersPage({ params }: FollowersPageProps) {
|
||||
const { username } = params;
|
||||
const { username } = await params;
|
||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||
|
||||
const followersData = await getFollowersList(username, token).catch(
|
||||
() => null
|
||||
);
|
||||
const [followersData, me] = await Promise.all([
|
||||
getFollowersList(username, token).catch(() => null),
|
||||
token ? getMe(token).catch(() => null) : null,
|
||||
]);
|
||||
|
||||
if (!followersData) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isOwnProfile = me?.username === username;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||
<header className="my-6">
|
||||
<h1 className="text-3xl font-bold">Followers</h1>
|
||||
<p className="text-muted-foreground">Users following @{username}.</p>
|
||||
</header>
|
||||
<main>
|
||||
<UserListCard users={followersData.users} />
|
||||
<main className="space-y-8">
|
||||
<UserListCard users={followersData.items} />
|
||||
{isOwnProfile && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Remote followers
|
||||
</h2>
|
||||
<RemoteFollowers />
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,32 +1,44 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getFollowingList } from "@/lib/api";
|
||||
import { getFollowingList, getMe } from "@/lib/api";
|
||||
import { UserListCard } from "@/components/user-list-card";
|
||||
import { RemoteFollowing } from "@/components/federation/remote-following";
|
||||
|
||||
interface FollowingPageProps {
|
||||
params: { username: string };
|
||||
params: Promise<{ username: string }>;
|
||||
}
|
||||
|
||||
export default async function FollowingPage({ params }: FollowingPageProps) {
|
||||
const { username } = params;
|
||||
const { username } = await params;
|
||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||
|
||||
const followingData = await getFollowingList(username, token).catch(
|
||||
() => null
|
||||
);
|
||||
const [followingData, me] = await Promise.all([
|
||||
getFollowingList(username, token).catch(() => null),
|
||||
token ? getMe(token).catch(() => null) : null,
|
||||
]);
|
||||
|
||||
if (!followingData) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isOwnProfile = me?.username === username;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||
<header className="my-6">
|
||||
<h1 className="text-3xl font-bold">Following</h1>
|
||||
<p className="text-muted-foreground">Users that @{username} follows.</p>
|
||||
</header>
|
||||
<main>
|
||||
<UserListCard users={followingData.users} />
|
||||
<main className="space-y-8">
|
||||
<UserListCard users={followingData.items} />
|
||||
{isOwnProfile && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Remote following
|
||||
</h2>
|
||||
<RemoteFollowing />
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import {
|
||||
getFollowersList,
|
||||
getFollowingList,
|
||||
getFriends,
|
||||
getMe,
|
||||
getRemoteFollowers,
|
||||
getRemoteFollowing,
|
||||
getUserProfile,
|
||||
getUserThoughts,
|
||||
Me,
|
||||
} from "@/lib/api";
|
||||
|
||||
interface ProfilePageProps {
|
||||
params: Promise<{ username: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: ProfilePageProps): Promise<Metadata> {
|
||||
const { username } = await params;
|
||||
const user = await getUserProfile(username, null).catch(() => null);
|
||||
if (!user) return { title: username };
|
||||
|
||||
const name = user.displayName || user.username;
|
||||
const description =
|
||||
user.bio ||
|
||||
`Follow ${name} on Thoughts and across the Fediverse.`;
|
||||
|
||||
return {
|
||||
title: `${name} (@${user.username})`,
|
||||
description,
|
||||
openGraph: {
|
||||
type: "profile",
|
||||
title: `${name} (@${user.username})`,
|
||||
description,
|
||||
images: user.avatarUrl ? [{ url: user.avatarUrl }] : [],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: `${name} (@${user.username})`,
|
||||
description,
|
||||
images: user.avatarUrl ? [user.avatarUrl] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { Calendar, Settings } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -14,17 +51,21 @@ import { notFound } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { FollowButton } from "@/components/follow-button";
|
||||
import { TopFriends } from "@/components/top-friends";
|
||||
import { Suspense } from "react";
|
||||
import { ProfileSkeleton } from "@/components/loading-skeleton";
|
||||
import { buildThoughtThreads } from "@/lib/utils";
|
||||
import { ThoughtThread } from "@/components/thought-thread";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PendingRequests } from "@/components/federation/pending-requests";
|
||||
|
||||
interface ProfilePageProps {
|
||||
params: { username: string };
|
||||
params: Promise<{ username: string }>;
|
||||
}
|
||||
|
||||
export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||
const { username } = params;
|
||||
const { username } = await params;
|
||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||
|
||||
const userProfilePromise = getUserProfile(username, token);
|
||||
@@ -55,33 +96,37 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
|
||||
|
||||
const thoughts =
|
||||
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.thoughts : [];
|
||||
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.items : [];
|
||||
const thoughtThreads = buildThoughtThreads(thoughts);
|
||||
|
||||
const followersCount =
|
||||
const localFollowersCount =
|
||||
followersResult.status === "fulfilled"
|
||||
? followersResult.value.users.length
|
||||
? followersResult.value.total
|
||||
: 0;
|
||||
const followingCount =
|
||||
const localFollowingCount =
|
||||
followingResult.status === "fulfilled"
|
||||
? followingResult.value.users.length
|
||||
? followingResult.value.total
|
||||
: 0;
|
||||
|
||||
const isOwnProfile = me?.username === user.username;
|
||||
const isFollowing =
|
||||
me?.following?.some(
|
||||
(followedUser) => followedUser.username === user.username
|
||||
) || false;
|
||||
|
||||
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
||||
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
|
||||
const [remoteFollowersCount, remoteFollowingCount] =
|
||||
isOwnProfile && token
|
||||
? await Promise.all([
|
||||
getRemoteFollowers(token).then((r) => r.length).catch(() => 0),
|
||||
getRemoteFollowing(token).then((r) => r.length).catch(() => 0),
|
||||
])
|
||||
: [0, 0];
|
||||
|
||||
const friends =
|
||||
typeof token === "string"
|
||||
? (await getFriends(token)).users.map((user) => user.username)
|
||||
: [];
|
||||
const followersCount = localFollowersCount + remoteFollowersCount;
|
||||
const followingCount = localFollowingCount + remoteFollowingCount;
|
||||
const isFollowing = user.isFollowedByViewer;
|
||||
|
||||
const shouldDisplayTopFriends = token && friends.length > 8;
|
||||
const apiDomain = process.env.NEXT_PUBLIC_API_URL
|
||||
? new URL(process.env.NEXT_PUBLIC_API_URL).hostname
|
||||
: null;
|
||||
const fediverseHandle =
|
||||
user.local && apiDomain ? `@${user.username}@${apiDomain}` : null;
|
||||
|
||||
return (
|
||||
<div id={`profile-page-${user.username}`}>
|
||||
@@ -148,6 +193,11 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||
>
|
||||
@{user.username}
|
||||
</p>
|
||||
{fediverseHandle && (
|
||||
<p className="text-xs text-muted-foreground/70 mt-0.5 font-mono select-all">
|
||||
{fediverseHandle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p
|
||||
@@ -189,15 +239,14 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
Joined {new Date(user.joinedAt).toLocaleDateString()}
|
||||
Joined {user.joinedAt ? new Date(user.joinedAt).toLocaleDateString() : "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{shouldDisplayTopFriends && (
|
||||
<TopFriends mode="top-friends" usernames={user.topFriends} />
|
||||
)}
|
||||
{token && <TopFriends mode="friends" usernames={friends || []} />}
|
||||
<Suspense fallback={<ProfileSkeleton />}>
|
||||
<TopFriends username={user.username} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -205,24 +254,31 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||
id="profile-card__thoughts"
|
||||
className="col-span-1 lg:col-span-3 space-y-4"
|
||||
>
|
||||
{thoughtThreads.map((thought) => (
|
||||
<ThoughtThread
|
||||
key={thought.id}
|
||||
thought={thought}
|
||||
authorDetails={authorDetails}
|
||||
currentUser={me}
|
||||
/>
|
||||
))}
|
||||
{thoughtThreads.length === 0 && (
|
||||
<Card
|
||||
id="profile-card__no-thoughts"
|
||||
className="flex items-center justify-center h-48"
|
||||
>
|
||||
<p className="text-center text-muted-foreground">
|
||||
This user hasn't posted any public thoughts yet.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
<Tabs key={user.id.toString()} defaultValue="thoughts">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="thoughts">Thoughts</TabsTrigger>
|
||||
{isOwnProfile && (
|
||||
<TabsTrigger value="federation">Requests</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
<TabsContent value="thoughts" className="space-y-4">
|
||||
{thoughtThreads.map((thought) => (
|
||||
<ThoughtThread
|
||||
key={thought.id}
|
||||
thought={thought}
|
||||
currentUser={me}
|
||||
/>
|
||||
))}
|
||||
{thoughtThreads.length === 0 && (
|
||||
<EmptyState message="This user hasn't posted any public thoughts yet." />
|
||||
)}
|
||||
</TabsContent>
|
||||
{isOwnProfile && (
|
||||
<TabsContent value="federation">
|
||||
<PendingRequests />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { getAllUsers } from "@/lib/api";
|
||||
import { UserListCard } from "@/components/user-list-card";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { PaginationNav } from "@/components/pagination-nav";
|
||||
|
||||
export default async function AllUsersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { page?: string };
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
const page = parseInt(searchParams.page ?? "1", 10);
|
||||
const { page: pageStr } = await searchParams;
|
||||
const page = parseInt(pageStr ?? "1", 10);
|
||||
const usersData = await getAllUsers(page).catch(() => null);
|
||||
|
||||
if (!usersData) {
|
||||
@@ -27,7 +22,8 @@ export default async function AllUsersPage({
|
||||
);
|
||||
}
|
||||
|
||||
const { items, totalPages } = usersData;
|
||||
const { items, total, per_page } = usersData;
|
||||
const totalPages = Math.ceil(total / per_page);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||
@@ -39,24 +35,11 @@ export default async function AllUsersPage({
|
||||
</header>
|
||||
<main>
|
||||
<UserListCard users={items} />
|
||||
{totalPages > 1 && (
|
||||
<Pagination className="mt-8">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={page > 1 ? `/users/all?page=${page - 1}` : "#"}
|
||||
aria-disabled={page <= 1}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={page < totalPages ? `/users/all?page=${page + 1}` : "#"}
|
||||
aria-disabled={page >= totalPages}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
<PaginationNav
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
buildHref={(p) => `/users/all?page=${p}`}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user