feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -4,6 +4,9 @@ WORKDIR /app
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
|
||||
ARG NEXT_PUBLIC_SERVER_SIDE_API_URL
|
||||
ENV NEXT_PUBLIC_SERVER_SIDE_API_URL=$NEXT_PUBLIC_SERVER_SIDE_API_URL
|
||||
|
||||
# Install dependencies with Bun for speed
|
||||
COPY --chown=node:node package.json bun.lock ./
|
||||
RUN npm install -g bun
|
||||
|
||||
@@ -1,36 +1,47 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Thoughts — Frontend
|
||||
|
||||
## Getting Started
|
||||
Next.js 15 (App Router) frontend for the [Thoughts](../) self-hosted microblogging server.
|
||||
|
||||
First, run the development server:
|
||||
## Features
|
||||
|
||||
- Post thoughts, reply, boost, and like
|
||||
- Home feed, public feed, per-user timelines
|
||||
- Browse and follow remote Fediverse actors by `@user@instance` handle
|
||||
- Full remote actor profiles — bio, banner, profile fields, posts tab, followers/following tabs
|
||||
- Full-text search for local users and thoughts; remote actor lookup via WebFinger
|
||||
- Notifications, API key management, profile editing
|
||||
- Dark/light theme
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
bun install
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Copy `.env.local.example` to `.env.local` (or set the variables directly):
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_SERVER_SIDE_API_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
`NEXT_PUBLIC_API_URL` is used by client-side fetches (runs in the browser).
|
||||
`NEXT_PUBLIC_SERVER_SIDE_API_URL` is used by server-side fetches (runs in Next.js SSR — can point to an internal service URL in Docker).
|
||||
|
||||
## Learn More
|
||||
## Run
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
```bash
|
||||
bun run dev # development — http://localhost:3000
|
||||
bun run build # production build
|
||||
bun run start # serve production build
|
||||
```
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## Docker
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_API_URL=https://api.yourdomain.example.com \
|
||||
--build-arg NEXT_PUBLIC_SERVER_SIDE_API_URL=http://thoughts:8000 \
|
||||
-t thoughts-frontend .
|
||||
docker run -p 3000:3000 thoughts-frontend
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -64,7 +64,7 @@ export function ApiKeyList({ initialApiKeys }: ApiKeyListProps) {
|
||||
try {
|
||||
const newKeyResponse = await createApiKey(values, token);
|
||||
setKeys((prev) => [...prev, newKeyResponse]);
|
||||
setNewKey(newKeyResponse.plaintextKey ?? null);
|
||||
setNewKey(newKeyResponse.key ?? null);
|
||||
form.reset();
|
||||
toast.success("API Key created successfully.");
|
||||
} catch {
|
||||
@@ -113,7 +113,7 @@ export function ApiKeyList({ initialApiKeys }: ApiKeyListProps) {
|
||||
{`Created on ${format(key.createdAt, "PPP")}`}
|
||||
</p>
|
||||
<p className="text-xs font-mono text-muted-foreground mt-1">
|
||||
{`${key.keyPrefix}...`}
|
||||
{key.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Me, UpdateProfileSchema, updateProfile } from "@/lib/api";
|
||||
import { Me, UpdateProfileSchema } from "@/lib/api";
|
||||
import { updateProfile } from "@/app/actions/profile";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
@@ -16,19 +15,15 @@ import {
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TopFriendsCombobox } from "@/components/top-friends-combobox";
|
||||
|
||||
interface EditProfileFormProps {
|
||||
currentUser: Me;
|
||||
}
|
||||
|
||||
export function EditProfileForm({ currentUser }: EditProfileFormProps) {
|
||||
const router = useRouter();
|
||||
const { token } = useAuth();
|
||||
|
||||
const form = useForm<z.infer<typeof UpdateProfileSchema>>({
|
||||
resolver: zodResolver(UpdateProfileSchema),
|
||||
@@ -38,18 +33,14 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) {
|
||||
avatarUrl: currentUser.avatarUrl ?? undefined,
|
||||
headerUrl: currentUser.headerUrl ?? undefined,
|
||||
customCss: currentUser.customCss ?? undefined,
|
||||
topFriends: currentUser.topFriends ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof UpdateProfileSchema>) {
|
||||
if (!token) return;
|
||||
toast.info("Updating your profile...");
|
||||
try {
|
||||
await updateProfile(values, token);
|
||||
await updateProfile(currentUser.username, values);
|
||||
toast.success("Profile updated successfully!");
|
||||
router.push(`/users/${currentUser.username}`);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast.error(`Failed to update profile. ${err}`);
|
||||
}
|
||||
@@ -135,25 +126,6 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="topFriends"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Top Friends</FormLabel>
|
||||
<FormControl>
|
||||
<TopFriendsCombobox
|
||||
value={field.value || []}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Select up to 8 of your friends to display on your profile.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t px-6 py-4">
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
|
||||
12
thoughts-frontend/components/empty-state.tsx
Normal file
12
thoughts-frontend/components/empty-state.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
interface EmptyStateProps {
|
||||
message: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({ message, className }: EmptyStateProps) {
|
||||
return (
|
||||
<p className={`text-center text-muted-foreground pt-8 ${className ?? ""}`}>
|
||||
{message}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
47
thoughts-frontend/components/federation/federation-panel.tsx
Normal file
47
thoughts-frontend/components/federation/federation-panel.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PendingRequests } from "./pending-requests";
|
||||
import { RemoteFollowers } from "./remote-followers";
|
||||
import { RemoteFollowing } from "./remote-following";
|
||||
import { getPendingFollowRequests } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
export function FederationPanel() {
|
||||
const { token } = useAuth();
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
getPendingFollowRequests(token)
|
||||
.then((r) => setPendingCount(r.length))
|
||||
.catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="requests">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="requests">
|
||||
Requests
|
||||
{pendingCount > 0 && (
|
||||
<span className="ml-1.5 rounded-full bg-primary text-primary-foreground text-xs px-1.5 py-0.5">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="followers">Followers</TabsTrigger>
|
||||
<TabsTrigger value="following">Following</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="requests">
|
||||
<PendingRequests />
|
||||
</TabsContent>
|
||||
<TabsContent value="followers">
|
||||
<RemoteFollowers />
|
||||
</TabsContent>
|
||||
<TabsContent value="following">
|
||||
<RemoteFollowing />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
95
thoughts-frontend/components/federation/pending-requests.tsx
Normal file
95
thoughts-frontend/components/federation/pending-requests.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getPendingFollowRequests,
|
||||
acceptFollowRequest,
|
||||
rejectFollowRequest,
|
||||
type RemoteActor,
|
||||
} from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import { fullFediverseHandle } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function PendingRequests({ compact = false }: Props) {
|
||||
const { token } = useAuth();
|
||||
const [requests, setRequests] = useState<RemoteActor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
getPendingFollowRequests(token)
|
||||
.then(setRequests)
|
||||
.catch(() => toast.error("Failed to load follow requests"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
const accept = async (actorUrl: string) => {
|
||||
if (!token) return;
|
||||
setRequests((prev) => prev.filter((r) => r.url !== actorUrl));
|
||||
await acceptFollowRequest(actorUrl, token).catch(() => {
|
||||
toast.error("Failed to accept follow request");
|
||||
});
|
||||
};
|
||||
|
||||
const reject = async (actorUrl: string) => {
|
||||
if (!token) return;
|
||||
setRequests((prev) => prev.filter((r) => r.url !== actorUrl));
|
||||
await rejectFollowRequest(actorUrl, token).catch(() => {
|
||||
toast.error("Failed to reject follow request");
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||
if (requests.length === 0)
|
||||
return <p className="text-sm text-muted-foreground">No pending requests.</p>;
|
||||
|
||||
return (
|
||||
<ul className={compact ? "space-y-2" : "space-y-3"}>
|
||||
{requests.map((actor) => (
|
||||
<li
|
||||
key={actor.url}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<Link
|
||||
href={`/users/@${fullFediverseHandle(actor.handle, actor.url)}`}
|
||||
className="flex items-center gap-2 min-w-0 hover:opacity-80"
|
||||
>
|
||||
<UserAvatar
|
||||
src={actor.avatarUrl}
|
||||
alt={actor.displayName}
|
||||
className="h-8 w-8 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{actor.displayName || actor.handle}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate font-mono">
|
||||
@{fullFediverseHandle(actor.handle, actor.url)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button size="sm" onClick={() => accept(actor.url)}>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => reject(actor.url)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
66
thoughts-frontend/components/federation/remote-followers.tsx
Normal file
66
thoughts-frontend/components/federation/remote-followers.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getRemoteFollowers, rejectFollowRequest, type RemoteActor } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import { fullFediverseHandle } from "@/lib/utils";
|
||||
|
||||
export function RemoteFollowers() {
|
||||
const { token } = useAuth();
|
||||
const [followers, setFollowers] = useState<RemoteActor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
getRemoteFollowers(token)
|
||||
.then(setFollowers)
|
||||
.catch(() => toast.error("Failed to load followers"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
const remove = async (actorUrl: string) => {
|
||||
if (!token) return;
|
||||
setFollowers((prev) => prev.filter((f) => f.url !== actorUrl));
|
||||
await rejectFollowRequest(actorUrl, token).catch(() => {
|
||||
toast.error("Failed to remove follower");
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||
if (followers.length === 0)
|
||||
return <p className="text-sm text-muted-foreground">No remote followers yet.</p>;
|
||||
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{followers.map((actor) => (
|
||||
<li key={actor.url} className="flex items-center justify-between gap-3">
|
||||
<Link
|
||||
href={`/users/@${fullFediverseHandle(actor.handle, actor.url)}`}
|
||||
className="flex items-center gap-2 min-w-0 hover:opacity-80"
|
||||
>
|
||||
<UserAvatar
|
||||
src={actor.avatarUrl}
|
||||
alt={actor.displayName}
|
||||
className="h-8 w-8 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{actor.displayName || actor.handle}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate font-mono">
|
||||
@{fullFediverseHandle(actor.handle, actor.url)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Button size="sm" variant="outline" onClick={() => remove(actor.url)}>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
71
thoughts-frontend/components/federation/remote-following.tsx
Normal file
71
thoughts-frontend/components/federation/remote-following.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getRemoteFollowing, unfollowRemoteActor, type RemoteActor } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import { fullFediverseHandle } from "@/lib/utils";
|
||||
|
||||
export function RemoteFollowing() {
|
||||
const { token } = useAuth();
|
||||
const [following, setFollowing] = useState<RemoteActor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
getRemoteFollowing(token)
|
||||
.then(setFollowing)
|
||||
.catch(() => toast.error("Failed to load following"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
const unfollow = async (actor: RemoteActor) => {
|
||||
if (!token) return;
|
||||
const handle = fullFediverseHandle(actor.handle, actor.url);
|
||||
setFollowing((prev) => prev.filter((f) => f.url !== actor.url));
|
||||
await unfollowRemoteActor(handle, token).catch(() => {
|
||||
toast.error("Failed to unfollow");
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||
if (following.length === 0)
|
||||
return <p className="text-sm text-muted-foreground">Not following anyone remotely yet.</p>;
|
||||
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{following.map((actor) => (
|
||||
<li key={actor.url} className="flex items-center justify-between gap-3">
|
||||
<Link
|
||||
href={`/users/@${fullFediverseHandle(actor.handle, actor.url)}`}
|
||||
className="flex items-center gap-2 min-w-0 hover:opacity-80"
|
||||
>
|
||||
<UserAvatar
|
||||
src={actor.avatarUrl}
|
||||
alt={actor.displayName}
|
||||
className="h-8 w-8 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{actor.displayName || actor.handle}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate font-mono">
|
||||
@{fullFediverseHandle(actor.handle, actor.url)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => unfollow(actor)}
|
||||
>
|
||||
Unfollow
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +1,41 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { followUser, unfollowUser } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { UserPlus, UserMinus } from "lucide-react";
|
||||
import { useOptimistic } from "react"
|
||||
import { followUser, unfollowUser } from "@/app/actions/social"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "sonner"
|
||||
import { UserPlus, UserMinus } from "lucide-react"
|
||||
|
||||
interface FollowButtonProps {
|
||||
username: string;
|
||||
isInitiallyFollowing: boolean;
|
||||
username: string
|
||||
isInitiallyFollowing: boolean
|
||||
}
|
||||
|
||||
export function FollowButton({
|
||||
username,
|
||||
isInitiallyFollowing,
|
||||
}: FollowButtonProps) {
|
||||
const [isFollowing, setIsFollowing] = useState(isInitiallyFollowing);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!token) {
|
||||
toast.error("You must be logged in to follow users.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const action = isFollowing ? unfollowUser : followUser;
|
||||
export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) {
|
||||
const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing)
|
||||
|
||||
async function handleClick() {
|
||||
const next = !optimisticFollowing
|
||||
setOptimisticFollowing(next)
|
||||
try {
|
||||
// Optimistic update
|
||||
setIsFollowing(!isFollowing);
|
||||
await action(username, token);
|
||||
router.refresh(); // Re-fetch server component data to get the latest follower count etc.
|
||||
await (next ? followUser(username) : unfollowUser(username))
|
||||
} catch {
|
||||
// Revert on error
|
||||
setIsFollowing(isFollowing);
|
||||
toast.error(`Failed to ${isFollowing ? "unfollow" : "follow"} user.`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setOptimisticFollowing(!next) // revert
|
||||
toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
variant={isFollowing ? "secondary" : "default"}
|
||||
data-following={isFollowing}
|
||||
variant={optimisticFollowing ? "secondary" : "default"}
|
||||
data-following={optimisticFollowing}
|
||||
>
|
||||
{isFollowing ? (
|
||||
<>
|
||||
<UserMinus className="mr-2 h-4 w-4" /> Unfollow
|
||||
</>
|
||||
{optimisticFollowing ? (
|
||||
<><UserMinus className="mr-2 h-4 w-4" /> Unfollow</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="mr-2 h-4 w-4" /> Follow
|
||||
</>
|
||||
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
57
thoughts-frontend/components/loading-skeleton.tsx
Normal file
57
thoughts-frontend/components/loading-skeleton.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export function ThoughtSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center gap-4">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfileSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6 flex items-center gap-4">
|
||||
<Skeleton className="h-16 w-16 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function TagsSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<Skeleton className="h-4 w-24 mb-3" />
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-6 w-full rounded-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function CountSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
76
thoughts-frontend/components/pagination-nav.tsx
Normal file
76
thoughts-frontend/components/pagination-nav.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
|
||||
interface Props {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
buildHref: (page: number) => string;
|
||||
}
|
||||
|
||||
function pageNumbers(
|
||||
page: number,
|
||||
totalPages: number
|
||||
): (number | "ellipsis")[] {
|
||||
if (totalPages <= 7) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const pages: (number | "ellipsis")[] = [1];
|
||||
|
||||
if (page > 3) pages.push("ellipsis");
|
||||
|
||||
const start = Math.max(2, page - 1);
|
||||
const end = Math.min(totalPages - 1, page + 1);
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
|
||||
if (page < totalPages - 2) pages.push("ellipsis");
|
||||
|
||||
pages.push(totalPages);
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
export function PaginationNav({ page, totalPages, buildHref }: Props) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<Pagination className="mt-8">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={page > 1 ? buildHref(page - 1) : "#"}
|
||||
aria-disabled={page <= 1}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{pageNumbers(page, totalPages).map((p, i) =>
|
||||
p === "ellipsis" ? (
|
||||
<PaginationItem key={`ellipsis-${i}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink href={buildHref(p)} isActive={p === page}>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={page < totalPages ? buildHref(page + 1) : "#"}
|
||||
aria-disabled={page >= totalPages}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { CreateThoughtSchema, createThought } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { toast } from "sonner";
|
||||
import { Globe, Lock, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Confetti } from "./confetti";
|
||||
|
||||
export function PostThoughtForm() {
|
||||
const router = useRouter();
|
||||
const { token } = useAuth();
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
|
||||
resolver: zodResolver(CreateThoughtSchema),
|
||||
defaultValues: { content: "", visibility: "Public" },
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof CreateThoughtSchema>) {
|
||||
if (!token) {
|
||||
toast.error("You must be logged in to post.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createThought(values, token);
|
||||
toast.success("Your thought has been posted!");
|
||||
setShowConfetti(true);
|
||||
form.reset();
|
||||
router.refresh(); // This is the key to updating the feed
|
||||
} catch {
|
||||
toast.error("Failed to post thought. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} />
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What's on your mind?"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Visibility" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="Public">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" /> Public
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="FriendsOnly">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" /> Friends Only
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="Private">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4" /> Private
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? "Posting..." : "Post Thought"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
thoughts-frontend/components/remote-user-card.tsx
Normal file
66
thoughts-frontend/components/remote-user-card.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import Link from "next/link";
|
||||
import { followUser } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { toast } from "sonner";
|
||||
import { UserPlus } from "lucide-react";
|
||||
|
||||
interface RemoteUserCardProps {
|
||||
actor: {
|
||||
handle: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function RemoteUserCard({ actor }: RemoteUserCardProps) {
|
||||
const [followed, setFollowed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { token } = useAuth();
|
||||
|
||||
const handleFollow = async () => {
|
||||
if (!token) {
|
||||
toast.error("You must be logged in to follow users.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await followUser(actor.handle, token);
|
||||
setFollowed(true);
|
||||
toast.success(`Follow request sent to ${actor.handle}`);
|
||||
} catch {
|
||||
toast.error("Failed to send follow request.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<Link
|
||||
href={`/users/@${actor.handle}`}
|
||||
className="flex items-center gap-3 hover:opacity-80"
|
||||
>
|
||||
<UserAvatar src={actor.avatarUrl} alt={actor.displayName ?? actor.handle} />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{actor.displayName ?? actor.handle}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{actor.handle}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={handleFollow}
|
||||
disabled={loading || followed}
|
||||
variant={followed ? "secondary" : "default"}
|
||||
size="sm"
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{followed ? "Requested" : "Follow"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { ActorConnection, getActorFollowers, getActorFollowing } from "@/lib/api";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { RemoteUserCard } from "@/components/remote-user-card";
|
||||
|
||||
interface ConnectionsProps {
|
||||
handle: string;
|
||||
token: string | null;
|
||||
type: "followers" | "following";
|
||||
/** Parent sets this to true when the tab becomes active for the first time. */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function Connections({ handle, token, type, active }: ConnectionsProps) {
|
||||
const [items, setItems] = useState<ActorConnection[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const load = async (p: number) => {
|
||||
const fetchFn = type === "followers" ? getActorFollowers : getActorFollowing;
|
||||
const result = await fetchFn(handle, p, token).catch(() => null);
|
||||
if (!result) return;
|
||||
setItems((prev) => (p === 1 ? result.items : [...prev, ...result.items]));
|
||||
setHasMore(result.hasMore);
|
||||
setLoaded(true);
|
||||
setPage(p);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (active && !loaded) {
|
||||
load(1);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active]);
|
||||
|
||||
const emptyMessage =
|
||||
type === "followers"
|
||||
? "No followers cached yet — check back soon."
|
||||
: "No following cached yet — check back soon.";
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<Card className="flex items-center justify-center h-48">
|
||||
<p className="text-center text-muted-foreground">Loading {type}…</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Card className="flex items-center justify-center h-48">
|
||||
<p className="text-center text-muted-foreground">{emptyMessage}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((f) => (
|
||||
<RemoteUserCard key={f.url} actor={f} />
|
||||
))}
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => load(page + 1)}
|
||||
className="w-full text-sm text-muted-foreground hover:text-foreground py-2"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
thoughts-frontend/components/remote-user-profile/index.tsx
Normal file
144
thoughts-frontend/components/remote-user-profile/index.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { UserMinus, UserPlus } from "lucide-react";
|
||||
import { followUser, unfollowUser, RemoteActor, Thought, Me } from "@/lib/api";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ThoughtList } from "@/components/thought-list";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ProfileCard } from "./profile-card";
|
||||
import { Connections } from "./connections";
|
||||
|
||||
interface RemoteUserProfileProps {
|
||||
actor: RemoteActor;
|
||||
initialPosts: Thought[];
|
||||
me: Me | null;
|
||||
initialFollowed?: boolean;
|
||||
}
|
||||
|
||||
export function RemoteUserProfile({
|
||||
actor,
|
||||
initialPosts,
|
||||
me,
|
||||
initialFollowed = false,
|
||||
}: RemoteUserProfileProps) {
|
||||
const [followed, setFollowed] = useState(initialFollowed);
|
||||
const [followLoading, setFollowLoading] = useState(false);
|
||||
const { token } = useAuth();
|
||||
|
||||
const [followersActive, setFollowersActive] = useState(false);
|
||||
const [followingActive, setFollowingActive] = useState(false);
|
||||
|
||||
const handleFollow = async () => {
|
||||
if (!token) {
|
||||
toast.error("You must be logged in to follow users.");
|
||||
return;
|
||||
}
|
||||
setFollowLoading(true);
|
||||
try {
|
||||
if (followed) {
|
||||
await unfollowUser(actor.handle, token);
|
||||
setFollowed(false);
|
||||
} else {
|
||||
await followUser(actor.handle, token);
|
||||
setFollowed(true);
|
||||
toast.success(`Follow request sent to ${actor.handle}`);
|
||||
}
|
||||
} catch {
|
||||
toast.error(followed ? "Failed to unfollow." : "Failed to send follow request.");
|
||||
} finally {
|
||||
setFollowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: string) => {
|
||||
if (tab === "followers") setFollowersActive(true);
|
||||
if (tab === "following") setFollowingActive(true);
|
||||
};
|
||||
|
||||
const isOwnProfile = me?.username === actor.handle;
|
||||
|
||||
const followButton =
|
||||
!isOwnProfile && token ? (
|
||||
<Button
|
||||
onClick={handleFollow}
|
||||
disabled={followLoading}
|
||||
variant={followed ? "secondary" : "default"}
|
||||
size="sm"
|
||||
>
|
||||
{followed ? (
|
||||
<>
|
||||
<UserMinus className="mr-2 h-4 w-4" /> Unfollow
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="mr-2 h-4 w-4" /> Follow
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="h-48 bg-muted bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: actor.bannerUrl ? `url(${actor.bannerUrl})` : "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="container mx-auto max-w-6xl p-4 -mt-16 grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
<aside className="col-span-1 space-y-6">
|
||||
<div className="sticky top-20 space-y-6">
|
||||
<Card className="p-6 bg-card/80 backdrop-blur-lg">
|
||||
<ProfileCard actor={actor} action={followButton} />
|
||||
</Card>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="col-span-1 lg:col-span-3">
|
||||
<Tabs defaultValue="posts" onValueChange={handleTabChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="posts">Posts</TabsTrigger>
|
||||
<TabsTrigger value="followers">Followers</TabsTrigger>
|
||||
<TabsTrigger value="following">Following</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="posts" className="space-y-4 mt-4">
|
||||
{initialPosts.length > 0 ? (
|
||||
<ThoughtList thoughts={initialPosts} currentUser={me} />
|
||||
) : (
|
||||
<Card className="flex items-center justify-center h-48">
|
||||
<p className="text-center text-muted-foreground">
|
||||
Posts are being fetched — check back soon.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="followers" className="mt-4">
|
||||
<Connections
|
||||
handle={actor.handle}
|
||||
token={token}
|
||||
type="followers"
|
||||
active={followersActive}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="following" className="mt-4">
|
||||
<Connections
|
||||
handle={actor.handle}
|
||||
token={token}
|
||||
type="following"
|
||||
active={followingActive}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Link from "next/link";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
import { RemoteActor } from "@/lib/api";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ProfileCardProps {
|
||||
actor: RemoteActor;
|
||||
/** Slot rendered next to the avatar (e.g. follow/unfollow button). */
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function ProfileCard({ actor, action }: ProfileCardProps) {
|
||||
let hostname: string | null = null;
|
||||
try {
|
||||
if (actor.url) hostname = new URL(actor.url).hostname;
|
||||
} catch {
|
||||
hostname = actor.url;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="w-24 h-24 rounded-full border-4 border-background shrink-0">
|
||||
<UserAvatar
|
||||
src={actor.avatarUrl}
|
||||
alt={actor.displayName}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 min-w-0">
|
||||
<h1 className="text-2xl font-bold truncate">
|
||||
{actor.displayName ?? actor.handle}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground truncate">{actor.handle}</p>
|
||||
</div>
|
||||
|
||||
{actor.bio && (
|
||||
<div
|
||||
className="mt-4 text-sm [&_a]:underline [&_a]:text-primary [&_p]:mb-2"
|
||||
dangerouslySetInnerHTML={{ __html: actor.bio }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button asChild variant="outline" size="sm" className="mt-4 w-full">
|
||||
<Link
|
||||
href={actor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center overflow-hidden"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{hostname}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{actor.alsoKnownAs && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Also known as:{" "}
|
||||
<Link
|
||||
href={actor.alsoKnownAs}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{actor.alsoKnownAs}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{actor.attachment.length > 0 && (
|
||||
<div className="mt-4 space-y-0 text-sm">
|
||||
{actor.attachment.map((field) => (
|
||||
<div
|
||||
key={field.name}
|
||||
className="grid grid-cols-[minmax(0,5rem)_1fr] gap-2 border-t py-1"
|
||||
>
|
||||
<span
|
||||
className="font-medium text-muted-foreground truncate"
|
||||
title={field.name}
|
||||
>
|
||||
{field.name}
|
||||
</span>
|
||||
<span
|
||||
className="break-all min-w-0 [&_a]:underline [&_a]:text-primary"
|
||||
dangerouslySetInnerHTML={{ __html: field.value }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CreateThoughtSchema, createThought } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
import { Confetti } from "./confetti";
|
||||
|
||||
interface ReplyFormProps {
|
||||
parentThoughtId: string;
|
||||
onReplySuccess: () => void; // A callback to close the form after success
|
||||
}
|
||||
|
||||
export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
|
||||
const router = useRouter();
|
||||
const { token } = useAuth();
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
|
||||
resolver: zodResolver(CreateThoughtSchema),
|
||||
defaultValues: {
|
||||
content: "",
|
||||
replyToId: parentThoughtId,
|
||||
visibility: "Public", // Replies default to Public
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof CreateThoughtSchema>) {
|
||||
if (!token) {
|
||||
toast.error("You must be logged in to reply.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createThought(values, token);
|
||||
toast.success("Your reply has been posted!");
|
||||
form.reset();
|
||||
setShowConfetti(true);
|
||||
console.log("Showing confetti");
|
||||
onReplySuccess();
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error("Failed to post reply. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} />
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 p-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Post your reply..."
|
||||
className="resize-none bg-white glass-effect glossy-efect bottom shadow-fa-sm"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onReplySuccess} // Close button
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? "Replying..." : "Reply"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
import { deleteThought, Me, Thought } from "@/lib/api";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Me, Thought } from "@/lib/api";
|
||||
import { deleteThought } from "@/app/actions/thoughts";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -36,43 +36,35 @@ import {
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { ReplyForm } from "@/components/reply-form";
|
||||
import { ThoughtForm } from "@/components/thought-form";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ThoughtCardProps {
|
||||
thought: Thought;
|
||||
author: {
|
||||
username: string;
|
||||
displayName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
currentUser: Me | null;
|
||||
isReply?: boolean;
|
||||
}
|
||||
|
||||
export function ThoughtCard({
|
||||
thought,
|
||||
author,
|
||||
currentUser,
|
||||
isReply = false,
|
||||
}: ThoughtCardProps) {
|
||||
const { author } = thought;
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false);
|
||||
const [isReplyOpen, setIsReplyOpen] = useState(false);
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const timeAgo = formatDistanceToNow(new Date(thought.createdAt), {
|
||||
addSuffix: true,
|
||||
});
|
||||
|
||||
const isAuthor = currentUser?.username === thought.authorUsername;
|
||||
const isAuthor = currentUser?.username === thought.author.username;
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
await deleteThought(thought.id, token);
|
||||
await deleteThought(thought.id);
|
||||
toast.success("Thought deleted successfully.");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete thought:", error);
|
||||
toast.error("Failed to delete thought.");
|
||||
@@ -106,6 +98,22 @@ export function ThoughtCard({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!thought.replyToId && thought.replyToUrl && (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<CornerUpLeft className="h-4 w-4 text-primary/70" />
|
||||
<span>
|
||||
Replying to{" "}
|
||||
<a
|
||||
href={thought.replyToUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline text-primary text-shadow-sm"
|
||||
>
|
||||
original post ↗
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Card className="mt-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
@@ -121,9 +129,13 @@ export function ThoughtCard({
|
||||
<span className="font-bold">
|
||||
{author.displayName || author.username}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground text-shadow-sm">
|
||||
<time
|
||||
dateTime={new Date(thought.createdAt).toISOString()}
|
||||
title={format(new Date(thought.createdAt), "PPP p")}
|
||||
className="text-sm text-muted-foreground text-shadow-sm"
|
||||
>
|
||||
{timeAgo}
|
||||
</span>
|
||||
</time>
|
||||
</div>
|
||||
</Link>
|
||||
<DropdownMenu>
|
||||
@@ -152,9 +164,20 @@ export function ThoughtCard({
|
||||
</DropdownMenu>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap break-words text-shadow-sm">
|
||||
{thought.content}
|
||||
</p>
|
||||
{thought.author.local ? (
|
||||
<p className="whitespace-pre-wrap break-words text-shadow-sm">
|
||||
{thought.content}
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className="text-sm break-words [&_a]:underline [&_a]:text-primary [&_p]:mb-2 [&_.media-notice]:text-muted-foreground [&_.media-notice]:italic"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
thought.content.trim() ||
|
||||
'<p class="media-notice">📎 Media attachment — not supported</p>',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{token && (
|
||||
@@ -172,9 +195,9 @@ export function ThoughtCard({
|
||||
|
||||
{isReplyOpen && (
|
||||
<div className="border-t m-4 rounded-2xl border-border/50 bg-secondary/20 ">
|
||||
<ReplyForm
|
||||
parentThoughtId={thought.id}
|
||||
onReplySuccess={() => setIsReplyOpen(false)}
|
||||
<ThoughtForm
|
||||
replyToId={thought.id}
|
||||
onSuccess={() => setIsReplyOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
142
thoughts-frontend/components/thought-form.tsx
Normal file
142
thoughts-frontend/components/thought-form.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client"
|
||||
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { CreateThoughtSchema } from "@/lib/api"
|
||||
import { useAuth } from "@/hooks/use-auth"
|
||||
import { toast } from "sonner"
|
||||
import { Globe, Lock, Users } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Confetti } from "./confetti"
|
||||
import { createThought } from "@/app/actions/thoughts"
|
||||
|
||||
interface ThoughtFormProps {
|
||||
/** Set to the parent thought ID when composing a reply. */
|
||||
replyToId?: string
|
||||
/** Called after successful submit (e.g. close the reply panel). */
|
||||
onSuccess?: () => void
|
||||
/** Whether to wrap in a Card. Defaults to true when no replyToId. */
|
||||
card?: boolean
|
||||
}
|
||||
|
||||
export function ThoughtForm({ replyToId, onSuccess, card = !replyToId }: ThoughtFormProps) {
|
||||
const { token } = useAuth()
|
||||
const [showConfetti, setShowConfetti] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
|
||||
resolver: zodResolver(CreateThoughtSchema),
|
||||
defaultValues: {
|
||||
content: "",
|
||||
visibility: "public",
|
||||
...(replyToId ? { inReplyToId: replyToId } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(values: z.infer<typeof CreateThoughtSchema>) {
|
||||
if (!token) {
|
||||
toast.error("You must be logged in.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await createThought(values)
|
||||
toast.success(replyToId ? "Reply posted!" : "Thought posted!")
|
||||
setShowConfetti(true)
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
} catch {
|
||||
toast.error(replyToId ? "Failed to post reply." : "Failed to post thought.")
|
||||
}
|
||||
}
|
||||
|
||||
const inner = (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={replyToId ? "Post your reply..." : "What's on your mind?"}
|
||||
className={`resize-none ${replyToId ? "bg-white shadow-fa-sm" : ""}`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className={`flex ${replyToId ? "justify-end gap-2" : "justify-between items-center"}`}>
|
||||
{!replyToId && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Visibility" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="public">
|
||||
<div className="flex items-center gap-2"><Globe className="h-4 w-4" /> Public</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="followers">
|
||||
<div className="flex items-center gap-2"><Users className="h-4 w-4" /> Followers</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="unlisted">
|
||||
<div className="flex items-center gap-2"><Lock className="h-4 w-4" /> Unlisted</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="direct">
|
||||
<div className="flex items-center gap-2"><Lock className="h-4 w-4" /> Direct</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{replyToId && (
|
||||
<Button type="button" variant="ghost" onClick={() => onSuccess?.()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting
|
||||
? (replyToId ? "Replying..." : "Posting...")
|
||||
: (replyToId ? "Reply" : "Post Thought")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} />
|
||||
{card
|
||||
? <Card><CardContent className="p-4">{inner}</CardContent></Card>
|
||||
: <div className="space-y-2 p-4">{inner}</div>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,15 +4,10 @@ import { Card, CardContent } from "./ui/card";
|
||||
|
||||
interface ThoughtListProps {
|
||||
thoughts: Thought[];
|
||||
authorDetails: Map<string, { avatarUrl?: string | null }>;
|
||||
currentUser: Me | null;
|
||||
}
|
||||
|
||||
export function ThoughtList({
|
||||
thoughts,
|
||||
authorDetails,
|
||||
currentUser,
|
||||
}: ThoughtListProps) {
|
||||
export function ThoughtList({ thoughts, currentUser }: ThoughtListProps) {
|
||||
if (thoughts.length === 0) {
|
||||
return (
|
||||
<p className="text-center text-muted-foreground pt-8">
|
||||
@@ -25,21 +20,13 @@ export function ThoughtList({
|
||||
<Card>
|
||||
<CardContent className="divide-y p-0">
|
||||
<div className="space-y-6 p-4">
|
||||
{thoughts.map((thought) => {
|
||||
const author = {
|
||||
username: thought.authorUsername,
|
||||
displayName: thought.authorDisplayName,
|
||||
...authorDetails.get(thought.authorUsername),
|
||||
};
|
||||
return (
|
||||
<ThoughtCard
|
||||
key={thought.id}
|
||||
thought={thought}
|
||||
author={author}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{thoughts.map((thought) => (
|
||||
<ThoughtCard
|
||||
key={thought.id}
|
||||
thought={thought}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -3,28 +3,19 @@ import { ThoughtCard } from "./thought-card";
|
||||
|
||||
interface ThoughtThreadProps {
|
||||
thought: ThoughtThreadType;
|
||||
authorDetails: Map<string, { avatarUrl?: string | null }>;
|
||||
currentUser: Me | null;
|
||||
isReply?: boolean;
|
||||
}
|
||||
|
||||
export function ThoughtThread({
|
||||
thought,
|
||||
authorDetails,
|
||||
currentUser,
|
||||
isReply = false,
|
||||
}: ThoughtThreadProps) {
|
||||
const author = {
|
||||
username: thought.authorUsername,
|
||||
displayName: thought.authorDisplayName,
|
||||
...authorDetails.get(thought.authorUsername),
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={`thought-thread-${thought.id}`} className="flex flex-col gap-0">
|
||||
<ThoughtCard
|
||||
thought={thought}
|
||||
author={author}
|
||||
currentUser={currentUser}
|
||||
isReply={isReply}
|
||||
/>
|
||||
@@ -38,7 +29,6 @@ export function ThoughtThread({
|
||||
<ThoughtThread
|
||||
key={reply.id}
|
||||
thought={reply}
|
||||
authorDetails={authorDetails}
|
||||
currentUser={currentUser}
|
||||
isReply={true}
|
||||
/>
|
||||
|
||||
@@ -1,51 +1,25 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
import { getUserProfile, User } from "@/lib/api";
|
||||
import { getTopFriends } from "@/lib/api";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
interface TopFriendsProps {
|
||||
mode: "friends" | "top-friends";
|
||||
usernames: string[];
|
||||
username: string;
|
||||
}
|
||||
|
||||
export async function TopFriends({
|
||||
mode = "top-friends",
|
||||
usernames,
|
||||
}: TopFriendsProps) {
|
||||
export async function TopFriends({ username }: TopFriendsProps) {
|
||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||
const data = await getTopFriends(username, token).catch(() => ({ topFriends: [] }));
|
||||
const friends = data.topFriends;
|
||||
|
||||
if (usernames.length === 0) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<CardHeader className="p-0 pb-2">
|
||||
<CardTitle className="text-lg text-shadow-md">Top Friends</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No top friends to display.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const friendsResults = await Promise.allSettled(
|
||||
usernames.map((username) => getUserProfile(username, token))
|
||||
);
|
||||
|
||||
const friends = friendsResults
|
||||
.filter(
|
||||
(result): result is PromiseFulfilledResult<User> =>
|
||||
result.status === "fulfilled"
|
||||
)
|
||||
.map((result) => result.value);
|
||||
if (friends.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card id="top-friends" className="p-4">
|
||||
<CardHeader id="top-friends__header" className="p-0 pb-2">
|
||||
<CardTitle id="top-friends__title" className="text-lg text-shadow-md">
|
||||
{mode === "top-friends" ? "Top Friends" : "Friends"}
|
||||
Top Friends
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent id="top-friends__content" className="p-0">
|
||||
@@ -59,7 +33,7 @@ export async function TopFriends({
|
||||
<UserAvatar src={friend.avatarUrl} alt={friend.username} />
|
||||
<span
|
||||
id={`top-friends__name-${friend.id}`}
|
||||
className="text-xs truncate w-full group-hover:underline font-medium text-shadow-sm"
|
||||
className="text-xs truncate w-full font-medium text-shadow-sm"
|
||||
>
|
||||
{friend.displayName || friend.username}
|
||||
</span>
|
||||
|
||||
@@ -1,84 +1,102 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const UserSchema = z.object({
|
||||
id: z.uuid(),
|
||||
id: z.string().uuid(),
|
||||
username: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
bio: z.string().nullable(),
|
||||
avatarUrl: z.url().nullable(),
|
||||
headerUrl: z.url().nullable(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
headerUrl: z.string().nullable(),
|
||||
customCss: z.string().nullable(),
|
||||
topFriends: z.array(z.string()),
|
||||
joinedAt: z.coerce.date(),
|
||||
local: z.boolean(),
|
||||
isFollowedByViewer: z.boolean(),
|
||||
joinedAt: z.coerce.date().nullable(),
|
||||
});
|
||||
|
||||
export const MeSchema = z.object({
|
||||
id: z.uuid(),
|
||||
username: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
bio: z.string().nullable(),
|
||||
avatarUrl: z.url().nullable(),
|
||||
headerUrl: z.url().nullable(),
|
||||
customCss: z.string().nullable(),
|
||||
topFriends: z.array(z.string()),
|
||||
joinedAt: z.coerce.date(),
|
||||
following: z.array(UserSchema),
|
||||
export const MeSchema = UserSchema;
|
||||
|
||||
export const ProfileFieldSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
export type ProfileField = z.infer<typeof ProfileFieldSchema>;
|
||||
|
||||
export const RemoteActorSchema = z.object({
|
||||
handle: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
url: z.string(),
|
||||
bio: z.string().nullable(),
|
||||
bannerUrl: z.string().nullable(),
|
||||
alsoKnownAs: z.string().nullable(),
|
||||
outboxUrl: z.string().nullable(),
|
||||
followersUrl: z.string().nullable(),
|
||||
followingUrl: z.string().nullable(),
|
||||
attachment: z.array(ProfileFieldSchema),
|
||||
});
|
||||
export type RemoteActor = z.infer<typeof RemoteActorSchema>;
|
||||
|
||||
export const ThoughtSchema = z.object({
|
||||
id: z.uuid(),
|
||||
authorUsername: z.string(),
|
||||
authorDisplayName: z.string().nullable(),
|
||||
id: z.string().uuid(),
|
||||
content: z.string(),
|
||||
visibility: z.enum(["Public", "FriendsOnly", "Private"]),
|
||||
replyToId: z.uuid().nullable(),
|
||||
author: UserSchema,
|
||||
replyToId: z.string().uuid().nullable(),
|
||||
replyToUrl: z.string().url().nullable().optional(),
|
||||
visibility: z.string(),
|
||||
contentWarning: z.string().nullable(),
|
||||
sensitive: z.boolean(),
|
||||
likeCount: z.number(),
|
||||
boostCount: z.number(),
|
||||
replyCount: z.number(),
|
||||
likedByViewer: z.boolean(),
|
||||
boostedByViewer: z.boolean(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date().nullable(),
|
||||
});
|
||||
|
||||
export const RegisterSchema = z.object({
|
||||
username: z.string().min(3),
|
||||
email: z.email(),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
username: z.string().min(3),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
export const CreateThoughtSchema = z.object({
|
||||
content: z.string().min(1).max(128),
|
||||
visibility: z.enum(["Public", "FriendsOnly", "Private"]).optional(),
|
||||
replyToId: z.string().uuid().optional(),
|
||||
visibility: z.enum(["public", "followers", "unlisted", "direct"]).optional(),
|
||||
inReplyToId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export const UpdateProfileSchema = z.object({
|
||||
displayName: z.string().max(50).optional(),
|
||||
bio: z.string().max(4000).optional(),
|
||||
avatarUrl: z.url().or(z.literal("")).optional(),
|
||||
headerUrl: z.url().or(z.literal("")).optional(),
|
||||
avatarUrl: z.string().optional(),
|
||||
headerUrl: z.string().optional(),
|
||||
customCss: z.string().optional(),
|
||||
topFriends: z.array(z.string()).max(8).optional(),
|
||||
});
|
||||
|
||||
export const SearchResultsSchema = z.object({
|
||||
users: z.object({ users: z.array(UserSchema) }),
|
||||
thoughts: z.object({ thoughts: z.array(ThoughtSchema) }),
|
||||
query: z.string(),
|
||||
thoughts: z.array(ThoughtSchema),
|
||||
users: z.array(UserSchema),
|
||||
});
|
||||
|
||||
export const ApiKeySchema = z.object({
|
||||
id: z.uuid(),
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
keyPrefix: z.string(),
|
||||
createdAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export const ApiKeyResponseSchema = ApiKeySchema.extend({
|
||||
plaintextKey: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ApiKeyListSchema = z.object({
|
||||
apiKeys: z.array(ApiKeySchema),
|
||||
keys: z.array(ApiKeySchema),
|
||||
});
|
||||
|
||||
export const CreateApiKeySchema = z.object({
|
||||
@@ -87,41 +105,59 @@ export const CreateApiKeySchema = z.object({
|
||||
|
||||
export const ThoughtThreadSchema: z.ZodType<{
|
||||
id: string;
|
||||
authorUsername: string;
|
||||
authorDisplayName: string | null;
|
||||
content: string;
|
||||
visibility: "Public" | "FriendsOnly" | "Private";
|
||||
author: z.infer<typeof UserSchema>;
|
||||
replyToId: string | null;
|
||||
visibility: string;
|
||||
contentWarning: string | null;
|
||||
sensitive: boolean;
|
||||
likeCount: number;
|
||||
boostCount: number;
|
||||
replyCount: number;
|
||||
likedByViewer: boolean;
|
||||
boostedByViewer: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
replies: ThoughtThread[];
|
||||
}> = z.object({
|
||||
id: z.uuid(),
|
||||
authorUsername: z.string(),
|
||||
authorDisplayName: z.string().nullable(),
|
||||
id: z.string().uuid(),
|
||||
content: z.string(),
|
||||
visibility: z.enum(["Public", "FriendsOnly", "Private"]),
|
||||
replyToId: z.uuid().nullable(),
|
||||
author: UserSchema,
|
||||
replyToId: z.string().uuid().nullable(),
|
||||
visibility: z.string(),
|
||||
contentWarning: z.string().nullable(),
|
||||
sensitive: z.boolean(),
|
||||
likeCount: z.number(),
|
||||
boostCount: z.number(),
|
||||
replyCount: z.number(),
|
||||
likedByViewer: z.boolean(),
|
||||
boostedByViewer: z.boolean(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date().nullable(),
|
||||
replies: z.lazy(() => z.array(ThoughtThreadSchema)),
|
||||
});
|
||||
|
||||
export type User = z.infer<typeof UserSchema>;
|
||||
export type Me = z.infer<typeof MeSchema>;
|
||||
export type Thought = z.infer<typeof ThoughtSchema>;
|
||||
export type ThoughtThread = z.infer<typeof ThoughtThreadSchema>;
|
||||
export type Register = z.infer<typeof RegisterSchema>;
|
||||
export type Login = z.infer<typeof LoginSchema>;
|
||||
export type ApiKey = z.infer<typeof ApiKeySchema>;
|
||||
export type ApiKeyResponse = z.infer<typeof ApiKeyResponseSchema>;
|
||||
export type ThoughtThread = z.infer<typeof ThoughtThreadSchema>;
|
||||
|
||||
const API_BASE_URL =
|
||||
typeof window === "undefined"
|
||||
? process.env.NEXT_PUBLIC_SERVER_SIDE_API_URL // Server-side
|
||||
: process.env.NEXT_PUBLIC_API_URL; // Client-side
|
||||
? process.env.NEXT_PUBLIC_SERVER_SIDE_API_URL
|
||||
: process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
type ApiFetchOptions = Omit<RequestInit, 'next'> & {
|
||||
next?: { tags?: string[]; revalidate?: number | false }
|
||||
}
|
||||
|
||||
async function apiFetch<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
options: ApiFetchOptions = {},
|
||||
schema: z.ZodType<T>,
|
||||
token?: string | null
|
||||
): Promise<T> {
|
||||
@@ -129,25 +165,27 @@ async function apiFetch<T>(
|
||||
throw new Error("API_BASE_URL is not defined");
|
||||
}
|
||||
|
||||
const { next, ...restOptions } = options;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
...(restOptions.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const fullUrl = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(fullUrl, {
|
||||
...options,
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...restOptions,
|
||||
headers,
|
||||
...(next ? { next } : {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
if (response.status === 204) {
|
||||
return null as T;
|
||||
}
|
||||
@@ -156,197 +194,274 @@ async function apiFetch<T>(
|
||||
return schema.parse(data);
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const registerUser = (data: z.infer<typeof RegisterSchema>) =>
|
||||
apiFetch("/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}, UserSchema);
|
||||
apiFetch(
|
||||
"/auth/register",
|
||||
{ method: "POST", body: JSON.stringify(data) },
|
||||
z.object({ token: z.string(), user: UserSchema })
|
||||
);
|
||||
|
||||
export const loginUser = (data: z.infer<typeof LoginSchema>) =>
|
||||
apiFetch("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}, z.object({ token: z.string() }));
|
||||
apiFetch("/auth/login", { method: "POST", body: JSON.stringify(data) }, z.object({ token: z.string() }));
|
||||
|
||||
export const getFeed = (token: string, page: number = 1, pageSize: number = 20) =>
|
||||
apiFetch(
|
||||
`/feed?page=${page}&page_size=${pageSize}`,
|
||||
{},
|
||||
z.object({ items: z.array(ThoughtSchema), totalPages: z.number() }),
|
||||
token
|
||||
);
|
||||
// ── Current user ──────────────────────────────────────────────────────────
|
||||
|
||||
export const getMe = (token: string) =>
|
||||
apiFetch("/users/me", { next: { tags: ['me'] } }, MeSchema, token);
|
||||
|
||||
export const updateProfile = (data: z.infer<typeof UpdateProfileSchema>, token: string) =>
|
||||
apiFetch("/users/me", { method: "PATCH", body: JSON.stringify(data) }, UserSchema, token);
|
||||
|
||||
export const getMeFollowingList = (token: string) =>
|
||||
apiFetch("/users/me/following", { next: { tags: ['me'] } }, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const getUserProfile = (username: string, token: string | null) =>
|
||||
apiFetch(`/users/${username}`, {}, UserSchema, token);
|
||||
|
||||
export const getUserThoughts = (username: string, token: string | null) =>
|
||||
apiFetch(
|
||||
`/users/${username}/thoughts`,
|
||||
{},
|
||||
z.object({ thoughts: z.array(ThoughtSchema) }),
|
||||
token
|
||||
);
|
||||
|
||||
export const createThought = (
|
||||
data: z.infer<typeof CreateThoughtSchema>,
|
||||
token: string
|
||||
) =>
|
||||
apiFetch(
|
||||
"/thoughts",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
ThoughtSchema,
|
||||
token
|
||||
);
|
||||
|
||||
export const followUser = (username: string, token: string) =>
|
||||
apiFetch(
|
||||
`/users/${username}/follow`,
|
||||
{ method: "POST" },
|
||||
z.null(), // Expect a 204 No Content response, which we treat as null
|
||||
token
|
||||
);
|
||||
|
||||
export const unfollowUser = (username: string, token: string) =>
|
||||
apiFetch(
|
||||
`/users/${username}/follow`,
|
||||
{ method: "DELETE" },
|
||||
z.null(), // Expect a 204 No Content response
|
||||
token
|
||||
);
|
||||
|
||||
export const getMe = (token: string) =>
|
||||
apiFetch("/users/me", {}, MeSchema, token);
|
||||
|
||||
export const getPopularTags = () =>
|
||||
apiFetch(
|
||||
"/tags/popular",
|
||||
{},
|
||||
z.array(z.string()) // Expect an array of strings
|
||||
);
|
||||
|
||||
export const deleteThought = (thoughtId: string, token: string) =>
|
||||
apiFetch(
|
||||
`/thoughts/${thoughtId}`,
|
||||
{ method: "DELETE" },
|
||||
z.null(), // Expect a 204 No Content response
|
||||
token
|
||||
);
|
||||
|
||||
export const updateProfile = (
|
||||
data: z.infer<typeof UpdateProfileSchema>,
|
||||
token: string
|
||||
) =>
|
||||
apiFetch(
|
||||
"/users/me",
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
UserSchema, // Expect the updated user object back
|
||||
token
|
||||
);
|
||||
|
||||
export const getThoughtsByTag = (tagName: string, token: string | null) =>
|
||||
apiFetch(
|
||||
`/tags/${tagName}`,
|
||||
{},
|
||||
z.object({ thoughts: z.array(ThoughtSchema) }),
|
||||
token
|
||||
);
|
||||
|
||||
export const getThoughtById = (thoughtId: string, token: string | null) =>
|
||||
apiFetch(
|
||||
`/thoughts/${thoughtId}`,
|
||||
{},
|
||||
ThoughtSchema, // Expect a single thought object
|
||||
token
|
||||
);
|
||||
|
||||
export const getFollowingList = (username: string, token: string | null) =>
|
||||
apiFetch(
|
||||
`/users/${username}/following`,
|
||||
{},
|
||||
z.object({ users: z.array(UserSchema) }),
|
||||
token
|
||||
);
|
||||
apiFetch(`/users/${username}`, { next: { tags: [`profile:${username}`] } }, UserSchema, token);
|
||||
|
||||
export const getFollowersList = (username: string, token: string | null) =>
|
||||
apiFetch(`/users/${username}/followers`, { next: { tags: [`profile:${username}`] } }, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
|
||||
|
||||
export const getFollowingList = (username: string, token: string | null) =>
|
||||
apiFetch(`/users/${username}/following`, { next: { tags: [`profile:${username}`] } }, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
|
||||
|
||||
export const getTopFriends = (username: string, token: string | null) =>
|
||||
apiFetch(
|
||||
`/users/${username}/followers`,
|
||||
{},
|
||||
z.object({ users: z.array(UserSchema) }),
|
||||
`/users/${username}/top-friends`,
|
||||
{ next: { tags: [`profile:${username}`] } },
|
||||
z.object({ topFriends: z.array(UserSchema) }),
|
||||
token
|
||||
);
|
||||
|
||||
export const getFriends = (token: string) =>
|
||||
export const followUser = (username: string, token: string) =>
|
||||
apiFetch(`/users/${username}/follow`, { method: "POST" }, z.null(), token);
|
||||
|
||||
export const unfollowUser = (username: string, token: string) =>
|
||||
apiFetch(`/users/${username}/follow`, { method: "DELETE" }, z.null(), token);
|
||||
|
||||
export const markNotificationRead = (id: string, token: string) =>
|
||||
apiFetch(
|
||||
"/friends",
|
||||
{},
|
||||
z.object({ users: z.array(UserSchema) }),
|
||||
token
|
||||
);
|
||||
|
||||
|
||||
|
||||
export const search = (query: string, token: string | null) =>
|
||||
apiFetch(
|
||||
`/search?q=${encodeURIComponent(query)}`,
|
||||
{},
|
||||
SearchResultsSchema,
|
||||
token
|
||||
);
|
||||
|
||||
|
||||
export const getApiKeys = (token: string) =>
|
||||
apiFetch(`/users/me/api-keys`, {}, ApiKeyListSchema, token);
|
||||
|
||||
export const createApiKey = (
|
||||
data: z.infer<typeof CreateApiKeySchema>,
|
||||
token: string
|
||||
) =>
|
||||
apiFetch(
|
||||
`/users/me/api-keys`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
ApiKeyResponseSchema,
|
||||
token
|
||||
);
|
||||
|
||||
export const deleteApiKey = (keyId: string, token: string) =>
|
||||
apiFetch(
|
||||
`/users/me/api-keys/${keyId}`,
|
||||
{ method: "DELETE" },
|
||||
`/notifications/${id}`,
|
||||
{ method: "PATCH", body: JSON.stringify({ read: true }) },
|
||||
z.null(),
|
||||
token
|
||||
);
|
||||
|
||||
export const getThoughtThread = (thoughtId: string, token: string | null) =>
|
||||
apiFetch(`/thoughts/${thoughtId}/thread`, {}, ThoughtThreadSchema, token);
|
||||
export const markAllNotificationsRead = (token: string) =>
|
||||
apiFetch(
|
||||
"/notifications",
|
||||
{ method: "PATCH", body: JSON.stringify({ read: true }) },
|
||||
z.null(),
|
||||
token
|
||||
);
|
||||
|
||||
export const lookupRemoteActor = (handle: string, token: string | null) =>
|
||||
apiFetch(
|
||||
`/users/lookup?handle=${encodeURIComponent(handle)}`,
|
||||
{ next: { tags: [`remote-actor:${handle}`] } },
|
||||
RemoteActorSchema,
|
||||
token
|
||||
);
|
||||
|
||||
export const getRemoteActorPosts = (
|
||||
handle: string,
|
||||
page: number,
|
||||
token: string | null
|
||||
) =>
|
||||
apiFetch(
|
||||
`/federation/actors/${encodeURIComponent(handle)}/posts?page=${page}&per_page=20`,
|
||||
{ next: { tags: [`remote-actor:${handle}`] } },
|
||||
z.object({
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
per_page: z.number(),
|
||||
items: z.array(ThoughtSchema),
|
||||
}),
|
||||
token
|
||||
);
|
||||
|
||||
export const ActorConnectionSchema = z.object({
|
||||
handle: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
url: z.string(),
|
||||
});
|
||||
export type ActorConnection = z.infer<typeof ActorConnectionSchema>;
|
||||
|
||||
const ActorConnectionPageSchema = z.object({
|
||||
items: z.array(ActorConnectionSchema),
|
||||
page: z.number(),
|
||||
hasMore: z.boolean(),
|
||||
});
|
||||
|
||||
export const getActorFollowers = (
|
||||
handle: string,
|
||||
page: number,
|
||||
token: string | null
|
||||
) =>
|
||||
apiFetch(
|
||||
`/federation/actors/${encodeURIComponent(handle)}/followers-list?page=${page}`,
|
||||
{ next: { tags: [`remote-actor:${handle}`] } },
|
||||
ActorConnectionPageSchema,
|
||||
token
|
||||
);
|
||||
|
||||
export const getActorFollowing = (
|
||||
handle: string,
|
||||
page: number,
|
||||
token: string | null
|
||||
) =>
|
||||
apiFetch(
|
||||
`/federation/actors/${encodeURIComponent(handle)}/following-list?page=${page}`,
|
||||
{ next: { tags: [`remote-actor:${handle}`] } },
|
||||
ActorConnectionPageSchema,
|
||||
token
|
||||
);
|
||||
|
||||
export const getAllUsers = (page: number = 1, pageSize: number = 20) =>
|
||||
apiFetch(
|
||||
`/users/all?page=${page}&page_size=${pageSize}`,
|
||||
{},
|
||||
z.object({
|
||||
items: z.array(UserSchema),
|
||||
page: z.number(),
|
||||
pageSize: z.number(),
|
||||
totalPages: z.number(),
|
||||
totalItems: z.number(),
|
||||
})
|
||||
`/users?page=${page}&per_page=${pageSize}`,
|
||||
{ next: { tags: ['users'] } },
|
||||
z.object({ items: z.array(UserSchema), total: z.number(), page: z.number(), per_page: z.number() })
|
||||
.transform((d) => ({ ...d, totalPages: Math.ceil(d.total / d.per_page) }))
|
||||
);
|
||||
|
||||
export const getAllUsersCount = () =>
|
||||
apiFetch("/users/count", { next: { tags: ['users'] } }, z.object({ count: z.number() }));
|
||||
|
||||
// ── Thoughts ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const getFeed = (token: string, page: number = 1, pageSize: number = 20) =>
|
||||
apiFetch(
|
||||
`/users/count`,
|
||||
{},
|
||||
z.object({
|
||||
count: z.number(),
|
||||
})
|
||||
);
|
||||
`/feed?page=${page}&per_page=${pageSize}`,
|
||||
{ next: { tags: ['feed'] } },
|
||||
z.object({ items: z.array(ThoughtSchema), total: z.number(), page: z.number(), per_page: z.number() })
|
||||
.transform((d) => ({ ...d, totalPages: Math.ceil(d.total / d.per_page) })),
|
||||
token
|
||||
);
|
||||
|
||||
export const getUserThoughts = (username: string, token: string | null) =>
|
||||
apiFetch(
|
||||
`/users/${username}/thoughts`,
|
||||
{ next: { tags: [`profile:${username}`] } },
|
||||
z.object({ items: z.array(ThoughtSchema), total: z.number(), page: z.number(), per_page: z.number() }),
|
||||
token
|
||||
);
|
||||
|
||||
export const createThought = (data: z.infer<typeof CreateThoughtSchema>, token: string) =>
|
||||
apiFetch("/thoughts", { method: "POST", body: JSON.stringify(data) }, ThoughtSchema, token);
|
||||
|
||||
export const deleteThought = (thoughtId: string, token: string) =>
|
||||
apiFetch(`/thoughts/${thoughtId}`, { method: "DELETE" }, z.null(), token);
|
||||
|
||||
export const getThoughtById = (thoughtId: string, token: string | null) =>
|
||||
apiFetch(`/thoughts/${thoughtId}`, { next: { tags: [`thought:${thoughtId}`] } }, ThoughtSchema, token);
|
||||
|
||||
export const getThoughtThread = async (thoughtId: string, token: string | null): Promise<ThoughtThread> => {
|
||||
const thoughts = await apiFetch(`/thoughts/${thoughtId}/thread`, { next: { tags: [`thought:${thoughtId}`] } }, z.array(ThoughtSchema), token);
|
||||
type T = z.infer<typeof ThoughtSchema>;
|
||||
const repliesMap: Record<string, T[]> = {};
|
||||
for (const t of thoughts) {
|
||||
if (t.replyToId) {
|
||||
(repliesMap[t.replyToId] ??= []).push(t);
|
||||
}
|
||||
}
|
||||
function build(t: T): ThoughtThread {
|
||||
return { ...t, replies: (repliesMap[t.id] ?? []).map(build) };
|
||||
}
|
||||
const root = thoughts.find((t) => t.id === thoughtId) ?? thoughts[0];
|
||||
if (!root) throw new Error("Thread not found");
|
||||
return build(root);
|
||||
};
|
||||
|
||||
// ── Tags ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const getThoughtsByTag = (tagName: string, token: string | null) =>
|
||||
apiFetch(
|
||||
`/tags/${tagName}`,
|
||||
{ next: { tags: [`tag:${tagName}`, 'feed'] } },
|
||||
z.object({ tag: z.string(), items: z.array(ThoughtSchema), total: z.number(), page: z.number(), per_page: z.number() }),
|
||||
token
|
||||
);
|
||||
|
||||
export const getPopularTags = () =>
|
||||
apiFetch(
|
||||
"/tags/popular",
|
||||
{ next: { tags: ['tags:popular'] } },
|
||||
z.object({ tags: z.array(z.object({ name: z.string(), thought_count: z.number() })) })
|
||||
.transform((d) => d.tags.map((t) => t.name))
|
||||
);
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const search = (query: string, token: string | null) =>
|
||||
apiFetch(`/search?q=${encodeURIComponent(query)}`, { next: { tags: ['search'] } }, SearchResultsSchema, token);
|
||||
|
||||
// ── API Keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const getApiKeys = (token: string) =>
|
||||
apiFetch("/api-keys", { next: { tags: ['api-keys'] } }, z.object({ keys: z.array(ApiKeySchema) }), token);
|
||||
|
||||
export const createApiKey = (data: z.infer<typeof CreateApiKeySchema>, token: string) =>
|
||||
apiFetch("/api-keys", { method: "POST", body: JSON.stringify(data) }, ApiKeyResponseSchema, token);
|
||||
|
||||
export const deleteApiKey = (keyId: string, token: string) =>
|
||||
apiFetch(`/api-keys/${keyId}`, { method: "DELETE" }, z.null(), token);
|
||||
|
||||
// ── Legacy alias used by top-friends-combobox ─────────────────────────────
|
||||
|
||||
export const getFriends = (token: string) =>
|
||||
getMeFollowingList(token).then((r) => ({ users: r.items }));
|
||||
|
||||
// ── Federation management ─────────────────────────────────────────────────
|
||||
|
||||
export const getPendingFollowRequests = (token: string) =>
|
||||
apiFetch(
|
||||
"/federation/me/followers/pending",
|
||||
{ next: { tags: ['federation:pending'] } },
|
||||
z.array(RemoteActorSchema),
|
||||
token
|
||||
);
|
||||
|
||||
export const acceptFollowRequest = (actorUrl: string, token: string) =>
|
||||
apiFetch(
|
||||
"/federation/me/followers/accept",
|
||||
{ method: "POST", body: JSON.stringify({ actor_url: actorUrl }) },
|
||||
z.null(),
|
||||
token
|
||||
);
|
||||
|
||||
export const rejectFollowRequest = (actorUrl: string, token: string) =>
|
||||
apiFetch(
|
||||
"/federation/me/followers",
|
||||
{ method: "DELETE", body: JSON.stringify({ actor_url: actorUrl }) },
|
||||
z.null(),
|
||||
token
|
||||
);
|
||||
|
||||
export const getRemoteFollowers = (token: string) =>
|
||||
apiFetch(
|
||||
"/federation/me/followers",
|
||||
{ next: { tags: ['federation:followers'] } },
|
||||
z.array(RemoteActorSchema),
|
||||
token
|
||||
);
|
||||
|
||||
export const getRemoteFollowing = (token: string) =>
|
||||
apiFetch(
|
||||
"/federation/me/following",
|
||||
{ next: { tags: ['federation:following'] } },
|
||||
z.array(RemoteActorSchema),
|
||||
token
|
||||
);
|
||||
|
||||
export const unfollowRemoteActor = (handle: string, token: string) =>
|
||||
apiFetch(
|
||||
"/federation/me/following",
|
||||
{ method: "DELETE", body: JSON.stringify({ handle }) },
|
||||
z.null(),
|
||||
token
|
||||
);
|
||||
|
||||
@@ -6,6 +6,17 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/** Construct a full fediverse handle like `user@instance.social`.
|
||||
* Falls back gracefully for existing DB rows that only stored the username. */
|
||||
export function fullFediverseHandle(handle: string, actorUrl: string): string {
|
||||
if (handle.includes("@")) return handle;
|
||||
try {
|
||||
return `${handle}@${new URL(actorUrl).hostname}`;
|
||||
} catch {
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildThoughtThreads(thoughts: Thought[]): ThoughtThreadType[] {
|
||||
const thoughtMap = new Map<string, Thought>();
|
||||
thoughts.forEach((t) => thoughtMap.set(t.id, t));
|
||||
|
||||
23
thoughts-frontend/middleware.ts
Normal file
23
thoughts-frontend/middleware.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const parts = request.nextUrl.pathname.split("/");
|
||||
|
||||
// /users/@user@instance or /users/%40user%40instance
|
||||
if (parts.length === 3 && parts[1] === "users") {
|
||||
const decoded = decodeURIComponent(parts[2]);
|
||||
if (decoded.startsWith("@") && decoded.indexOf("@", 1) !== -1) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/remote-actor";
|
||||
url.searchParams.set("handle", decoded);
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: "/users/:path*",
|
||||
};
|
||||
8567
thoughts-frontend/package-lock.json
generated
Normal file
8567
thoughts-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user