Files
markone/packages/web/src/app/bookmarks/index.tsx

244 lines
6.2 KiB
TypeScript
Raw Normal View History

2025-05-29 00:11:17 +01:00
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { useCallback, useEffect, useId, useRef } from "react"
2025-05-13 18:34:08 +01:00
import { fetchApi, useAuthenticatedQuery } from "~/api"
2025-05-29 00:11:17 +01:00
import { ActionBar } from "~/app/bookmarks/-action-bar.tsx"
import { useLogOut } from "~/auth.ts"
import { Button } from "~/components/button.tsx"
2025-05-13 18:34:08 +01:00
import { LoadingSpinner } from "~/components/loading-spinner"
2025-05-29 00:11:17 +01:00
import { useMnemonics } from "~/hooks/use-mnemonics.ts"
import { BookmarkList } from "./-bookmark-list"
import { ActionBarContentKind, DialogKind, useBookmarkPageStore } from "./-store"
2025-05-13 18:34:08 +01:00
export const Route = createFileRoute("/bookmarks/")({
component: RouteComponent,
})
function RouteComponent() {
return (
<main className="w-full flex justify-center">
<BookmarkListPane />
<BookmarkListActionBar className="fixed left-0 right-0 bottom-0" />
2025-05-13 18:34:08 +01:00
</main>
)
}
function BookmarkListPane() {
return (
<div className="flex flex-col py-16 container max-w-3xl md:flex-row lg:py-32">
<header className="mb-4 md:mb-0 md:mr-16 text-start">
<h1 className="font-bold text-start">
<span className="invisible md:hidden">&nbsp;&gt;&nbsp;</span>
YOUR BOOKMARKS
</h1>
</header>
<div className="flex-1">
<BookmarkListContainer />
</div>
</div>
)
}
function BookmarkListContainer() {
2025-05-25 15:40:16 +01:00
const searchParamsString = new URLSearchParams(Route.useSearch()).toString()
const { data: bookmarks, status } = useAuthenticatedQuery(
searchParamsString ? ["bookmarks", searchParamsString] : ["bookmarks"],
async () => {
const res = await fetchApi(searchParamsString ? `/bookmarks?${searchParamsString}` : "/bookmarks")
return await res.json()
},
)
2025-05-13 18:34:08 +01:00
const handleBookmarkListItemAction = useBookmarkPageStore((state) => state.handleBookmarkListItemAction)
switch (status) {
case "success":
return (
<BookmarkList
className="-mt-2"
alwaysExpandItem={false}
bookmarks={bookmarks}
onItemAction={handleBookmarkListItemAction}
/>
)
case "pending":
return (
<p>
Loading <LoadingSpinner />
</p>
)
case "error":
return <p>error loading bookmarks</p>
}
}
2025-05-29 00:11:17 +01:00
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:
2025-05-29 14:17:52 +01:00
return <p>{content.message}</p>
2025-05-29 00:11:17 +01:00
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 }