mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 20:00:23 +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 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,
|
||||||
|
|||||||
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