wip: implement bookmark preview
This commit is contained in:
@@ -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 }
|
||||
|
@@ -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 }
|
||||
|
@@ -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(
|
||||
|
@@ -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"],
|
||||
|
@@ -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}`, {
|
||||
|
@@ -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>
|
||||
|
@@ -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 })
|
||||
|
@@ -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) {
|
||||
|
@@ -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"
|
||||
/>
|
||||
|
@@ -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 }
|
||||
|
Reference in New Issue
Block a user