mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 14:30:26 +01:00
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:
@@ -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,
|
||||
|
||||
101
packages/web/src/routes/api/bookmarks-add.ts
Normal file
101
packages/web/src/routes/api/bookmarks-add.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
56
packages/web/src/routes/api/bookmarks-delete.ts
Normal file
56
packages/web/src/routes/api/bookmarks-delete.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
40
packages/web/src/routes/api/bookmarks-list.ts
Normal file
40
packages/web/src/routes/api/bookmarks-list.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
310
packages/web/src/routes/bookmarks.tsx
Normal file
310
packages/web/src/routes/bookmarks.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user