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

247 lines
6.0 KiB
TypeScript

import { Outlet, createFileRoute } from "@tanstack/react-router"
import { useState, useId, useRef, useEffect } from "react"
import { BadRequestError, ApiErrorCode } from "~/api"
import { useCreateBookmark, useDeleteBookmark } from "~/bookmark/api"
import { Button } from "~/components/button"
import { Dialog, DialogTitle, DialogBody, DialogActionRow } from "~/components/dialog"
import { FormField } from "~/components/form-field"
import { LoadingSpinner } from "~/components/loading-spinner"
import { Message, MessageVariant } from "~/components/message"
import { useMnemonics } from "~/hooks/use-mnemonics"
import { useBookmarkPageStore, ActiveDialog, LayoutMode } from "./bookmarks/-store"
export const Route = createFileRoute("/bookmarks")({
component: RouteComponent,
})
function RouteComponent() {
const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode)
useEffect(() => {
function mediaQueryListener(this: MediaQueryList) {
console.log(this.matches)
if (this.matches) {
setLayoutMode(LayoutMode.SideBySide)
} else {
setLayoutMode(LayoutMode.Popup)
}
}
const q = window.matchMedia("(width >= 64rem)")
q.addEventListener("change", mediaQueryListener)
mediaQueryListener.call(q)
return () => {
q.removeEventListener("change", mediaQueryListener)
}
}, [setLayoutMode])
return (
<div className="relative">
<Outlet />
<PageDialog />
</div>
)
}
function PageDialog() {
const dialog = useBookmarkPageStore((state) => state.activeDialog)
switch (dialog) {
case ActiveDialog.None:
return null
case ActiveDialog.AddBookmark:
return <AddBookmarkDialog />
case ActiveDialog.DeleteBookmark:
return <DeleteBookmarkDialog />
}
}
function AddBookmarkDialog() {
const [isWebsiteUnreachable, setIsWebsiteUnreachable] = useState(false)
const createBookmarkMutation = useCreateBookmark()
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
const formId = useId()
const linkInputRef = useRef<HTMLInputElement | null>(null)
useMnemonics(
{
c: () => {
if (linkInputRef.current !== document.activeElement) {
cancel()
}
},
Escape: () => {
linkInputRef.current?.blur()
},
},
{ ignore: () => false },
)
useEffect(() => {
setTimeout(() => {
if (linkInputRef.current) {
linkInputRef.current.focus()
}
}, 0)
}, [])
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const url = formData.get("link")
if (url && typeof url === "string") {
try {
await createBookmarkMutation.mutateAsync({ url, force: isWebsiteUnreachable })
setActiveDialog(ActiveDialog.None)
} catch (error) {
if (error instanceof BadRequestError && error.code === ApiErrorCode.WebsiteUnreachable) {
setIsWebsiteUnreachable(true)
} else {
setIsWebsiteUnreachable(false)
}
}
}
}
function cancel() {
setActiveDialog(ActiveDialog.None)
}
function message() {
if (createBookmarkMutation.isPending) {
return (
<p>
Loading <LoadingSpinner />
</p>
)
}
if (isWebsiteUnreachable) {
return (
<Message variant={MessageVariant.Warning} className="px-4">
The link does not seem to be reachable. Click "SAVE" to save anyways.
</Message>
)
}
if (createBookmarkMutation.status === "error") {
return (
<Message variant={MessageVariant.Error} className="px-4">
An error occurred when saving bookmark
</Message>
)
}
return null
}
return (
<Dialog>
<DialogTitle>NEW BOOKMARK</DialogTitle>
<DialogBody>
{message()}
<form id={formId} className="px-8" onSubmit={onSubmit}>
<FormField
ref={linkInputRef}
type="text"
name="link"
label="LINK"
className="w-full"
labelClassName="bg-stone-300 dark:bg-stone-800"
/>
</form>
</DialogBody>
<DialogActionRow>
<Button type="submit" disabled={createBookmarkMutation.isPending} form={formId}>
SAVE
</Button>
<Button type="button" disabled={createBookmarkMutation.isPending} onClick={cancel}>
<span className="underline">C</span>ANCEL
</Button>
</DialogActionRow>
</Dialog>
)
}
function DeleteBookmarkDialog() {
// biome-ignore lint/style/noNonNullAssertion: this cannot be null when delete bookmark dialog is visible
const bookmark = useBookmarkPageStore((state) => state.bookmarkToBeDeleted!)
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
const markBookmarkForDeletion = useBookmarkPageStore((state) => state.markBookmarkForDeletion)
const deleteBookmarkMutation = useDeleteBookmark()
useMnemonics(
{
y: proceed,
n: cancel,
},
{ ignore: () => false },
)
async function proceed() {
try {
await deleteBookmarkMutation.mutateAsync({ bookmark })
setActiveDialog(ActiveDialog.None)
markBookmarkForDeletion(null)
} catch (error) {
console.error(error)
}
}
function cancel() {
setActiveDialog(ActiveDialog.None)
markBookmarkForDeletion(null)
}
function body() {
switch (deleteBookmarkMutation.status) {
case "pending":
return (
<p>
Deleting <LoadingSpinner />
</p>
)
case "idle":
return (
<p>
The bookmark titled:
<br />
<br />
<strong>
<em>"{bookmark.title}"</em>
</strong>
<br />
<br />
will be deleted. Proceed?
</p>
)
case "error":
return <p className="text-red-500">Failed to delete the bookmark!</p>
}
}
function title() {
switch (deleteBookmarkMutation.status) {
case "pending":
return "PLEASE WAIT"
case "idle":
return "CONFIRM"
case "error":
return "ERROR OCCURRED"
}
}
return (
<Dialog>
<DialogTitle>{title()}</DialogTitle>
<DialogBody>{body()}</DialogBody>
<DialogActionRow>
<Button disabled={deleteBookmarkMutation.isPending} onClick={proceed}>
{deleteBookmarkMutation.isError ? "Retry" : "Proceed"} (y)
</Button>
<Button disabled={deleteBookmarkMutation.isPending} onClick={cancel}>
Cancel (n)
</Button>
</DialogActionRow>
</Dialog>
)
}