diff --git a/apps/web/app/(authed)/_nav.tsx b/apps/web/app/(authed)/_nav.tsx new file mode 100644 index 0000000..5168782 --- /dev/null +++ b/apps/web/app/(authed)/_nav.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; +import { Container } from "@/app/_components/ui/container"; +import { UserMenu } from "./_user-menu"; +import { SearchBox } from "./_search-box"; +import type { Session } from "next-auth"; + +export function Nav({ user }: { user: Session["user"] }) { + return ( +
+ + + + shared-memory + + + + +
+ +
+ + +
+
+ ); +} + +function NavLink({ href, children }: { href: string; children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/(authed)/_search-box.tsx b/apps/web/app/(authed)/_search-box.tsx new file mode 100644 index 0000000..bb72d03 --- /dev/null +++ b/apps/web/app/(authed)/_search-box.tsx @@ -0,0 +1,19 @@ +import { Input } from "@/app/_components/ui/input"; + +/** + * Global search — submits a GET to /memories with `?q=`. Server-rendered + * results page handles the actual memory.search call. + */ +export function SearchBox() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/(authed)/_user-menu.tsx b/apps/web/app/(authed)/_user-menu.tsx new file mode 100644 index 0000000..f42e1d2 --- /dev/null +++ b/apps/web/app/(authed)/_user-menu.tsx @@ -0,0 +1,28 @@ +import { signOut } from "@/auth"; +import { Button } from "@/app/_components/ui/button"; +import type { Session } from "next-auth"; + +async function signOutAction() { + "use server"; + await signOut({ redirectTo: "/" }); +} + +export function UserMenu({ user }: { user: Session["user"] }) { + const label = user.email ?? user.name ?? user.id; + // Compact, single-line label; truncate on small screens via Tailwind. + return ( +
+ + {label} + +
+ +
+
+ ); +} diff --git a/apps/web/app/(authed)/dashboard/page.tsx b/apps/web/app/(authed)/dashboard/page.tsx new file mode 100644 index 0000000..f6b5f15 --- /dev/null +++ b/apps/web/app/(authed)/dashboard/page.tsx @@ -0,0 +1,154 @@ +import Link from "next/link"; +import { and, desc, eq, isNull, sql, count } from "drizzle-orm"; +import { auth } from "@/auth"; +import { db } from "@/lib/db/client"; +import { memories, projects } from "@/lib/db/schema"; +import { Container, PageHeader } from "@/app/_components/ui/container"; +import { Card, CardBody, CardHeader } from "@/app/_components/ui/card"; +import { Badge } from "@/app/_components/ui/badge"; +import { Button } from "@/app/_components/ui/button"; +import { EmptyState } from "@/app/_components/ui/empty-state"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardPage() { + const session = await auth(); + const userId = session!.user.id; + + const [counts, recent, topProjects] = await Promise.all([ + db + .select({ + total: count(memories.id), + }) + .from(memories) + .where(and(eq(memories.userId, userId), isNull(memories.deletedAt))), + db + .select({ + id: memories.id, + content: memories.content, + scope: memories.scope, + tags: memories.tags, + createdAt: memories.createdAt, + projectKey: projects.key, + }) + .from(memories) + .leftJoin(projects, eq(memories.projectId, projects.id)) + .where(and(eq(memories.userId, userId), isNull(memories.deletedAt))) + .orderBy(desc(memories.createdAt)) + .limit(5), + db + .select({ + id: projects.id, + key: projects.key, + displayName: projects.displayName, + memoryCount: sql`count(${memories.id})::int`, + }) + .from(projects) + .leftJoin( + memories, + and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)), + ) + .where(eq(projects.userId, userId)) + .groupBy(projects.id) + .orderBy(desc(sql`count(${memories.id})`)) + .limit(4), + ]); + + const memoryTotal = counts[0]?.total ?? 0; + + return ( + + + + + } + /> + +
+
+

Recent

+ {recent.length === 0 ? ( + + + + } + /> + ) : ( + recent.map((m) => ( + + + +
+ + {m.scope} + + {m.projectKey ? · {m.projectKey} : null} + + {new Date(m.createdAt).toLocaleDateString()} + +
+

{m.content}

+ {m.tags.length ? ( +
+ {m.tags.slice(0, 6).map((t) => ( + {t} + ))} +
+ ) : null} +
+
+ + )) + )} +
+ +
+

Projects

+ {topProjects.length === 0 ? ( +

No projects yet.

+ ) : ( + + {topProjects.map((p, i) => ( + 0 ? "border-t border-border" : ""}`} + > +
+ + {p.key} + + {p.memoryCount} +
+ {p.displayName && p.displayName !== p.key ? ( + + {p.displayName} + + ) : null} + + ))} + + All projects → + +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(authed)/layout.tsx b/apps/web/app/(authed)/layout.tsx new file mode 100644 index 0000000..94df073 --- /dev/null +++ b/apps/web/app/(authed)/layout.tsx @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; +import type { ReactNode } from "react"; +import { auth } from "@/auth"; +import { Nav } from "./_nav"; + +export const dynamic = "force-dynamic"; + +export default async function AuthedLayout({ children }: { children: ReactNode }) { + const session = await auth(); + if (!session?.user) { + redirect("/api/auth/signin?callbackUrl=/memories"); + } + return ( + <> +