diff --git a/packages/server/src/bookmark/bookmark.ts b/packages/server/src/bookmark/bookmark.ts index 6d4c508..a0c59c0 100644 --- a/packages/server/src/bookmark/bookmark.ts +++ b/packages/server/src/bookmark/bookmark.ts @@ -22,18 +22,14 @@ VALUES ($id, $userId, $kind, $title, $url) insert(DEMO_BOOKMARKS) } -function insertBookmark(bookmark: Bookmark, user: User) { - const query = db.query(` -INSERT INTO bookmarks (id, user_id, kind, title, url) -VALUES ($id, $userId, $kind, $title, $url) -`) - query.run({ - id: bookmark.id, - userId: user.id, - kind: bookmark.kind, - title: bookmark.title, - url: bookmark.url, - }) +function findBookmarkHtml(id: string, user: User): string | null { + const query = db.query("SELECT content_html 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 } -export { insertDemoBookmarks } +export { insertDemoBookmarks, findBookmarkHtml } diff --git a/packages/server/src/bookmark/handlers.ts b/packages/server/src/bookmark/handlers.ts index 246a62c..dd243da 100644 --- a/packages/server/src/bookmark/handlers.ts +++ b/packages/server/src/bookmark/handlers.ts @@ -7,12 +7,13 @@ import { HttpError } from "~/error.ts" import type { User } from "~/user/user.ts" import { JSDOM } from "jsdom" import { Readability } from "@mozilla/readability" +import { findBookmarkHtml } from "./bookmark.ts" const BOOKMARK_PAGINATION_LIMIT = 100 const ListUserBookmarksParams = type({ limit: ["number", "=", BOOKMARK_PAGINATION_LIMIT], - skip: ["number", "=", 5], + skip: ["number", "=", 0], }) const AddBookmarkRequestBody = type({ @@ -31,11 +32,10 @@ async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user const listBookmarksQuery = db.query( ` -SELECT bookmarks.id, bookmarks.kind, bookmarks.title, bookmarks.url, tags.name as tag FROM bookmarks -LEFT JOIN tags -ON bookmarks.id = tags.bookmark_id +SELECT bookmarks.id, bookmarks.kind, bookmarks.title, bookmarks.url FROM bookmarks WHERE bookmarks.user_id = $userId -ORDER BY bookmarks.id LIMIT $limit OFFSET $skip +ORDER BY bookmarks.id DESC +LIMIT $limit OFFSET $skip `, ) @@ -67,26 +67,47 @@ async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User } const websiteResponse = await fetch(body.url).catch(() => { + if (body.force) { + return null + } throw new HttpError(400, "WebsiteUnreachable") }) - const websiteText = await websiteResponse.text().catch(() => { - throw new HttpError(400, "UnsupportedWebsite") - }) - - const dom = new JSDOM(websiteText, { - url: body.url, - }) - const reader = new Readability(dom.window.document) - const article = reader.parse() + const websiteText = websiteResponse + ? await websiteResponse.text().catch(() => { + throw new HttpError(400, "UnsupportedWebsite") + }) + : null const bookmark: LinkBookmark = { kind: "link", id: ulid(), - title: body.title || article?.title || "Untitled", + title: "", url: body.url, tags: body.tags.map((tag) => ({ id: ulid(), name: tag })), } + if (body.title) { + bookmark.title = body.title + } + + let contentHtml: string + if (websiteText) { + const dom = new JSDOM(websiteText, { + url: body.url, + }) + const reader = new Readability(dom.window.document) + const article = reader.parse() + if (!bookmark.title) { + bookmark.title = article?.title || "Untitled" + } + contentHtml = article?.content || "" + } else { + contentHtml = "" + if (!bookmark.title) { + bookmark.title = "Untitled" + } + } + const query = db.query(` INSERT INTO bookmarks (id, user_id, kind, title, url, content_html) VALUES ($id, $userId, $kind, $title, $url, $html) @@ -97,7 +118,7 @@ VALUES ($id, $userId, $kind, $title, $url, $html) kind: bookmark.kind, title: bookmark.title, url: bookmark.url, - html: article?.content ?? websiteText, + html: contentHtml, }) const insertTagQuery = db.query(` @@ -122,4 +143,23 @@ VALUES ($id, $bookmarkId, $name) return Response.json(undefined, { status: 204 }) } -export { addBookmark, listUserBookmarks, deleteUserBookmark } +async function fetchBookmark(request: Bun.BunRequest<"/api/bookmark/:id">, user: User) { + switch (request.headers.get("Accept")) { + case "text/html": { + const html = findBookmarkHtml(request.params.id, user) + if (html === null) { + throw new HttpError(404) + } + return new Response(html, { + status: 200, + headers: { + "Content-Type": "text/html", + }, + }) + } + default: + throw new HttpError(400, "UnsupportedAcceptHeader") + } +} + +export { addBookmark, fetchBookmark, listUserBookmarks, deleteUserBookmark } diff --git a/packages/server/src/database.ts b/packages/server/src/database.ts index 57d0adf..76693af 100644 --- a/packages/server/src/database.ts +++ b/packages/server/src/database.ts @@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS bookmarks( kind TEXT NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, - content_html TEXT NOT NULL, + content_html TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS tags( diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 62d2532..a6c841d 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,7 +1,7 @@ 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 } from "./bookmark/handlers.ts" +import { addBookmark, listUserBookmarks, deleteUserBookmark, fetchBookmark } from "./bookmark/handlers.ts" import { migrateDatabase } from "./database.ts" import { httpHandler, preflightHandler } from "./http-handler.ts" import { createDemoUser } from "./user/user.ts" @@ -28,6 +28,7 @@ async function main() { POST: authenticated(addBookmark), }, "/api/bookmark/:id": { + GET: authenticated(fetchBookmark), DELETE: authenticated(deleteUserBookmark), OPTIONS: preflightHandler({ allowedMethods: ["GET", "POST", "DELETE", "OPTIONS"], diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts index da85103..9639dc8 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -24,7 +24,7 @@ interface ErrorBody { message?: string } -type QueryKey = ["bookmarks", ...ReadonlyArray] +type QueryKey = ["bookmarks", ...ReadonlyArray] | ["bookmarks", string, ...ReadonlyArray] async function fetchApi(route: string, init?: RequestInit): Promise { const response = await fetch(`${import.meta.env.VITE_API_URL}/api${route}`, { diff --git a/packages/web/src/app/bookmarks.tsx b/packages/web/src/app/bookmarks.tsx index ab09f2d..9b76d6f 100644 --- a/packages/web/src/app/bookmarks.tsx +++ b/packages/web/src/app/bookmarks.tsx @@ -1,7 +1,7 @@ import type { LinkBookmark } from "@markone/core/bookmark" import { createFileRoute, useNavigate } from "@tanstack/react-router" import clsx from "clsx" -import { useEffect, useId, useState } from "react" +import { useCallback, useEffect, useId, useRef, useState } from "react" import { create } from "zustand" import { fetchApi, useAuthenticatedQuery, BadRequestError, ApiErrorCode } from "~/api" import { useCreateBookmark, useDeleteBookmark } from "~/bookmark/api" @@ -131,8 +131,8 @@ function Page() { + - ) @@ -178,7 +178,7 @@ function DeleteBookmarkDialog() { y: proceed, n: cancel, }, - { active: true }, + { ignore: () => false }, ) async function proceed() { @@ -207,10 +207,14 @@ function DeleteBookmarkDialog() { case "idle": return (

- The bookmark titled{" "} + The bookmark titled: +
+
"{bookmark.title}" - {" "} + +
+
will be deleted. Proceed?

) @@ -251,14 +255,30 @@ function AddBookmarkDialog() { const createBookmarkMutation = useCreateBookmark() const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog) const formId = useId() + const linkInputRef = useRef(null) useMnemonics( { - c: cancel, + c: () => { + if (linkInputRef.current !== document.activeElement) { + cancel() + } + }, + Escape: () => { + linkInputRef.current?.blur() + }, }, - { active: true }, + { ignore: () => false }, ) + useEffect(() => { + setTimeout(() => { + if (linkInputRef.current) { + linkInputRef.current.focus() + } + }, 0) + }, []) + async function onSubmit(event: React.FormEvent) { event.preventDefault() @@ -267,9 +287,12 @@ function AddBookmarkDialog() { 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) } } } @@ -311,6 +334,7 @@ function AddBookmarkDialog() { {message()}
-

Content here

+ ) } +function BookmarkPreviewFrame() { + const selectedBookmarkId = useBookmarkPageStore((state) => state.selectedBookmarkId) + const { data, status } = useAuthenticatedQuery(["bookmarks", selectedBookmarkId], () => + fetchApi(`/bookmark/${selectedBookmarkId}`, { + headers: { + Accept: "text/html", + }, + }).then((res) => res.text()), + ) + + switch (status) { + case "pending": + return ( +

+ Loading +

+ ) + case "success": + return