diff --git a/.run/server.ts.run.xml b/.run/server.ts.run.xml new file mode 100644 index 0000000..f77ea62 --- /dev/null +++ b/.run/server.ts.run.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/bun.lock b/bun.lock index 553b425..3db7968 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "dependencies": { "@mozilla/readability": "^0.6.0", "arktype": "^2.1.20", + "flexsearch": "^0.8.204", "jsdom": "^26.1.0", "uid-safe": "^2.1.5", "ulid": "^3.0.0", @@ -43,6 +44,7 @@ "@tailwindcss/vite": "^4.1.5", "@tanstack/react-query": "^5.75.2", "@tanstack/react-router": "^1.119.0", + "arktype": "^2.1.20", "clsx": "^2.1.1", "dayjs": "^1.11.13", "jotai": "^2.12.4", @@ -491,7 +493,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.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="], + "@types/bun": ["@types/bun@1.2.14", "", { "dependencies": { "bun-types": "1.2.14" } }, "sha512-VsFZKs8oKHzI7zwvECiAJ5oSorWndIWEVhfbYqZd4HI/45kzW7PN2Rr5biAzvGvRuNmYLSANY+H59ubHq8xw7Q=="], "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], @@ -579,7 +581,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="], + "bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], @@ -709,6 +711,8 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "flexsearch": ["flexsearch@0.8.204", "", {}, "sha512-Vh+WUZfUHsVP6w4o5uAkYle8Gz/oEuztSWvpSY3h71AE8ox+goTQ2X5YG4x6VlKKfubkMwhewk8kBTOVKMObHA=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], @@ -1291,8 +1295,6 @@ "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=="], @@ -1309,8 +1311,6 @@ "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/tag.ts b/packages/core/src/tag.ts index fd79af1..85aef50 100644 --- a/packages/core/src/tag.ts +++ b/packages/core/src/tag.ts @@ -3,4 +3,6 @@ interface Tag { name: string } -export type { Tag } +type TagId = Tag["id"] + +export type { Tag, TagId } diff --git a/packages/server/package.json b/packages/server/package.json index 58b1402..60d9cb2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,6 +17,7 @@ "dependencies": { "@mozilla/readability": "^0.6.0", "arktype": "^2.1.20", + "flexsearch": "^0.8.204", "jsdom": "^26.1.0", "uid-safe": "^2.1.5", "ulid": "^3.0.0" diff --git a/packages/server/src/auth/auth.ts b/packages/server/src/auth/auth.ts index 54d866d..46b7ef2 100644 --- a/packages/server/src/auth/auth.ts +++ b/packages/server/src/auth/auth.ts @@ -2,6 +2,7 @@ import { DEMO_USER, type User } from "@markone/core/user" import { type } from "arktype" import dayjs from "dayjs" import { ulid } from "ulid" +import { initializeSearchIndexForUser } from "~/bookmark/search.js" import { db } from "~/database.ts" import { HttpError } from "~/error.ts" import { httpHandler } from "~/http-handler.ts" @@ -151,7 +152,8 @@ async function login(request: Bun.BunRequest<"/api/login">) { const session = verifySession(request.cookies) if (session) { extendSession(session) - return Response.json(session, { status: 200 }) + initializeSearchIndexForUser(session.user) + return Response.json(session.user, { status: 200 }) } // then, check if there is a valid auth token @@ -159,6 +161,7 @@ async function login(request: Bun.BunRequest<"/api/login">) { const foundUser = await verifyAuthToken(request.cookies) if (foundUser) { await createSessionForUser(foundUser, request.cookies) + initializeSearchIndexForUser(foundUser) return Response.json(foundUser, { status: 200 }) } } @@ -196,6 +199,8 @@ async function login(request: Bun.BunRequest<"/api/login">) { rememberLoginForUser(user, request.cookies) } + initializeSearchIndexForUser(user) + return Response.json(user, { status: 200 }) } diff --git a/packages/server/src/bookmark/bookmark.ts b/packages/server/src/bookmark/bookmark.ts index 36d6428..64c08e5 100644 --- a/packages/server/src/bookmark/bookmark.ts +++ b/packages/server/src/bookmark/bookmark.ts @@ -2,6 +2,7 @@ import type { Bookmark, BookmarkId, Tag, TaggedBookmark } from "@markone/core" import type { User } from "@markone/core/user" import { Readability } from "@mozilla/readability" import { JSDOM } from "jsdom" +import { addToSearchIndex, updateSearchIndex } from "~/bookmark/search.js" import { db } from "~/database.ts" import { findTagsByNames, insertTags } from "~/tag/tag.js" import { DEMO_BOOKMARKS } from "./demo-bookmarks.ts" @@ -42,6 +43,7 @@ VALUES ($id, $userId, $title, $url, $mimeType, $content) mimeType: cachedContent?.mimeType || null, content: cachedContent?.data ?? null, }) + addToSearchIndex(bookmark, user) } function findBookmarkCachedContent(id: string, user: User): Buffer | null { @@ -52,7 +54,19 @@ function findBookmarkCachedContent(id: string, user: User): Buffer | null { return row?.content ?? null } -function findBookmark(id: string, user: User): Bookmark | null { +function findUserBookmarks({ limit = -1, skip = -1 }: { limit: number; skip: number }, user: User): Bookmark[] { + let query = "SELECT id, title, url FROM bookmarks WHERE user_id = $userId ORDER BY id DESC" + if (limit >= 0) { + query += ` LIMIT ${limit}` + } + if (skip >= 0) { + query += ` SKIP ${skip}` + } + + return db.query(query).all({ userId: user.id }) +} + +function findBookmark(id: string, user: User): TaggedBookmark | null { const bookmarkQuery = db.query( "SELECT id, title, url FROM bookmarks WHERE id = $id AND user_id = $userId", ) @@ -66,13 +80,67 @@ function findBookmark(id: string, user: User): Bookmark | null { INNER JOIN bookmark_tags ON bookmark_tags.tag_id = tags.id AND bookmark_tags.bookmark_id = $bookmarkId `) - const tags = tagsQuery.all({ bookmarkId: id }) - - bookmark.tags = tags + bookmark.tags = tagsQuery.all({ bookmarkId: id }) return bookmark } +function findBookmarks( + { ids, tagIds, limit, skip }: { ids: BookmarkId[]; tagIds: Tag["id"][]; limit?: number; skip?: number }, + user: User, +) { + type ParamType = (string | number)[] + + if (tagIds.length === 0) { + let query = `SELECT id, title, url FROM bookmarks WHERE user_id = ?${ids.length > 0 ? `AND id IN (${Array(ids.length).fill("?").join(",")})` : ""}` + if (limit) { + query += " LIMIT ?" + } + if (skip) { + query += " SKIP ?" + } + + const params: (string | number)[] = [user.id, ...ids] + if (limit) { + params.push(limit) + } + if (skip) { + params.push(skip) + } + + return db.query(query).all(...params) + } + + const params: ParamType = [user.id, ...ids, ...tagIds] + if (limit) { + params.push(limit) + } + if (skip) { + params.push(skip) + } + + return db + .query(` + SELECT bookmarks.id, bookmarks.title, bookmarks.url FROM bookmarks + INNER JOIN bookmark_tags + ON bookmark_tags.bookmark_id = bookmarks.id + WHERE bookmarks.user_id = ? + ${ids.length > 0 ? `AND bookmarks.id IN (${Array(ids.length).fill("?").join(",")})` : ""} + AND bookmark_tags.tag_id IN (${Array(tagIds.length).fill("?").join(",")}) + ORDER BY bookmarks.id DESC + ${limit ? "LIMIT ?" : ""} ${skip ? "OFFSET ?" : ""} + `) + .all(...params) +} + +function findBookmarksByIds(ids: BookmarkId[], user: User) { + return db + .query( + `SELECT id, title, url FROM bookmarks WHERE user_id = ? AND id IN (${Array(ids.length).fill("?").join(",")})`, + ) + .all(user.id, ...ids) +} + function updateBookmarkTitle(bookmark: Bookmark, newTitle: string, user: User) { const query = db.query("UPDATE bookmarks SET title = $title WHERE id = $id AND user_id = $userId") query.run({ @@ -80,6 +148,7 @@ function updateBookmarkTitle(bookmark: Bookmark, newTitle: string, user: User) { id: bookmark.id, userId: user.id, }) + updateSearchIndex({ ...bookmark, title: newTitle }, user) } function deleteBookmark(id: BookmarkId, user: User) { @@ -207,7 +276,10 @@ export { updateBookmarkTitle, deleteBookmark, findBookmark, + findBookmarks, + findUserBookmarks, findBookmarkCachedContent, + findBookmarksByIds, cacheContent, assignTagsToBookmark, findBookmarkTags, diff --git a/packages/server/src/bookmark/handlers.ts b/packages/server/src/bookmark/handlers.ts index 2e0a614..1b89dc1 100644 --- a/packages/server/src/bookmark/handlers.ts +++ b/packages/server/src/bookmark/handlers.ts @@ -1,4 +1,4 @@ -import type { Bookmark, Tag, TaggedBookmark } from "@markone/core" +import type { Bookmark, Tag, TagId, TaggedBookmark } from "@markone/core" import { DEMO_USER } from "@markone/core/user" import { type } from "arktype" import { ulid } from "ulid" @@ -14,11 +14,13 @@ import { findBookmark, findBookmarkCachedContent, findBookmarkTags, + findBookmarks, insertBookmark, updateBookmarkTags, updateBookmarkTitle, } from "./bookmark.ts" +import { fuzzySearchBookmarks } from "~/bookmark/search.js" import { insertTags } from "~/tag/tag.js" const BOOKMARK_PAGINATION_LIMIT = 100 @@ -27,6 +29,7 @@ const ListUserBookmarksParams = type({ limit: ["number", "=", BOOKMARK_PAGINATION_LIMIT], skip: ["number", "=", 0], "tags?": "string", + "q?": "string", }) const AddBookmarkRequestBody = type({ @@ -53,38 +56,23 @@ async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user } let results: Bookmark[] - if (queryParams.tags) { - const tagNames = queryParams.tags.split(",") - const tagIdsQuery = db.query<{ id: string }, string[]>( - `SELECT id FROM tags WHERE name IN (${Array(tagNames.length).fill("?").join(",")})`, - ) - - const tagIds = tagIdsQuery.all(...tagNames).map(({ id }) => id) - - const query = db.query(` - SELECT bookmarks.id, bookmarks.title, bookmarks.url FROM bookmarks - INNER JOIN bookmark_tags - ON bookmark_tags.bookmark_id = bookmarks.id - WHERE bookmarks.user_id = ? AND bookmark_tags.tag_id IN (${Array(tagIds.length).fill("?").join(",")}) - ORDER BY bookmarks.id DESC - LIMIT ? OFFSET ? - `) - - results = query.all(...[user.id, ...tagIds, queryParams.limit, queryParams.skip]) as Bookmark[] + if (queryParams.q || queryParams.tags) { + let tagIds: TagId[] = [] + if (queryParams.tags) { + const tagNames = queryParams.tags.split(",") + const tagIdsQuery = db.query<{ id: string }, string[]>( + `SELECT id FROM tags WHERE name IN (${Array(tagNames.length).fill("?").join(",")})`, + ) + tagIds = tagIdsQuery.all(...tagNames).map(({ id }) => id) + } + if (queryParams.q) { + results = fuzzySearchBookmarks({ searchTerm: queryParams.q, tagIds }, user) + } else { + results = findBookmarks({ ids: [], tagIds }, user) + } } else { - const query = db.query(` - SELECT bookmarks.id, bookmarks.title, bookmarks.url FROM bookmarks - WHERE bookmarks.user_id = $userId - ORDER BY bookmarks.id DESC - LIMIT $limit OFFSET $skip - `) - - results = query.all({ - userId: user.id, - limit: queryParams.limit, - skip: queryParams.skip, - }) as Bookmark[] + results = findBookmarks({ ids: [], tagIds: [], limit: queryParams.limit, skip: queryParams.skip }, user) } return Response.json(results, { status: 200 }) diff --git a/packages/server/src/bookmark/search.ts b/packages/server/src/bookmark/search.ts new file mode 100644 index 0000000..cc59610 --- /dev/null +++ b/packages/server/src/bookmark/search.ts @@ -0,0 +1,39 @@ +import type { Bookmark, BookmarkId, TagId, User } from "@markone/core" +import { Index } from "flexsearch" +import { findBookmarks, findUserBookmarks } from "./bookmark.js" + +const indices = new Map() + +function initializeSearchIndexForUser(user: User) { + const bookmarks = findUserBookmarks({ limit: -1, skip: -1 }, user) + const index = new Index({ tokenize: "forward" }) + for (const bookmark of bookmarks) { + index.add(bookmark.id, bookmark.title) + } + indices.set(user.id, index) + return index +} + +function addToSearchIndex(bookmark: Bookmark, user: User) { + const index = indices.get(user.id) + index?.add(bookmark.id, bookmark.title) +} + +function updateSearchIndex(updatedBookmark: Bookmark, user: User) { + const index = indices.get(user.id) + index?.update(updatedBookmark.id, updatedBookmark.title) +} + +function fuzzySearchBookmarks({ searchTerm, tagIds }: { searchTerm: string; tagIds: TagId[] }, user: User): Bookmark[] { + let index = indices.get(user.id) + if (!index) { + index = initializeSearchIndexForUser(user) + } + const bookmarkIds = index.search(searchTerm) as BookmarkId[] + if (bookmarkIds.length === 0) { + return [] + } + return findBookmarks({ ids: bookmarkIds, tagIds }, user) +} + +export { initializeSearchIndexForUser, addToSearchIndex, updateSearchIndex, fuzzySearchBookmarks } diff --git a/packages/web/package.json b/packages/web/package.json index 4388717..d054e94 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,6 +14,7 @@ "@tailwindcss/vite": "^4.1.5", "@tanstack/react-query": "^5.75.2", "@tanstack/react-router": "^1.119.0", + "arktype": "^2.1.20", "clsx": "^2.1.1", "dayjs": "^1.11.13", "jotai": "^2.12.4", diff --git a/packages/web/src/app/bookmarks.tsx b/packages/web/src/app/bookmarks.tsx index c485343..e5eeff0 100644 --- a/packages/web/src/app/bookmarks.tsx +++ b/packages/web/src/app/bookmarks.tsx @@ -1,16 +1,30 @@ import { Outlet, createFileRoute } from "@tanstack/react-router" +import { type } from "arktype" import { useEffect } from "react" import { AddBookmarkDialog } from "./bookmarks/-dialogs/add-bookmark-dialog" import { DeleteBookmarkDialog } from "./bookmarks/-dialogs/delete-bookmark-dialog" import { EditBookmarkDialog } from "./bookmarks/-dialogs/edit-bookmark-dialog" -import { DialogKind, LayoutMode, useBookmarkPageStore } from "./bookmarks/-store" +import { ActionBarContentKind, DialogKind, LayoutMode, useBookmarkPageStore } from "./bookmarks/-store" + +const bookmarkSearchParams = type({ + "q?": "string", +}) export const Route = createFileRoute("/bookmarks")({ component: RouteComponent, + validateSearch: bookmarkSearchParams, }) function RouteComponent() { + const { q } = Route.useSearch() const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode) + const setActionBarContent = useBookmarkPageStore((state) => state.setActionBarContent) + + useEffect(() => { + if (q) { + setActionBarContent({ kind: ActionBarContentKind.SearchBar }) + } + }, [q, setActionBarContent]) useEffect(() => { function mediaQueryListener(this: MediaQueryList) { diff --git a/packages/web/src/app/bookmarks/$bookmarkId.tsx b/packages/web/src/app/bookmarks/$bookmarkId.tsx index 8b2d610..7d698a8 100644 --- a/packages/web/src/app/bookmarks/$bookmarkId.tsx +++ b/packages/web/src/app/bookmarks/$bookmarkId.tsx @@ -8,9 +8,9 @@ import { useBookmark } from "~/bookmark/api" import { Button, LinkButton } from "~/components/button" import { LoadingSpinner } from "~/components/loading-spinner" import { useMnemonics } from "~/hooks/use-mnemonics" -import { ActionBar, BookmarkListActionBar } from "./-action-bar" +import { ActionBar } from "./-action-bar" import { BookmarkList } from "./-bookmark-list" -import { LayoutMode, useBookmarkPageStore } from "./-store" +import { DialogKind, LayoutMode, useBookmarkPageStore } from "./-store" export const Route = createFileRoute("/bookmarks/$bookmarkId")({ component: RouteComponent, @@ -72,7 +72,7 @@ function BookmarkListSidebar() { - + ) } @@ -116,6 +116,29 @@ function BookmarkListContainer() { } } +function BookmarkListActionBar() { + const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog) + + function addBookmark() { + setActiveDialog({ kind: DialogKind.AddBookmark }) + } + + useMnemonics( + { + a: addBookmark, + }, + { ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) }, + ) + + return ( + + + + ) +} + function BookmarkPreview() { const { bookmarkId } = Route.useParams() const { data: previewHtml, status: previewQueryStatus } = useAuthenticatedQuery( diff --git a/packages/web/src/app/bookmarks/-action-bar.tsx b/packages/web/src/app/bookmarks/-action-bar.tsx index 57ed19e..6bbaa11 100644 --- a/packages/web/src/app/bookmarks/-action-bar.tsx +++ b/packages/web/src/app/bookmarks/-action-bar.tsx @@ -1,10 +1,4 @@ -import { useNavigate } from "@tanstack/react-router" -import { useCallback } from "react" import { twMerge } from "tailwind-merge" -import { useLogOut } from "~/auth" -import { Button } from "~/components/button" -import { useMnemonics } from "~/hooks/use-mnemonics" -import { DialogKind, useBookmarkPageStore } from "./-store" function ActionBar({ ref, @@ -24,54 +18,4 @@ function ActionBar({ ) } -function BookmarkListActionBar({ className }: { className?: string }) { - const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog) - const statusMessage = useBookmarkPageStore((state) => state.statusMessage) - - useMnemonics( - { - a: addBookmark, - }, - { ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) }, - ) - - function addBookmark() { - setActiveDialog({ kind: DialogKind.AddBookmark }) - } - - return ( - - {statusMessage ? ( -

{statusMessage}

- ) : ( - <> - - - - - )} -
- ) -} - -function LogOutButton() { - const logOutMutation = useLogOut() - const navigate = useNavigate() - - function logOut() { - logOutMutation.mutate() - navigate({ to: "/", replace: true }) - } - - return ( - - ) -} - -export { ActionBar, BookmarkListActionBar } +export { ActionBar } diff --git a/packages/web/src/app/bookmarks/-dialogs/delete-bookmark-dialog.tsx b/packages/web/src/app/bookmarks/-dialogs/delete-bookmark-dialog.tsx index bc1a05c..a0e09b4 100644 --- a/packages/web/src/app/bookmarks/-dialogs/delete-bookmark-dialog.tsx +++ b/packages/web/src/app/bookmarks/-dialogs/delete-bookmark-dialog.tsx @@ -1,10 +1,10 @@ -import { Bookmark } from "@markone/core" +import type { Bookmark } from "@markone/core" import { useDeleteBookmark } from "~/bookmark/api" import { Button } from "~/components/button" import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog" import { LoadingSpinner } from "~/components/loading-spinner" import { useMnemonics } from "~/hooks/use-mnemonics" -import { DialogKind, useBookmarkPageStore } from "../-store" +import { useBookmarkPageStore } from "../-store" function DeleteBookmarkDialog({ bookmark }: { bookmark: Bookmark }) { const closeDialog = useBookmarkPageStore((state) => state.closeDialog) diff --git a/packages/web/src/app/bookmarks/-store.tsx b/packages/web/src/app/bookmarks/-store.tsx index 68a1d46..5a4165f 100644 --- a/packages/web/src/app/bookmarks/-store.tsx +++ b/packages/web/src/app/bookmarks/-store.tsx @@ -14,23 +14,36 @@ enum DialogKind { DeleteBookmark = "DeleteBookmark", EditBookmark = "EditBookmark", } - interface NoDialogData { kind: DialogKind.None | DialogKind.AddBookmark } - interface DeleteBookmarkDialogData { kind: DialogKind.DeleteBookmark data: Bookmark } - interface EditBookmarkDialogData { kind: DialogKind.EditBookmark data: Bookmark } - type DialogData = NoDialogData | DeleteBookmarkDialogData | EditBookmarkDialogData +enum ActionBarContentKind { + Normal = "Normal", + StatusMessage = "StatusMessage", + SearchBar = "SearchBar", +} +interface Normal { + kind: ActionBarContentKind.Normal +} +interface StatusMessage { + kind: ActionBarContentKind.StatusMessage + message: string +} +interface SearchBar { + kind: ActionBarContentKind.SearchBar +} +type ActionBarContent = Normal | StatusMessage | SearchBar + const STATUS_MESSAGE_DURATION_MS = 2000 const NO_DIALOG: DialogData = { kind: DialogKind.None } as const @@ -38,9 +51,14 @@ interface BookmarkPageState { bookmarkToBeDeleted: Bookmark | null bookmarkToBeEdited: Bookmark | null layoutMode: LayoutMode - dialog: NoDialogData | DeleteBookmarkDialogData | EditBookmarkDialogData + dialog: DialogData + actionBarContent: ActionBarContent statusMessage: string + isSearchBarActive: boolean + openSearchBar: () => void + closeSearchBar: () => void + setActionBarContent: (content: ActionBarContent) => void handleBookmarkListItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void setActiveDialog: (dialog: DialogData) => void closeDialog: () => void @@ -53,7 +71,21 @@ const useBookmarkPageStore = create()((set, get) => ({ bookmarkToBeEdited: null, layoutMode: LayoutMode.Popup, dialog: NO_DIALOG, + actionBarContent: { kind: ActionBarContentKind.Normal }, statusMessage: "", + isSearchBarActive: false, + + openSearchBar() { + set({ isSearchBarActive: true }) + }, + + closeSearchBar() { + set({ isSearchBarActive: false }) + }, + + setActionBarContent(content: ActionBarContent) { + set({ actionBarContent: content }) + }, async handleBookmarkListItemAction(bookmark: Bookmark, action: BookmarkListItemAction) { switch (action) { @@ -111,5 +143,5 @@ const useBookmarkPageStore = create()((set, get) => ({ }, })) -export { LayoutMode, DialogKind, useBookmarkPageStore } +export { LayoutMode, DialogKind, ActionBarContentKind, useBookmarkPageStore } export type { BookmarkPageState } diff --git a/packages/web/src/app/bookmarks/index.tsx b/packages/web/src/app/bookmarks/index.tsx index 7db8e0b..19dbeeb 100644 --- a/packages/web/src/app/bookmarks/index.tsx +++ b/packages/web/src/app/bookmarks/index.tsx @@ -1,9 +1,13 @@ -import { createFileRoute } from "@tanstack/react-router" -import { BookmarkList } from "./-bookmark-list" -import { useBookmarkPageStore } from "./-store" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { useCallback, useEffect, useId, useRef } from "react" import { fetchApi, useAuthenticatedQuery } from "~/api" +import { ActionBar } from "~/app/bookmarks/-action-bar.tsx" +import { useLogOut } from "~/auth.ts" +import { Button } from "~/components/button.tsx" import { LoadingSpinner } from "~/components/loading-spinner" -import { BookmarkListActionBar } from "./-action-bar" +import { useMnemonics } from "~/hooks/use-mnemonics.ts" +import { BookmarkList } from "./-bookmark-list" +import { ActionBarContentKind, DialogKind, useBookmarkPageStore } from "./-store" export const Route = createFileRoute("/bookmarks/")({ component: RouteComponent, @@ -67,3 +71,177 @@ function BookmarkListContainer() { return

error loading bookmarks

} } + +function BookmarkListActionBar({ className }: { className?: string }) { + const content = useBookmarkPageStore((state) => state.actionBarContent) + return ( + + {(() => { + switch (content.kind) { + case ActionBarContentKind.Normal: + return + case ActionBarContentKind.StatusMessage: + return ( +
+

{content.message}

+
+ ) + case ActionBarContentKind.SearchBar: + return + } + })()} +
+ ) +} + +function SearchBar() { + const searchInputId = useId() + const inputRef = useRef(null) + const setActionBarContent = useBookmarkPageStore((state) => state.setActionBarContent) + const searchTimeoutRef = useRef | null>(null) + const navigate = Route.useNavigate() + const { q } = Route.useSearch() + + useMnemonics( + { + Escape: () => { + navigate({ + search: (prevSearch: Record) => ({ + ...prevSearch, + tags: undefined, + q: undefined, + }), + }) + setActionBarContent({ kind: ActionBarContentKind.Normal }) + }, + }, + { ignore: () => false }, + ) + + useEffect(() => { + setInterval(() => { + inputRef.current?.focus() + }, 0) + }, []) + + useEffect( + () => () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current) + } + }, + [], + ) + + function pushSearchQuery(event: React.ChangeEvent) { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current) + } + const q = event.currentTarget.value + searchTimeoutRef.current = setTimeout(() => { + navigate({ + search: (prevSearch: Record) => { + if (!q) { + return { ...prevSearch, tags: undefined, q: undefined } + } + + if (q.startsWith("#")) { + const parts = q.split(" ") + let searchTermBegin = -1 + for (let i = 0; i < parts.length; ++i) { + if (!parts[i].startsWith("#")) { + searchTermBegin = i + break + } + } + const tags = (searchTermBegin >= 0 ? parts.slice(0, searchTermBegin) : parts) + .map((tag) => tag.substring(1)) + .join(",") + const query = searchTermBegin >= 0 ? parts.slice(searchTermBegin).join(" ") : "" + + if (query) { + return { ...prevSearch, tags, q: query } + } + + return { ...prevSearch, tags, q: undefined } + } + + return { ...prevSearch, tags: undefined, q } + }, + }) + }, 500) + } + + function closeSearchBar() { + setActionBarContent({ kind: ActionBarContentKind.Normal }) + } + + return ( +
+ + + +
+ ) +} + +function ActionButtons() { + const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog) + const setActionBarContent = useBookmarkPageStore((state) => state.setActionBarContent) + + useMnemonics( + { + a: addBookmark, + s: openSearchBar, + }, + { ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) }, + ) + + function addBookmark() { + setActiveDialog({ kind: DialogKind.AddBookmark }) + } + + function openSearchBar() { + setActionBarContent({ kind: ActionBarContentKind.SearchBar }) + } + + return ( +
+ + + +
+ ) +} +function LogOutButton() { + const logOutMutation = useLogOut() + const navigate = useNavigate() + + function logOut() { + logOutMutation.mutate() + navigate({ to: "/", replace: true }) + } + + return ( + + ) +} + +export { BookmarkListActionBar } diff --git a/packages/web/src/hooks/use-mnemonics.ts b/packages/web/src/hooks/use-mnemonics.ts index 7c8ec3c..b30a993 100644 --- a/packages/web/src/hooks/use-mnemonics.ts +++ b/packages/web/src/hooks/use-mnemonics.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react" +import { useEffect } from "react" function useMnemonics( mnemonicMap: Record void>, @@ -6,7 +6,7 @@ function useMnemonics( ) { useEffect(() => { function onKeyDown(event: KeyboardEvent) { - if (!ignore(event)) { + if (event.key === "Escape" || document.activeElement?.tagName !== "INPUT") { mnemonicMap[event.key]?.(event) } } @@ -14,7 +14,7 @@ function useMnemonics( return () => { document.removeEventListener("keydown", onKeyDown) } - }, [mnemonicMap, ignore]) + }, [mnemonicMap]) } export { useMnemonics }