From b0d458e5ca2996a7950886cac1a3a1fa2ad108cc Mon Sep 17 00:00:00 2001 From: Kenneth Date: Wed, 21 May 2025 13:18:16 +0100 Subject: [PATCH] implement bookmark tagging --- bun.lock | 9 + package.json | 2 +- packages/core/src/bookmark.ts | 12 +- packages/server/src/auth/auth-token.ts | 2 +- packages/server/src/bookmark/bookmark.ts | 192 +++++++---- .../server/src/bookmark/demo-bookmarks.ts | 110 +++--- packages/server/src/bookmark/handlers.ts | 132 ++++++-- packages/server/src/bookmark/tag.ts | 29 ++ packages/server/src/database.ts | 12 +- packages/server/src/server.ts | 17 +- packages/web/package.json | 1 + packages/web/src/api.ts | 12 +- packages/web/src/app/bookmarks.tsx | 120 +------ .../web/src/app/bookmarks/$bookmarkId.tsx | 60 ++-- .../web/src/app/bookmarks/-bookmark-list.tsx | 87 +++-- .../-dialogs/add-bookmark-dialog.tsx | 319 ++++++++++++++++++ packages/web/src/app/bookmarks/-store.tsx | 10 +- packages/web/src/bookmark/api.ts | 29 +- packages/web/src/components/form-field.tsx | 24 +- packages/web/src/components/with-query.tsx | 9 + 20 files changed, 826 insertions(+), 362 deletions(-) create mode 100644 packages/server/src/bookmark/tag.ts create mode 100644 packages/web/src/app/bookmarks/-dialogs/add-bookmark-dialog.tsx create mode 100644 packages/web/src/components/with-query.tsx diff --git a/bun.lock b/bun.lock index 5b30ab7..553b425 100644 --- a/bun.lock +++ b/bun.lock @@ -38,6 +38,7 @@ "name": "@markone/web", "version": "0.0.0", "dependencies": { + "@floating-ui/react-dom": "^2.1.2", "@markone/core": "workspace:*", "@tailwindcss/vite": "^4.1.5", "@tanstack/react-query": "^5.75.2", @@ -348,6 +349,14 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg=="], + "@floating-ui/core": ["@floating-ui/core@1.7.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.2", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], diff --git a/package.json b/package.json index 4e9f48b..cb7d037 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "markone", "private": true, "scripts": { - "dev": "bun --filter '*' dev" + "dev": "bun --filter '*' --elide-lines 100 dev" }, "workspaces": ["packages/*"], "devDependencies": { diff --git a/packages/core/src/bookmark.ts b/packages/core/src/bookmark.ts index acf1413..a9f05b5 100644 --- a/packages/core/src/bookmark.ts +++ b/packages/core/src/bookmark.ts @@ -1,25 +1,17 @@ type BookmarkKind = "link" | "placeholder" -interface LinkBookmark { - kind: "link" +interface Bookmark { id: string title: string url: string tags: BookmarkTag[] } -interface PlaceholderBookmark { - id: string - kind: "placeholder" - tags: BookmarkTag[] -} - interface BookmarkTag { id: string name: string } -type Bookmark = LinkBookmark | PlaceholderBookmark type BookmarkId = Bookmark["id"] -export type { Bookmark, BookmarkId, BookmarkKind, LinkBookmark, BookmarkTag } +export type { Bookmark, BookmarkId, BookmarkKind, BookmarkTag } diff --git a/packages/server/src/auth/auth-token.ts b/packages/server/src/auth/auth-token.ts index 7e02e30..6df281c 100644 --- a/packages/server/src/auth/auth-token.ts +++ b/packages/server/src/auth/auth-token.ts @@ -2,5 +2,5 @@ import { db } from "~/database.ts" function deleteAuthTokenById(id: string) { const query = db.query("DELETE FROM auth_tokens WHERE id = $id") - query.run({ id }) + query.run({ k}) } diff --git a/packages/server/src/bookmark/bookmark.ts b/packages/server/src/bookmark/bookmark.ts index 342e7d9..8621f02 100644 --- a/packages/server/src/bookmark/bookmark.ts +++ b/packages/server/src/bookmark/bookmark.ts @@ -1,107 +1,171 @@ +import type { Bookmark, BookmarkTag } from "@markone/core/bookmark" import type { User } from "@markone/core/user" import { Readability } from "@mozilla/readability" import { JSDOM } from "jsdom" import { db } from "~/database.ts" import { DEMO_BOOKMARKS } from "./demo-bookmarks.ts" -import type { Bookmark, BookmarkTag } from "@markone/core/bookmark" class LinkUnreachable {} class UnsupportedLink {} -interface CachedPage { +interface CachedContent { title: string - readableHtml: string + mimeType: string + data: Buffer | null } function insertDemoBookmarks(user: User) { const query = db.query(` -INSERT OR IGNORE INTO bookmarks (id, user_id, kind, title, url) -VALUES ($id, $userId, $kind, $title, $url) +INSERT OR IGNORE INTO bookmarks (id, user_id, title, url, mime_type, content) +VALUES ${Array(DEMO_BOOKMARKS.length).fill("(?,?,?,?,NULL,NULL)").join(",")} `) - const insert = db.transaction((bookmarks) => { - for (const bookmark of bookmarks) { - query.run({ - id: bookmark.id, - userId: user.id, - kind: bookmark.kind, - title: bookmark.title, - url: bookmark.url, - }) - } - }) - insert(DEMO_BOOKMARKS) + + const args: Parameters = [] + for (const bookmark of DEMO_BOOKMARKS) { + args.push(bookmark.id, user.id, bookmark.title, bookmark.url) + } + + query.run(...args) } -function findBookmarkHtml(id: string, user: User): string | null { - const query = db.query("SELECT content_html FROM bookmarks WHERE id = $id AND user_id = $userId") +function insertBookmark(bookmark: Bookmark, cachedContent: CachedContent | null, user: User) { + const query = db.query(` +INSERT INTO bookmarks (id, user_id, title, url, mime_type, content) +VALUES ($id, $userId, $title, $url, $mimeType, $content) +`) + query.run({ + id: bookmark.id, + userId: user.id, + title: bookmark.title, + url: bookmark.url, + mimeType: cachedContent?.mimeType || null, + content: cachedContent?.data ?? null, + }) +} + +function findBookmarkCachedContent(id: string, user: User): Buffer | null { + const query = db.query<{ content: Buffer | null }, { id: string; userId: string }>( + "SELECT content FROM bookmarks WHERE id = $id AND user_id = $userId", + ) const row = query.get({ id, userId: user.id }) - if (!row) { - return null - } - const { content_html } = row as { content_html: string } - return content_html + return row?.content ?? null } function findBookmark(id: string, user: User): Bookmark | null { const bookmarkQuery = db.query( - "SELECT id, kind, title, url FROM bookmarks WHERE id = $id AND user_id = $userId", + "SELECT id, title, url FROM bookmarks WHERE id = $id AND user_id = $userId", ) const bookmark = bookmarkQuery.get({ id, userId: user.id }) if (!bookmark) { return null } - const tagsQuery = db.query("SELECT id, name FROM tags WHERE bookmark_id = $id") - const tags = tagsQuery.all({ id }) + const tagsQuery = db.query(` + SELECT tags.id, tags.name FROM tags + INNER JOIN bookmark_tags + ON bookmark_tags.tag_id = tags.id AND bookmark_tags.bookmark_id = $bookmarkId + `) + const tags = tagsQuery.all({ bookmarkId: id }) bookmark.tags = tags return bookmark } -async function cacheWebsite(url: string): Promise { - const websiteText = await fetch(url) - .catch(() => { - throw new LinkUnreachable() - }) - .then((res) => res.text()) - .catch(() => { +async function cacheContent(url: string): Promise { + const res = await fetch(url).catch(() => { + throw new LinkUnreachable() + }) + + let contentType = res.headers.get("Content-Type") + if (!contentType) { + contentType = "application/octet-stream" + } + + if (contentType.startsWith("text/html")) { + const matches = contentType.match(/charset=([^; ]+)/gi) + let charset: string + if (!matches || matches.length <= 2) { + charset = "utf-8" + } else { + charset = matches[1] || "utf-8" + } + + const websiteText = await res.text().catch(() => { throw new UnsupportedLink() }) - const dom = new JSDOM(websiteText, { url }) - const reader = new Readability(dom.window.document) - const article = reader.parse() + const dom = new JSDOM(websiteText, { url }) + const reader = new Readability(dom.window.document) + const article = reader.parse() - if (!article) { - return null + if (!article) { + return null + } + + if (article.content) { + const newDom = new JSDOM(article.content, { url }) + const doc = newDom.window.document + + const lightStyleLink = doc.createElement("link") + lightStyleLink.rel = "stylesheet" + lightStyleLink.href = "/reader-styles/sakura.css" + lightStyleLink.media = "screen" + + const darkStyleLink = doc.createElement("link") + darkStyleLink.rel = "stylesheet" + darkStyleLink.href = "/reader-styles/sakura-dark.css" + darkStyleLink.media = "screen and (prefers-color-scheme: dark)" + + doc.head.appendChild(lightStyleLink) + doc.head.appendChild(darkStyleLink) + + article.content = newDom.serialize() + } + + return { + title: article.title || "Untitled", + mimeType: "text/html", + data: article.content ? Buffer.from(article.content, charset as BufferEncoding) : null, + } } - if (article.content) { - const newDom = new JSDOM(article.content, { url }) - const doc = newDom.window.document - - const lightStyleLink = doc.createElement("link") - lightStyleLink.rel = "stylesheet" - lightStyleLink.href = "/reader-styles/sakura.css" - lightStyleLink.media = "screen" - - const darkStyleLink = doc.createElement("link") - darkStyleLink.rel = "stylesheet" - darkStyleLink.href = "/reader-styles/sakura-dark.css" - darkStyleLink.media = "screen and (prefers-color-scheme: dark)" - - doc.head.appendChild(lightStyleLink) - doc.head.appendChild(darkStyleLink) - - article.content = newDom.serialize() - } - - return { - title: article.title || "Untitled", - readableHtml: article.content || "", - } + throw new UnsupportedLink() } -export { insertDemoBookmarks, findBookmark, findBookmarkHtml, cacheWebsite } +function assignTagsToBookmark(tags: BookmarkTag[], bookmark: Bookmark) { + const query = db.query(` + INSERT INTO bookmark_tags (tag_id, bookmark_id) + VALUES ${Array(tags.length).fill("(?,?)").join(",")} + `) + + const args: Parameters = [] + for (const tag of tags) { + args.push(tag.id) + args.push(bookmark.id) + } + + query.run(...args) +} + +function findBookmarkTags(bookmark: Bookmark): BookmarkTag[] { + const query = db.query(` + SELECT tags.name as name, tags.id as id FROM bookmark_tags + INNER JOIN tags + ON tags.id = bookmark_tags.tag_id + WHERE bookmark_tags.bookmark_id = $bookmarkId + `) + const tags = query.all({ bookmarkId: bookmark.id }) + return tags +} + +export { + insertDemoBookmarks, + insertBookmark, + findBookmark, + findBookmarkCachedContent, + cacheContent, + assignTagsToBookmark, + findBookmarkTags, +} export { LinkUnreachable, UnsupportedLink } diff --git a/packages/server/src/bookmark/demo-bookmarks.ts b/packages/server/src/bookmark/demo-bookmarks.ts index 5eb7f4c..c099635 100644 --- a/packages/server/src/bookmark/demo-bookmarks.ts +++ b/packages/server/src/bookmark/demo-bookmarks.ts @@ -2,64 +2,94 @@ import type { Bookmark } from "@markone/core/bookmark" const DEMO_BOOKMARKS: Bookmark[] = [ { - kind: "link", - id: "01JTKBKVMWRRDR20PY9X4HGPVY", - title: "ULID Specification", - url: "https://github.com/ulid/spec", + id: "01HYN4G66K0000000000000000", + title: "Google", + url: "https://www.google.com", + tags: [ + { id: "01HYN4G66K0000000000000001", name: "search" }, + { id: "01HYN4G66K0000000000000002", name: "tech" }, + ], }, { - kind: "link", - id: "01JTKBKVMW8392A9361PFGZEGM", - title: "Another Example Site", - url: "https://www.example.net", + id: "01HYN4G66K0000000000000003", + title: "MDN Web Docs", + url: "https://developer.mozilla.org", + tags: [ + { id: "01HYN4G66K0000000000000004", name: "documentation" }, + { id: "01HYN4G66K0000000000000005", name: "web development" }, + ], }, { - kind: "link", - id: "01JTKBKVMW3YKY4CMANEK7502N", - title: "Documentation Hub", - url: "https://docs.example.com", + id: "01HYN4G66K0000000000000006", + title: "GitHub", + url: "https://github.com", + tags: [ + { id: "01HYN4G66K0000000000000007", name: "code" }, + { id: "01HYN4G66K0000000000000008", name: "version control" }, + ], }, { - kind: "link", - id: "01JTKBKVMWHH7QG0CFQ6Q40E2G", - title: "API Reference", - url: "https://api.example.com/docs", + id: "01HYN4G66K0000000000000009", + title: "Stack Overflow", + url: "https://stackoverflow.com", + tags: [ + { id: "01HYN4G66K0000000000000010", name: "q&a" }, + { id: "01HYN4G66K0000000000000011", name: "programming" }, + ], }, { - kind: "link", - id: "01JTKBKVMWVR4T3KXXB3PHZD60", - title: "Blog Posts", - url: "https://blog.example.com", + id: "01HYN4G66K0000000000000012", + title: "TypeScript Handbook", + url: "https://www.typescriptlang.org/docs/handbook/intro.html", + tags: [ + { id: "01HYN4G66K0000000000000013", name: "typescript" }, + { id: "01HYN4G66K0000000000000014", name: "documentation" }, + ], }, { - kind: "link", - id: "01JTKBKVMWAP0GGKWBN7Z3CYAM", - title: "Support Forum", - url: "https://forum.example.com", + id: "01HYN4G66K0000000000000015", + title: "Reddit", + url: "https://www.reddit.com", + tags: [ + { id: "01HYN4G66K0000000000000016", name: "social" }, + { id: "01HYN4G66K0000000000000017", name: "news" }, + ], }, { - kind: "link", - id: "01JTKBKVMW1YJB63F2YCMZNGFE", - title: "Tutorials", - url: "https://tutorials.example.com", + id: "01HYN4G66K0000000000000018", + title: "YouTube", + url: "https://www.youtube.com", + tags: [ + { id: "01HYN4G66K0000000000000019", name: "video" }, + { id: "01HYN4G66K0000000000000020", name: "entertainment" }, + ], }, { - kind: "link", - id: "01JTKBKVMW93QYS2EK983RQS40", - title: "Resource Library", - url: "https://resources.example.com", + id: "01HYN4G66K0000000000000021", + title: "Figma", + url: "https://www.figma.com", + tags: [ + { id: "01HYN4G66K0000000000000022", name: "design" }, + { id: "01HYN4G66K0000000000000023", name: "ui/ux" }, + ], }, { - kind: "link", - id: "01JTKBKVMW6NG61138T8SCRFYT", - title: "Community Page", - url: "https://community.example.com", + id: "01HYN4G66K0000000000000024", + title: "The New York Times", + url: "https://www.nytimes.com", + tags: [ + { id: "01HYN4G66K0000000000000025", name: "news" }, + { id: "01HYN4G66K0000000000000026", name: "current events" }, + ], }, { - kind: "link", - id: "01JTKBKVMWDBKSXYHGFXC0HEZB", - title: "Project Repository", - url: "https://github.com/example/project", + id: "01HYN4G66K0000000000000027", + title: "ChatGPT", + url: "https://chat.openai.com", + tags: [ + { id: "01HYN4G66K0000000000000028", name: "ai" }, + { id: "01HYN4G66K0000000000000029", name: "chatbot" }, + ], }, ] diff --git a/packages/server/src/bookmark/handlers.ts b/packages/server/src/bookmark/handlers.ts index 5c50c74..b95f675 100644 --- a/packages/server/src/bookmark/handlers.ts +++ b/packages/server/src/bookmark/handlers.ts @@ -1,11 +1,21 @@ +import type { Bookmark, BookmarkTag } from "@markone/core/bookmark" import { DEMO_USER } from "@markone/core/user" -import type { LinkBookmark, BookmarkTag } from "@markone/core/bookmark" import { type } from "arktype" import { ulid } from "ulid" import { db } from "~/database.ts" import { HttpError } from "~/error.ts" import type { User } from "~/user/user.ts" -import { LinkUnreachable, UnsupportedLink, findBookmarkHtml, cacheWebsite, findBookmark } from "./bookmark.ts" +import { + LinkUnreachable, + UnsupportedLink, + assignTagsToBookmark, + cacheContent, + findBookmark, + findBookmarkCachedContent, + findBookmarkTags, + insertBookmark, +} from "./bookmark.ts" +import { insertTags } from "./tag.ts" const BOOKMARK_PAGINATION_LIMIT = 100 @@ -16,12 +26,15 @@ const ListUserBookmarksParams = type({ const AddBookmarkRequestBody = type({ "title?": "string", - kind: "string", url: "string", tags: "string[]", "force?": "boolean", }) +const AddTagRequestBody = type({ + name: "string", +}) + async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user: User) { const queryParams = ListUserBookmarksParams(request.params) if (queryParams instanceof type.errors) { @@ -30,7 +43,7 @@ async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user const listBookmarksQuery = db.query( ` -SELECT bookmarks.id, bookmarks.kind, bookmarks.title, bookmarks.url FROM bookmarks +SELECT bookmarks.id, bookmarks.title, bookmarks.url FROM bookmarks WHERE bookmarks.user_id = $userId ORDER BY bookmarks.id DESC LIMIT $limit OFFSET $skip @@ -61,10 +74,11 @@ async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User if (user.id !== DEMO_USER.id) { const body = AddBookmarkRequestBody(await request.json()) if (body instanceof type.errors) { + console.log(body) throw new HttpError(400) } - const cachedWebsite = await cacheWebsite(body.url).catch((error) => { + const cachedContent = await cacheContent(body.url).catch((error) => { if (error instanceof LinkUnreachable) { if (body.force) { return null @@ -78,48 +92,45 @@ async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User throw new HttpError(500) }) - const bookmark: LinkBookmark = { - kind: "link", + const tagNames = new Set(body.tags) + for (const tag of tagNames) { + if (/[\s#]/g.test(tag)) { + throw new HttpError(400, "InvalidTag", "Tags cannot contain '#' or whitespaces") + } + } + + const bookmark: Bookmark = { id: ulid(), title: "", url: body.url, - tags: body.tags.map((tag) => ({ id: ulid(), name: tag })), + tags: [], } if (body.title) { bookmark.title = body.title - } else if (cachedWebsite?.title) { - bookmark.title = cachedWebsite.title + } else if (cachedContent?.title) { + bookmark.title = cachedContent.title } - const query = db.query(` -INSERT INTO bookmarks (id, user_id, kind, title, url, content_html) -VALUES ($id, $userId, $kind, $title, $url, $html) -`) - query.run({ - id: bookmark.id, - userId: user.id, - kind: bookmark.kind, - title: bookmark.title, - url: bookmark.url, - html: cachedWebsite?.readableHtml ?? "", - }) + insertBookmark(bookmark, cachedContent, user) + + if (tagNames.size > 0) { + const tagQuery = db.query, string[]>(` + SELECT id, name FROM tags + WHERE user_id = ? AND name IN (${Array(tagNames.size).fill("?").join(",")}) + `) + const tags = tagQuery.all(user.id, ...tagNames) - const insertTagQuery = db.query(` -INSERT INTO tags(id, bookmark_id, name) -VALUES ($id, $bookmarkId, $name) -`) - const insertTags = db.transaction((tags: BookmarkTag[]) => { for (const tag of tags) { - insertTagQuery.run({ - bookmarkId: bookmark.id, - id: tag.id, - name: tag.name, - }) + if (tag.id && tag.name) { + bookmark.tags.push(tag as BookmarkTag) + tagNames.delete(tag.name) + } } - }) - insertTags(bookmark.tags) + const createdTags = insertTags([...tagNames], user) + assignTagsToBookmark(createdTags, bookmark) + } return Response.json(bookmark, { status: 200 }) } @@ -130,7 +141,7 @@ VALUES ($id, $bookmarkId, $name) async function fetchBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) { switch (request.headers.get("Accept")) { case "text/html": { - const html = findBookmarkHtml(request.params.id, user) + const html = findBookmarkCachedContent(request.params.id, user) if (html === null) { throw new HttpError(404) } @@ -155,4 +166,53 @@ async function fetchBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user } } -export { addBookmark, fetchBookmark, listUserBookmarks, deleteUserBookmark } +async function listUserTags(request: Bun.BunRequest<"/api/tags">, user: User) { + const query = db.query("SELECT id, name FROM tags WHERE user_id = $id") + const tags = query.all({ id: user.id }) + return Response.json(tags, { status: 200 }) +} + +async function createUserTag(request: Bun.BunRequest<"/api/tags">, user: User) { + if (user.id !== DEMO_USER.id) { + const query = db.query( + "INSERT INTO tags (id, name, user_id) VALUES ($id, $name, $userId)", + ) + const json = await request.json().catch(() => { + throw new HttpError(400) + }) + const body = AddTagRequestBody(json) + if (body instanceof type.errors) { + throw new HttpError(400) + } + + const tag: BookmarkTag = { + id: ulid(), + name: body.name, + } + + query.run({ id: tag.id, name: tag.name, userId: user.id }) + + return Response.json(tag, { status: 200 }) + } + + return Response.json(undefined, { status: 204 }) +} + +async function listBookmarkTags(request: Bun.BunRequest<"/api/bookmarks/:id/tags">, user: User) { + const bookmark = findBookmark(request.params.id, user) + if (!bookmark) { + throw new HttpError(404) + } + const tags = findBookmarkTags(bookmark) + return Response.json(tags, { status: 200 }) +} + +export { + addBookmark, + fetchBookmark, + listUserBookmarks, + deleteUserBookmark, + listUserTags, + createUserTag, + listBookmarkTags, +} diff --git a/packages/server/src/bookmark/tag.ts b/packages/server/src/bookmark/tag.ts new file mode 100644 index 0000000..dce22aa --- /dev/null +++ b/packages/server/src/bookmark/tag.ts @@ -0,0 +1,29 @@ +import type { BookmarkTag } from "@markone/core/bookmark" +import type { User } from "@markone/core/user" +import { ulid } from "ulid" +import { db } from "~/database.ts" + +function insertTags(names: string[], user: User): BookmarkTag[] { + console.log(names) + const insertTags = db.query(` + INSERT INTO tags (id, name, user_id) + VALUES ${Array(names.length).fill("(?,?,?)").join(",")} + `) + + const args: Parameters = [] + const tags: BookmarkTag[] = [] + for (const name of names) { + const tag: BookmarkTag = { + id: ulid(), + name, + } + args.push(tag.id, tag.name, user.id) + tags.push(tag) + } + + insertTags.run(...args) + + return tags +} + +export { insertTags } diff --git a/packages/server/src/database.ts b/packages/server/src/database.ts index 76693af..00be5eb 100644 --- a/packages/server/src/database.ts +++ b/packages/server/src/database.ts @@ -23,16 +23,22 @@ CREATE TABLE IF NOT EXISTS users( CREATE TABLE IF NOT EXISTS bookmarks( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, - kind TEXT NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, - content_html TEXT NOT NULL + mime_type TEXT, + content BLOB ); CREATE TABLE IF NOT EXISTS tags( id TEXT PRIMARY KEY, name TEXT NOT NULL, - bookmark_id TEXT NOT NULL + user_id TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS bookmark_tags( + tag_id TEXT NOT NULL, + bookmark_id TEXT NOT NULL, + PRIMARY KEY (tag_id, bookmark_id) ); CREATE TABLE IF NOT EXISTS sessions( diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index f3726e2..f3af9fc 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,7 +1,15 @@ import { authenticated, login, logout, signUp } from "./auth/auth.ts" import { startBackgroundSessionCleanup } from "./auth/session.ts" import { insertDemoBookmarks } from "./bookmark/bookmark.ts" -import { addBookmark, listUserBookmarks, deleteUserBookmark, fetchBookmark } from "./bookmark/handlers.ts" +import { + addBookmark, + listUserBookmarks, + deleteUserBookmark, + fetchBookmark, + listUserTags, + createUserTag, + listBookmarkTags, +} from "./bookmark/handlers.ts" import { migrateDatabase } from "./database.ts" import { httpHandler, preflightHandler } from "./http-handler.ts" import { createDemoUser } from "./user/user.ts" @@ -35,6 +43,13 @@ async function main() { allowedHeaders: ["Accept"], }), }, + "/api/bookmarks/:id/tags": { + GET: authenticated(listBookmarkTags), + }, + "/api/tags": { + GET: authenticated(listUserTags), + POST: authenticated(createUserTag), + }, }, port: 8080, diff --git a/packages/web/package.json b/packages/web/package.json index c9f47cc..4388717 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@floating-ui/react-dom": "^2.1.2", "@markone/core": "workspace:*", "@tailwindcss/vite": "^4.1.5", "@tanstack/react-query": "^5.75.2", diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts index 9639dc8..788ba5a 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -18,13 +18,16 @@ class BadRequestError extends Error { } class InternalError extends Error {} class UnauthenticatedError extends Error {} +class NotFoundError extends Error {} interface ErrorBody { code: string message?: string } -type QueryKey = ["bookmarks", ...ReadonlyArray] | ["bookmarks", string, ...ReadonlyArray] +type QueryKey = + | ["bookmarks" | "tags", ...ReadonlyArray] + | ["bookmarks" | "tags", string, ...ReadonlyArray] async function fetchApi(route: string, init?: RequestInit): Promise { const response = await fetch(`${import.meta.env.VITE_API_URL}/api${route}`, { @@ -42,6 +45,8 @@ async function fetchApi(route: string, init?: RequestInit): Promise { case 401: { throw new UnauthenticatedError() } + case 404: + throw new NotFoundError() default: throw new InternalError() } @@ -50,8 +55,8 @@ async function fetchApi(route: string, init?: RequestInit): Promise { function useAuthenticatedQuery(queryKey: QueryKey, fn: () => Promise) { const query = useQuery({ queryKey, - queryFn: () => fn(), - retry: (_, error) => !(error instanceof UnauthenticatedError), + queryFn: fn, + retry: false, }) const navigate = useNavigate() @@ -76,6 +81,7 @@ export { BadRequestError, InternalError, UnauthenticatedError, + NotFoundError, fetchApi, useAuthenticatedQuery, mutationOptions, diff --git a/packages/web/src/app/bookmarks.tsx b/packages/web/src/app/bookmarks.tsx index fb72f2a..bea6bd4 100644 --- a/packages/web/src/app/bookmarks.tsx +++ b/packages/web/src/app/bookmarks.tsx @@ -1,14 +1,12 @@ import { Outlet, createFileRoute } from "@tanstack/react-router" -import { useState, useId, useRef, useEffect } from "react" -import { BadRequestError, ApiErrorCode } from "~/api" -import { useCreateBookmark, useDeleteBookmark } from "~/bookmark/api" +import { useEffect } from "react" +import { useDeleteBookmark } from "~/bookmark/api" import { Button } from "~/components/button" -import { Dialog, DialogTitle, DialogBody, DialogActionRow } from "~/components/dialog" -import { FormField } from "~/components/form-field" +import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog" import { LoadingSpinner } from "~/components/loading-spinner" -import { Message, MessageVariant } from "~/components/message" import { useMnemonics } from "~/hooks/use-mnemonics" -import { useBookmarkPageStore, ActiveDialog, LayoutMode } from "./bookmarks/-store" +import { AddBookmarkDialog } from "./bookmarks/-dialogs/add-bookmark-dialog" +import { ActiveDialog, LayoutMode, useBookmarkPageStore } from "./bookmarks/-store" export const Route = createFileRoute("/bookmarks")({ component: RouteComponent, @@ -56,116 +54,10 @@ function PageDialog() { } } -function AddBookmarkDialog() { - const [isWebsiteUnreachable, setIsWebsiteUnreachable] = useState(false) - const createBookmarkMutation = useCreateBookmark() - const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog) - const formId = useId() - const linkInputRef = useRef(null) - - useMnemonics( - { - c: () => { - if (linkInputRef.current !== document.activeElement) { - cancel() - } - }, - Escape: () => { - linkInputRef.current?.blur() - }, - }, - { ignore: () => false }, - ) - - useEffect(() => { - setTimeout(() => { - if (linkInputRef.current) { - linkInputRef.current.focus() - } - }, 0) - }, []) - - async function onSubmit(event: React.FormEvent) { - event.preventDefault() - - const formData = new FormData(event.currentTarget) - const url = formData.get("link") - if (url && typeof url === "string") { - try { - await createBookmarkMutation.mutateAsync({ url, force: isWebsiteUnreachable }) - setActiveDialog(ActiveDialog.None) - } catch (error) { - if (error instanceof BadRequestError && error.code === ApiErrorCode.WebsiteUnreachable) { - setIsWebsiteUnreachable(true) - } else { - setIsWebsiteUnreachable(false) - } - } - } - } - - function cancel() { - setActiveDialog(ActiveDialog.None) - } - - function message() { - if (createBookmarkMutation.isPending) { - return ( -

- Loading -

- ) - } - if (isWebsiteUnreachable) { - return ( - - The link does not seem to be reachable. Click "SAVE" to save anyways. - - ) - } - if (createBookmarkMutation.status === "error") { - return ( - - An error occurred when saving bookmark - - ) - } - return null - } - - return ( - - NEW BOOKMARK - - {message()} -
- - -
- - - - -
- ) -} - function DeleteBookmarkDialog() { // biome-ignore lint/style/noNonNullAssertion: this cannot be null when delete bookmark dialog is visible const bookmark = useBookmarkPageStore((state) => state.bookmarkToBeDeleted!) const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog) - const markBookmarkForDeletion = useBookmarkPageStore((state) => state.markBookmarkForDeletion) const deleteBookmarkMutation = useDeleteBookmark() useMnemonics( @@ -180,7 +72,6 @@ function DeleteBookmarkDialog() { try { await deleteBookmarkMutation.mutateAsync({ bookmark }) setActiveDialog(ActiveDialog.None) - markBookmarkForDeletion(null) } catch (error) { console.error(error) } @@ -188,7 +79,6 @@ function DeleteBookmarkDialog() { function cancel() { setActiveDialog(ActiveDialog.None) - markBookmarkForDeletion(null) } function body() { diff --git a/packages/web/src/app/bookmarks/$bookmarkId.tsx b/packages/web/src/app/bookmarks/$bookmarkId.tsx index 30e076c..7064703 100644 --- a/packages/web/src/app/bookmarks/$bookmarkId.tsx +++ b/packages/web/src/app/bookmarks/$bookmarkId.tsx @@ -1,16 +1,16 @@ +import type { Bookmark } from "@markone/core/bookmark" import { createFileRoute, useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router" -import { LayoutMode, useBookmarkPageStore } from "./-store" import clsx from "clsx" -import { fetchApi, useAuthenticatedQuery } from "~/api" -import { LoadingSpinner } from "~/components/loading-spinner" -import { BookmarkList } from "./-bookmark-list" -import { useCallback, useEffect, useRef } from "react" -import type { LinkBookmark } from "@markone/core/bookmark" -import { ActionBar, BookmarkListActionBar } from "./-action-bar" -import { Button, LinkButton } from "~/components/button" -import { useMnemonics } from "~/hooks/use-mnemonics" -import { useBookmark } from "~/bookmark/api" import { atom, useAtom } from "jotai" +import { useCallback, useEffect, useRef } from "react" +import { fetchApi, useAuthenticatedQuery } from "~/api" +import { useBookmark } from "~/bookmark/api" +import { Button, LinkButton } from "~/components/button" +import { LoadingSpinner } from "~/components/loading-spinner" +import { useMnemonics } from "~/hooks/use-mnemonics" +import { ActionBar, BookmarkListActionBar } from "./-action-bar" +import { BookmarkList } from "./-bookmark-list" +import { LayoutMode, useBookmarkPageStore } from "./-store" export const Route = createFileRoute("/bookmarks/$bookmarkId")({ component: RouteComponent, @@ -64,7 +64,7 @@ function BookmarkPreviewContainer({ children }: React.PropsWithChildren) { function BookmarkListSidebar() { return ( -
+

 >  @@ -86,7 +86,7 @@ function BookmarkListContainer() { const handleBookmarkListItemAction = useBookmarkPageStore((state) => state.handleBookmarkListItemAction) const onSelectedBookmarkChange = useCallback( - (bookmark: LinkBookmark) => { + (bookmark: Bookmark) => { navigate({ to: `/bookmarks/${bookmark.id}` }) }, [navigate], @@ -118,19 +118,29 @@ function BookmarkListContainer() { function BookmarkPreview() { const { bookmarkId } = Route.useParams() - const { data: previewHtml, status: previewQueryStatus } = useAuthenticatedQuery( - ["bookmarks", `${bookmarkId}.html`], - () => - fetchApi(`/bookmarks/${bookmarkId}`, { - headers: { - Accept: "text/html", - }, - }).then((res) => res.text()), + const { + data: previewHtml, + status: previewQueryStatus, + error, + } = useAuthenticatedQuery(["bookmarks", `${bookmarkId}.html`], () => + fetchApi(`/bookmarks/${bookmarkId}`, { + headers: { + Accept: "text/html", + }, + }).then((res) => res.text()), ) const { data: bookmark, status: bookmarkQueryStatus } = useBookmark(bookmarkId) const [_titleBarHeight] = useAtom(titleBarHeight) const [_actionBarHeight] = useAtom(actionBarHeight) + if (previewQueryStatus === "error") { + return ( +
+

Preview not available

+
+ ) + } + if (previewQueryStatus === "success" && bookmarkQueryStatus === "success") { return (