feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1
@@ -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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getFeed,
|
||||
getFriends,
|
||||
getMe,
|
||||
getTopFriends,
|
||||
getUserProfile,
|
||||
Me,
|
||||
User,
|
||||
@@ -60,7 +61,7 @@ async function FeedPage({
|
||||
const { items: allThoughts, totalPages } = feedData!;
|
||||
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(
|
||||
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 shouldDisplayTopFriends =
|
||||
token && me?.topFriends && me.topFriends.length > 8;
|
||||
|
||||
console.log("Should display top friends:", shouldDisplayTopFriends);
|
||||
const topFriendsData = me
|
||||
? await getTopFriends(me.username, token).catch(() => ({ topFriends: [] }))
|
||||
: { topFriends: [] };
|
||||
const shouldDisplayTopFriends = topFriendsData.topFriends.length > 0;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<PopularTags />
|
||||
{shouldDisplayTopFriends && (
|
||||
<TopFriends mode="top-friends" usernames={me.topFriends} />
|
||||
<TopFriends mode="top-friends" usernames={topFriendsData.topFriends} />
|
||||
)}
|
||||
{!shouldDisplayTopFriends && token && friends.length > 0 && (
|
||||
<TopFriends mode="friends" usernames={friends || []} />
|
||||
@@ -141,7 +142,7 @@ async function FeedPage({
|
||||
<div className="sticky top-20 space-y-6">
|
||||
<PopularTags />
|
||||
{shouldDisplayTopFriends && (
|
||||
<TopFriends mode="top-friends" usernames={me.topFriends} />
|
||||
<TopFriends mode="top-friends" usernames={topFriendsData.topFriends} />
|
||||
)}
|
||||
{!shouldDisplayTopFriends && token && friends.length > 0 && (
|
||||
<TopFriends mode="friends" usernames={friends || []} />
|
||||
|
||||
@@ -30,7 +30,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
|
||||
|
||||
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
||||
if (results) {
|
||||
results.users.users.forEach((user: User) => {
|
||||
results.users.forEach((user: User) => {
|
||||
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
|
||||
});
|
||||
}
|
||||
@@ -48,21 +48,21 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
|
||||
<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}
|
||||
thoughts={results.thoughts}
|
||||
authorDetails={authorDetails}
|
||||
currentUser={me}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="users">
|
||||
<UserListCard users={results.users.users} />
|
||||
<UserListCard users={results.users} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
|
||||
@@ -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,11 +23,11 @@ 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 authors = [...new Set(allThoughts.map((t) => t.author.username))];
|
||||
const userProfiles = await Promise.all(
|
||||
authors.map((username) => getUserProfile(username, token).catch(() => null))
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ThoughtPageProps {
|
||||
}
|
||||
|
||||
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) {
|
||||
collectAuthors(reply).forEach((author) => authors.add(author));
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function FollowersPage({ params }: FollowersPageProps) {
|
||||
<p className="text-muted-foreground">Users following @{username}.</p>
|
||||
</header>
|
||||
<main>
|
||||
<UserListCard users={followersData.users} />
|
||||
<UserListCard users={followersData.items} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function FollowingPage({ params }: FollowingPageProps) {
|
||||
<p className="text-muted-foreground">Users that @{username} follows.</p>
|
||||
</header>
|
||||
<main>
|
||||
<UserListCard users={followingData.users} />
|
||||
<UserListCard users={followingData.items} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getFollowingList,
|
||||
getFriends,
|
||||
getMe,
|
||||
getTopFriends,
|
||||
getUserProfile,
|
||||
getUserThoughts,
|
||||
Me,
|
||||
@@ -55,33 +56,31 @@ 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 =
|
||||
followersResult.status === "fulfilled"
|
||||
? followersResult.value.users.length
|
||||
? followersResult.value.total
|
||||
: 0;
|
||||
const followingCount =
|
||||
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 isFollowing = user.isFollowedByViewer;
|
||||
|
||||
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
||||
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
|
||||
|
||||
const friends =
|
||||
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 (
|
||||
<div id={`profile-page-${user.username}`}>
|
||||
@@ -195,7 +194,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||
</Card>
|
||||
|
||||
{shouldDisplayTopFriends && (
|
||||
<TopFriends mode="top-friends" usernames={user.topFriends} />
|
||||
<TopFriends mode="top-friends" usernames={topFriendsData.topFriends} />
|
||||
)}
|
||||
{token && <TopFriends mode="friends" usernames={friends || []} />}
|
||||
</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>
|
||||
|
||||
@@ -16,11 +16,9 @@ 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;
|
||||
@@ -38,7 +36,6 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) {
|
||||
avatarUrl: currentUser.avatarUrl ?? undefined,
|
||||
headerUrl: currentUser.headerUrl ?? undefined,
|
||||
customCss: currentUser.customCss ?? undefined,
|
||||
topFriends: currentUser.topFriends ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -135,25 +132,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}>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function PostThoughtForm() {
|
||||
|
||||
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
|
||||
resolver: zodResolver(CreateThoughtSchema),
|
||||
defaultValues: { content: "", visibility: "Public" },
|
||||
defaultValues: { content: "", visibility: "public" },
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof CreateThoughtSchema>) {
|
||||
@@ -93,19 +93,24 @@ export function PostThoughtForm() {
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="Public">
|
||||
<SelectItem value="public">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" /> Public
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="FriendsOnly">
|
||||
<SelectItem value="followers">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" /> Friends Only
|
||||
<Users className="h-4 w-4" /> Followers
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="Private">
|
||||
<SelectItem value="unlisted">
|
||||
<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>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
@@ -33,8 +33,8 @@ export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
|
||||
resolver: zodResolver(CreateThoughtSchema),
|
||||
defaultValues: {
|
||||
content: "",
|
||||
replyToId: parentThoughtId,
|
||||
visibility: "Public", // Replies default to Public
|
||||
inReplyToId: parentThoughtId,
|
||||
visibility: "public",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function ThoughtCard({
|
||||
addSuffix: true,
|
||||
});
|
||||
|
||||
const isAuthor = currentUser?.username === thought.authorUsername;
|
||||
const isAuthor = currentUser?.username === thought.author.username;
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!token) return;
|
||||
|
||||
@@ -27,9 +27,9 @@ export function ThoughtList({
|
||||
<div className="space-y-6 p-4">
|
||||
{thoughts.map((thought) => {
|
||||
const author = {
|
||||
username: thought.authorUsername,
|
||||
displayName: thought.authorDisplayName,
|
||||
...authorDetails.get(thought.authorUsername),
|
||||
username: thought.author.username,
|
||||
displayName: thought.author.displayName,
|
||||
...authorDetails.get(thought.author.username),
|
||||
};
|
||||
return (
|
||||
<ThoughtCard
|
||||
|
||||
@@ -15,9 +15,9 @@ export function ThoughtThread({
|
||||
isReply = false,
|
||||
}: ThoughtThreadProps) {
|
||||
const author = {
|
||||
username: thought.authorUsername,
|
||||
displayName: thought.authorDisplayName,
|
||||
...authorDetails.get(thought.authorUsername),
|
||||
username: thought.author.username,
|
||||
displayName: thought.author.displayName,
|
||||
...authorDetails.get(thought.author.username),
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
Reference in New Issue
Block a user