From 43e36c743b48cea46d93f7a85171f5a4e01daca2 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 28 May 2026 04:22:26 +0200 Subject: [PATCH] feat: add /friends page and /settings/friends top-friends management --- thoughts-frontend/app/friends/page.tsx | 43 +++++ .../app/settings/friends/page.tsx | 34 ++++ thoughts-frontend/components/friends-list.tsx | 47 +++++ .../components/remote-friend-card.tsx | 33 ++++ .../components/top-friends-editor.tsx | 161 ++++++++++++++++++ .../components/top-friends-strip.tsx | 39 +++++ thoughts-frontend/lib/api.ts | 31 ++++ 7 files changed, 388 insertions(+) create mode 100644 thoughts-frontend/app/friends/page.tsx create mode 100644 thoughts-frontend/app/settings/friends/page.tsx create mode 100644 thoughts-frontend/components/friends-list.tsx create mode 100644 thoughts-frontend/components/remote-friend-card.tsx create mode 100644 thoughts-frontend/components/top-friends-editor.tsx create mode 100644 thoughts-frontend/components/top-friends-strip.tsx diff --git a/thoughts-frontend/app/friends/page.tsx b/thoughts-frontend/app/friends/page.tsx new file mode 100644 index 0000000..0be5f52 --- /dev/null +++ b/thoughts-frontend/app/friends/page.tsx @@ -0,0 +1,43 @@ +// app/friends/page.tsx +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { getMe, getMyFriends, getMyRemoteFriends, getTopFriends } from "@/lib/api"; +import { FriendsList } from "@/components/friends-list"; +import { TopFriendsStrip } from "@/components/top-friends-strip"; + +export default async function FriendsPage() { + const token = (await cookies()).get("auth_token")?.value; + if (!token) redirect("/login"); + + const me = await getMe(token).catch(() => null); + if (!me) redirect("/login"); + + const [localFriendsData, remoteFriends, topFriendsData] = await Promise.all([ + getMyFriends(token).catch(() => ({ + items: [], + total: 0, + page: 1, + perPage: 50, + })), + getMyRemoteFriends(token).catch(() => []), + getTopFriends(me.username, token).catch(() => ({ topFriends: [] })), + ]); + + return ( +
+
+

Friends

+

+ People who follow you and who you follow back. +

+
+
+ + +
+
+ ); +} diff --git a/thoughts-frontend/app/settings/friends/page.tsx b/thoughts-frontend/app/settings/friends/page.tsx new file mode 100644 index 0000000..4f0a6a8 --- /dev/null +++ b/thoughts-frontend/app/settings/friends/page.tsx @@ -0,0 +1,34 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { getMe, getMyFriends, getTopFriends } from "@/lib/api"; +import { TopFriendsEditor } from "@/components/top-friends-editor"; + +export default async function FriendsSettingsPage() { + const token = (await cookies()).get("auth_token")?.value; + if (!token) redirect("/login"); + + const me = await getMe(token).catch(() => null); + if (!me) redirect("/login"); + + const [localFriendsData, topFriendsData] = await Promise.all([ + getMyFriends(token).catch(() => ({ items: [], total: 0, page: 1, perPage: 50 })), + getTopFriends(me.username, token).catch(() => ({ topFriends: [] })), + ]); + + return ( +
+
+

Top Friends

+

+ Choose up to 8 friends to feature on your profile. Only local friends + can be top friends. +

+
+ +
+ ); +} diff --git a/thoughts-frontend/components/friends-list.tsx b/thoughts-frontend/components/friends-list.tsx new file mode 100644 index 0000000..6285c7c --- /dev/null +++ b/thoughts-frontend/components/friends-list.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; +import { User, RemoteActor } from "@/lib/api"; +import { UserAvatar } from "./user-avatar"; +import { RemoteFriendCard } from "./remote-friend-card"; +import { Card, CardContent } from "./ui/card"; + +interface FriendsListProps { + localFriends: User[]; + remoteFriends: RemoteActor[]; +} + +export function FriendsList({ localFriends, remoteFriends }: FriendsListProps) { + if (localFriends.length === 0 && remoteFriends.length === 0) { + return ( +

+ No friends yet. Follow someone and wait for them to follow back! +

+ ); + } + + return ( + + + {localFriends.map((user) => ( + + +
+

+ {user.displayName || user.username} +

+

+ @{user.username} +

+
+ + ))} + {remoteFriends.map((actor) => ( + + ))} +
+
+ ); +} diff --git a/thoughts-frontend/components/remote-friend-card.tsx b/thoughts-frontend/components/remote-friend-card.tsx new file mode 100644 index 0000000..7ba1a93 --- /dev/null +++ b/thoughts-frontend/components/remote-friend-card.tsx @@ -0,0 +1,33 @@ +import { RemoteActor } from "@/lib/api"; +import { UserAvatar } from "./user-avatar"; + +interface RemoteFriendCardProps { + actor: RemoteActor; +} + +export function RemoteFriendCard({ actor }: RemoteFriendCardProps) { + return ( + + +
+

+ {actor.displayName || actor.handle} +

+

+ @{actor.handle} +

+
+ + federated + +
+ ); +} diff --git a/thoughts-frontend/components/top-friends-editor.tsx b/thoughts-frontend/components/top-friends-editor.tsx new file mode 100644 index 0000000..b52844c --- /dev/null +++ b/thoughts-frontend/components/top-friends-editor.tsx @@ -0,0 +1,161 @@ +// components/top-friends-editor.tsx +"use client"; + +import { useState } from "react"; +import { User, setTopFriends } from "@/lib/api"; +import { UserAvatar } from "./user-avatar"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Card, CardContent } from "./ui/card"; + +interface TopFriendsEditorProps { + token: string; + initialTopFriends: User[]; + localFriends: User[]; +} + +export function TopFriendsEditor({ + token, + initialTopFriends, + localFriends, +}: TopFriendsEditorProps) { + const [topFriends, setTopFriendsState] = useState(initialTopFriends); + const [search, setSearch] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + const topIds = new Set(topFriends.map((f) => f.id)); + const suggestions = localFriends.filter( + (f) => + !topIds.has(f.id) && + (f.username.toLowerCase().includes(search.toLowerCase()) || + (f.displayName ?? "").toLowerCase().includes(search.toLowerCase())) + ); + + function remove(id: string) { + setTopFriendsState((prev) => prev.filter((f) => f.id !== id)); + setSaved(false); + } + + function add(user: User) { + if (topFriends.length >= 8) return; + setTopFriendsState((prev) => [...prev, user]); + setSearch(""); + setSaved(false); + } + + async function save() { + setSaving(true); + setError(null); + setSaved(false); + try { + await setTopFriends(token, topFriends.map((f) => f.id)); + setSaved(true); + } catch { + setError("Failed to save. Please try again."); + } finally { + setSaving(false); + } + } + + return ( +
+ + + {topFriends.length === 0 && ( +

+ No top friends yet. Add up to 8 from the search below. +

+ )} + {topFriends.map((friend, i) => ( +
+ + {i + 1} + + +
+

+ {friend.displayName || friend.username} +

+

+ @{friend.username} +

+
+ +
+ ))} +
+
+ + {topFriends.length < 8 && ( +
+ setSearch(e.target.value)} + /> + {search.length > 0 && ( + + + {suggestions.length === 0 ? ( +

+ No matches. +

+ ) : ( + suggestions.slice(0, 5).map((user) => ( + + )) + )} +
+
+ )} +
+ )} + +
+ + {saved && ( +

Saved!

+ )} + {error && ( +

{error}

+ )} +
+
+ ); +} diff --git a/thoughts-frontend/components/top-friends-strip.tsx b/thoughts-frontend/components/top-friends-strip.tsx new file mode 100644 index 0000000..227ad12 --- /dev/null +++ b/thoughts-frontend/components/top-friends-strip.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; +import { User } from "@/lib/api"; +import { UserAvatar } from "./user-avatar"; + +interface TopFriendsStripProps { + topFriends: User[]; +} + +export function TopFriendsStrip({ topFriends }: TopFriendsStripProps) { + return ( +
+
+ {topFriends.length === 0 ? ( +

No top friends yet.

+ ) : ( + topFriends.map((f) => ( + + + + )) + )} +
+ + Edit → + +
+ ); +} diff --git a/thoughts-frontend/lib/api.ts b/thoughts-frontend/lib/api.ts index c94b390..322825b 100644 --- a/thoughts-frontend/lib/api.ts +++ b/thoughts-frontend/lib/api.ts @@ -480,6 +480,37 @@ export const getRemoteFollowing = (token: string) => token ); +// ── Friends ─────────────────────────────────────────────────────────────── + +export const getMyFriends = (token: string, page = 1, perPage = 50) => + apiFetch( + `/users/me/friends?page=${page}&per_page=${perPage}`, + { cache: "no-store" }, + z.object({ + items: z.array(UserSchema), + total: z.number(), + page: z.number(), + perPage: z.number(), + }), + token + ); + +export const getMyRemoteFriends = (token: string) => + apiFetch( + "/federation/me/friends", + { cache: "no-store" }, + z.array(RemoteActorSchema), + token + ); + +export const setTopFriends = (token: string, friendIds: string[]) => + apiFetch( + "/users/me/top-friends", + { method: "PUT", body: JSON.stringify({ friendIds }) }, + z.null(), + token + ); + export const unfollowRemoteActor = (handle: string, token: string) => apiFetch( "/federation/me/following",