From d165605a68da0521e9866a0251f9f15dac7b0904 Mon Sep 17 00:00:00 2001 From: Nikita Date: Wed, 31 Dec 2025 19:56:32 +0200 Subject: [PATCH] Add bookmarks page with duplicate URL validation - /bookmarks route for viewing and adding bookmarks - Real-time duplicate URL detection as user types - Session-authenticated API endpoints for list/add/delete --- packages/web/src/routeTree.gen.ts | 84 +++++ packages/web/src/routes/api/bookmarks-add.ts | 101 ++++++ .../web/src/routes/api/bookmarks-delete.ts | 56 ++++ packages/web/src/routes/api/bookmarks-list.ts | 40 +++ packages/web/src/routes/bookmarks.tsx | 310 ++++++++++++++++++ 5 files changed, 591 insertions(+) create mode 100644 packages/web/src/routes/api/bookmarks-add.ts create mode 100644 packages/web/src/routes/api/bookmarks-delete.ts create mode 100644 packages/web/src/routes/api/bookmarks-list.ts create mode 100644 packages/web/src/routes/bookmarks.tsx diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 5c8928db..7fa9c3da 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as GlideRouteImport } from './routes/glide' import { Route as ChatRouteImport } from './routes/chat' import { Route as CanvasRouteImport } from './routes/canvas' +import { Route as BookmarksRouteImport } from './routes/bookmarks' import { Route as BlocksRouteImport } from './routes/blocks' import { Route as AuthRouteImport } from './routes/auth' import { Route as ArchiveRouteImport } from './routes/archive' @@ -44,6 +45,9 @@ import { Route as ApiChatThreadsRouteImport } from './routes/api/chat-threads' import { Route as ApiChatMessagesRouteImport } from './routes/api/chat-messages' import { Route as ApiCanvasRouteImport } from './routes/api/canvas' import { Route as ApiBrowserSessionsRouteImport } from './routes/api/browser-sessions' +import { Route as ApiBookmarksListRouteImport } from './routes/api/bookmarks-list' +import { Route as ApiBookmarksDeleteRouteImport } from './routes/api/bookmarks-delete' +import { Route as ApiBookmarksAddRouteImport } from './routes/api/bookmarks-add' import { Route as ApiBookmarksRouteImport } from './routes/api/bookmarks' import { Route as ApiArchivesRouteImport } from './routes/api/archives' import { Route as ApiApiKeysRouteImport } from './routes/api/api-keys' @@ -133,6 +137,11 @@ const CanvasRoute = CanvasRouteImport.update({ path: '/canvas', getParentRoute: () => rootRouteImport, } as any) +const BookmarksRoute = BookmarksRouteImport.update({ + id: '/bookmarks', + path: '/bookmarks', + getParentRoute: () => rootRouteImport, +} as any) const BlocksRoute = BlocksRouteImport.update({ id: '/blocks', path: '/blocks', @@ -258,6 +267,21 @@ const ApiBrowserSessionsRoute = ApiBrowserSessionsRouteImport.update({ path: '/api/browser-sessions', getParentRoute: () => rootRouteImport, } as any) +const ApiBookmarksListRoute = ApiBookmarksListRouteImport.update({ + id: '/api/bookmarks-list', + path: '/api/bookmarks-list', + getParentRoute: () => rootRouteImport, +} as any) +const ApiBookmarksDeleteRoute = ApiBookmarksDeleteRouteImport.update({ + id: '/api/bookmarks-delete', + path: '/api/bookmarks-delete', + getParentRoute: () => rootRouteImport, +} as any) +const ApiBookmarksAddRoute = ApiBookmarksAddRouteImport.update({ + id: '/api/bookmarks-add', + path: '/api/bookmarks-add', + getParentRoute: () => rootRouteImport, +} as any) const ApiBookmarksRoute = ApiBookmarksRouteImport.update({ id: '/api/bookmarks', path: '/api/bookmarks', @@ -462,6 +486,7 @@ export interface FileRoutesByFullPath { '/archive': typeof ArchiveRouteWithChildren '/auth': typeof AuthRoute '/blocks': typeof BlocksRoute + '/bookmarks': typeof BookmarksRoute '/canvas': typeof CanvasRouteWithChildren '/chat': typeof ChatRoute '/glide': typeof GlideRoute @@ -475,6 +500,9 @@ export interface FileRoutesByFullPath { '/api/api-keys': typeof ApiApiKeysRoute '/api/archives': typeof ApiArchivesRouteWithChildren '/api/bookmarks': typeof ApiBookmarksRoute + '/api/bookmarks-add': typeof ApiBookmarksAddRoute + '/api/bookmarks-delete': typeof ApiBookmarksDeleteRoute + '/api/bookmarks-list': typeof ApiBookmarksListRoute '/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren '/api/canvas': typeof ApiCanvasRouteWithChildren '/api/chat-messages': typeof ApiChatMessagesRoute @@ -537,6 +565,7 @@ export interface FileRoutesByTo { '/archive': typeof ArchiveRouteWithChildren '/auth': typeof AuthRoute '/blocks': typeof BlocksRoute + '/bookmarks': typeof BookmarksRoute '/chat': typeof ChatRoute '/glide': typeof GlideRoute '/login': typeof LoginRoute @@ -549,6 +578,9 @@ export interface FileRoutesByTo { '/api/api-keys': typeof ApiApiKeysRoute '/api/archives': typeof ApiArchivesRouteWithChildren '/api/bookmarks': typeof ApiBookmarksRoute + '/api/bookmarks-add': typeof ApiBookmarksAddRoute + '/api/bookmarks-delete': typeof ApiBookmarksDeleteRoute + '/api/bookmarks-list': typeof ApiBookmarksListRoute '/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren '/api/canvas': typeof ApiCanvasRouteWithChildren '/api/chat-messages': typeof ApiChatMessagesRoute @@ -612,6 +644,7 @@ export interface FileRoutesById { '/archive': typeof ArchiveRouteWithChildren '/auth': typeof AuthRoute '/blocks': typeof BlocksRoute + '/bookmarks': typeof BookmarksRoute '/canvas': typeof CanvasRouteWithChildren '/chat': typeof ChatRoute '/glide': typeof GlideRoute @@ -625,6 +658,9 @@ export interface FileRoutesById { '/api/api-keys': typeof ApiApiKeysRoute '/api/archives': typeof ApiArchivesRouteWithChildren '/api/bookmarks': typeof ApiBookmarksRoute + '/api/bookmarks-add': typeof ApiBookmarksAddRoute + '/api/bookmarks-delete': typeof ApiBookmarksDeleteRoute + '/api/bookmarks-list': typeof ApiBookmarksListRoute '/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren '/api/canvas': typeof ApiCanvasRouteWithChildren '/api/chat-messages': typeof ApiChatMessagesRoute @@ -689,6 +725,7 @@ export interface FileRouteTypes { | '/archive' | '/auth' | '/blocks' + | '/bookmarks' | '/canvas' | '/chat' | '/glide' @@ -702,6 +739,9 @@ export interface FileRouteTypes { | '/api/api-keys' | '/api/archives' | '/api/bookmarks' + | '/api/bookmarks-add' + | '/api/bookmarks-delete' + | '/api/bookmarks-list' | '/api/browser-sessions' | '/api/canvas' | '/api/chat-messages' @@ -764,6 +804,7 @@ export interface FileRouteTypes { | '/archive' | '/auth' | '/blocks' + | '/bookmarks' | '/chat' | '/glide' | '/login' @@ -776,6 +817,9 @@ export interface FileRouteTypes { | '/api/api-keys' | '/api/archives' | '/api/bookmarks' + | '/api/bookmarks-add' + | '/api/bookmarks-delete' + | '/api/bookmarks-list' | '/api/browser-sessions' | '/api/canvas' | '/api/chat-messages' @@ -838,6 +882,7 @@ export interface FileRouteTypes { | '/archive' | '/auth' | '/blocks' + | '/bookmarks' | '/canvas' | '/chat' | '/glide' @@ -851,6 +896,9 @@ export interface FileRouteTypes { | '/api/api-keys' | '/api/archives' | '/api/bookmarks' + | '/api/bookmarks-add' + | '/api/bookmarks-delete' + | '/api/bookmarks-list' | '/api/browser-sessions' | '/api/canvas' | '/api/chat-messages' @@ -914,6 +962,7 @@ export interface RootRouteChildren { ArchiveRoute: typeof ArchiveRouteWithChildren AuthRoute: typeof AuthRoute BlocksRoute: typeof BlocksRoute + BookmarksRoute: typeof BookmarksRoute CanvasRoute: typeof CanvasRouteWithChildren ChatRoute: typeof ChatRoute GlideRoute: typeof GlideRoute @@ -927,6 +976,9 @@ export interface RootRouteChildren { ApiApiKeysRoute: typeof ApiApiKeysRoute ApiArchivesRoute: typeof ApiArchivesRouteWithChildren ApiBookmarksRoute: typeof ApiBookmarksRoute + ApiBookmarksAddRoute: typeof ApiBookmarksAddRoute + ApiBookmarksDeleteRoute: typeof ApiBookmarksDeleteRoute + ApiBookmarksListRoute: typeof ApiBookmarksListRoute ApiBrowserSessionsRoute: typeof ApiBrowserSessionsRouteWithChildren ApiCanvasRoute: typeof ApiCanvasRouteWithChildren ApiChatMessagesRoute: typeof ApiChatMessagesRoute @@ -1040,6 +1092,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CanvasRouteImport parentRoute: typeof rootRouteImport } + '/bookmarks': { + id: '/bookmarks' + path: '/bookmarks' + fullPath: '/bookmarks' + preLoaderRoute: typeof BookmarksRouteImport + parentRoute: typeof rootRouteImport + } '/blocks': { id: '/blocks' path: '/blocks' @@ -1215,6 +1274,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiBrowserSessionsRouteImport parentRoute: typeof rootRouteImport } + '/api/bookmarks-list': { + id: '/api/bookmarks-list' + path: '/api/bookmarks-list' + fullPath: '/api/bookmarks-list' + preLoaderRoute: typeof ApiBookmarksListRouteImport + parentRoute: typeof rootRouteImport + } + '/api/bookmarks-delete': { + id: '/api/bookmarks-delete' + path: '/api/bookmarks-delete' + fullPath: '/api/bookmarks-delete' + preLoaderRoute: typeof ApiBookmarksDeleteRouteImport + parentRoute: typeof rootRouteImport + } + '/api/bookmarks-add': { + id: '/api/bookmarks-add' + path: '/api/bookmarks-add' + fullPath: '/api/bookmarks-add' + preLoaderRoute: typeof ApiBookmarksAddRouteImport + parentRoute: typeof rootRouteImport + } '/api/bookmarks': { id: '/api/bookmarks' path: '/api/bookmarks' @@ -1639,6 +1719,7 @@ const rootRouteChildren: RootRouteChildren = { ArchiveRoute: ArchiveRouteWithChildren, AuthRoute: AuthRoute, BlocksRoute: BlocksRoute, + BookmarksRoute: BookmarksRoute, CanvasRoute: CanvasRouteWithChildren, ChatRoute: ChatRoute, GlideRoute: GlideRoute, @@ -1652,6 +1733,9 @@ const rootRouteChildren: RootRouteChildren = { ApiApiKeysRoute: ApiApiKeysRoute, ApiArchivesRoute: ApiArchivesRouteWithChildren, ApiBookmarksRoute: ApiBookmarksRoute, + ApiBookmarksAddRoute: ApiBookmarksAddRoute, + ApiBookmarksDeleteRoute: ApiBookmarksDeleteRoute, + ApiBookmarksListRoute: ApiBookmarksListRoute, ApiBrowserSessionsRoute: ApiBrowserSessionsRouteWithChildren, ApiCanvasRoute: ApiCanvasRouteWithChildren, ApiChatMessagesRoute: ApiChatMessagesRoute, diff --git a/packages/web/src/routes/api/bookmarks-add.ts b/packages/web/src/routes/api/bookmarks-add.ts new file mode 100644 index 00000000..928c0eac --- /dev/null +++ b/packages/web/src/routes/api/bookmarks-add.ts @@ -0,0 +1,101 @@ +import { createFileRoute } from "@tanstack/react-router" +import { eq, and } from "drizzle-orm" +import { getDb } from "@/db/connection" +import { bookmarks } from "@/db/schema" +import { getAuth } from "@/lib/auth" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +function normalizeUrl(url: string): string { + try { + const parsed = new URL(url.startsWith("http") ? url : `https://${url}`) + return parsed.href.toLowerCase().replace(/\/$/, "") + } catch { + return url.toLowerCase().trim() + } +} + +export const Route = createFileRoute("/api/bookmarks-add")({ + server: { + handlers: { + POST: async ({ request }) => { + try { + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + if (!session?.user?.id) { + return json({ error: "Unauthorized" }, 401) + } + + const body = (await request.json()) as { + url?: string + title?: string + description?: string + tags?: string + } + + const { url, title, description, tags } = body + + if (!url) { + return json({ error: "URL is required" }, 400) + } + + const db = getDb(process.env.DATABASE_URL!) + const normalizedUrl = normalizeUrl(url) + + // Check for existing bookmark with same URL + const existing = await db + .select() + .from(bookmarks) + .where(eq(bookmarks.user_id, session.user.id)) + + const duplicate = existing.find( + (b) => normalizeUrl(b.url) === normalizedUrl + ) + + if (duplicate) { + return json( + { + error: "Bookmark already exists", + existing: { + id: duplicate.id, + title: duplicate.title, + url: duplicate.url, + }, + }, + 409 + ) + } + + // Insert bookmark + const [bookmark] = await db + .insert(bookmarks) + .values({ + user_id: session.user.id, + url, + title: title || null, + description: description || null, + tags: tags || null, + }) + .returning() + + return json({ + success: true, + bookmark: { + id: bookmark.id, + url: bookmark.url, + title: bookmark.title, + created_at: bookmark.created_at, + }, + }) + } catch (error) { + console.error("Error adding bookmark:", error) + return json({ error: "Failed to add bookmark" }, 500) + } + }, + }, + }, +}) diff --git a/packages/web/src/routes/api/bookmarks-delete.ts b/packages/web/src/routes/api/bookmarks-delete.ts new file mode 100644 index 00000000..f6e15007 --- /dev/null +++ b/packages/web/src/routes/api/bookmarks-delete.ts @@ -0,0 +1,56 @@ +import { createFileRoute } from "@tanstack/react-router" +import { eq, and } from "drizzle-orm" +import { getDb } from "@/db/connection" +import { bookmarks } from "@/db/schema" +import { getAuth } from "@/lib/auth" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +export const Route = createFileRoute("/api/bookmarks-delete")({ + server: { + handlers: { + DELETE: async ({ request }) => { + try { + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + if (!session?.user?.id) { + return json({ error: "Unauthorized" }, 401) + } + + const url = new URL(request.url) + const bookmarkId = url.searchParams.get("id") + + if (!bookmarkId) { + return json({ error: "Bookmark ID is required" }, 400) + } + + const db = getDb(process.env.DATABASE_URL!) + + // Delete only if it belongs to the user + const [deleted] = await db + .delete(bookmarks) + .where( + and( + eq(bookmarks.id, bookmarkId), + eq(bookmarks.user_id, session.user.id) + ) + ) + .returning() + + if (!deleted) { + return json({ error: "Bookmark not found" }, 404) + } + + return json({ success: true }) + } catch (error) { + console.error("Error deleting bookmark:", error) + return json({ error: "Failed to delete bookmark" }, 500) + } + }, + }, + }, +}) diff --git a/packages/web/src/routes/api/bookmarks-list.ts b/packages/web/src/routes/api/bookmarks-list.ts new file mode 100644 index 00000000..1eb69faf --- /dev/null +++ b/packages/web/src/routes/api/bookmarks-list.ts @@ -0,0 +1,40 @@ +import { createFileRoute } from "@tanstack/react-router" +import { eq } from "drizzle-orm" +import { getDb } from "@/db/connection" +import { bookmarks } from "@/db/schema" +import { getAuth } from "@/lib/auth" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +export const Route = createFileRoute("/api/bookmarks-list")({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + if (!session?.user?.id) { + return json({ error: "Unauthorized" }, 401) + } + + const db = getDb(process.env.DATABASE_URL!) + + const userBookmarks = await db + .select() + .from(bookmarks) + .where(eq(bookmarks.user_id, session.user.id)) + .orderBy(bookmarks.created_at) + + return json({ bookmarks: userBookmarks }) + } catch (error) { + console.error("Error fetching bookmarks:", error) + return json({ error: "Failed to fetch bookmarks" }, 500) + } + }, + }, + }, +}) diff --git a/packages/web/src/routes/bookmarks.tsx b/packages/web/src/routes/bookmarks.tsx new file mode 100644 index 00000000..f00dc6d4 --- /dev/null +++ b/packages/web/src/routes/bookmarks.tsx @@ -0,0 +1,310 @@ +import { useState, useEffect, type FormEvent } from "react" +import { createFileRoute } from "@tanstack/react-router" +import { authClient } from "@/lib/auth-client" +import { Bookmark, Plus, Trash2, ExternalLink, AlertCircle } from "lucide-react" + +type BookmarkData = { + id: string + url: string + title: string | null + description: string | null + tags: string | null + created_at: string +} + +export const Route = createFileRoute("/bookmarks")({ + component: BookmarksPage, + ssr: false, +}) + +function BookmarksPage() { + const { data: session, isPending: authPending } = authClient.useSession() + + const [bookmarks, setBookmarks] = useState([]) + const [loading, setLoading] = useState(true) + const [isAdding, setIsAdding] = useState(false) + const [newUrl, setNewUrl] = useState("") + const [newTitle, setNewTitle] = useState("") + const [newTags, setNewTags] = useState("") + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const [duplicateUrl, setDuplicateUrl] = useState(null) + + const fetchBookmarks = async () => { + try { + const res = await fetch("/api/bookmarks-list", { credentials: "include" }) + if (res.ok) { + const data = await res.json() + setBookmarks(data.bookmarks || []) + } + } catch { + // Ignore + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (session?.user) { + fetchBookmarks() + } else { + setLoading(false) + } + }, [session?.user]) + + // Check for duplicate URL as user types + useEffect(() => { + if (!newUrl.trim()) { + setDuplicateUrl(null) + return + } + + const normalizedInput = normalizeUrl(newUrl.trim()) + const existing = bookmarks.find( + (b) => normalizeUrl(b.url) === normalizedInput + ) + + if (existing) { + setDuplicateUrl(existing.title || existing.url) + } else { + setDuplicateUrl(null) + } + }, [newUrl, bookmarks]) + + const normalizeUrl = (url: string) => { + try { + const parsed = new URL(url.startsWith("http") ? url : `https://${url}`) + return parsed.href.toLowerCase().replace(/\/$/, "") + } catch { + return url.toLowerCase().trim() + } + } + + const handleAddBookmark = async (e: FormEvent) => { + e.preventDefault() + if (!newUrl.trim() || duplicateUrl) return + + setSubmitting(true) + setError(null) + + try { + const res = await fetch("/api/bookmarks-add", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + url: newUrl.trim(), + title: newTitle.trim() || null, + tags: newTags.trim() || null, + }), + }) + + const data = await res.json() + + if (!res.ok) { + setError(data.error || "Failed to add bookmark") + } else { + setNewUrl("") + setNewTitle("") + setNewTags("") + setIsAdding(false) + fetchBookmarks() + } + } catch { + setError("Network error") + } finally { + setSubmitting(false) + } + } + + const handleDelete = async (id: string) => { + try { + const res = await fetch(`/api/bookmarks-delete?id=${id}`, { + method: "DELETE", + credentials: "include", + }) + if (res.ok) { + setBookmarks(bookmarks.filter((b) => b.id !== id)) + } + } catch { + // Ignore + } + } + + if (authPending) { + return
+ } + + if (!session?.user) { + return ( +
+
+

Please sign in to view bookmarks

+ + Sign in + +
+
+ ) + } + + return ( +
+
+
+
+ +

Bookmarks

+
+ +
+ + {isAdding && ( +
+
+ + setNewUrl(e.target.value)} + placeholder="https://example.com" + className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500" + /> + {duplicateUrl && ( +
+ + Already bookmarked: {duplicateUrl} +
+ )} +
+ +
+ + setNewTitle(e.target.value)} + placeholder="Page title" + className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500" + /> +
+ +
+ + setNewTags(e.target.value)} + placeholder="comma, separated, tags" + className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500" + /> +
+ + {error &&

{error}

} + +
+ + +
+
+ )} + + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : bookmarks.length === 0 ? ( +
+ +

No bookmarks yet

+

Click "Add Bookmark" to save your first one

+
+ ) : ( +
+ {bookmarks.map((bookmark) => ( +
+
+ + + {bookmark.title || bookmark.url} + + + + {bookmark.title && ( +

+ {bookmark.url} +

+ )} + {bookmark.tags && ( +
+ {bookmark.tags.split(",").map((tag, i) => ( + + {tag.trim()} + + ))} +
+ )} +
+ +
+ ))} +
+ )} +
+
+ ) +}