implement tag filtering ui

This commit is contained in:
2025-05-31 13:44:08 +01:00
parent 835a76709c
commit 11d4cc19af
6 changed files with 208 additions and 76 deletions

View File

@@ -60,7 +60,7 @@ async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user
if (queryParams.q || queryParams.tags) { if (queryParams.q || queryParams.tags) {
let tagIds: TagId[] = [] let tagIds: TagId[] = []
if (queryParams.tags) { if (queryParams.tags) {
const tagNames = queryParams.tags.split(",") const tagNames = queryParams.tags.split(" ")
const tagIdsQuery = db.query<{ id: string }, string[]>( const tagIdsQuery = db.query<{ id: string }, string[]>(
`SELECT id FROM tags WHERE name IN (${Array(tagNames.length).fill("?").join(",")})`, `SELECT id FROM tags WHERE name IN (${Array(tagNames.length).fill("?").join(",")})`,
) )

View File

@@ -52,12 +52,14 @@ interface BookmarkPageState {
bookmarkToBeEdited: Bookmark | null bookmarkToBeEdited: Bookmark | null
layoutMode: LayoutMode layoutMode: LayoutMode
dialog: DialogData dialog: DialogData
hasDialog: boolean
actionBarContent: ActionBarContent actionBarContent: ActionBarContent
statusMessage: string statusMessage: string
isSearchBarActive: boolean isTagFilterWindowOpen: boolean
openSearchBar: () => void openTagFilterWindow: () => void
closeSearchBar: () => void closeTagFilterWindow: () => void
toggleTagFilterWindow: () => void
setActionBarContent: (content: ActionBarContent) => void setActionBarContent: (content: ActionBarContent) => void
handleBookmarkListItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void handleBookmarkListItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void
setActiveDialog: (dialog: DialogData) => void setActiveDialog: (dialog: DialogData) => void
@@ -73,14 +75,22 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
dialog: NO_DIALOG, dialog: NO_DIALOG,
actionBarContent: { kind: ActionBarContentKind.Normal }, actionBarContent: { kind: ActionBarContentKind.Normal },
statusMessage: "", statusMessage: "",
isSearchBarActive: false, isTagFilterWindowOpen: false,
openSearchBar() { get hasDialog(): boolean {
set({ isSearchBarActive: true }) return get().dialog.kind !== DialogKind.None
}, },
closeSearchBar() { toggleTagFilterWindow() {
set({ isSearchBarActive: false }) set({ isTagFilterWindowOpen: !get().isTagFilterWindowOpen })
},
openTagFilterWindow() {
set({ isTagFilterWindowOpen: true })
},
closeTagFilterWindow() {
set({ isTagFilterWindowOpen: false })
}, },
setActionBarContent(content: ActionBarContent) { setActionBarContent(content: ActionBarContent) {

View File

@@ -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 { 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 { fetchApi, useAuthenticatedQuery } from "~/api"
import { ActionBar } from "~/app/bookmarks/-action-bar.tsx" import { ActionBar } from "~/app/bookmarks/-action-bar.tsx"
import { useLogOut } from "~/auth.ts" import { useLogOut } from "~/auth.ts"
import { useTags } from "~/bookmark/api.ts"
import { Button } from "~/components/button.tsx" import { Button } from "~/components/button.tsx"
import { LoadingSpinner } from "~/components/loading-spinner" 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 { useMnemonics } from "~/hooks/use-mnemonics.ts"
import { BookmarkList } from "./-bookmark-list" import { BookmarkList } from "./-bookmark-list"
import { ActionBarContentKind, DialogKind, useBookmarkPageStore } from "./-store" import { ActionBarContentKind, DialogKind, useBookmarkPageStore } from "./-store"
@@ -73,20 +78,30 @@ function BookmarkListContainer() {
} }
function BookmarkListActionBar({ className }: { className?: string }) { function BookmarkListActionBar({ className }: { className?: string }) {
const isTagFilterWindowOpen = useBookmarkPageStore((state) => state.isTagFilterWindowOpen)
const content = useBookmarkPageStore((state) => state.actionBarContent) const content = useBookmarkPageStore((state) => state.actionBarContent)
const { refs, floatingStyles } = useFloating({
placement: "top",
whileElementsMounted: autoUpdate,
middleware: [offset(8)],
})
return ( return (
<ActionBar className={className}> <>
{(() => { <ActionBar ref={refs.setReference} className={className}>
switch (content.kind) { {(() => {
case ActionBarContentKind.Normal: switch (content.kind) {
return <ActionButtons /> case ActionBarContentKind.Normal:
case ActionBarContentKind.StatusMessage: return <ActionButtons />
return <p>{content.message}</p> case ActionBarContentKind.StatusMessage:
case ActionBarContentKind.SearchBar: return <p>{content.message}</p>
return <SearchBar /> case ActionBarContentKind.SearchBar:
} return <SearchBar />
})()} }
</ActionBar> })()}
</ActionBar>
{isTagFilterWindowOpen ? <TagFilterWindow ref={refs.setFloating} style={floatingStyles} /> : null}
</>
) )
} }
@@ -98,20 +113,22 @@ function SearchBar() {
const navigate = Route.useNavigate() const navigate = Route.useNavigate()
const { q } = Route.useSearch() const { q } = Route.useSearch()
useMnemonics( useDocumentEvent(
{ "keydown",
Escape: () => { useCallback(
navigate({ (event) => {
search: (prevSearch: Record<string, string | undefined>) => ({ if (event.key === "Escape") {
...prevSearch, navigate({
tags: undefined, search: (prevSearch: Record<string, string | undefined>) => ({
q: undefined, ...prevSearch,
}), q: undefined,
}) }),
setActionBarContent({ kind: ActionBarContentKind.Normal }) })
setActionBarContent({ kind: ActionBarContentKind.Normal })
}
}, },
}, [navigate, setActionBarContent],
{ ignore: () => false }, ),
) )
useEffect(() => { useEffect(() => {
@@ -195,11 +212,13 @@ function SearchBar() {
function ActionButtons() { function ActionButtons() {
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog) const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
const setActionBarContent = useBookmarkPageStore((state) => state.setActionBarContent) const setActionBarContent = useBookmarkPageStore((state) => state.setActionBarContent)
const toggleTagFilterWindow = useBookmarkPageStore((state) => state.toggleTagFilterWindow)
useMnemonics( useMnemonics(
{ {
a: addBookmark, a: addBookmark,
s: openSearchBar, s: openSearchBar,
t: toggleTagFilterWindow,
}, },
{ ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) }, { ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) },
) )
@@ -220,10 +239,99 @@ function ActionButtons() {
<Button onClick={openSearchBar}> <Button onClick={openSearchBar}>
<span className="underline">S</span>EARCH <span className="underline">S</span>EARCH
</Button> </Button>
<LogOutButton /> <Button onClick={toggleTagFilterWindow}>
<span className="underline">T</span>AGS
</Button>
</div> </div>
) )
} }
function TagFilterWindow({ ref, style }: { ref: React.Ref<HTMLDivElement>; 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<string, string | undefined>) => {
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 (
<p className="p-4 text-center">
Loading tags <LoadingSpinner />
</p>
)
case "success":
return (
<ul className="p-4 flex flex-row flex-wrap gap-2">
{tags.map((tag) => (
<TagFilterItem
key={tag.id}
tag={tag}
checked={filterByTags.has(tag.name)}
onChange={onTagFilterItemChange}
/>
))}
</ul>
)
case "error":
return <Message variant={MessageVariant.Error}>Failed to load tags!</Message>
}
}
return (
<div ref={ref} style={style} className="border w-full md:w-100">
<p className="bg-stone-900 dark:bg-stone-200 text-stone-300 dark:text-stone-800 text-center">TAGS</p>
{content()}
</div>
)
}
const TagFilterItem = memo(
({ tag, checked, onChange }: { tag: Tag; checked: boolean; onChange: (tag: Tag, selected: boolean) => void }) => {
const id = useId()
return (
<li key={tag.id} className="flex flex-row items-center space-x-1">
<input
id={id}
type="checkbox"
checked={checked}
onChange={(event) => {
onChange(tag, event.target.checked)
}}
/>
<label htmlFor={id}>#{tag.name}</label>
</li>
)
},
)
function LogOutButton() { function LogOutButton() {
const logOutMutation = useLogOut() const logOutMutation = useLogOut()
const navigate = useNavigate() const navigate = useNavigate()

View File

@@ -167,46 +167,43 @@ function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref<HTMLDivEle
} }
}, [shouldResetSelection]) }, [shouldResetSelection])
useMnemonics( useMnemonics({
{ ArrowUp: (event) => {
ArrowUp: (event) => { event.preventDefault()
event.preventDefault() if (selectedTag) {
if (selectedTag) { const i = filteredTags.findIndex((tag) => tag.id === selectedTag.id)
const i = filteredTags.findIndex((tag) => tag.id === selectedTag.id) if (i === 0) {
if (i === 0) { setSelectedTag(null)
setSelectedTag(null) } else if (i === -1) {
} 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 {
setSelectedTag(filteredTags[0]) setSelectedTag(filteredTags[0])
} else {
setSelectedTag(filteredTags[i - 1])
} }
}, } else {
Enter: (event) => { setSelectedTag(filteredTags.at(-1) ?? null)
if (lastTag) { }
event.preventDefault()
event.stopPropagation()
addTag(selectedTag)
}
},
}, },
{ 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) { function addTag(selectedTag: Tag | null | undefined) {
if (selectedTag) { if (selectedTag) {

View File

@@ -0,0 +1,15 @@
import { useEffect } from "react"
function useDocumentEvent<TEvent extends keyof DocumentEventMap>(
type: TEvent,
listener: (this: Document, event: DocumentEventMap[TEvent]) => void,
) {
useEffect(() => {
document.addEventListener<TEvent>(type, listener)
return () => {
document.removeEventListener(type, listener)
}
}, [type, listener])
}
export { useDocumentEvent }

View File

@@ -1,12 +1,14 @@
import { useEffect } from "react" import { useEffect } from "react"
const DONT_IGNORE = () => false
function useMnemonics( function useMnemonics(
mnemonicMap: Record<string, (event: KeyboardEvent) => void>, mnemonicMap: Record<string, (event: KeyboardEvent) => void>,
{ ignore }: { ignore: (event: KeyboardEvent) => boolean }, { ignore }: { ignore: (event: KeyboardEvent) => boolean } = { ignore: DONT_IGNORE },
) { ) {
useEffect(() => { useEffect(() => {
function onKeyDown(event: KeyboardEvent) { function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape" || document.activeElement?.tagName !== "INPUT") { if (!ignore(event)) {
mnemonicMap[event.key]?.(event) mnemonicMap[event.key]?.(event)
} }
} }
@@ -14,7 +16,7 @@ function useMnemonics(
return () => { return () => {
document.removeEventListener("keydown", onKeyDown) document.removeEventListener("keydown", onKeyDown)
} }
}, [mnemonicMap]) }, [mnemonicMap, ignore])
} }
export { useMnemonics } export { useMnemonics }