diff --git a/apps/web/app/(authed)/_nav.tsx b/apps/web/app/(authed)/_nav.tsx index 5168782..c7a687d 100644 --- a/apps/web/app/(authed)/_nav.tsx +++ b/apps/web/app/(authed)/_nav.tsx @@ -18,6 +18,7 @@ export function Nav({ user }: { user: Session["user"] }) { diff --git a/apps/web/app/(authed)/snippets/[name]/page.tsx b/apps/web/app/(authed)/snippets/[name]/page.tsx new file mode 100644 index 0000000..71f9600 --- /dev/null +++ b/apps/web/app/(authed)/snippets/[name]/page.tsx @@ -0,0 +1,251 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { and, eq, isNull } from "drizzle-orm"; +import { auth } from "@/auth"; +import { db } from "@/lib/db/client"; +import { snippets, projects } from "@/lib/db/schema"; +import { updateSnippetAction, deleteSnippetAction } from "@/lib/snippet-actions"; +import { getSnippet, type SnippetWithProjectKey } from "@/lib/snippets"; +import { Container, PageHeader } from "@/app/_components/ui/container"; +import { Card, CardBody, CardHeader } from "@/app/_components/ui/card"; +import { Input, Textarea, Label } from "@/app/_components/ui/input"; +import { Button } from "@/app/_components/ui/button"; +import { Badge } from "@/app/_components/ui/badge"; + +export const dynamic = "force-dynamic"; + +interface SiblingHit { + scope: "project" | "user"; + projectKey: string | null; +} + +/** + * When a snippet name exists in more than one scope (e.g. a user-scope + * default plus one or more project-scope variants), we need to either + * disambiguate by query string or, if no hint is given, show a picker. + */ +async function findAllMatches(userId: string, name: string): Promise { + const rows = await db + .select({ + scope: snippets.scope, + projectKey: projects.key, + }) + .from(snippets) + .leftJoin(projects, eq(snippets.projectId, projects.id)) + .where(and(eq(snippets.userId, userId), eq(snippets.name, name), isNull(snippets.deletedAt))); + return rows as SiblingHit[]; +} + +export default async function SnippetDetailPage({ + params, + searchParams, +}: { + params: Promise<{ name: string }>; + searchParams: Promise<{ scope?: string; project?: string; edit?: string }>; +}) { + const session = await auth(); + const userId = session!.user.id; + const { name: rawName } = await params; + const name = decodeURIComponent(rawName); + const sp = await searchParams; + const scope: "project" | "user" | undefined = + sp.scope === "user" || sp.scope === "project" ? sp.scope : undefined; + const project = sp.project?.trim() || undefined; + const isEditing = sp.edit === "1"; + + const siblings = await findAllMatches(userId, name); + if (siblings.length === 0) notFound(); + + // If multiple matches and the user hasn't disambiguated, show a picker. + if (!scope && siblings.length > 1) { + return ( + + + + + } + /> + + {siblings.map((s, i) => { + const params = new URLSearchParams({ scope: s.scope }); + if (s.scope === "project" && s.projectKey) { + params.set("project", s.projectKey); + } + return ( + 0 ? "border-t border-border" : ""}`} + > +
+ {s.scope} + {s.projectKey ? ( + {s.projectKey} + ) : ( + applies everywhere + )} +
+ + ); + })} +
+
+ ); + } + + const snippet: SnippetWithProjectKey | null = await getSnippet(userId, { + name, + scope, + projectKey: project, + }); + + if (!snippet) notFound(); + + return ( + + + {snippet.scope} + {snippet.projectKey ? ` · ${snippet.projectKey}` : ""} + + } + actions={ + <> + + + + {!isEditing ? ( + + + + ) : null} + + } + /> + + + + {snippet.scope} + {snippet.projectKey ? {snippet.projectKey} : null} + · Created {new Date(snippet.createdAt).toLocaleString()} + {snippet.updatedAt.getTime() !== snippet.createdAt.getTime() ? ( + · Updated {new Date(snippet.updatedAt).toLocaleString()} + ) : null} + + + {isEditing ? ( + +
+ + + {snippet.scope === "project" && snippet.projectKey ? ( + + ) : null} + +
+ + +
+ +
+ +