diff --git a/bun.lock b/bun.lock index 0f45870..5b30ab7 100644 --- a/bun.lock +++ b/bun.lock @@ -44,6 +44,7 @@ "@tanstack/react-router": "^1.119.0", "clsx": "^2.1.1", "dayjs": "^1.11.13", + "jotai": "^2.12.4", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^3.2.0", @@ -481,7 +482,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="], - "@types/bun": ["@types/bun@1.2.12", "", { "dependencies": { "bun-types": "1.2.12" } }, "sha512-lY/GQTXDGsolT/TiH72p1tuyUORuRrdV7VwOTOjDOt8uTBJQOJc5zz3ufwwDl0VBaoxotSk4LdP0hhjLJ6ypIQ=="], + "@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="], "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], @@ -569,7 +570,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.2.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-tvWMx5vPqbRXgE8WUZI94iS1xAYs8bkqESR9cxBB1Wi+urvfTrF1uzuDgBHFAdO0+d2lmsbG3HmeKMvUyj6pWA=="], + "bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], @@ -847,6 +848,8 @@ "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + "jotai": ["jotai@2.12.4", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-eFXLJol4oOLM8BS1+QV+XwaYQITG8n1tatBCFl4F5HE3zR5j2WIK8QpMt7VJIYmlogNUZfvB7wjwLoVk+umB9Q=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], @@ -1279,6 +1282,8 @@ "regjsparser/jsesc": ["jsesc@3.0.2", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g=="], + "server/@types/bun": ["@types/bun@1.2.12", "", { "dependencies": { "bun-types": "1.2.12" } }, "sha512-lY/GQTXDGsolT/TiH72p1tuyUORuRrdV7VwOTOjDOt8uTBJQOJc5zz3ufwwDl0VBaoxotSk4LdP0hhjLJ6ypIQ=="], + "sharp/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], "source-map/whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="], @@ -1295,6 +1300,8 @@ "prebuild-install/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "server/@types/bun/bun-types": ["bun-types@1.2.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-tvWMx5vPqbRXgE8WUZI94iS1xAYs8bkqESR9cxBB1Wi+urvfTrF1uzuDgBHFAdO0+d2lmsbG3HmeKMvUyj6pWA=="], + "source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], "source-map/whatwg-url/webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], diff --git a/packages/core/src/bookmark.ts b/packages/core/src/bookmark.ts index 25bc44b..acf1413 100644 --- a/packages/core/src/bookmark.ts +++ b/packages/core/src/bookmark.ts @@ -11,6 +11,7 @@ interface LinkBookmark { interface PlaceholderBookmark { id: string kind: "placeholder" + tags: BookmarkTag[] } interface BookmarkTag { diff --git a/packages/server/src/bookmark/bookmark.ts b/packages/server/src/bookmark/bookmark.ts index 43c9cd9..342e7d9 100644 --- a/packages/server/src/bookmark/bookmark.ts +++ b/packages/server/src/bookmark/bookmark.ts @@ -3,6 +3,7 @@ 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 {} @@ -41,6 +42,23 @@ function findBookmarkHtml(id: string, user: User): string | null { return content_html } +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", + ) + 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 }) + + bookmark.tags = tags + + return bookmark +} + async function cacheWebsite(url: string): Promise { const websiteText = await fetch(url) .catch(() => { @@ -85,5 +103,5 @@ async function cacheWebsite(url: string): Promise { } } -export { insertDemoBookmarks, findBookmarkHtml, cacheWebsite } +export { insertDemoBookmarks, findBookmark, findBookmarkHtml, cacheWebsite } export { LinkUnreachable, UnsupportedLink } diff --git a/packages/server/src/bookmark/handlers.ts b/packages/server/src/bookmark/handlers.ts index 8832917..5c50c74 100644 --- a/packages/server/src/bookmark/handlers.ts +++ b/packages/server/src/bookmark/handlers.ts @@ -5,7 +5,7 @@ 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 } from "./bookmark.ts" +import { LinkUnreachable, UnsupportedLink, findBookmarkHtml, cacheWebsite, findBookmark } from "./bookmark.ts" const BOOKMARK_PAGINATION_LIMIT = 100 @@ -46,7 +46,7 @@ LIMIT $limit OFFSET $skip return Response.json(results, { status: 200 }) } -async function deleteUserBookmark(request: Bun.BunRequest<"/api/bookmark/:id">, user: User) { +async function deleteUserBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) { if (user.id !== DEMO_USER.id) { const deleteBookmarkQuery = db.query("DELETE FROM bookmarks WHERE user_id = $userId AND id = $id") const tx = db.transaction(() => { @@ -127,7 +127,7 @@ VALUES ($id, $bookmarkId, $name) return Response.json(undefined, { status: 204 }) } -async function fetchBookmark(request: Bun.BunRequest<"/api/bookmark/:id">, user: User) { +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) @@ -141,6 +141,15 @@ async function fetchBookmark(request: Bun.BunRequest<"/api/bookmark/:id">, user: }, }) } + + case "application/json": { + const bookmark = findBookmark(request.params.id, user) + if (bookmark === null) { + throw new HttpError(404) + } + return Response.json(bookmark, { status: 200 }) + } + default: throw new HttpError(400, "UnsupportedAcceptHeader") } diff --git a/packages/server/src/http-handler.ts b/packages/server/src/http-handler.ts index 9e4c634..0f675bb 100644 --- a/packages/server/src/http-handler.ts +++ b/packages/server/src/http-handler.ts @@ -31,7 +31,10 @@ function httpHandler( } } -function preflightHandler({ allowedMethods }: { allowedMethods: HttpMethod[] }) { +function preflightHandler({ + allowedMethods, + allowedHeaders, +}: { allowedMethods: HttpMethod[]; allowedHeaders: string[] }) { return async (request: Bun.BunRequest) => new Response(undefined, { status: 200, @@ -39,6 +42,7 @@ function preflightHandler({ allowedMethods }: { allowedMet "Access-Control-Allow-Origin": ALLOWED_ORIGINS.join(", "), "Access-Control-Allow-Methods": allowedMethods.join(", "), "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": allowedHeaders.join(", "), }, }) } diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index a6c841d..f3726e2 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -27,11 +27,12 @@ async function main() { GET: authenticated(listUserBookmarks), POST: authenticated(addBookmark), }, - "/api/bookmark/:id": { + "/api/bookmarks/:id": { GET: authenticated(fetchBookmark), DELETE: authenticated(deleteUserBookmark), OPTIONS: preflightHandler({ allowedMethods: ["GET", "POST", "DELETE", "OPTIONS"], + allowedHeaders: ["Accept"], }), }, }, diff --git a/packages/web/package.json b/packages/web/package.json index 7a367bb..c9f47cc 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -15,6 +15,7 @@ "@tanstack/react-router": "^1.119.0", "clsx": "^2.1.1", "dayjs": "^1.11.13", + "jotai": "^2.12.4", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^3.2.0", diff --git a/packages/web/src/app/bookmarks/$bookmarkId.tsx b/packages/web/src/app/bookmarks/$bookmarkId.tsx index f7b44e3..30e076c 100644 --- a/packages/web/src/app/bookmarks/$bookmarkId.tsx +++ b/packages/web/src/app/bookmarks/$bookmarkId.tsx @@ -1,23 +1,38 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router" +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 } from "react" +import { useCallback, useEffect, useRef } from "react" import type { LinkBookmark } from "@markone/core/bookmark" -import { ActionBar } from "./-action-bar" +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" export const Route = createFileRoute("/bookmarks/$bookmarkId")({ component: RouteComponent, }) +const actionBarHeight = atom(0) +const setActionBarHeight = atom(null, (_, set, update: number) => { + set(actionBarHeight, update) +}) +const titleBarHeight = atom(0) +const setTitleBarHeight = atom(null, (_, set, update: number) => { + set(titleBarHeight, update) +}) + function RouteComponent() { return (
+ +
) @@ -37,9 +52,10 @@ function BookmarkPreviewContainer({ children }: React.PropsWithChildren) { return (
{children}
@@ -48,7 +64,7 @@ function BookmarkPreviewContainer({ children }: React.PropsWithChildren) { function BookmarkListSidebar() { return ( -
+

 >  @@ -56,7 +72,7 @@ function BookmarkListSidebar() {

- +
) } @@ -102,25 +118,126 @@ function BookmarkListContainer() { function BookmarkPreview() { const { bookmarkId } = Route.useParams() - const { data, status } = useAuthenticatedQuery(["bookmarks", `${bookmarkId}.html`], () => - fetchApi(`/bookmark/${bookmarkId}`, { - headers: { - Accept: "text/html", - }, - }).then((res) => res.text()), + const { data: previewHtml, status: previewQueryStatus } = 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) - switch (status) { - case "pending": - return ( -

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