feat: update frontend to work with v2 backend — camelCase, new endpoints, nested author
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m38s
test / unit (pull_request) Successful in 16m2s
test / integration (pull_request) Failing after 17m2s

This commit is contained in:
2026-05-14 17:14:27 +02:00
parent 7110f30e16
commit 44385adb6b
17 changed files with 203 additions and 286 deletions

View File

@@ -33,7 +33,7 @@ export default function LoginPage() {
const form = useForm<z.infer<typeof LoginSchema>>({ const form = useForm<z.infer<typeof LoginSchema>>({
resolver: zodResolver(LoginSchema), resolver: zodResolver(LoginSchema),
defaultValues: { username: "", password: "" }, defaultValues: { email: "", password: "" },
}); });
async function onSubmit(values: z.infer<typeof LoginSchema>) { async function onSubmit(values: z.infer<typeof LoginSchema>) {
@@ -43,7 +43,7 @@ export default function LoginPage() {
setToken(token); setToken(token);
router.push("/"); // Redirect to homepage on successful login router.push("/"); // Redirect to homepage on successful login
} catch { } 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 ... */} {/* ... Form fields for username and password ... */}
<FormField <FormField
control={form.control} control={form.control}
name="username" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input placeholder="frutiger" {...field} /> <Input type="email" placeholder="you@example.com" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -3,6 +3,7 @@ import {
getFeed, getFeed,
getFriends, getFriends,
getMe, getMe,
getTopFriends,
getUserProfile, getUserProfile,
Me, Me,
User, User,
@@ -60,7 +61,7 @@ async function FeedPage({
const { items: allThoughts, totalPages } = feedData!; const { items: allThoughts, totalPages } = feedData!;
const thoughtThreads = buildThoughtThreads(allThoughts); const thoughtThreads = buildThoughtThreads(allThoughts);
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))]; const authors = [...new Set(allThoughts.map((t) => t.author.username))];
const userProfiles = await Promise.all( const userProfiles = await Promise.all(
authors.map((username) => getUserProfile(username, token).catch(() => null)) authors.map((username) => getUserProfile(username, token).catch(() => null))
); );
@@ -72,10 +73,10 @@ async function FeedPage({
); );
const friends = (await getFriends(token)).users.map((user) => user.username); const friends = (await getFriends(token)).users.map((user) => user.username);
const shouldDisplayTopFriends = const topFriendsData = me
token && me?.topFriends && me.topFriends.length > 8; ? await getTopFriends(me.username, token).catch(() => ({ topFriends: [] }))
: { topFriends: [] };
console.log("Should display top friends:", shouldDisplayTopFriends); const shouldDisplayTopFriends = topFriendsData.topFriends.length > 0;
return ( return (
<div className="container mx-auto max-w-6xl p-4 sm:p-6"> <div className="container mx-auto max-w-6xl p-4 sm:p-6">
@@ -96,7 +97,7 @@ async function FeedPage({
<div className="block lg:hidden space-y-6"> <div className="block lg:hidden space-y-6">
<PopularTags /> <PopularTags />
{shouldDisplayTopFriends && ( {shouldDisplayTopFriends && (
<TopFriends mode="top-friends" usernames={me.topFriends} /> <TopFriends mode="top-friends" usernames={topFriendsData.topFriends} />
)} )}
{!shouldDisplayTopFriends && token && friends.length > 0 && ( {!shouldDisplayTopFriends && token && friends.length > 0 && (
<TopFriends mode="friends" usernames={friends || []} /> <TopFriends mode="friends" usernames={friends || []} />
@@ -141,7 +142,7 @@ async function FeedPage({
<div className="sticky top-20 space-y-6"> <div className="sticky top-20 space-y-6">
<PopularTags /> <PopularTags />
{shouldDisplayTopFriends && ( {shouldDisplayTopFriends && (
<TopFriends mode="top-friends" usernames={me.topFriends} /> <TopFriends mode="top-friends" usernames={topFriendsData.topFriends} />
)} )}
{!shouldDisplayTopFriends && token && friends.length > 0 && ( {!shouldDisplayTopFriends && token && friends.length > 0 && (
<TopFriends mode="friends" usernames={friends || []} /> <TopFriends mode="friends" usernames={friends || []} />

View File

@@ -30,7 +30,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
const authorDetails = new Map<string, { avatarUrl?: string | null }>(); const authorDetails = new Map<string, { avatarUrl?: string | null }>();
if (results) { if (results) {
results.users.users.forEach((user: User) => { results.users.forEach((user: User) => {
authorDetails.set(user.username, { avatarUrl: user.avatarUrl }); authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
}); });
} }
@@ -48,21 +48,21 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
<Tabs defaultValue="thoughts" className="w-full"> <Tabs defaultValue="thoughts" className="w-full">
<TabsList> <TabsList>
<TabsTrigger value="thoughts"> <TabsTrigger value="thoughts">
Thoughts ({results.thoughts.thoughts.length}) Thoughts ({results.thoughts.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="users"> <TabsTrigger value="users">
Users ({results.users.users.length}) Users ({results.users.length})
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="thoughts"> <TabsContent value="thoughts">
<ThoughtList <ThoughtList
thoughts={results.thoughts.thoughts} thoughts={results.thoughts}
authorDetails={authorDetails} authorDetails={authorDetails}
currentUser={me} currentUser={me}
/> />
</TabsContent> </TabsContent>
<TabsContent value="users"> <TabsContent value="users">
<UserListCard users={results.users.users} /> <UserListCard users={results.users} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
) : ( ) : (

View File

@@ -10,7 +10,7 @@ export default async function ApiKeysPage() {
} }
const initialApiKeys = await getApiKeys(token).catch(() => ({ const initialApiKeys = await getApiKeys(token).catch(() => ({
apiKeys: [], keys: [],
})); }));
return ( return (
@@ -21,7 +21,7 @@ export default async function ApiKeysPage() {
Manage API keys for third-party applications. Manage API keys for third-party applications.
</p> </p>
</div> </div>
<ApiKeyList initialApiKeys={initialApiKeys.apiKeys} /> <ApiKeyList initialApiKeys={initialApiKeys.keys} />
</div> </div>
); );
} }

View File

@@ -23,11 +23,11 @@ export default async function TagPage({ params }: TagPageProps) {
notFound(); notFound();
} }
const allThoughts = thoughtsResult.value.thoughts; const allThoughts = thoughtsResult.value.items;
const thoughtThreads = buildThoughtThreads(allThoughts); const thoughtThreads = buildThoughtThreads(allThoughts);
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null; const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))]; const authors = [...new Set(allThoughts.map((t) => t.author.username))];
const userProfiles = await Promise.all( const userProfiles = await Promise.all(
authors.map((username) => getUserProfile(username, token).catch(() => null)) authors.map((username) => getUserProfile(username, token).catch(() => null))
); );

View File

@@ -15,7 +15,7 @@ interface ThoughtPageProps {
} }
function collectAuthors(thread: ThoughtThreadType): string[] { function collectAuthors(thread: ThoughtThreadType): string[] {
const authors = new Set<string>([thread.authorUsername]); const authors = new Set<string>([thread.author.username]);
for (const reply of thread.replies) { for (const reply of thread.replies) {
collectAuthors(reply).forEach((author) => authors.add(author)); collectAuthors(reply).forEach((author) => authors.add(author));
} }

View File

@@ -26,7 +26,7 @@ export default async function FollowersPage({ params }: FollowersPageProps) {
<p className="text-muted-foreground">Users following @{username}.</p> <p className="text-muted-foreground">Users following @{username}.</p>
</header> </header>
<main> <main>
<UserListCard users={followersData.users} /> <UserListCard users={followersData.items} />
</main> </main>
</div> </div>
); );

View File

@@ -26,7 +26,7 @@ export default async function FollowingPage({ params }: FollowingPageProps) {
<p className="text-muted-foreground">Users that @{username} follows.</p> <p className="text-muted-foreground">Users that @{username} follows.</p>
</header> </header>
<main> <main>
<UserListCard users={followingData.users} /> <UserListCard users={followingData.items} />
</main> </main>
</div> </div>
); );

View File

@@ -3,6 +3,7 @@ import {
getFollowingList, getFollowingList,
getFriends, getFriends,
getMe, getMe,
getTopFriends,
getUserProfile, getUserProfile,
getUserThoughts, getUserThoughts,
Me, Me,
@@ -55,33 +56,31 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null; const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
const thoughts = const thoughts =
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.thoughts : []; thoughtsResult.status === "fulfilled" ? thoughtsResult.value.items : [];
const thoughtThreads = buildThoughtThreads(thoughts); const thoughtThreads = buildThoughtThreads(thoughts);
const followersCount = const followersCount =
followersResult.status === "fulfilled" followersResult.status === "fulfilled"
? followersResult.value.users.length ? followersResult.value.total
: 0; : 0;
const followingCount = const followingCount =
followingResult.status === "fulfilled" followingResult.status === "fulfilled"
? followingResult.value.users.length ? followingResult.value.total
: 0; : 0;
const isOwnProfile = me?.username === user.username; const isOwnProfile = me?.username === user.username;
const isFollowing = const isFollowing = user.isFollowedByViewer;
me?.following?.some(
(followedUser) => followedUser.username === user.username
) || false;
const authorDetails = new Map<string, { avatarUrl?: string | null }>(); const authorDetails = new Map<string, { avatarUrl?: string | null }>();
authorDetails.set(user.username, { avatarUrl: user.avatarUrl }); authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
const friends = const friends =
typeof token === "string" typeof token === "string"
? (await getFriends(token)).users.map((user) => user.username) ? (await getFriends(token)).users.map((u) => u.username)
: []; : [];
const shouldDisplayTopFriends = token && friends.length > 8; const topFriendsData = await getTopFriends(username, token).catch(() => ({ topFriends: [] }));
const shouldDisplayTopFriends = topFriendsData.topFriends.length > 0;
return ( return (
<div id={`profile-page-${user.username}`}> <div id={`profile-page-${user.username}`}>
@@ -195,7 +194,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</Card> </Card>
{shouldDisplayTopFriends && ( {shouldDisplayTopFriends && (
<TopFriends mode="top-friends" usernames={user.topFriends} /> <TopFriends mode="top-friends" usernames={topFriendsData.topFriends} />
)} )}
{token && <TopFriends mode="friends" usernames={friends || []} />} {token && <TopFriends mode="friends" usernames={friends || []} />}
</div> </div>

View File

@@ -64,7 +64,7 @@ export function ApiKeyList({ initialApiKeys }: ApiKeyListProps) {
try { try {
const newKeyResponse = await createApiKey(values, token); const newKeyResponse = await createApiKey(values, token);
setKeys((prev) => [...prev, newKeyResponse]); setKeys((prev) => [...prev, newKeyResponse]);
setNewKey(newKeyResponse.plaintextKey ?? null); setNewKey(newKeyResponse.key ?? null);
form.reset(); form.reset();
toast.success("API Key created successfully."); toast.success("API Key created successfully.");
} catch { } catch {
@@ -113,7 +113,7 @@ export function ApiKeyList({ initialApiKeys }: ApiKeyListProps) {
{`Created on ${format(key.createdAt, "PPP")}`} {`Created on ${format(key.createdAt, "PPP")}`}
</p> </p>
<p className="text-xs font-mono text-muted-foreground mt-1"> <p className="text-xs font-mono text-muted-foreground mt-1">
{`${key.keyPrefix}...`} {key.id}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -16,11 +16,9 @@ import {
FormLabel, FormLabel,
FormControl, FormControl,
FormMessage, FormMessage,
FormDescription,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { TopFriendsCombobox } from "@/components/top-friends-combobox";
interface EditProfileFormProps { interface EditProfileFormProps {
currentUser: Me; currentUser: Me;
@@ -38,7 +36,6 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) {
avatarUrl: currentUser.avatarUrl ?? undefined, avatarUrl: currentUser.avatarUrl ?? undefined,
headerUrl: currentUser.headerUrl ?? undefined, headerUrl: currentUser.headerUrl ?? undefined,
customCss: currentUser.customCss ?? undefined, customCss: currentUser.customCss ?? undefined,
topFriends: currentUser.topFriends ?? [],
}, },
}); });
@@ -135,25 +132,6 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) {
</FormItem> </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> </CardContent>
<CardFooter className="border-t px-6 py-4"> <CardFooter className="border-t px-6 py-4">
<Button type="submit" disabled={form.formState.isSubmitting}> <Button type="submit" disabled={form.formState.isSubmitting}>

View File

@@ -35,7 +35,7 @@ export function PostThoughtForm() {
const form = useForm<z.infer<typeof CreateThoughtSchema>>({ const form = useForm<z.infer<typeof CreateThoughtSchema>>({
resolver: zodResolver(CreateThoughtSchema), resolver: zodResolver(CreateThoughtSchema),
defaultValues: { content: "", visibility: "Public" }, defaultValues: { content: "", visibility: "public" },
}); });
async function onSubmit(values: z.infer<typeof CreateThoughtSchema>) { async function onSubmit(values: z.infer<typeof CreateThoughtSchema>) {
@@ -93,19 +93,24 @@ export function PostThoughtForm() {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="Public"> <SelectItem value="public">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Globe className="h-4 w-4" /> Public <Globe className="h-4 w-4" /> Public
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value="FriendsOnly"> <SelectItem value="followers">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="h-4 w-4" /> Friends Only <Users className="h-4 w-4" /> Followers
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value="Private"> <SelectItem value="unlisted">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Lock className="h-4 w-4" /> Private <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> </div>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>

View File

@@ -33,8 +33,8 @@ export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
resolver: zodResolver(CreateThoughtSchema), resolver: zodResolver(CreateThoughtSchema),
defaultValues: { defaultValues: {
content: "", content: "",
replyToId: parentThoughtId, inReplyToId: parentThoughtId,
visibility: "Public", // Replies default to Public visibility: "public",
}, },
}); });

View File

@@ -65,7 +65,7 @@ export function ThoughtCard({
addSuffix: true, addSuffix: true,
}); });
const isAuthor = currentUser?.username === thought.authorUsername; const isAuthor = currentUser?.username === thought.author.username;
const handleDelete = async () => { const handleDelete = async () => {
if (!token) return; if (!token) return;

View File

@@ -27,9 +27,9 @@ export function ThoughtList({
<div className="space-y-6 p-4"> <div className="space-y-6 p-4">
{thoughts.map((thought) => { {thoughts.map((thought) => {
const author = { const author = {
username: thought.authorUsername, username: thought.author.username,
displayName: thought.authorDisplayName, displayName: thought.author.displayName,
...authorDetails.get(thought.authorUsername), ...authorDetails.get(thought.author.username),
}; };
return ( return (
<ThoughtCard <ThoughtCard

View File

@@ -15,9 +15,9 @@ export function ThoughtThread({
isReply = false, isReply = false,
}: ThoughtThreadProps) { }: ThoughtThreadProps) {
const author = { const author = {
username: thought.authorUsername, username: thought.author.username,
displayName: thought.authorDisplayName, displayName: thought.author.displayName,
...authorDetails.get(thought.authorUsername), ...authorDetails.get(thought.author.username),
}; };
return ( return (

View File

@@ -1,84 +1,80 @@
import { z } from "zod"; import { z } from "zod";
export const UserSchema = z.object({ export const UserSchema = z.object({
id: z.uuid(), id: z.string().uuid(),
username: z.string(), username: z.string(),
displayName: z.string().nullable(), displayName: z.string().nullable(),
bio: z.string().nullable(), bio: z.string().nullable(),
avatarUrl: z.url().nullable(), avatarUrl: z.string().nullable(),
headerUrl: z.url().nullable(), headerUrl: z.string().nullable(),
customCss: z.string().nullable(), customCss: z.string().nullable(),
topFriends: z.array(z.string()), local: z.boolean(),
isFollowedByViewer: z.boolean(),
joinedAt: z.coerce.date(), joinedAt: z.coerce.date(),
}); });
export const MeSchema = z.object({ export const MeSchema = UserSchema;
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 ThoughtSchema = z.object({ export const ThoughtSchema = z.object({
id: z.uuid(), id: z.string().uuid(),
authorUsername: z.string(),
authorDisplayName: z.string().nullable(),
content: z.string(), content: z.string(),
visibility: z.enum(["Public", "FriendsOnly", "Private"]), author: UserSchema,
replyToId: z.uuid().nullable(), 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(), createdAt: z.coerce.date(),
updatedAt: z.coerce.date().nullable(),
}); });
export const RegisterSchema = z.object({ export const RegisterSchema = z.object({
username: z.string().min(3), username: z.string().min(3),
email: z.email(), email: z.string().email(),
password: z.string().min(6), password: z.string().min(6),
}); });
export const LoginSchema = z.object({ export const LoginSchema = z.object({
username: z.string().min(3), email: z.string().email(),
password: z.string().min(6), password: z.string().min(6),
}); });
export const CreateThoughtSchema = z.object({ export const CreateThoughtSchema = z.object({
content: z.string().min(1).max(128), content: z.string().min(1).max(128),
visibility: z.enum(["Public", "FriendsOnly", "Private"]).optional(), visibility: z.enum(["public", "followers", "unlisted", "direct"]).optional(),
replyToId: z.string().uuid().optional(), inReplyToId: z.string().uuid().optional(),
}); });
export const UpdateProfileSchema = z.object({ export const UpdateProfileSchema = z.object({
displayName: z.string().max(50).optional(), displayName: z.string().max(50).optional(),
bio: z.string().max(4000).optional(), bio: z.string().max(4000).optional(),
avatarUrl: z.url().or(z.literal("")).optional(), avatarUrl: z.string().optional(),
headerUrl: z.url().or(z.literal("")).optional(), headerUrl: z.string().optional(),
customCss: z.string().optional(), customCss: z.string().optional(),
topFriends: z.array(z.string()).max(8).optional(),
}); });
export const SearchResultsSchema = z.object({ export const SearchResultsSchema = z.object({
users: z.object({ users: z.array(UserSchema) }), query: z.string(),
thoughts: z.object({ thoughts: z.array(ThoughtSchema) }), thoughts: z.array(ThoughtSchema),
users: z.array(UserSchema),
}); });
export const ApiKeySchema = z.object({ export const ApiKeySchema = z.object({
id: z.uuid(), id: z.string().uuid(),
name: z.string(), name: z.string(),
keyPrefix: z.string(),
createdAt: z.coerce.date(), createdAt: z.coerce.date(),
}); });
export const ApiKeyResponseSchema = ApiKeySchema.extend({ export const ApiKeyResponseSchema = ApiKeySchema.extend({
plaintextKey: z.string().optional(), key: z.string().optional(),
}); });
export const ApiKeyListSchema = z.object({ export const ApiKeyListSchema = z.object({
apiKeys: z.array(ApiKeySchema), keys: z.array(ApiKeySchema),
}); });
export const CreateApiKeySchema = z.object({ export const CreateApiKeySchema = z.object({
@@ -87,37 +83,51 @@ export const CreateApiKeySchema = z.object({
export const ThoughtThreadSchema: z.ZodType<{ export const ThoughtThreadSchema: z.ZodType<{
id: string; id: string;
authorUsername: string;
authorDisplayName: string | null;
content: string; content: string;
visibility: "Public" | "FriendsOnly" | "Private"; author: z.infer<typeof UserSchema>;
replyToId: string | null; replyToId: string | null;
visibility: string;
contentWarning: string | null;
sensitive: boolean;
likeCount: number;
boostCount: number;
replyCount: number;
likedByViewer: boolean;
boostedByViewer: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date | null;
replies: ThoughtThread[]; replies: ThoughtThread[];
}> = z.object({ }> = z.object({
id: z.uuid(), id: z.string().uuid(),
authorUsername: z.string(),
authorDisplayName: z.string().nullable(),
content: z.string(), content: z.string(),
visibility: z.enum(["Public", "FriendsOnly", "Private"]), author: UserSchema,
replyToId: z.uuid().nullable(), 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(), createdAt: z.coerce.date(),
updatedAt: z.coerce.date().nullable(),
replies: z.lazy(() => z.array(ThoughtThreadSchema)), replies: z.lazy(() => z.array(ThoughtThreadSchema)),
}); });
export type User = z.infer<typeof UserSchema>; export type User = z.infer<typeof UserSchema>;
export type Me = z.infer<typeof MeSchema>; export type Me = z.infer<typeof MeSchema>;
export type Thought = z.infer<typeof ThoughtSchema>; export type Thought = z.infer<typeof ThoughtSchema>;
export type ThoughtThread = z.infer<typeof ThoughtThreadSchema>;
export type Register = z.infer<typeof RegisterSchema>; export type Register = z.infer<typeof RegisterSchema>;
export type Login = z.infer<typeof LoginSchema>; export type Login = z.infer<typeof LoginSchema>;
export type ApiKey = z.infer<typeof ApiKeySchema>; export type ApiKey = z.infer<typeof ApiKeySchema>;
export type ApiKeyResponse = z.infer<typeof ApiKeyResponseSchema>; export type ApiKeyResponse = z.infer<typeof ApiKeyResponseSchema>;
export type ThoughtThread = z.infer<typeof ThoughtThreadSchema>;
const API_BASE_URL = const API_BASE_URL =
typeof window === "undefined" typeof window === "undefined"
? process.env.NEXT_PUBLIC_SERVER_SIDE_API_URL // Server-side ? process.env.NEXT_PUBLIC_SERVER_SIDE_API_URL
: process.env.NEXT_PUBLIC_API_URL; // Client-side : process.env.NEXT_PUBLIC_API_URL;
async function apiFetch<T>( async function apiFetch<T>(
endpoint: string, endpoint: string,
@@ -138,8 +148,7 @@ async function apiFetch<T>(
headers["Authorization"] = `Bearer ${token}`; headers["Authorization"] = `Bearer ${token}`;
} }
const fullUrl = `${API_BASE_URL}${endpoint}`; const response = await fetch(`${API_BASE_URL}${endpoint}`, {
const response = await fetch(fullUrl, {
...options, ...options,
headers, headers,
}); });
@@ -156,197 +165,122 @@ async function apiFetch<T>(
return schema.parse(data); return schema.parse(data);
} }
// ── Auth ──────────────────────────────────────────────────────────────────
export const registerUser = (data: z.infer<typeof RegisterSchema>) => export const registerUser = (data: z.infer<typeof RegisterSchema>) =>
apiFetch("/auth/register", { apiFetch("/auth/register", { method: "POST", body: JSON.stringify(data) }, UserSchema);
method: "POST",
body: JSON.stringify(data),
}, UserSchema);
export const loginUser = (data: z.infer<typeof LoginSchema>) => export const loginUser = (data: z.infer<typeof LoginSchema>) =>
apiFetch("/auth/login", { apiFetch("/auth/login", { method: "POST", body: JSON.stringify(data) }, z.object({ token: z.string() }));
method: "POST",
body: JSON.stringify(data),
}, z.object({ token: z.string() }));
export const getFeed = (token: string, page: number = 1, pageSize: number = 20) => // ── Current user ──────────────────────────────────────────────────────────
apiFetch(
`/feed?page=${page}&page_size=${pageSize}`, export const getMe = (token: string) =>
{}, apiFetch("/users/me", {}, MeSchema, token);
z.object({ items: z.array(ThoughtSchema), totalPages: z.number() }),
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-list", {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
// ── Users ─────────────────────────────────────────────────────────────────
export const getUserProfile = (username: string, token: string | null) => export const getUserProfile = (username: string, token: string | null) =>
apiFetch(`/users/${username}`, {}, UserSchema, token); apiFetch(`/users/${username}`, {}, UserSchema, token);
export const getFollowersList = (username: string, token: string | null) =>
apiFetch(`/users/${username}/follower-list`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
export const getFollowingList = (username: string, token: string | null) =>
apiFetch(`/users/${username}/following-list`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
export const getTopFriends = (username: string, token: string | null) =>
apiFetch(`/users/${username}/top-friends`, {}, z.object({ topFriends: z.array(z.string()) }), token);
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 getAllUsers = (page: number = 1, pageSize: number = 20) =>
apiFetch(
`/users?page=${page}&per_page=${pageSize}`,
{},
z.object({ items: z.array(UserSchema), total: z.number(), page: z.number(), perPage: z.number() })
.transform((d) => ({ ...d, totalPages: Math.ceil(d.total / d.perPage) }))
);
export const getAllUsersCount = () =>
apiFetch("/users/count", {}, z.object({ count: z.number() }));
// ── Thoughts ──────────────────────────────────────────────────────────────
export const getFeed = (token: string, page: number = 1, pageSize: number = 20) =>
apiFetch(
`/feed?page=${page}&per_page=${pageSize}`,
{},
z.object({ items: z.array(ThoughtSchema), total: z.number(), page: z.number(), perPage: z.number() })
.transform((d) => ({ ...d, totalPages: Math.ceil(d.total / d.perPage) })),
token
);
export const getUserThoughts = (username: string, token: string | null) => export const getUserThoughts = (username: string, token: string | null) =>
apiFetch( apiFetch(
`/users/${username}/thoughts`, `/users/${username}/thoughts`,
{}, {},
z.object({ thoughts: z.array(ThoughtSchema) }), z.object({ items: z.array(ThoughtSchema), total: z.number(), page: z.number(), perPage: z.number() }),
token token
); );
export const createThought = ( export const createThought = (data: z.infer<typeof CreateThoughtSchema>, token: string) =>
data: z.infer<typeof CreateThoughtSchema>, apiFetch("/thoughts", { method: "POST", body: JSON.stringify(data) }, ThoughtSchema, token);
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) => export const deleteThought = (thoughtId: string, token: string) =>
apiFetch( apiFetch(`/thoughts/${thoughtId}`, { method: "DELETE" }, z.null(), token);
`/thoughts/${thoughtId}`,
{ method: "DELETE" },
z.null(), // Expect a 204 No Content response
token
);
export const updateProfile = ( export const getThoughtById = (thoughtId: string, token: string | null) =>
data: z.infer<typeof UpdateProfileSchema>, apiFetch(`/thoughts/${thoughtId}`, {}, ThoughtSchema, token);
token: string
) => export const getThoughtThread = (thoughtId: string, token: string | null) =>
apiFetch( apiFetch(`/thoughts/${thoughtId}/thread`, {}, ThoughtThreadSchema, token);
"/users/me",
{ // ── Tags ──────────────────────────────────────────────────────────────────
method: "PUT",
body: JSON.stringify(data),
},
UserSchema, // Expect the updated user object back
token
);
export const getThoughtsByTag = (tagName: string, token: string | null) => export const getThoughtsByTag = (tagName: string, token: string | null) =>
apiFetch( apiFetch(
`/tags/${tagName}`, `/tags/${tagName}`,
{}, {},
z.object({ thoughts: z.array(ThoughtSchema) }), z.object({ tag: z.string(), items: z.array(ThoughtSchema), total: z.number(), page: z.number(), perPage: z.number() }),
token token
); );
export const getThoughtById = (thoughtId: string, token: string | null) => export const getPopularTags = () =>
apiFetch( apiFetch(
`/thoughts/${thoughtId}`, "/tags/popular",
{}, {},
ThoughtSchema, // Expect a single thought object z.object({ tags: z.array(z.object({ name: z.string(), thoughtCount: z.number() })) })
token .transform((d) => d.tags.map((t) => t.name))
); );
export const getFollowingList = (username: string, token: string | null) => // ── Search ────────────────────────────────────────────────────────────────
apiFetch(
`/users/${username}/following`,
{},
z.object({ users: z.array(UserSchema) }),
token
);
export const getFollowersList = (username: string, token: string | null) =>
apiFetch(
`/users/${username}/followers`,
{},
z.object({ users: z.array(UserSchema) }),
token
);
export const getFriends = (token: string) =>
apiFetch(
"/friends",
{},
z.object({ users: z.array(UserSchema) }),
token
);
export const search = (query: string, token: string | null) => export const search = (query: string, token: string | null) =>
apiFetch( apiFetch(`/search?q=${encodeURIComponent(query)}`, {}, SearchResultsSchema, token);
`/search?q=${encodeURIComponent(query)}`,
{},
SearchResultsSchema,
token
);
// ── API Keys ──────────────────────────────────────────────────────────────
export const getApiKeys = (token: string) => export const getApiKeys = (token: string) =>
apiFetch(`/users/me/api-keys`, {}, ApiKeyListSchema, token); apiFetch("/api-keys", {}, z.object({ keys: z.array(ApiKeySchema) }), token);
export const createApiKey = ( export const createApiKey = (data: z.infer<typeof CreateApiKeySchema>, token: string) =>
data: z.infer<typeof CreateApiKeySchema>, apiFetch("/api-keys", { method: "POST", body: JSON.stringify(data) }, ApiKeyResponseSchema, token);
token: string
) =>
apiFetch(
`/users/me/api-keys`,
{
method: "POST",
body: JSON.stringify(data),
},
ApiKeyResponseSchema,
token
);
export const deleteApiKey = (keyId: string, token: string) => export const deleteApiKey = (keyId: string, token: string) =>
apiFetch( apiFetch(`/api-keys/${keyId}`, { method: "DELETE" }, z.null(), token);
`/users/me/api-keys/${keyId}`,
{ method: "DELETE" },
z.null(),
token
);
export const getThoughtThread = (thoughtId: string, token: string | null) => // ── Legacy alias used by top-friends-combobox ─────────────────────────────
apiFetch(`/thoughts/${thoughtId}/thread`, {}, ThoughtThreadSchema, token);
export const getFriends = (token: string) =>
export const getAllUsers = (page: number = 1, pageSize: number = 20) => getMeFollowingList(token).then((r) => ({ users: r.items }));
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(),
})
);
export const getAllUsersCount = () =>
apiFetch(
`/users/count`,
{},
z.object({
count: z.number(),
})
);