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
This commit is contained in:
Nikita
2025-12-31 19:56:32 +02:00
parent 4d072aacb5
commit d165605a68
5 changed files with 591 additions and 0 deletions

View File

@@ -19,6 +19,7 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as GlideRouteImport } from './routes/glide' import { Route as GlideRouteImport } from './routes/glide'
import { Route as ChatRouteImport } from './routes/chat' import { Route as ChatRouteImport } from './routes/chat'
import { Route as CanvasRouteImport } from './routes/canvas' import { Route as CanvasRouteImport } from './routes/canvas'
import { Route as BookmarksRouteImport } from './routes/bookmarks'
import { Route as BlocksRouteImport } from './routes/blocks' import { Route as BlocksRouteImport } from './routes/blocks'
import { Route as AuthRouteImport } from './routes/auth' import { Route as AuthRouteImport } from './routes/auth'
import { Route as ArchiveRouteImport } from './routes/archive' 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 ApiChatMessagesRouteImport } from './routes/api/chat-messages'
import { Route as ApiCanvasRouteImport } from './routes/api/canvas' import { Route as ApiCanvasRouteImport } from './routes/api/canvas'
import { Route as ApiBrowserSessionsRouteImport } from './routes/api/browser-sessions' 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 ApiBookmarksRouteImport } from './routes/api/bookmarks'
import { Route as ApiArchivesRouteImport } from './routes/api/archives' import { Route as ApiArchivesRouteImport } from './routes/api/archives'
import { Route as ApiApiKeysRouteImport } from './routes/api/api-keys' import { Route as ApiApiKeysRouteImport } from './routes/api/api-keys'
@@ -133,6 +137,11 @@ const CanvasRoute = CanvasRouteImport.update({
path: '/canvas', path: '/canvas',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const BookmarksRoute = BookmarksRouteImport.update({
id: '/bookmarks',
path: '/bookmarks',
getParentRoute: () => rootRouteImport,
} as any)
const BlocksRoute = BlocksRouteImport.update({ const BlocksRoute = BlocksRouteImport.update({
id: '/blocks', id: '/blocks',
path: '/blocks', path: '/blocks',
@@ -258,6 +267,21 @@ const ApiBrowserSessionsRoute = ApiBrowserSessionsRouteImport.update({
path: '/api/browser-sessions', path: '/api/browser-sessions',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } 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({ const ApiBookmarksRoute = ApiBookmarksRouteImport.update({
id: '/api/bookmarks', id: '/api/bookmarks',
path: '/api/bookmarks', path: '/api/bookmarks',
@@ -462,6 +486,7 @@ export interface FileRoutesByFullPath {
'/archive': typeof ArchiveRouteWithChildren '/archive': typeof ArchiveRouteWithChildren
'/auth': typeof AuthRoute '/auth': typeof AuthRoute
'/blocks': typeof BlocksRoute '/blocks': typeof BlocksRoute
'/bookmarks': typeof BookmarksRoute
'/canvas': typeof CanvasRouteWithChildren '/canvas': typeof CanvasRouteWithChildren
'/chat': typeof ChatRoute '/chat': typeof ChatRoute
'/glide': typeof GlideRoute '/glide': typeof GlideRoute
@@ -475,6 +500,9 @@ export interface FileRoutesByFullPath {
'/api/api-keys': typeof ApiApiKeysRoute '/api/api-keys': typeof ApiApiKeysRoute
'/api/archives': typeof ApiArchivesRouteWithChildren '/api/archives': typeof ApiArchivesRouteWithChildren
'/api/bookmarks': typeof ApiBookmarksRoute '/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/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren
'/api/canvas': typeof ApiCanvasRouteWithChildren '/api/canvas': typeof ApiCanvasRouteWithChildren
'/api/chat-messages': typeof ApiChatMessagesRoute '/api/chat-messages': typeof ApiChatMessagesRoute
@@ -537,6 +565,7 @@ export interface FileRoutesByTo {
'/archive': typeof ArchiveRouteWithChildren '/archive': typeof ArchiveRouteWithChildren
'/auth': typeof AuthRoute '/auth': typeof AuthRoute
'/blocks': typeof BlocksRoute '/blocks': typeof BlocksRoute
'/bookmarks': typeof BookmarksRoute
'/chat': typeof ChatRoute '/chat': typeof ChatRoute
'/glide': typeof GlideRoute '/glide': typeof GlideRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
@@ -549,6 +578,9 @@ export interface FileRoutesByTo {
'/api/api-keys': typeof ApiApiKeysRoute '/api/api-keys': typeof ApiApiKeysRoute
'/api/archives': typeof ApiArchivesRouteWithChildren '/api/archives': typeof ApiArchivesRouteWithChildren
'/api/bookmarks': typeof ApiBookmarksRoute '/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/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren
'/api/canvas': typeof ApiCanvasRouteWithChildren '/api/canvas': typeof ApiCanvasRouteWithChildren
'/api/chat-messages': typeof ApiChatMessagesRoute '/api/chat-messages': typeof ApiChatMessagesRoute
@@ -612,6 +644,7 @@ export interface FileRoutesById {
'/archive': typeof ArchiveRouteWithChildren '/archive': typeof ArchiveRouteWithChildren
'/auth': typeof AuthRoute '/auth': typeof AuthRoute
'/blocks': typeof BlocksRoute '/blocks': typeof BlocksRoute
'/bookmarks': typeof BookmarksRoute
'/canvas': typeof CanvasRouteWithChildren '/canvas': typeof CanvasRouteWithChildren
'/chat': typeof ChatRoute '/chat': typeof ChatRoute
'/glide': typeof GlideRoute '/glide': typeof GlideRoute
@@ -625,6 +658,9 @@ export interface FileRoutesById {
'/api/api-keys': typeof ApiApiKeysRoute '/api/api-keys': typeof ApiApiKeysRoute
'/api/archives': typeof ApiArchivesRouteWithChildren '/api/archives': typeof ApiArchivesRouteWithChildren
'/api/bookmarks': typeof ApiBookmarksRoute '/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/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren
'/api/canvas': typeof ApiCanvasRouteWithChildren '/api/canvas': typeof ApiCanvasRouteWithChildren
'/api/chat-messages': typeof ApiChatMessagesRoute '/api/chat-messages': typeof ApiChatMessagesRoute
@@ -689,6 +725,7 @@ export interface FileRouteTypes {
| '/archive' | '/archive'
| '/auth' | '/auth'
| '/blocks' | '/blocks'
| '/bookmarks'
| '/canvas' | '/canvas'
| '/chat' | '/chat'
| '/glide' | '/glide'
@@ -702,6 +739,9 @@ export interface FileRouteTypes {
| '/api/api-keys' | '/api/api-keys'
| '/api/archives' | '/api/archives'
| '/api/bookmarks' | '/api/bookmarks'
| '/api/bookmarks-add'
| '/api/bookmarks-delete'
| '/api/bookmarks-list'
| '/api/browser-sessions' | '/api/browser-sessions'
| '/api/canvas' | '/api/canvas'
| '/api/chat-messages' | '/api/chat-messages'
@@ -764,6 +804,7 @@ export interface FileRouteTypes {
| '/archive' | '/archive'
| '/auth' | '/auth'
| '/blocks' | '/blocks'
| '/bookmarks'
| '/chat' | '/chat'
| '/glide' | '/glide'
| '/login' | '/login'
@@ -776,6 +817,9 @@ export interface FileRouteTypes {
| '/api/api-keys' | '/api/api-keys'
| '/api/archives' | '/api/archives'
| '/api/bookmarks' | '/api/bookmarks'
| '/api/bookmarks-add'
| '/api/bookmarks-delete'
| '/api/bookmarks-list'
| '/api/browser-sessions' | '/api/browser-sessions'
| '/api/canvas' | '/api/canvas'
| '/api/chat-messages' | '/api/chat-messages'
@@ -838,6 +882,7 @@ export interface FileRouteTypes {
| '/archive' | '/archive'
| '/auth' | '/auth'
| '/blocks' | '/blocks'
| '/bookmarks'
| '/canvas' | '/canvas'
| '/chat' | '/chat'
| '/glide' | '/glide'
@@ -851,6 +896,9 @@ export interface FileRouteTypes {
| '/api/api-keys' | '/api/api-keys'
| '/api/archives' | '/api/archives'
| '/api/bookmarks' | '/api/bookmarks'
| '/api/bookmarks-add'
| '/api/bookmarks-delete'
| '/api/bookmarks-list'
| '/api/browser-sessions' | '/api/browser-sessions'
| '/api/canvas' | '/api/canvas'
| '/api/chat-messages' | '/api/chat-messages'
@@ -914,6 +962,7 @@ export interface RootRouteChildren {
ArchiveRoute: typeof ArchiveRouteWithChildren ArchiveRoute: typeof ArchiveRouteWithChildren
AuthRoute: typeof AuthRoute AuthRoute: typeof AuthRoute
BlocksRoute: typeof BlocksRoute BlocksRoute: typeof BlocksRoute
BookmarksRoute: typeof BookmarksRoute
CanvasRoute: typeof CanvasRouteWithChildren CanvasRoute: typeof CanvasRouteWithChildren
ChatRoute: typeof ChatRoute ChatRoute: typeof ChatRoute
GlideRoute: typeof GlideRoute GlideRoute: typeof GlideRoute
@@ -927,6 +976,9 @@ export interface RootRouteChildren {
ApiApiKeysRoute: typeof ApiApiKeysRoute ApiApiKeysRoute: typeof ApiApiKeysRoute
ApiArchivesRoute: typeof ApiArchivesRouteWithChildren ApiArchivesRoute: typeof ApiArchivesRouteWithChildren
ApiBookmarksRoute: typeof ApiBookmarksRoute ApiBookmarksRoute: typeof ApiBookmarksRoute
ApiBookmarksAddRoute: typeof ApiBookmarksAddRoute
ApiBookmarksDeleteRoute: typeof ApiBookmarksDeleteRoute
ApiBookmarksListRoute: typeof ApiBookmarksListRoute
ApiBrowserSessionsRoute: typeof ApiBrowserSessionsRouteWithChildren ApiBrowserSessionsRoute: typeof ApiBrowserSessionsRouteWithChildren
ApiCanvasRoute: typeof ApiCanvasRouteWithChildren ApiCanvasRoute: typeof ApiCanvasRouteWithChildren
ApiChatMessagesRoute: typeof ApiChatMessagesRoute ApiChatMessagesRoute: typeof ApiChatMessagesRoute
@@ -1040,6 +1092,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CanvasRouteImport preLoaderRoute: typeof CanvasRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/bookmarks': {
id: '/bookmarks'
path: '/bookmarks'
fullPath: '/bookmarks'
preLoaderRoute: typeof BookmarksRouteImport
parentRoute: typeof rootRouteImport
}
'/blocks': { '/blocks': {
id: '/blocks' id: '/blocks'
path: '/blocks' path: '/blocks'
@@ -1215,6 +1274,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiBrowserSessionsRouteImport preLoaderRoute: typeof ApiBrowserSessionsRouteImport
parentRoute: typeof rootRouteImport 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': { '/api/bookmarks': {
id: '/api/bookmarks' id: '/api/bookmarks'
path: '/api/bookmarks' path: '/api/bookmarks'
@@ -1639,6 +1719,7 @@ const rootRouteChildren: RootRouteChildren = {
ArchiveRoute: ArchiveRouteWithChildren, ArchiveRoute: ArchiveRouteWithChildren,
AuthRoute: AuthRoute, AuthRoute: AuthRoute,
BlocksRoute: BlocksRoute, BlocksRoute: BlocksRoute,
BookmarksRoute: BookmarksRoute,
CanvasRoute: CanvasRouteWithChildren, CanvasRoute: CanvasRouteWithChildren,
ChatRoute: ChatRoute, ChatRoute: ChatRoute,
GlideRoute: GlideRoute, GlideRoute: GlideRoute,
@@ -1652,6 +1733,9 @@ const rootRouteChildren: RootRouteChildren = {
ApiApiKeysRoute: ApiApiKeysRoute, ApiApiKeysRoute: ApiApiKeysRoute,
ApiArchivesRoute: ApiArchivesRouteWithChildren, ApiArchivesRoute: ApiArchivesRouteWithChildren,
ApiBookmarksRoute: ApiBookmarksRoute, ApiBookmarksRoute: ApiBookmarksRoute,
ApiBookmarksAddRoute: ApiBookmarksAddRoute,
ApiBookmarksDeleteRoute: ApiBookmarksDeleteRoute,
ApiBookmarksListRoute: ApiBookmarksListRoute,
ApiBrowserSessionsRoute: ApiBrowserSessionsRouteWithChildren, ApiBrowserSessionsRoute: ApiBrowserSessionsRouteWithChildren,
ApiCanvasRoute: ApiCanvasRouteWithChildren, ApiCanvasRoute: ApiCanvasRouteWithChildren,
ApiChatMessagesRoute: ApiChatMessagesRoute, ApiChatMessagesRoute: ApiChatMessagesRoute,

View File

@@ -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)
}
},
},
},
})

View File

@@ -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)
}
},
},
},
})

View File

@@ -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)
}
},
},
},
})

View File

@@ -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<BookmarkData[]>([])
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<string | null>(null)
const [duplicateUrl, setDuplicateUrl] = useState<string | null>(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 <div className="min-h-screen bg-black" />
}
if (!session?.user) {
return (
<div className="min-h-screen bg-black text-white grid place-items-center">
<div className="text-center space-y-4">
<p className="text-slate-400">Please sign in to view bookmarks</p>
<a
href="/login"
className="inline-block px-4 py-2 rounded-lg text-sm font-semibold text-white bg-teal-600 hover:bg-teal-500 transition-colors"
>
Sign in
</a>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-black text-white">
<div className="max-w-2xl mx-auto px-4 py-10">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<Bookmark className="w-6 h-6 text-teal-400" />
<h1 className="text-2xl font-semibold">Bookmarks</h1>
</div>
<button
type="button"
onClick={() => setIsAdding(true)}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-teal-600 hover:bg-teal-500 transition-colors"
>
<Plus className="w-4 h-4" />
Add Bookmark
</button>
</div>
{isAdding && (
<form
onSubmit={handleAddBookmark}
className="mb-6 p-4 bg-[#0c0f18] border border-white/10 rounded-xl space-y-4"
>
<div className="space-y-2">
<label className="text-sm text-white/70">URL</label>
<input
type="url"
required
value={newUrl}
onChange={(e) => 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 && (
<div className="flex items-center gap-2 text-amber-400 text-sm">
<AlertCircle className="w-4 h-4" />
<span>Already bookmarked: {duplicateUrl}</span>
</div>
)}
</div>
<div className="space-y-2">
<label className="text-sm text-white/70">Title (optional)</label>
<input
type="text"
value={newTitle}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
<label className="text-sm text-white/70">Tags (optional)</label>
<input
type="text"
value={newTags}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-sm text-rose-400">{error}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={submitting || !!duplicateUrl}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-teal-600 hover:bg-teal-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{submitting ? "Adding..." : "Add Bookmark"}
</button>
<button
type="button"
onClick={() => {
setIsAdding(false)
setNewUrl("")
setNewTitle("")
setNewTags("")
setError(null)
}}
className="px-4 py-2 rounded-lg text-sm font-medium text-white/70 hover:text-white transition-colors"
>
Cancel
</button>
</div>
</form>
)}
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-16 bg-white/5 rounded-xl animate-pulse"
/>
))}
</div>
) : bookmarks.length === 0 ? (
<div className="text-center py-12 text-white/50">
<Bookmark className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No bookmarks yet</p>
<p className="text-sm mt-1">Click "Add Bookmark" to save your first one</p>
</div>
) : (
<div className="space-y-3">
{bookmarks.map((bookmark) => (
<div
key={bookmark.id}
className="p-4 bg-[#0c0f18] border border-white/10 rounded-xl flex items-start justify-between gap-4"
>
<div className="flex-1 min-w-0">
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="text-white hover:text-teal-400 font-medium flex items-center gap-2 group"
>
<span className="truncate">
{bookmark.title || bookmark.url}
</span>
<ExternalLink className="w-3.5 h-3.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
</a>
{bookmark.title && (
<p className="text-sm text-white/50 truncate mt-0.5">
{bookmark.url}
</p>
)}
{bookmark.tags && (
<div className="flex flex-wrap gap-1.5 mt-2">
{bookmark.tags.split(",").map((tag, i) => (
<span
key={i}
className="px-2 py-0.5 text-xs bg-white/5 rounded-full text-white/60"
>
{tag.trim()}
</span>
))}
</div>
)}
</div>
<button
type="button"
onClick={() => handleDelete(bookmark.id)}
className="p-2 text-white/40 hover:text-rose-400 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
</div>
)
}