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)
}
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 }

View File

@@ -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 }

View File

@@ -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(

View File

@@ -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"],

View File

@@ -24,7 +24,7 @@ interface ErrorBody {
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> {
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 { 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() {
<BookmarkListSection />
</div>
<BookmarkPreview />
<ActionBar />
</Main>
<ActionBar />
<PageDialog />
</div>
)
@@ -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 (
<p>
The bookmark titled{" "}
The bookmark titled:
<br />
<br />
<strong>
<em>"{bookmark.title}"</em>
</strong>{" "}
</strong>
<br />
<br />
will be deleted. Proceed?
</p>
)
@@ -251,14 +255,30 @@ function AddBookmarkDialog() {
const createBookmarkMutation = useCreateBookmark()
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
const formId = useId()
const linkInputRef = useRef<HTMLInputElement | null>(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<HTMLFormElement>) {
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()}
<form id={formId} className="px-8" onSubmit={onSubmit}>
<FormField
ref={linkInputRef}
type="text"
name="link"
label="LINK"
@@ -426,11 +450,36 @@ function BookmarkPreview() {
},
)}
>
<p>Content here</p>
<BookmarkPreviewFrame />
</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 }) {
const url = new URL(bookmark.url)
const selectedBookmarkIndex = useBookmarkPageStore((state) => state.selectedBookmarkIndex)
@@ -446,7 +495,12 @@ function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index:
{
d: deleteItem,
},
{ active: isSelected },
{
ignore: useCallback(
() => !isSelected || useBookmarkPageStore.getState().activeDialog !== ActiveDialog.None,
[isSelected],
),
},
)
function deleteItem() {
@@ -499,7 +553,7 @@ function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index:
<p className="opacity-80 text-sm">{url.host}</p>
{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">
<p className="text-sm">#dev #devops #devops #devops #devops #devops #devops</p>
<p className="text-sm">#dev</p>
<div className="flex space-x-2">
<OpenBookmarkPreviewButton />
<Button variant="light" className="text-sm">
@@ -579,7 +633,7 @@ function ActionBar() {
{
a: addBookmark,
},
{ active: true },
{ ignore: useCallback(() => useBookmarkPageStore.getState().activeDialog !== ActiveDialog.None, []) },
)
function addBookmark() {
@@ -587,7 +641,7 @@ function ActionBar() {
}
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}>
<span className="underline">A</span>DD
</Button>

View File

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

View File

@@ -17,7 +17,7 @@ function DialogTitle({ 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) {

View File

@@ -9,17 +9,31 @@ interface FormFieldProps {
className?: string
labelClassName?: string
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()
return (
<div className={clsx("flex flex-col-reverse focus:text-teal-600", className)}>
<input
ref={ref}
id={id}
required={required}
name={name}
type={type}
// biome-ignore lint/a11y/noAutofocus: <explanation>
autoFocus={autoFocus}
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"
/>

View File

@@ -1,17 +1,20 @@
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(() => {
function onKeyDown(event: KeyboardEvent) {
mnemonicMap[event.key]?.(event)
}
if (active) {
document.addEventListener("keydown", onKeyDown)
if (!ignore(event)) {
mnemonicMap[event.key]?.(event)
}
}
document.addEventListener("keydown", onKeyDown)
return () => {
document.removeEventListener("keydown", onKeyDown)
}
}, [mnemonicMap, active])
}, [mnemonicMap, ignore])
}
export { useMnemonics }