implement tag filtering ui
This commit is contained in:
@@ -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(",")})`,
|
||||||
)
|
)
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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()
|
||||||
|
@@ -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) {
|
||||||
|
15
packages/web/src/hooks/use-document-event.ts
Normal file
15
packages/web/src/hooks/use-document-event.ts
Normal 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 }
|
@@ -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 }
|
||||||
|
Reference in New Issue
Block a user