feat: implement settings layout and navigation, add tag and thought pages with API integration

This commit is contained in:
2025-09-06 21:56:41 +02:00
parent 5344e0d6a8
commit 85e3425d4b
9 changed files with 257 additions and 18 deletions

View File

@@ -0,0 +1,35 @@
// app/settings/layout.tsx
import { SettingsNav } from "@/components/settings-nav";
import { Separator } from "@/components/ui/separator";
const sidebarNavItems = [
{
title: "Profile",
href: "/settings/profile",
},
// You can add more links here later, e.g., "Account", "API Keys"
];
export default function SettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="container mx-auto max-w-5xl space-y-6 p-10 pb-16">
<div className="space-y-0.5">
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
<p className="text-muted-foreground">
Manage your account settings and profile.
</p>
</div>
<Separator className="my-6" />
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
<aside className="-mx-4 lg:w-1/5">
<SettingsNav items={sidebarNavItems} />
</aside>
<div className="flex-1 lg:max-w-2xl">{children}</div>
</div>
</div>
);
}

View File

@@ -1,15 +1,9 @@
// app/settings/profile/page.tsx
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getMe } from "@/lib/api"; import { getMe } from "@/lib/api";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { EditProfileForm } from "@/components/edit-profile-form"; import { EditProfileForm } from "@/components/edit-profile-form";
// This is a Server Component to fetch initial data
export default async function EditProfilePage() { export default async function EditProfilePage() {
const token = (await cookies()).get("auth_token")?.value; const token = (await cookies()).get("auth_token")?.value;
@@ -25,15 +19,13 @@ export default async function EditProfilePage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card> <div>
<CardHeader> <h3 className="text-lg font-medium">Profile</h3>
<CardTitle>Edit Profile</CardTitle> <p className="text-sm text-muted-foreground">
<CardDescription> This is how others will see you on the site.
Update your public profile information. </p>
</CardDescription> </div>
</CardHeader> <EditProfileForm currentUser={me} />
<EditProfileForm currentUser={me} />
</Card>
</div> </div>
); );
} }

View File

@@ -0,0 +1,68 @@
// app/tags/[tagName]/page.tsx
import { cookies } from "next/headers";
import { getThoughtsByTag, getUserProfile, getMe, Me, User } from "@/lib/api";
import { buildThoughtThreads } from "@/lib/utils";
import { ThoughtThread } from "@/components/thought-thread";
import { notFound } from "next/navigation";
import { Hash } from "lucide-react";
interface TagPageProps {
params: { tagName: string };
}
export default async function TagPage({ params }: TagPageProps) {
const { tagName } = params;
const token = (await cookies()).get("auth_token")?.value ?? null;
const [thoughtsResult, meResult] = await Promise.allSettled([
getThoughtsByTag(tagName, token),
token ? getMe(token) : Promise.resolve(null),
]);
if (thoughtsResult.status === "rejected") {
notFound();
}
const allThoughts = thoughtsResult.value.thoughts;
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))];
const userProfiles = await Promise.all(
authors.map((username) => getUserProfile(username, token).catch(() => null))
);
const authorDetails = new Map<string, { avatarUrl?: string | null }>(
userProfiles
.filter((u): u is User => !!u)
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
);
const { topLevelThoughts, repliesByParentId } =
buildThoughtThreads(allThoughts);
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
<header className="my-6">
<h1 className="flex items-center gap-2 text-3xl font-bold">
<Hash className="h-7 w-7" />
{tagName}
</h1>
</header>
<main className="space-y-6">
{topLevelThoughts.map((thought) => (
<ThoughtThread
key={thought.id}
thought={thought}
repliesByParentId={repliesByParentId}
authorDetails={authorDetails}
currentUser={me}
/>
))}
{topLevelThoughts.length === 0 && (
<p className="text-center text-muted-foreground pt-8">
No thoughts found for this tag.
</p>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { cookies } from "next/headers";
import {
getThoughtById,
getUserThoughts,
getUserProfile,
getMe,
Me,
Thought,
} from "@/lib/api";
import { buildThoughtThreads } from "@/lib/utils";
import { ThoughtThread } from "@/components/thought-thread";
import { notFound } from "next/navigation";
interface ThoughtPageProps {
params: { thoughtId: string };
}
async function findConversationRoot(
startThought: Thought,
token: string | null
): Promise<Thought> {
let currentThought = startThought;
while (currentThought.replyToId) {
const parentThought = await getThoughtById(
currentThought.replyToId,
token
).catch(() => null);
if (!parentThought) break;
currentThought = parentThought;
}
return currentThought;
}
export default async function ThoughtPage({ params }: ThoughtPageProps) {
const { thoughtId } = params;
const token = (await cookies()).get("auth_token")?.value ?? null;
const initialThought = await getThoughtById(thoughtId, token).catch(
() => null
);
if (!initialThought) {
notFound();
}
const rootThought = await findConversationRoot(initialThought, token);
const [thoughtsResult, meResult] = await Promise.allSettled([
getUserThoughts(rootThought.authorUsername, token),
token ? getMe(token) : Promise.resolve(null),
]);
if (thoughtsResult.status === "rejected") {
notFound();
}
const allThoughts = thoughtsResult.value.thoughts;
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
const author = await getUserProfile(rootThought.authorUsername, token).catch(
() => null
);
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
if (author) {
authorDetails.set(author.username, { avatarUrl: author.avatarUrl });
}
const { repliesByParentId } = buildThoughtThreads(allThoughts);
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
<header className="my-6">
<h1 className="text-3xl font-bold">Conversation</h1>
</header>
<main>
<ThoughtThread
thought={rootThought}
repliesByParentId={repliesByParentId}
authorDetails={authorDetails}
currentUser={me}
/>
</main>
</div>
);
}

View File

@@ -51,7 +51,7 @@ export function EditProfileForm({ currentUser }: EditProfileFormProps) {
router.push(`/users/${currentUser.username}`); router.push(`/users/${currentUser.username}`);
router.refresh(); // Ensure fresh data is loaded router.refresh(); // Ensure fresh data is loaded
} catch (err) { } catch (err) {
toast.error("Failed to update profile."); toast.error(`Failed to update profile. ${err}`);
} }
} }

View File

@@ -6,7 +6,6 @@ import { Button } from "./ui/button";
import { UserNav } from "./user-nav"; import { UserNav } from "./user-nav";
import { MainNav } from "./main-nav"; import { MainNav } from "./main-nav";
import { ThemeToggle } from "./theme-toggle"; import { ThemeToggle } from "./theme-toggle";
import { Wind } from "lucide-react";
export function Header() { export function Header() {
const { token } = useAuth(); const { token } = useAuth();

View File

@@ -0,0 +1,43 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
interface SettingsNavProps extends React.HTMLAttributes<HTMLElement> {
items: {
href: string;
title: string;
}[];
}
export function SettingsNav({ className, items, ...props }: SettingsNavProps) {
const pathname = usePathname();
return (
<nav
className={cn(
"flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1",
className
)}
{...props}
>
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
buttonVariants({ variant: "ghost" }),
pathname === item.href
? "bg-muted hover:bg-muted"
: "hover:bg-transparent hover:underline",
"justify-start"
)}
>
{item.title}
</Link>
))}
</nav>
);
}

View File

@@ -72,6 +72,7 @@ export function ThoughtCard({
toast.success("Thought deleted successfully."); toast.success("Thought deleted successfully.");
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
console.error("Failed to delete thought:", error);
toast.error("Failed to delete thought."); toast.error("Failed to delete thought.");
} finally { } finally {
setIsAlertOpen(false); setIsAlertOpen(false);

View File

@@ -193,3 +193,19 @@ export const updateProfile = (
UserSchema, // Expect the updated user object back UserSchema, // Expect the updated user object back
token token
); );
export const getThoughtsByTag = (tagName: string, token: string | null) =>
apiFetch(
`/tags/${tagName}`,
{},
z.object({ thoughts: z.array(ThoughtSchema) }),
token
);
export const getThoughtById = (thoughtId: string, token: string | null) =>
apiFetch(
`/thoughts/${thoughtId}`,
{},
ThoughtSchema, // Expect a single thought object
token
);