move bookmark preview to its own route

This commit is contained in:
2025-05-13 18:34:08 +01:00
parent 7f8b38e218
commit c73082b9c9
10 changed files with 861 additions and 606 deletions

View File

@@ -1,124 +1,29 @@
import type { LinkBookmark } from "@markone/core/bookmark"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import clsx from "clsx"
import { useCallback, useEffect, useId, useRef, useState } from "react"
import { create } from "zustand"
import { fetchApi, useAuthenticatedQuery, BadRequestError, ApiErrorCode } from "~/api"
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, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
import { Message, MessageVariant } from "~/components/message"
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 { useLogOut } from "~/auth"
import { useBookmarkPageStore, ActiveDialog, LayoutMode } from "./bookmarks/-store"
const LAYOUT_MODE = {
popup: "popup",
sideBySide: "side-by-side",
} as const
type LayoutMode = (typeof LAYOUT_MODE)[keyof typeof LAYOUT_MODE]
export const Route = createFileRoute("/bookmarks")({
component: RouteComponent,
})
enum ActiveDialog {
None = "None",
AddBookmark = "AddBookmark",
DeleteBookmark = "DeleteBookmark",
}
interface BookmarkPageState {
bookmarkCount: number
selectedBookmarkId: string
selectedBookmarkIndex: number
isBookmarkItemExpanded: boolean
isBookmarkPreviewOpened: boolean
bookmarkToBeDeleted: LinkBookmark | null
layoutMode: LayoutMode
activeDialog: ActiveDialog
actionBarHeight: number
setActionBarHeight: (height: number) => void
setActiveDialog: (dialog: ActiveDialog) => void
setBookmarkItemExpanded: (isExpanded: boolean) => void
setBookmarkPreviewOpened: (isOpened: boolean) => void
setLayoutMode: (mode: LayoutMode) => void
selectBookmark: (bookmark: LinkBookmark, index: number) => void
reconcileSelection: (bookmarks: LinkBookmark[]) => void
markBookmarkForDeletion: (bookmark: LinkBookmark | null) => void
}
const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
bookmarkCount: 0,
bookmarks: [],
selectedBookmarkId: "",
selectedBookmarkIndex: 0,
isBookmarkItemExpanded: false,
isBookmarkPreviewOpened: false,
bookmarkToBeDeleted: null,
layoutMode: LAYOUT_MODE.popup,
activeDialog: ActiveDialog.None,
actionBarHeight: 0,
setActionBarHeight(height: number) {
set({ actionBarHeight: height })
},
setActiveDialog(dialog: ActiveDialog) {
set({ activeDialog: dialog })
},
setBookmarkItemExpanded(isExpanded: boolean) {
set({ isBookmarkItemExpanded: isExpanded })
},
setBookmarkPreviewOpened(isOpened: boolean) {
set({ isBookmarkPreviewOpened: isOpened })
},
setLayoutMode(mode: LayoutMode) {
set({ layoutMode: mode })
},
selectBookmark(bookmark: LinkBookmark, index: number) {
set({ selectedBookmarkId: bookmark.id, selectedBookmarkIndex: index })
},
reconcileSelection(bookmarks: LinkBookmark[]) {
const { selectedBookmarkId, selectedBookmarkIndex } = get()
if (bookmarks.length === 0) {
set({ selectedBookmarkId: "", selectedBookmarkIndex: 0 })
} else if (!selectedBookmarkId) {
set({ selectedBookmarkId: bookmarks[selectedBookmarkIndex].id })
} else {
const newIndex = bookmarks.findIndex((bookmark) => bookmark.id === selectedBookmarkId)
if (newIndex !== selectedBookmarkIndex) {
if (newIndex >= 0) {
set({ selectedBookmarkIndex: newIndex })
} else if (selectedBookmarkIndex >= bookmarks.length - 1) {
set({ selectedBookmarkIndex: bookmarks.length - 1 })
} else if (selectedBookmarkIndex === 0) {
set({ selectedBookmarkIndex: 0 })
} else {
set({ selectedBookmarkIndex: selectedBookmarkIndex + 1 })
}
}
}
},
markBookmarkForDeletion(bookmark: LinkBookmark | null) {
set({ bookmarkToBeDeleted: bookmark })
},
}))
function Page() {
function RouteComponent() {
const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode)
useEffect(() => {
function mediaQueryListener(this: MediaQueryList) {
console.log(this.matches)
if (this.matches) {
setLayoutMode(LAYOUT_MODE.sideBySide)
setLayoutMode(LayoutMode.SideBySide)
} else {
setLayoutMode(LAYOUT_MODE.popup)
setLayoutMode(LayoutMode.Popup)
}
}
@@ -134,61 +39,11 @@ function Page() {
return (
<div className="relative">
<Main>
<BookmarkListPane />
<BookmarkPreview />
<ActionBar />
</Main>
<Outlet />
<PageDialog />
</div>
)
}
function Main({ children }: React.PropsWithChildren) {
const isPreviewOpened = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
return (
<main
className={clsx(
"relative w-full",
isPreviewOpened && layoutMode === LAYOUT_MODE.sideBySide ? "grid grid-cols-3" : "flex justify-center",
)}
>
{children}
</main>
)
}
function BookmarkListPane() {
const isBookmarkPreviewOpened = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
return (
<div
className={clsx(
"flex flex-col py-16",
isBookmarkPreviewOpened ? "w-full" : "container max-w-3xl md:flex-row lg:py-32",
)}
>
<header className="mb-4 md:mb-0 md:mr-16 text-start">
<h1 className={clsx("font-bold text-start", { "mb-4": isBookmarkPreviewOpened })}>
<span
className={clsx("invisible", {
"md:hidden": !isBookmarkPreviewOpened,
})}
>
&nbsp;&gt;&nbsp;
</span>
YOUR BOOKMARKS
</h1>
</header>
<div className="flex-1">
<BookmarkListSection />
</div>
</div>
)
}
function PageDialog() {
const dialog = useBookmarkPageStore((state) => state.activeDialog)
switch (dialog) {
@@ -201,90 +56,6 @@ function PageDialog() {
}
}
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>
)
}
function AddBookmarkDialog() {
const [isWebsiteUnreachable, setIsWebsiteUnreachable] = useState(false)
const createBookmarkMutation = useCreateBookmark()
@@ -390,367 +161,86 @@ function AddBookmarkDialog() {
)
}
function BookmarkListSection() {
const { data: bookmarks, status } = useAuthenticatedQuery(["bookmarks"], () =>
fetchApi("/bookmarks").then((res) => res.json()),
)
switch (status) {
case "pending":
return (
<p>
Loading&nbsp;
<LoadingSpinner />
</p>
)
case "success":
if (bookmarks) {
return <BookmarkList bookmarks={bookmarks} />
}
return null
default:
return null
}
}
function BookmarkList({ bookmarks }: { bookmarks: LinkBookmark[] }) {
const reconcileSelection = useBookmarkPageStore((state) => state.reconcileSelection)
useEffect(() => {
reconcileSelection(bookmarks)
}, [bookmarks, reconcileSelection])
useMnemonics(
{
j: selectNextItem,
ArrowDown: selectNextItem,
k: selectPrevItem,
ArrowUp: selectPrevItem,
h: collapseItem,
ArrowLeft: collapseItem,
l: expandItem,
ArrowRight: expandItem,
Enter: () => {
const state = useBookmarkPageStore.getState()
expandItem()
state.setBookmarkPreviewOpened(true)
},
},
{
ignore: useCallback(() => useBookmarkPageStore.getState().activeDialog !== ActiveDialog.None, []),
},
)
function selectPrevItem() {
const state = useBookmarkPageStore.getState()
const prevIndex = state.selectedBookmarkIndex - 1
if (prevIndex >= 0) {
state.selectBookmark(bookmarks[prevIndex], prevIndex)
}
}
function selectNextItem() {
const state = useBookmarkPageStore.getState()
const nextIndex = state.selectedBookmarkIndex + 1
if (nextIndex < bookmarks.length) {
state.selectBookmark(bookmarks[nextIndex], nextIndex)
}
}
function expandItem() {
const state = useBookmarkPageStore.getState()
state.setBookmarkItemExpanded(true)
}
function collapseItem() {
const state = useBookmarkPageStore.getState()
state.setBookmarkItemExpanded(false)
state.setBookmarkPreviewOpened(false)
}
return (
<div className="flex flex-col -mt-2">
{bookmarks.length === 0 ? (
<p className="mt-2">You have not saved any bookmark!</p>
) : (
bookmarks.map((bookmark, i) => <BookmarkListItem key={bookmark.id} index={i} bookmark={bookmark} />)
)}
</div>
)
}
function BookmarkPreview() {
const isVisible = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
if (!isVisible) {
return null
}
return (
<div
className={clsx(
"h-screen col-span-2 flex justify-center items-center border-l border-stone-700 dark:border-stone-300 flex dark:bg-stone-900",
{
"absolute inset-0 border-l-0": layoutMode === LAYOUT_MODE.popup,
},
)}
>
<BookmarkPreviewContent />
</div>
)
}
function BookmarkPreviewContent() {
const selectedBookmarkId = useBookmarkPageStore((state) => state.selectedBookmarkId)
const actionBarHeight = useBookmarkPageStore((state) => state.actionBarHeight)
const { data, status } = useAuthenticatedQuery(["bookmarks", `${selectedBookmarkId}.html`], () =>
fetchApi(`/bookmark/${selectedBookmarkId}`, {
headers: {
Accept: "text/html",
},
}).then((res) => res.text()),
)
switch (status) {
case "pending":
return (
<p>
Loading <LoadingSpinner />
</p>
)
case "success":
return (
<iframe
key="preview-iframe"
title="asd"
className={clsx("w-full h-full")}
style={{ paddingBottom: actionBarHeight }}
srcDoc={data}
/>
)
default:
return null
}
}
function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index: number }) {
const url = new URL(bookmark.url)
const selectedBookmarkIndex = useBookmarkPageStore((state) => state.selectedBookmarkIndex)
const isBookmarkItemExpanded = useBookmarkPageStore((state) => state.isBookmarkItemExpanded)
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 setBookmarkItemExpanded = useBookmarkPageStore((state) => state.setBookmarkItemExpanded)
const selectBookmark = useBookmarkPageStore((state) => state.selectBookmark)
const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
const markBookmarkForDeletion = useBookmarkPageStore((state) => state.markBookmarkForDeletion)
const isSelected = selectedBookmarkIndex === index
const deleteBookmarkMutation = useDeleteBookmark()
useMnemonics(
{
d: deleteItem,
},
{
ignore: useCallback(
() => !isSelected || useBookmarkPageStore.getState().activeDialog !== ActiveDialog.None,
[isSelected],
),
y: proceed,
n: cancel,
},
{ ignore: () => false },
)
function deleteItem() {
if (isSelected) {
markBookmarkForDeletion(bookmark)
setActiveDialog(ActiveDialog.DeleteBookmark)
async function proceed() {
try {
await deleteBookmarkMutation.mutateAsync({ bookmark })
setActiveDialog(ActiveDialog.None)
markBookmarkForDeletion(null)
} catch (error) {
console.error(error)
}
}
function expandOrOpenPreview() {
if (!isSelected) {
selectBookmark(bookmark, index)
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>
}
setBookmarkItemExpanded(true)
if (useBookmarkPageStore.getState().layoutMode === LAYOUT_MODE.sideBySide) {
setBookmarkPreviewOpened(true)
}
function title() {
switch (deleteBookmarkMutation.status) {
case "pending":
return "PLEASE WAIT"
case "idle":
return "CONFIRM"
case "error":
return "ERROR OCCURRED"
}
}
return (
<div
className={clsx("group flex flex-row justify-start py-2", {
"bg-teal-600 text-stone-100": isBookmarkItemExpanded && isSelected,
"text-teal-600": isSelected && !isBookmarkItemExpanded,
})}
onMouseEnter={() => {
if (!isBookmarkItemExpanded) {
selectBookmark(bookmark, index)
}
}}
>
<button
type="button"
disabled={!isSelected}
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
invisible: !isSelected,
})}
onClick={() => {
setBookmarkItemExpanded(!isBookmarkItemExpanded)
setBookmarkPreviewOpened(false)
}}
>
<span className="sr-only">Options for this bookmark</span>
<span>&nbsp;</span>
<span className={isBookmarkItemExpanded ? "rotate-90" : ""}>&gt;</span>
<span>&nbsp;</span>
</button>
<div className="flex flex-col w-full">
<button type="button" className="text-start font-bold" onClick={expandOrOpenPreview}>
{bookmark.title}
</button>
<p className="opacity-80 text-sm">{url.host}</p>
{isBookmarkItemExpanded && isSelected ? (
<div className="flex flex-col space-y-1 md:flex-row md:space-y-0 md:space-x-2 items-end justify-between pt-2">
<p className="text-sm">#dev</p>
<div className="flex space-x-2">
<OpenBookmarkPreviewButton />
<Button variant="light" className="text-sm">
<span className="underline">E</span>dit
</Button>
<Button variant="light" className="text-sm" onClick={deleteItem}>
<span className="underline">D</span>elete
</Button>
<span className="-ml-2">&nbsp;</span>
</div>
</div>
) : null}
</div>
</div>
<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>
)
}
function OpenBookmarkPreviewButton() {
const isBookmarkPreviewOpened = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
const setBookmarkItemExpanded = useBookmarkPageStore((state) => state.setBookmarkItemExpanded)
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (isBookmarkPreviewOpened && event.key === "c") {
closePreview()
} else if (!isBookmarkPreviewOpened && event.key === "o") {
openPreview()
}
}
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("keydown", onKeyDown)
}
}, [isBookmarkPreviewOpened])
function closePreview() {
setBookmarkPreviewOpened(false)
setBookmarkItemExpanded(false)
}
function openPreview() {
setBookmarkPreviewOpened(true)
}
return (
<Button
variant="light"
className="text-sm"
onClick={() => {
if (isBookmarkPreviewOpened) {
closePreview()
} else {
openPreview()
}
}}
>
{isBookmarkPreviewOpened ? (
<>
<span className="underline">C</span>lose
</>
) : (
<>
<span className="underline">O</span>pen
</>
)}
</Button>
)
}
function ActionBar() {
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
const setActionBarHeight = useBookmarkPageStore((state) => state.setActionBarHeight)
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
const isBookmarkPreviewOpened = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
useMnemonics(
{
a: addBookmark,
},
{ ignore: useCallback(() => useBookmarkPageStore.getState().activeDialog !== ActiveDialog.None, []) },
)
function addBookmark() {
setActiveDialog(ActiveDialog.AddBookmark)
}
function closePreview() {
setBookmarkPreviewOpened(false)
}
return (
<div
ref={(el) => {
if (el) {
setActionBarHeight(el.clientHeight)
}
}}
className="fixed z-10 bottom-0 left-0 right-0 bg-stone-200 dark:bg-stone-900 border-t-1 flex flex-row justify-center py-4 space-x-4"
>
{layoutMode === LAYOUT_MODE.popup && isBookmarkPreviewOpened ? (
<Button onClick={closePreview}>CLOSE</Button>
) : (
<>
<Button onClick={addBookmark}>
<span className="underline">A</span>DD
</Button>
<Button>
<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 const Route = createFileRoute("/bookmarks")({
component: Page,
})