wip: implement bookmark preview

This commit is contained in:
2025-05-08 15:51:17 +01:00
parent 77cb38294c
commit 9b47b806d5
10 changed files with 164 additions and 58 deletions

View File

@@ -22,18 +22,14 @@ VALUES ($id, $userId, $kind, $title, $url)
insert(DEMO_BOOKMARKS) insert(DEMO_BOOKMARKS)
} }
function insertBookmark(bookmark: Bookmark, user: User) { function findBookmarkHtml(id: string, user: User): string | null {
const query = db.query(` const query = db.query("SELECT content_html FROM bookmarks WHERE id = $id AND user_id = $userId")
INSERT INTO bookmarks (id, user_id, kind, title, url) const row = query.get({ id, userId: user.id })
VALUES ($id, $userId, $kind, $title, $url) if (!row) {
`) return null
query.run({ }
id: bookmark.id, const { content_html } = row as { content_html: string }
userId: user.id, return content_html
kind: bookmark.kind,
title: bookmark.title,
url: bookmark.url,
})
} }
export { insertDemoBookmarks } export { insertDemoBookmarks, findBookmarkHtml }

View File

@@ -7,12 +7,13 @@ import { HttpError } from "~/error.ts"
import type { User } from "~/user/user.ts" import type { User } from "~/user/user.ts"
import { JSDOM } from "jsdom" import { JSDOM } from "jsdom"
import { Readability } from "@mozilla/readability" import { Readability } from "@mozilla/readability"
import { findBookmarkHtml } from "./bookmark.ts"
const BOOKMARK_PAGINATION_LIMIT = 100 const BOOKMARK_PAGINATION_LIMIT = 100
const ListUserBookmarksParams = type({ const ListUserBookmarksParams = type({
limit: ["number", "=", BOOKMARK_PAGINATION_LIMIT], limit: ["number", "=", BOOKMARK_PAGINATION_LIMIT],
skip: ["number", "=", 5], skip: ["number", "=", 0],
}) })
const AddBookmarkRequestBody = type({ const AddBookmarkRequestBody = type({
@@ -31,11 +32,10 @@ async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user
const listBookmarksQuery = db.query( const listBookmarksQuery = db.query(
` `
SELECT bookmarks.id, bookmarks.kind, bookmarks.title, bookmarks.url, tags.name as tag FROM bookmarks SELECT bookmarks.id, bookmarks.kind, bookmarks.title, bookmarks.url FROM bookmarks
LEFT JOIN tags
ON bookmarks.id = tags.bookmark_id
WHERE bookmarks.user_id = $userId 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(() => { const websiteResponse = await fetch(body.url).catch(() => {
if (body.force) {
return null
}
throw new HttpError(400, "WebsiteUnreachable") throw new HttpError(400, "WebsiteUnreachable")
}) })
const websiteText = await websiteResponse.text().catch(() => { const websiteText = websiteResponse
throw new HttpError(400, "UnsupportedWebsite") ? await websiteResponse.text().catch(() => {
}) throw new HttpError(400, "UnsupportedWebsite")
})
const dom = new JSDOM(websiteText, { : null
url: body.url,
})
const reader = new Readability(dom.window.document)
const article = reader.parse()
const bookmark: LinkBookmark = { const bookmark: LinkBookmark = {
kind: "link", kind: "link",
id: ulid(), id: ulid(),
title: body.title || article?.title || "Untitled", title: "",
url: body.url, url: body.url,
tags: body.tags.map((tag) => ({ id: ulid(), name: tag })), 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(` const query = db.query(`
INSERT INTO bookmarks (id, user_id, kind, title, url, content_html) INSERT INTO bookmarks (id, user_id, kind, title, url, content_html)
VALUES ($id, $userId, $kind, $title, $url, $html) VALUES ($id, $userId, $kind, $title, $url, $html)
@@ -97,7 +118,7 @@ VALUES ($id, $userId, $kind, $title, $url, $html)
kind: bookmark.kind, kind: bookmark.kind,
title: bookmark.title, title: bookmark.title,
url: bookmark.url, url: bookmark.url,
html: article?.content ?? websiteText, html: contentHtml,
}) })
const insertTagQuery = db.query(` const insertTagQuery = db.query(`
@@ -122,4 +143,23 @@ VALUES ($id, $bookmarkId, $name)
return Response.json(undefined, { status: 204 }) 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 }

View File

@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS bookmarks(
kind TEXT NOT NULL, kind TEXT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
content_html TEXT NOT NULL, content_html TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS tags( CREATE TABLE IF NOT EXISTS tags(

View File

@@ -1,7 +1,7 @@
import { authenticated, login, logout, signUp } from "./auth/auth.ts" import { authenticated, login, logout, signUp } from "./auth/auth.ts"
import { startBackgroundSessionCleanup } from "./auth/session.ts" import { startBackgroundSessionCleanup } from "./auth/session.ts"
import { insertDemoBookmarks } from "./bookmark/bookmark.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 { migrateDatabase } from "./database.ts"
import { httpHandler, preflightHandler } from "./http-handler.ts" import { httpHandler, preflightHandler } from "./http-handler.ts"
import { createDemoUser } from "./user/user.ts" import { createDemoUser } from "./user/user.ts"
@@ -28,6 +28,7 @@ async function main() {
POST: authenticated(addBookmark), POST: authenticated(addBookmark),
}, },
"/api/bookmark/:id": { "/api/bookmark/:id": {
GET: authenticated(fetchBookmark),
DELETE: authenticated(deleteUserBookmark), DELETE: authenticated(deleteUserBookmark),
OPTIONS: preflightHandler({ OPTIONS: preflightHandler({
allowedMethods: ["GET", "POST", "DELETE", "OPTIONS"], allowedMethods: ["GET", "POST", "DELETE", "OPTIONS"],

View File

@@ -24,7 +24,7 @@ interface ErrorBody {
message?: string message?: string
} }
type QueryKey = ["bookmarks", ...ReadonlyArray<unknown>] type QueryKey = ["bookmarks", ...ReadonlyArray<unknown>] | ["bookmarks", string, ...ReadonlyArray<unknown>]
async function fetchApi(route: string, init?: RequestInit): Promise<Response> { async function fetchApi(route: string, init?: RequestInit): Promise<Response> {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api${route}`, { const response = await fetch(`${import.meta.env.VITE_API_URL}/api${route}`, {

View File

@@ -1,7 +1,7 @@
import type { LinkBookmark } from "@markone/core/bookmark" import type { LinkBookmark } from "@markone/core/bookmark"
import { createFileRoute, useNavigate } from "@tanstack/react-router" import { createFileRoute, useNavigate } from "@tanstack/react-router"
import clsx from "clsx" import clsx from "clsx"
import { useEffect, useId, useState } from "react" import { useCallback, useEffect, useId, useRef, useState } from "react"
import { create } from "zustand" import { create } from "zustand"
import { fetchApi, useAuthenticatedQuery, BadRequestError, ApiErrorCode } from "~/api" import { fetchApi, useAuthenticatedQuery, BadRequestError, ApiErrorCode } from "~/api"
import { useCreateBookmark, useDeleteBookmark } from "~/bookmark/api" import { useCreateBookmark, useDeleteBookmark } from "~/bookmark/api"
@@ -131,8 +131,8 @@ function Page() {
<BookmarkListSection /> <BookmarkListSection />
</div> </div>
<BookmarkPreview /> <BookmarkPreview />
<ActionBar />
</Main> </Main>
<ActionBar />
<PageDialog /> <PageDialog />
</div> </div>
) )
@@ -178,7 +178,7 @@ function DeleteBookmarkDialog() {
y: proceed, y: proceed,
n: cancel, n: cancel,
}, },
{ active: true }, { ignore: () => false },
) )
async function proceed() { async function proceed() {
@@ -207,10 +207,14 @@ function DeleteBookmarkDialog() {
case "idle": case "idle":
return ( return (
<p> <p>
The bookmark titled{" "} The bookmark titled:
<br />
<br />
<strong> <strong>
<em>"{bookmark.title}"</em> <em>"{bookmark.title}"</em>
</strong>{" "} </strong>
<br />
<br />
will be deleted. Proceed? will be deleted. Proceed?
</p> </p>
) )
@@ -251,14 +255,30 @@ function AddBookmarkDialog() {
const createBookmarkMutation = useCreateBookmark() const createBookmarkMutation = useCreateBookmark()
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog) const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
const formId = useId() const formId = useId()
const linkInputRef = useRef<HTMLInputElement | null>(null)
useMnemonics( 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<HTMLFormElement>) { async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
@@ -267,9 +287,12 @@ function AddBookmarkDialog() {
if (url && typeof url === "string") { if (url && typeof url === "string") {
try { try {
await createBookmarkMutation.mutateAsync({ url, force: isWebsiteUnreachable }) await createBookmarkMutation.mutateAsync({ url, force: isWebsiteUnreachable })
setActiveDialog(ActiveDialog.None)
} catch (error) { } catch (error) {
if (error instanceof BadRequestError && error.code === ApiErrorCode.WebsiteUnreachable) { if (error instanceof BadRequestError && error.code === ApiErrorCode.WebsiteUnreachable) {
setIsWebsiteUnreachable(true) setIsWebsiteUnreachable(true)
} else {
setIsWebsiteUnreachable(false)
} }
} }
} }
@@ -311,6 +334,7 @@ function AddBookmarkDialog() {
{message()} {message()}
<form id={formId} className="px-8" onSubmit={onSubmit}> <form id={formId} className="px-8" onSubmit={onSubmit}>
<FormField <FormField
ref={linkInputRef}
type="text" type="text"
name="link" name="link"
label="LINK" label="LINK"
@@ -426,11 +450,36 @@ function BookmarkPreview() {
}, },
)} )}
> >
<p>Content here</p> <BookmarkPreviewFrame />
</div> </div>
) )
} }
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 (
<p>
Loading <LoadingSpinner />
</p>
)
case "success":
return <iframe className="bg-stone-200 dark:bg-stone-900 w-full h-full pb-20" srcDoc={data} />
default:
return null
}
}
function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index: number }) { function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index: number }) {
const url = new URL(bookmark.url) const url = new URL(bookmark.url)
const selectedBookmarkIndex = useBookmarkPageStore((state) => state.selectedBookmarkIndex) const selectedBookmarkIndex = useBookmarkPageStore((state) => state.selectedBookmarkIndex)
@@ -446,7 +495,12 @@ function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index:
{ {
d: deleteItem, d: deleteItem,
}, },
{ active: isSelected }, {
ignore: useCallback(
() => !isSelected || useBookmarkPageStore.getState().activeDialog !== ActiveDialog.None,
[isSelected],
),
},
) )
function deleteItem() { function deleteItem() {
@@ -499,7 +553,7 @@ function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index:
<p className="opacity-80 text-sm">{url.host}</p> <p className="opacity-80 text-sm">{url.host}</p>
{isBookmarkItemExpanded && isSelected ? ( {isBookmarkItemExpanded && isSelected ? (
<div className="flex flex-col space-y-1 md:flex-row md:space-y-0 md:space-x-2 items-end justify-between pt-2"> <div className="flex flex-col space-y-1 md:flex-row md:space-y-0 md:space-x-2 items-end justify-between pt-2">
<p className="text-sm">#dev #devops #devops #devops #devops #devops #devops</p> <p className="text-sm">#dev</p>
<div className="flex space-x-2"> <div className="flex space-x-2">
<OpenBookmarkPreviewButton /> <OpenBookmarkPreviewButton />
<Button variant="light" className="text-sm"> <Button variant="light" className="text-sm">
@@ -579,7 +633,7 @@ function ActionBar() {
{ {
a: addBookmark, a: addBookmark,
}, },
{ active: true }, { ignore: useCallback(() => useBookmarkPageStore.getState().activeDialog !== ActiveDialog.None, []) },
) )
function addBookmark() { function addBookmark() {
@@ -587,7 +641,7 @@ function ActionBar() {
} }
return ( return (
<div className="fixed z-10 bottom-0 left-0 right-0 border-t-1 flex flex-row justify-center py-4 space-x-4"> <div className="fixed z-10 bottom-0 left-0 right-0 bg-stone-200 dark:bg-stone-900 border-t-1 flex flex-row justify-center py-4 space-x-4">
<Button onClick={addBookmark}> <Button onClick={addBookmark}>
<span className="underline">A</span>DD <span className="underline">A</span>DD
</Button> </Button>

View File

@@ -9,11 +9,9 @@ function useDeleteBookmark() {
return useMutation({ return useMutation({
mutationFn: ({ bookmark }: { bookmark: Bookmark }) => mutationFn: ({ bookmark }: { bookmark: Bookmark }) =>
new Promise((resolve) => setTimeout(resolve, 5000)).then(() => fetchApi(`/bookmark/${bookmark.id}`, {
fetchApi(`/bookmark/${bookmark.id}`, { method: "DELETE",
method: "DELETE", }),
}),
),
onError: (error) => { onError: (error) => {
if (error instanceof UnauthenticatedError) { if (error instanceof UnauthenticatedError) {
navigate({ to: "/login", replace: true }) navigate({ to: "/login", replace: true })

View File

@@ -17,7 +17,7 @@ function DialogTitle({ children }: React.PropsWithChildren) {
} }
function DialogBody({ children }: React.PropsWithChildren) { function DialogBody({ children }: React.PropsWithChildren) {
return <div className="m-8 w-full text-center">{children}</div> return <div className="p-8 w-full text-center">{children}</div>
} }
function DialogActionRow({ children }: React.PropsWithChildren) { function DialogActionRow({ children }: React.PropsWithChildren) {

View File

@@ -9,17 +9,31 @@ interface FormFieldProps {
className?: string className?: string
labelClassName?: string labelClassName?: string
required?: boolean required?: boolean
autoFocus?: boolean
ref?: React.Ref<HTMLInputElement>
} }
function FormField({ name, label, type, className, labelClassName, required = false }: FormFieldProps) { function FormField({
name,
label,
type,
className,
labelClassName,
required = false,
autoFocus = false,
ref,
}: FormFieldProps) {
const id = useId() const id = useId()
return ( return (
<div className={clsx("flex flex-col-reverse focus:text-teal-600", className)}> <div className={clsx("flex flex-col-reverse focus:text-teal-600", className)}>
<input <input
ref={ref}
id={id} id={id}
required={required} required={required}
name={name} name={name}
type={type} type={type}
// biome-ignore lint/a11y/noAutofocus: <explanation>
autoFocus={autoFocus}
defaultValue="" defaultValue=""
className="peer px-3 pb-2 pt-3 border focus:border-2 border-stone-800 dark:border-stone-200 focus:border-teal-600 focus:ring-0 focus:outline-none" className="peer px-3 pb-2 pt-3 border focus:border-2 border-stone-800 dark:border-stone-200 focus:border-teal-600 focus:ring-0 focus:outline-none"
/> />

View File

@@ -1,17 +1,20 @@
import { useEffect } from "react" import { useEffect } from "react"
function useMnemonics(mnemonicMap: Record<string, (event: KeyboardEvent) => void>, { active }: { active: boolean }) { function useMnemonics(
mnemonicMap: Record<string, (event: KeyboardEvent) => void>,
{ ignore }: { ignore: (event: KeyboardEvent) => boolean },
) {
useEffect(() => { useEffect(() => {
function onKeyDown(event: KeyboardEvent) { function onKeyDown(event: KeyboardEvent) {
mnemonicMap[event.key]?.(event) if (!ignore(event)) {
} mnemonicMap[event.key]?.(event)
if (active) { }
document.addEventListener("keydown", onKeyDown)
} }
document.addEventListener("keydown", onKeyDown)
return () => { return () => {
document.removeEventListener("keydown", onKeyDown) document.removeEventListener("keydown", onKeyDown)
} }
}, [mnemonicMap, active]) }, [mnemonicMap, ignore])
} }
export { useMnemonics } export { useMnemonics }