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

@@ -1,84 +1,80 @@
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()),
local: z.boolean(),
isFollowedByViewer: z.boolean(),
joinedAt: z.coerce.date(),
});
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 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(),
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,37 +83,51 @@ 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;
async function apiFetch<T>(
endpoint: string,
@@ -138,8 +148,7 @@ async function apiFetch<T>(
headers["Authorization"] = `Bearer ${token}`;
}
const fullUrl = `${API_BASE_URL}${endpoint}`;
const response = await fetch(fullUrl, {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers,
});
@@ -147,7 +156,7 @@ async function apiFetch<T>(
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
if (response.status === 204) {
return null as T;
}
@@ -156,197 +165,122 @@ 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) }, 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", {}, 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-list", {}, 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 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) =>
apiFetch(
`/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
);
export const createThought = (
data: z.infer<typeof CreateThoughtSchema>,
token: string
) =>
apiFetch(
"/thoughts",
{
method: "POST",
body: JSON.stringify(data),
},
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 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, // 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
);
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) =>
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" },
z.null(),
token
);
apiFetch(`/thoughts/${thoughtId}`, {}, ThoughtSchema, token);
export const getThoughtThread = (thoughtId: string, token: string | null) =>
apiFetch(`/thoughts/${thoughtId}/thread`, {}, ThoughtThreadSchema, token);
// ── Tags ──────────────────────────────────────────────────────────────────
export const getAllUsers = (page: number = 1, pageSize: number = 20) =>
export const getThoughtsByTag = (tagName: string, token: string | null) =>
apiFetch(
`/users/all?page=${page}&page_size=${pageSize}`,
`/tags/${tagName}`,
{},
z.object({
items: z.array(UserSchema),
page: z.number(),
pageSize: z.number(),
totalPages: z.number(),
totalItems: z.number(),
})
z.object({ tag: z.string(), items: z.array(ThoughtSchema), total: z.number(), page: z.number(), perPage: z.number() }),
token
);
export const getAllUsersCount = () =>
export const getPopularTags = () =>
apiFetch(
`/users/count`,
"/tags/popular",
{},
z.object({
count: z.number(),
})
);
z.object({ tags: z.array(z.object({ name: z.string(), thoughtCount: 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<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 }));