import { z } from "zod"; export const UserSchema = z.object({ id: z.string().uuid(), username: z.string(), displayName: z.string().nullable(), bio: z.string().nullable(), avatarUrl: z.string().nullable(), headerUrl: z.string().nullable(), customCss: z.string().nullable(), local: z.boolean(), isFollowedByViewer: z.boolean(), joinedAt: z.coerce.date().nullable(), }); export const MeSchema = UserSchema; export const ThoughtSchema = z.object({ id: z.string().uuid(), content: z.string(), 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(), }); export const RegisterSchema = z.object({ username: z.string().min(3), email: z.string().email(), password: z.string().min(6), }); export const LoginSchema = z.object({ 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", "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.string().optional(), headerUrl: z.string().optional(), customCss: z.string().optional(), }); export const SearchResultsSchema = z.object({ query: z.string(), thoughts: z.array(ThoughtSchema), users: z.array(UserSchema), }); export const ApiKeySchema = z.object({ id: z.string().uuid(), name: z.string(), createdAt: z.coerce.date(), }); export const ApiKeyResponseSchema = ApiKeySchema.extend({ key: z.string().optional(), }); export const ApiKeyListSchema = z.object({ keys: z.array(ApiKeySchema), }); export const CreateApiKeySchema = z.object({ name: z.string().min(1, "Key name cannot be empty."), }); export const ThoughtThreadSchema: z.ZodType<{ id: string; content: string; author: z.infer; 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.string().uuid(), content: z.string(), 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; export type Me = z.infer; export type Thought = z.infer; export type ThoughtThread = z.infer; export type Register = z.infer; export type Login = z.infer; export type ApiKey = z.infer; export type ApiKeyResponse = z.infer; const API_BASE_URL = typeof window === "undefined" ? process.env.NEXT_PUBLIC_SERVER_SIDE_API_URL : process.env.NEXT_PUBLIC_API_URL; async function apiFetch( endpoint: string, options: RequestInit = {}, schema: z.ZodType, token?: string | null ): Promise { if (!API_BASE_URL) { throw new Error("API_BASE_URL is not defined"); } const headers: Record = { "Content-Type": "application/json", ...(options.headers as Record), }; if (token) { headers["Authorization"] = `Bearer ${token}`; } const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers, }); if (!response.ok) { throw new Error(`API request failed with status ${response.status}`); } if (response.status === 204) { return null as T; } const data = await response.json(); return schema.parse(data); } // ── Auth ────────────────────────────────────────────────────────────────── export const registerUser = (data: z.infer) => apiFetch( "/auth/register", { method: "POST", body: JSON.stringify(data) }, z.object({ token: z.string(), user: UserSchema }) ); export const loginUser = (data: z.infer) => apiFetch("/auth/login", { method: "POST", body: JSON.stringify(data) }, z.object({ token: z.string() })); // ── Current user ────────────────────────────────────────────────────────── export const getMe = (token: string) => apiFetch("/users/me", {}, MeSchema, token); export const updateProfile = (data: z.infer, 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) => apiFetch(`/users/${username}/profile`, {}, 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(), per_page: z.number() }) .transform((d) => ({ ...d, totalPages: Math.ceil(d.total / d.per_page) })) ); 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(), 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`, {}, z.object({ items: z.array(ThoughtSchema), total: z.number(), page: z.number(), per_page: z.number() }), token ); export const createThought = (data: z.infer, 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}`, {}, ThoughtSchema, token); export const getThoughtThread = async (thoughtId: string, token: string | null): Promise => { const thoughts = await apiFetch(`/thoughts/${thoughtId}/thread`, {}, z.array(ThoughtSchema), token); type T = z.infer; const repliesMap: Record = {}; 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}`, {}, 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", {}, 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)}`, {}, SearchResultsSchema, token); // ── API Keys ────────────────────────────────────────────────────────────── export const getApiKeys = (token: string) => apiFetch("/api-keys", {}, z.object({ keys: z.array(ApiKeySchema) }), token); export const createApiKey = (data: z.infer, 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 }));