From 11d4cc19af8d8c0986eb7d38cfdf2b0c8f9039d6 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sat, 31 May 2025 13:44:08 +0100 Subject: [PATCH] implement tag filtering ui --- packages/server/src/bookmark/handlers.ts | 2 +- packages/web/src/app/bookmarks/-store.tsx | 26 ++- packages/web/src/app/bookmarks/index.tsx | 162 +++++++++++++++---- packages/web/src/components/tags-input.tsx | 71 ++++---- packages/web/src/hooks/use-document-event.ts | 15 ++ packages/web/src/hooks/use-mnemonics.ts | 8 +- 6 files changed, 208 insertions(+), 76 deletions(-) create mode 100644 packages/web/src/hooks/use-document-event.ts diff --git a/packages/server/src/bookmark/handlers.ts b/packages/server/src/bookmark/handlers.ts index 1b89dc1..27ddac7 100644 --- a/packages/server/src/bookmark/handlers.ts +++ b/packages/server/src/bookmark/handlers.ts @@ -60,7 +60,7 @@ async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user if (queryParams.q || queryParams.tags) { let tagIds: TagId[] = [] if (queryParams.tags) { - const tagNames = queryParams.tags.split(",") + const tagNames = queryParams.tags.split(" ") const tagIdsQuery = db.query<{ id: string }, string[]>( `SELECT id FROM tags WHERE name IN (${Array(tagNames.length).fill("?").join(",")})`, ) diff --git a/packages/web/src/app/bookmarks/-store.tsx b/packages/web/src/app/bookmarks/-store.tsx index 6a690af..a546b9d 100644 --- a/packages/web/src/app/bookmarks/-store.tsx +++ b/packages/web/src/app/bookmarks/-store.tsx @@ -52,12 +52,14 @@ interface BookmarkPageState { bookmarkToBeEdited: Bookmark | null layoutMode: LayoutMode dialog: DialogData + hasDialog: boolean actionBarContent: ActionBarContent statusMessage: string - isSearchBarActive: boolean + isTagFilterWindowOpen: boolean - openSearchBar: () => void - closeSearchBar: () => void + openTagFilterWindow: () => void + closeTagFilterWindow: () => void + toggleTagFilterWindow: () => void setActionBarContent: (content: ActionBarContent) => void handleBookmarkListItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void setActiveDialog: (dialog: DialogData) => void @@ -73,14 +75,22 @@ const useBookmarkPageStore = create()((set, get) => ({ dialog: NO_DIALOG, actionBarContent: { kind: ActionBarContentKind.Normal }, statusMessage: "", - isSearchBarActive: false, + isTagFilterWindowOpen: false, - openSearchBar() { - set({ isSearchBarActive: true }) + get hasDialog(): boolean { + return get().dialog.kind !== DialogKind.None }, - closeSearchBar() { - set({ isSearchBarActive: false }) + toggleTagFilterWindow() { + set({ isTagFilterWindowOpen: !get().isTagFilterWindowOpen }) + }, + + openTagFilterWindow() { + set({ isTagFilterWindowOpen: true }) + }, + + closeTagFilterWindow() { + set({ isTagFilterWindowOpen: false }) }, setActionBarContent(content: ActionBarContent) { diff --git a/packages/web/src/app/bookmarks/index.tsx b/packages/web/src/app/bookmarks/index.tsx index 8df05b5..cf88078 100644 --- a/packages/web/src/app/bookmarks/index.tsx +++ b/packages/web/src/app/bookmarks/index.tsx @@ -1,10 +1,15 @@ +import { autoUpdate, offset, useFloating } from "@floating-ui/react-dom" +import type { Tag } from "@markone/core" import { createFileRoute, useNavigate } from "@tanstack/react-router" -import { useCallback, useEffect, useId, useRef } from "react" +import { memo, 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 { useTags } from "~/bookmark/api.ts" import { Button } from "~/components/button.tsx" import { LoadingSpinner } from "~/components/loading-spinner" +import { Message, MessageVariant } from "~/components/message.tsx" +import { useDocumentEvent } from "~/hooks/use-document-event.ts" import { useMnemonics } from "~/hooks/use-mnemonics.ts" import { BookmarkList } from "./-bookmark-list" import { ActionBarContentKind, DialogKind, useBookmarkPageStore } from "./-store" @@ -73,20 +78,30 @@ function BookmarkListContainer() { } function BookmarkListActionBar({ className }: { className?: string }) { + const isTagFilterWindowOpen = useBookmarkPageStore((state) => state.isTagFilterWindowOpen) const content = useBookmarkPageStore((state) => state.actionBarContent) + const { refs, floatingStyles } = useFloating({ + placement: "top", + whileElementsMounted: autoUpdate, + middleware: [offset(8)], + }) + return ( - - {(() => { - switch (content.kind) { - case ActionBarContentKind.Normal: - return - case ActionBarContentKind.StatusMessage: - return

{content.message}

- case ActionBarContentKind.SearchBar: - return - } - })()} -
+ <> + + {(() => { + switch (content.kind) { + case ActionBarContentKind.Normal: + return + case ActionBarContentKind.StatusMessage: + return

{content.message}

+ case ActionBarContentKind.SearchBar: + return + } + })()} +
+ {isTagFilterWindowOpen ? : null} + ) } @@ -98,20 +113,22 @@ function SearchBar() { const navigate = Route.useNavigate() const { q } = Route.useSearch() - useMnemonics( - { - Escape: () => { - navigate({ - search: (prevSearch: Record) => ({ - ...prevSearch, - tags: undefined, - q: undefined, - }), - }) - setActionBarContent({ kind: ActionBarContentKind.Normal }) + useDocumentEvent( + "keydown", + useCallback( + (event) => { + if (event.key === "Escape") { + navigate({ + search: (prevSearch: Record) => ({ + ...prevSearch, + q: undefined, + }), + }) + setActionBarContent({ kind: ActionBarContentKind.Normal }) + } }, - }, - { ignore: () => false }, + [navigate, setActionBarContent], + ), ) useEffect(() => { @@ -195,11 +212,13 @@ function SearchBar() { function ActionButtons() { const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog) const setActionBarContent = useBookmarkPageStore((state) => state.setActionBarContent) + const toggleTagFilterWindow = useBookmarkPageStore((state) => state.toggleTagFilterWindow) useMnemonics( { a: addBookmark, s: openSearchBar, + t: toggleTagFilterWindow, }, { ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) }, ) @@ -220,10 +239,99 @@ function ActionButtons() { - + ) } + +function TagFilterWindow({ ref, style }: { ref: React.Ref; style: React.CSSProperties }) { + const { data: tags, status } = useTags() + const navigate = Route.useNavigate() + const { tags: tagsQuery } = Route.useSearch() + const filterByTags = new Set(tagsQuery ? tagsQuery.split(" ") : []) + + const onTagFilterItemChange = useCallback( + (tag: Tag, selected: boolean) => { + navigate({ + search: (prevSearch: Record) => { + const tagsQuery = prevSearch.tags + const tags = tagsQuery ? tagsQuery.split(" ").filter((tagName) => Boolean(tagName)) : [] + + if (selected) { + tags.push(tag.name) + } else { + const i = tags.indexOf(tag.name) + if (i >= 0) { + tags.splice(i, 1) + } + } + + if (tags.length === 0) { + return { ...prevSearch, tags: undefined } + } + + return { ...prevSearch, tags: tags.join(" ") } + }, + }) + }, + [navigate], + ) + + function content() { + switch (status) { + case "pending": + return ( +

+ Loading tags +

+ ) + case "success": + return ( +
    + {tags.map((tag) => ( + + ))} +
+ ) + case "error": + return Failed to load tags! + } + } + + return ( +
+

TAGS

+ {content()} +
+ ) +} + +const TagFilterItem = memo( + ({ tag, checked, onChange }: { tag: Tag; checked: boolean; onChange: (tag: Tag, selected: boolean) => void }) => { + const id = useId() + return ( +
  • + { + onChange(tag, event.target.checked) + }} + /> + +
  • + ) + }, +) + function LogOutButton() { const logOutMutation = useLogOut() const navigate = useNavigate() diff --git a/packages/web/src/components/tags-input.tsx b/packages/web/src/components/tags-input.tsx index 463ca62..41efbee 100644 --- a/packages/web/src/components/tags-input.tsx +++ b/packages/web/src/components/tags-input.tsx @@ -167,46 +167,43 @@ function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref { - event.preventDefault() - if (selectedTag) { - const i = filteredTags.findIndex((tag) => tag.id === selectedTag.id) - if (i === 0) { - setSelectedTag(null) - } else if (i === -1) { - setSelectedTag(filteredTags[0]) - } else { - setSelectedTag(filteredTags[i - 1]) - } - } else { - setSelectedTag(filteredTags.at(-1) ?? null) - } - }, - ArrowDown: (event) => { - event.preventDefault() - if (selectedTag) { - const i = filteredTags.findIndex((tag) => tag.id === selectedTag.id) - if (i === filteredTags.length - 1) { - setSelectedTag(null) - } else { - setSelectedTag(filteredTags[i + 1]) - } - } else { + useMnemonics({ + ArrowUp: (event) => { + event.preventDefault() + if (selectedTag) { + const i = filteredTags.findIndex((tag) => tag.id === selectedTag.id) + if (i === 0) { + setSelectedTag(null) + } else if (i === -1) { setSelectedTag(filteredTags[0]) + } else { + setSelectedTag(filteredTags[i - 1]) } - }, - Enter: (event) => { - if (lastTag) { - event.preventDefault() - event.stopPropagation() - addTag(selectedTag) - } - }, + } else { + setSelectedTag(filteredTags.at(-1) ?? null) + } }, - { ignore: () => false }, - ) + ArrowDown: (event) => { + event.preventDefault() + if (selectedTag) { + const i = filteredTags.findIndex((tag) => tag.id === selectedTag.id) + if (i === filteredTags.length - 1) { + setSelectedTag(null) + } else { + setSelectedTag(filteredTags[i + 1]) + } + } else { + setSelectedTag(filteredTags[0]) + } + }, + Enter: (event) => { + if (lastTag) { + event.preventDefault() + event.stopPropagation() + addTag(selectedTag) + } + }, + }) function addTag(selectedTag: Tag | null | undefined) { if (selectedTag) { diff --git a/packages/web/src/hooks/use-document-event.ts b/packages/web/src/hooks/use-document-event.ts new file mode 100644 index 0000000..2f5d8e9 --- /dev/null +++ b/packages/web/src/hooks/use-document-event.ts @@ -0,0 +1,15 @@ +import { useEffect } from "react" + +function useDocumentEvent( + type: TEvent, + listener: (this: Document, event: DocumentEventMap[TEvent]) => void, +) { + useEffect(() => { + document.addEventListener(type, listener) + return () => { + document.removeEventListener(type, listener) + } + }, [type, listener]) +} + +export { useDocumentEvent } diff --git a/packages/web/src/hooks/use-mnemonics.ts b/packages/web/src/hooks/use-mnemonics.ts index b30a993..0ada433 100644 --- a/packages/web/src/hooks/use-mnemonics.ts +++ b/packages/web/src/hooks/use-mnemonics.ts @@ -1,12 +1,14 @@ import { useEffect } from "react" +const DONT_IGNORE = () => false + function useMnemonics( mnemonicMap: Record void>, - { ignore }: { ignore: (event: KeyboardEvent) => boolean }, + { ignore }: { ignore: (event: KeyboardEvent) => boolean } = { ignore: DONT_IGNORE }, ) { useEffect(() => { function onKeyDown(event: KeyboardEvent) { - if (event.key === "Escape" || document.activeElement?.tagName !== "INPUT") { + if (!ignore(event)) { mnemonicMap[event.key]?.(event) } } @@ -14,7 +16,7 @@ function useMnemonics( return () => { document.removeEventListener("keydown", onKeyDown) } - }, [mnemonicMap]) + }, [mnemonicMap, ignore]) } export { useMnemonics }