implement bookmark search

This commit is contained in:
2025-05-29 00:11:17 +01:00
parent 347451dbbc
commit d5a3266870
16 changed files with 425 additions and 119 deletions

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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() {
</h1>
</header>
<BookmarkListContainer />
<BookmarkListActionBar className="absolute bottom-0 left-0 right-0" />
<BookmarkListActionBar />
</div>
)
}
@@ -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 (
<ActionBar className="absolute bottom-0 left-0 right-0">
<Button onClick={addBookmark}>
<span className="underline">A</span>DD
</Button>
</ActionBar>
)
}
function BookmarkPreview() {
const { bookmarkId } = Route.useParams()
const { data: previewHtml, status: previewQueryStatus } = useAuthenticatedQuery(

View File

@@ -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 (
<ActionBar className={className}>
{statusMessage ? (
<p>{statusMessage}</p>
) : (
<>
<Button onClick={addBookmark}>
<span className="underline">A</span>DD
</Button>
<Button>
<span className="underline">S</span>EARCH
</Button>
<LogOutButton />
</>
)}
</ActionBar>
)
}
function LogOutButton() {
const logOutMutation = useLogOut()
const navigate = useNavigate()
function logOut() {
logOutMutation.mutate()
navigate({ to: "/", replace: true })
}
return (
<Button disabled={logOutMutation.isPending} onClick={logOut}>
LOG OUT
</Button>
)
}
export { ActionBar, BookmarkListActionBar }
export { ActionBar }

View File

@@ -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)

View File

@@ -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<BookmarkPageState>()((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<BookmarkPageState>()((set, get) => ({
},
}))
export { LayoutMode, DialogKind, useBookmarkPageStore }
export { LayoutMode, DialogKind, ActionBarContentKind, useBookmarkPageStore }
export type { BookmarkPageState }

View File

@@ -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 <p>error loading bookmarks</p>
}
}
function BookmarkListActionBar({ className }: { className?: string }) {
const content = useBookmarkPageStore((state) => state.actionBarContent)
return (
<ActionBar className={className}>
{(() => {
switch (content.kind) {
case ActionBarContentKind.Normal:
return <ActionButtons />
case ActionBarContentKind.StatusMessage:
return (
<div className="border-t-1 flex flex-row justify-center py-4 space-x-4">
<p>{content.message}</p>
</div>
)
case ActionBarContentKind.SearchBar:
return <SearchBar />
}
})()}
</ActionBar>
)
}
function SearchBar() {
const searchInputId = useId()
const inputRef = useRef<HTMLInputElement | null>(null)
const setActionBarContent = useBookmarkPageStore((state) => state.setActionBarContent)
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const navigate = Route.useNavigate()
const { q } = Route.useSearch()
useMnemonics(
{
Escape: () => {
navigate({
search: (prevSearch: Record<string, string | undefined>) => ({
...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<HTMLInputElement>) {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
const q = event.currentTarget.value
searchTimeoutRef.current = setTimeout(() => {
navigate({
search: (prevSearch: Record<string, string | undefined>) => {
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 (
<div className="-my-4 w-full flex items-center justify-center">
<label className="p-4 bg-stone-900 dark:bg-stone-200 text-stone-300 dark:text-stone-800" htmlFor={searchInputId}>
SEARCH
</label>
<input
ref={inputRef}
id={searchInputId}
type="text"
className="flex-1 text-center outline-0 ring-0"
defaultValue={q ?? ""}
onChange={pushSearchQuery}
/>
<Button className="mx-2" onClick={closeSearchBar}>
CLOSE
</Button>
</div>
)
}
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 (
<div className="flex flex-row justify-center space-x-4">
<Button onClick={addBookmark}>
<span className="underline">A</span>DD
</Button>
<Button onClick={openSearchBar}>
<span className="underline">S</span>EARCH
</Button>
<LogOutButton />
</div>
)
}
function LogOutButton() {
const logOutMutation = useLogOut()
const navigate = useNavigate()
function logOut() {
logOutMutation.mutate()
navigate({ to: "/", replace: true })
}
return (
<Button disabled={logOutMutation.isPending} onClick={logOut}>
LOG OUT
</Button>
)
}
export { BookmarkListActionBar }

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"
import { useEffect } from "react"
function useMnemonics(
mnemonicMap: Record<string, (event: KeyboardEvent) => 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 }