409 lines
11 KiB
TypeScript
409 lines
11 KiB
TypeScript
import { autoUpdate, offset, useFloating } from "@floating-ui/react-dom"
|
|
import type { Tag } from "@markone/core"
|
|
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router"
|
|
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"
|
|
import { LoadingSpinner } from "~/components/loading-spinner"
|
|
import { Message, MessageVariant } from "~/components/message.tsx"
|
|
import { SideNav, SideNavItem } from "~/components/side-nav"
|
|
import { useDocumentEvent } from "~/hooks/use-document-event.ts"
|
|
import { useMnemonics } from "~/hooks/use-mnemonics.ts"
|
|
import { BookmarkList } from "./-bookmark-list"
|
|
import { ActionBarContentKind, DialogKind, WindowKind, useBookmarkPageStore } from "./-store"
|
|
|
|
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" />
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function BookmarkListPane() {
|
|
return (
|
|
<div className="flex flex-col py-16 container max-w-3xl md:flex-row lg:py-32">
|
|
<_SideNav />
|
|
<div className="flex-1">
|
|
<BookmarkListContainer />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function _SideNav() {
|
|
const navigate = useNavigate()
|
|
|
|
useMnemonics(
|
|
{
|
|
b: () => navigate({ to: "/bookmarks" }),
|
|
c: () => navigate({ to: "/collections" }),
|
|
},
|
|
{
|
|
ignore: () => {
|
|
const state = useBookmarkPageStore.getState()
|
|
return state.dialog.kind !== DialogKind.None || state.actionBarContent.kind === ActionBarContentKind.SearchBar
|
|
},
|
|
},
|
|
)
|
|
|
|
return (
|
|
<SideNav>
|
|
<SideNavItem to="/bookmarks" label="BOOKMARKS" active />
|
|
<SideNavItem to="/collections" label="COLLECTIONS" />
|
|
</SideNav>
|
|
)
|
|
}
|
|
|
|
function BookmarkListContainer() {
|
|
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()
|
|
},
|
|
)
|
|
const handleBookmarkListItemAction = useBookmarkPageStore((state) => state.handleBookmarkListItemAction)
|
|
|
|
switch (status) {
|
|
case "success":
|
|
return (
|
|
<BookmarkList
|
|
className={bookmarks.length > 0 ? "-mt-2" : ""}
|
|
alwaysExpandItem={false}
|
|
bookmarks={bookmarks}
|
|
onItemAction={handleBookmarkListItemAction}
|
|
/>
|
|
)
|
|
|
|
case "pending":
|
|
return (
|
|
<p>
|
|
Loading <LoadingSpinner />
|
|
</p>
|
|
)
|
|
|
|
case "error":
|
|
return <p>error loading bookmarks</p>
|
|
}
|
|
}
|
|
|
|
function BookmarkListActionBar({ className }: { className?: string }) {
|
|
const activeWindow = useBookmarkPageStore((state) => state.activeWindow)
|
|
const content = useBookmarkPageStore((state) => state.actionBarContent)
|
|
const { refs, floatingStyles } = useFloating({
|
|
placement: "top",
|
|
whileElementsMounted: autoUpdate,
|
|
middleware: [offset(8)],
|
|
})
|
|
|
|
return (
|
|
<>
|
|
<ActionBar ref={refs.setReference} className={className}>
|
|
{(() => {
|
|
switch (content.kind) {
|
|
case ActionBarContentKind.Normal:
|
|
return <ActionButtons />
|
|
case ActionBarContentKind.StatusMessage:
|
|
return <p>{content.message}</p>
|
|
case ActionBarContentKind.SearchBar:
|
|
return <SearchBar />
|
|
}
|
|
})()}
|
|
</ActionBar>
|
|
{(() => {
|
|
switch (activeWindow) {
|
|
case WindowKind.TagFilter:
|
|
return <TagFilterWindow ref={refs.setFloating} style={floatingStyles} />
|
|
case WindowKind.AppMenu:
|
|
return <AppMenuWindow ref={refs.setFloating} style={floatingStyles} />
|
|
case WindowKind.None:
|
|
return null
|
|
}
|
|
})()}
|
|
</>
|
|
)
|
|
}
|
|
|
|
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()
|
|
|
|
useDocumentEvent(
|
|
"keydown",
|
|
useCallback(
|
|
(event) => {
|
|
if (event.key === "Escape") {
|
|
navigate({
|
|
search: (prevSearch: Record<string, string | undefined>) => ({
|
|
...prevSearch,
|
|
q: undefined,
|
|
}),
|
|
})
|
|
setActionBarContent({ kind: ActionBarContentKind.Normal })
|
|
}
|
|
},
|
|
[navigate, setActionBarContent],
|
|
),
|
|
)
|
|
|
|
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 setActiveWindow = useBookmarkPageStore((state) => state.setActiveWindow)
|
|
const activeWindow = useBookmarkPageStore((state) => state.activeWindow)
|
|
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
|
const setActionBarContent = useBookmarkPageStore((state) => state.setActionBarContent)
|
|
|
|
useMnemonics(
|
|
{
|
|
a: addBookmark,
|
|
s: openSearchBar,
|
|
t: toggleTagWindow,
|
|
},
|
|
{ ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) },
|
|
)
|
|
|
|
function addBookmark() {
|
|
setActiveDialog({ kind: DialogKind.AddBookmark })
|
|
}
|
|
|
|
function openSearchBar() {
|
|
setActionBarContent({ kind: ActionBarContentKind.SearchBar })
|
|
if (activeWindow !== WindowKind.TagFilter) {
|
|
setActiveWindow(WindowKind.None)
|
|
}
|
|
}
|
|
|
|
function toggleTagWindow() {
|
|
setActiveWindow(activeWindow === WindowKind.TagFilter ? WindowKind.None : WindowKind.TagFilter)
|
|
}
|
|
|
|
function toggleAppMenu() {
|
|
setActiveWindow(activeWindow === WindowKind.AppMenu ? WindowKind.None : WindowKind.AppMenu)
|
|
}
|
|
|
|
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>
|
|
<Button onClick={toggleTagWindow}>
|
|
<span className="underline">T</span>AGS
|
|
</Button>
|
|
<Button onClick={toggleAppMenu}>⋯</Button>
|
|
</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 AppMenuWindow({ ref, style }: { ref: React.Ref<HTMLDivElement>; style: React.CSSProperties }) {
|
|
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">MENU</p>
|
|
<div className="p-4">
|
|
<ul className="space-x-4 flex justify-center">
|
|
<li>
|
|
<LogOutButton />
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</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 }
|