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

@@ -15,6 +15,8 @@ import { Route as SignupImport } from "./signup"
import { Route as LoginImport } from "./login"
import { Route as BookmarksImport } from "./bookmarks"
import { Route as IndexImport } from "./index"
import { Route as BookmarksIndexImport } from "./bookmarks/index"
import { Route as BookmarksBookmarkIdImport } from "./bookmarks/$bookmarkId"
// Create/Update Routes
@@ -42,6 +44,18 @@ const IndexRoute = IndexImport.update({
getParentRoute: () => rootRoute,
} as any)
const BookmarksIndexRoute = BookmarksIndexImport.update({
id: "/",
path: "/",
getParentRoute: () => BookmarksRoute,
} as any)
const BookmarksBookmarkIdRoute = BookmarksBookmarkIdImport.update({
id: "/$bookmarkId",
path: "/$bookmarkId",
getParentRoute: () => BookmarksRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module "@tanstack/react-router" {
@@ -74,52 +88,98 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof SignupImport
parentRoute: typeof rootRoute
}
"/bookmarks/$bookmarkId": {
id: "/bookmarks/$bookmarkId"
path: "/$bookmarkId"
fullPath: "/bookmarks/$bookmarkId"
preLoaderRoute: typeof BookmarksBookmarkIdImport
parentRoute: typeof BookmarksImport
}
"/bookmarks/": {
id: "/bookmarks/"
path: "/"
fullPath: "/bookmarks/"
preLoaderRoute: typeof BookmarksIndexImport
parentRoute: typeof BookmarksImport
}
}
}
// Create and export the route tree
interface BookmarksRouteChildren {
BookmarksBookmarkIdRoute: typeof BookmarksBookmarkIdRoute
BookmarksIndexRoute: typeof BookmarksIndexRoute
}
const BookmarksRouteChildren: BookmarksRouteChildren = {
BookmarksBookmarkIdRoute: BookmarksBookmarkIdRoute,
BookmarksIndexRoute: BookmarksIndexRoute,
}
const BookmarksRouteWithChildren = BookmarksRoute._addFileChildren(
BookmarksRouteChildren,
)
export interface FileRoutesByFullPath {
"/": typeof IndexRoute
"/bookmarks": typeof BookmarksRoute
"/bookmarks": typeof BookmarksRouteWithChildren
"/login": typeof LoginRoute
"/signup": typeof SignupRoute
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
"/bookmarks/": typeof BookmarksIndexRoute
}
export interface FileRoutesByTo {
"/": typeof IndexRoute
"/bookmarks": typeof BookmarksRoute
"/login": typeof LoginRoute
"/signup": typeof SignupRoute
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
"/bookmarks": typeof BookmarksIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
"/": typeof IndexRoute
"/bookmarks": typeof BookmarksRoute
"/bookmarks": typeof BookmarksRouteWithChildren
"/login": typeof LoginRoute
"/signup": typeof SignupRoute
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
"/bookmarks/": typeof BookmarksIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: "/" | "/bookmarks" | "/login" | "/signup"
fullPaths:
| "/"
| "/bookmarks"
| "/login"
| "/signup"
| "/bookmarks/$bookmarkId"
| "/bookmarks/"
fileRoutesByTo: FileRoutesByTo
to: "/" | "/bookmarks" | "/login" | "/signup"
id: "__root__" | "/" | "/bookmarks" | "/login" | "/signup"
to: "/" | "/login" | "/signup" | "/bookmarks/$bookmarkId" | "/bookmarks"
id:
| "__root__"
| "/"
| "/bookmarks"
| "/login"
| "/signup"
| "/bookmarks/$bookmarkId"
| "/bookmarks/"
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
BookmarksRoute: typeof BookmarksRoute
BookmarksRoute: typeof BookmarksRouteWithChildren
LoginRoute: typeof LoginRoute
SignupRoute: typeof SignupRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
BookmarksRoute: BookmarksRoute,
BookmarksRoute: BookmarksRouteWithChildren,
LoginRoute: LoginRoute,
SignupRoute: SignupRoute,
}
@@ -144,13 +204,25 @@ export const routeTree = rootRoute
"filePath": "index.tsx"
},
"/bookmarks": {
"filePath": "bookmarks.tsx"
"filePath": "bookmarks.tsx",
"children": [
"/bookmarks/$bookmarkId",
"/bookmarks/"
]
},
"/login": {
"filePath": "login.tsx"
},
"/signup": {
"filePath": "signup.tsx"
},
"/bookmarks/$bookmarkId": {
"filePath": "bookmarks/$bookmarkId.tsx",
"parent": "/bookmarks"
},
"/bookmarks/": {
"filePath": "bookmarks/index.tsx",
"parent": "/bookmarks"
}
}
}

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,
})

View File

@@ -0,0 +1,126 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { LayoutMode, useBookmarkPageStore } from "./-store"
import clsx from "clsx"
import { fetchApi, useAuthenticatedQuery } from "~/api"
import { LoadingSpinner } from "~/components/loading-spinner"
import { BookmarkList } from "./-bookmark-list"
import { useCallback } from "react"
import type { LinkBookmark } from "@markone/core/bookmark"
import { ActionBar } from "./-action-bar"
export const Route = createFileRoute("/bookmarks/$bookmarkId")({
component: RouteComponent,
})
function RouteComponent() {
return (
<Main>
<BookmarkListSidebar />
<BookmarkPreviewContainer>
<BookmarkPreview />
</BookmarkPreviewContainer>
</Main>
)
}
function Main({ children }: React.PropsWithChildren) {
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
return (
<main className={clsx("w-full h-full grid", layoutMode === LayoutMode.SideBySide ? "grid-cols-4" : "grid-cols-1")}>
{children}
</main>
)
}
function BookmarkPreviewContainer({ children }: React.PropsWithChildren) {
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
return (
<div
className={clsx("col-span-3 h-screen border-l border-stone-700 dark:border-stone-300 flex dark:bg-stone-900", {
"absolute border-l-0": layoutMode === LayoutMode.Popup,
})}
>
{children}
</div>
)
}
function BookmarkListSidebar() {
return (
<div className="flex flex-col py-16 w-full h-screen relative">
<header className="mb-4 text-start">
<h1 className="font-bold text-start mb-4">
<span className="invisible">&nbsp;&gt;&nbsp;</span>
YOUR BOOKMARKS
</h1>
</header>
<BookmarkListContainer />
<ActionBar className="absolute bottom-0 left-0 right-0" />
</div>
)
}
function BookmarkListContainer() {
const { bookmarkId } = Route.useParams()
const navigate = useNavigate()
const { data: bookmarks, status } = useAuthenticatedQuery(["bookmarks"], () =>
fetchApi("/bookmarks").then((res) => res.json()),
)
const handleBookmarkListItemAction = useBookmarkPageStore((state) => state.handleBookmarkListItemAction)
const onSelectedBookmarkChange = useCallback(
(bookmark: LinkBookmark) => {
navigate({ to: `/bookmarks/${bookmark.id}` })
},
[navigate],
)
switch (status) {
case "success":
return (
<BookmarkList
alwaysExpandItem
bookmarks={bookmarks}
selectedBookmarkId={bookmarkId}
onSelectionChange={onSelectedBookmarkChange}
onItemAction={handleBookmarkListItemAction}
/>
)
case "pending":
return (
<p>
Loading <LoadingSpinner />
</p>
)
case "error":
return <p>error loading bookmarks</p>
}
}
function BookmarkPreview() {
const { bookmarkId } = Route.useParams()
const { data, status } = useAuthenticatedQuery(["bookmarks", `${bookmarkId}.html`], () =>
fetchApi(`/bookmark/${bookmarkId}`, {
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="w-full h-full" srcDoc={data} />
default:
return null
}
}

View File

@@ -0,0 +1,57 @@
import { Button } from "~/components/button"
import { ActiveDialog, LayoutMode, useBookmarkPageStore } from "./-store"
import { useNavigate } from "@tanstack/react-router"
import { useCallback } from "react"
import { useLogOut } from "~/auth"
import { useMnemonics } from "~/hooks/use-mnemonics"
import { twMerge } from "tailwind-merge"
function ActionBar({ className }: { className?: string }) {
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
useMnemonics(
{
a: addBookmark,
},
{ ignore: useCallback(() => useBookmarkPageStore.getState().activeDialog !== ActiveDialog.None, []) },
)
function addBookmark() {
setActiveDialog(ActiveDialog.AddBookmark)
}
return (
<div
className={twMerge(
"bg-stone-200 dark:bg-stone-900 border-t-1 flex flex-row justify-center py-4 space-x-4",
className,
)}
>
<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 { ActionBar }

View File

@@ -0,0 +1,376 @@
import type { LinkBookmark } from "@markone/core/bookmark"
import { Link } from "@tanstack/react-router"
import { createStore, useStore } from "zustand"
import { useEffect, useCallback, createContext, useRef, memo, useContext } from "react"
import { useMnemonics } from "~/hooks/use-mnemonics"
import { useBookmarkPageStore, ActiveDialog, LayoutMode } from "./-store"
import { Button } from "~/components/button"
import clsx from "clsx"
import { twMerge } from "tailwind-merge"
import { subscribeWithSelector } from "zustand/middleware"
enum BookmarkListItemAction {
Open = "Open",
Edit = "Edit",
Delete = "Delete",
}
type SelectionChangeCallback = (bookmark: LinkBookmark) => void
type ItemActionCallback = (bookmark: LinkBookmark, action: BookmarkListItemAction) => void
interface BookmarkListProps {
bookmarks: LinkBookmark[]
selectedBookmarkId?: string
alwaysExpandItem: boolean
onSelectionChange?: SelectionChangeCallback
onItemAction: (bookmark: LinkBookmark, action: BookmarkListItemAction) => void
className?: string
}
interface CreateStoreOptions {
bookmarks: LinkBookmark[]
selectedBookmarkId?: string
alwaysExpandItem: boolean
onItemAction: ItemActionCallback
}
interface BookmarkListState {
bookmarks: LinkBookmark[]
selectedIndex: number
selectedBookmarkId: string
alwaysExpandItem: boolean
isItemExpanded: boolean
onItemAction: ItemActionCallback
setBookmarks: (bookmarks: LinkBookmark[]) => void
setSelectedIndex: (index: number) => void
setSelectedBookmarkId: (id: string) => void
setIsItemExpanded: (expanded: boolean) => void
}
type BookmarkListStore = ReturnType<typeof createBookmarkListStore>
const BookmarkListStoreContext = createContext<BookmarkListStore | null>(null)
function createBookmarkListStore({
bookmarks,
selectedBookmarkId,
alwaysExpandItem,
onItemAction,
}: CreateStoreOptions) {
return createStore<BookmarkListState>()(
subscribeWithSelector((set) => ({
bookmarks,
alwaysExpandItem,
selectedIndex: selectedBookmarkId ? bookmarks.findIndex((bookmark) => bookmark.id === selectedBookmarkId) : 0,
selectedBookmarkId: selectedBookmarkId ?? bookmarks[0].id,
isItemExpanded: false,
onItemAction,
setBookmarks(bookmarks: LinkBookmark[]) {
set({ bookmarks })
},
setSelectedIndex(index: number) {
set({ selectedIndex: index })
},
setSelectedBookmarkId(id: string) {
set({ selectedBookmarkId: id })
},
setIsItemExpanded(expanded: boolean) {
set({ isItemExpanded: expanded })
},
})),
)
}
function useBookmarkListStoreContext() {
const store = useContext(BookmarkListStoreContext)
if (!store) throw new Error("BookmarkListStoreContext not found")
return store
}
function useBookmarkListStore<T>(selector: (state: BookmarkListState) => T): T {
const store = useBookmarkListStoreContext()
return useStore(store, selector)
}
function BookmarkList({
bookmarks,
selectedBookmarkId,
alwaysExpandItem,
onSelectionChange,
onItemAction,
className,
}: BookmarkListProps) {
const storeRef = useRef<BookmarkListStore | null>(null)
if (!storeRef.current) {
storeRef.current = createBookmarkListStore({ bookmarks, selectedBookmarkId, alwaysExpandItem, onItemAction })
}
const setSelectedBookmarkId = useStore(storeRef.current, (state) => state.setSelectedBookmarkId)
useEffect(() => {
// biome-ignore lint/style/noNonNullAssertion: storeRef.current is already set above, so cant be null
const store = storeRef.current!
const unsub = store.subscribe(
(state) => state,
({ bookmarks, selectedIndex }) => {
onSelectionChange?.(bookmarks[selectedIndex])
},
{
equalityFn: (stateA, stateB) => stateA.selectedIndex === stateB.selectedIndex,
},
)
return () => {
unsub()
}
}, [onSelectionChange])
useEffect(() => {
// biome-ignore lint/style/noNonNullAssertion: storeRef.current is already set above, so cant be null
const store = storeRef.current!
if (selectedBookmarkId !== store.getState().selectedBookmarkId && selectedBookmarkId) {
setSelectedBookmarkId(selectedBookmarkId)
}
}, [setSelectedBookmarkId, selectedBookmarkId])
return (
<BookmarkListStoreContext value={storeRef.current}>
<_BookmarkList className={className} />
</BookmarkListStoreContext>
)
}
const _BookmarkList = memo(({ className }: { className?: string }) => {
const store = useBookmarkListStoreContext()
useMnemonics(
{
j: selectNextItem,
ArrowDown: selectNextItem,
k: selectPrevItem,
ArrowUp: selectPrevItem,
h: collapseItem,
ArrowLeft: collapseItem,
l: expandItem,
ArrowRight: expandItem,
d: deleteItem,
Enter: openItem,
},
{
ignore: useCallback(() => useBookmarkPageStore.getState().activeDialog !== ActiveDialog.None, []),
},
)
function openItem() {
const { bookmarks, selectedIndex, onItemAction } = store.getState()
expandItem()
onItemAction(bookmarks[selectedIndex], BookmarkListItemAction.Open)
}
function deleteItem() {
const { bookmarks, selectedIndex, onItemAction } = store.getState()
onItemAction(bookmarks[selectedIndex], BookmarkListItemAction.Delete)
}
function selectPrevItem() {
const { bookmarks, selectedIndex, setSelectedBookmarkId } = store.getState()
const prevIndex = selectedIndex - 1
if (prevIndex >= 0) {
setSelectedBookmarkId(bookmarks[prevIndex].id)
}
}
function selectNextItem() {
const { bookmarks, selectedIndex, setSelectedBookmarkId } = store.getState()
const nextIndex = selectedIndex + 1
if (nextIndex < bookmarks.length) {
setSelectedBookmarkId(bookmarks[nextIndex].id)
}
}
function expandItem() {
store.getState().setIsItemExpanded(true)
}
function collapseItem() {
const state = store.getState()
if (!state.alwaysExpandItem) {
store.getState().setIsItemExpanded(false)
}
}
return (
<ul className={twMerge("flex flex-col", className)}>
<ListContainer />
</ul>
)
})
function ListContainer() {
const bookmarks = useBookmarkListStore((state) => state.bookmarks)
const selectedItemId = useBookmarkListStore((state) => state.selectedBookmarkId)
return 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} selected={bookmark.id === selectedItemId} />
))
)
}
const BookmarkListItem = memo(
({ bookmark, index, selected }: { bookmark: LinkBookmark; index: number; selected: boolean }) => {
const url = new URL(bookmark.url)
const store = useBookmarkListStoreContext()
const alwaysExpandItem = useBookmarkListStore((state) => state.alwaysExpandItem)
const isBookmarkItemExpanded = useBookmarkListStore((state) => state.alwaysExpandItem || state.isItemExpanded)
const setIsItemExpanded = useBookmarkListStore((state) => state.setIsItemExpanded)
const selectBookmark = useBookmarkPageStore((state) => state.selectBookmark)
const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
const onItemAction = useBookmarkListStore((state) => state.onItemAction)
useEffect(() => {
if (selected) {
store.getState().setSelectedIndex(index)
}
}, [selected, index, store.getState])
function deleteItem() {
onItemAction(bookmark, BookmarkListItemAction.Delete)
}
function expandOrOpenPreview() {
if (!selected) {
selectBookmark(bookmark, index)
}
setIsItemExpanded(true)
if (useBookmarkPageStore.getState().layoutMode === LayoutMode.SideBySide) {
setBookmarkPreviewOpened(true)
}
}
return (
<li
className={clsx("group flex flex-row justify-start py-2", {
"bg-teal-600 text-stone-100": isBookmarkItemExpanded && selected,
"text-teal-600": selected && !isBookmarkItemExpanded,
})}
onMouseEnter={() => {
if (!isBookmarkItemExpanded) {
selectBookmark(bookmark, index)
}
}}
>
<button
type="button"
disabled={alwaysExpandItem || !selected}
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
invisible: !selected,
})}
onClick={() => {
setIsItemExpanded(!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">
<Link
to={`/bookmarks/${bookmark.id}`}
className={clsx("block w-full text-start font-bold", { underline: selected })}
onClick={expandOrOpenPreview}
>
{bookmark.title}
</Link>
<p className="opacity-80 text-sm">{url.host}</p>
{isBookmarkItemExpanded && selected ? (
<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>
</li>
)
},
)
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>
)
}
export { BookmarkList, BookmarkListItemAction }

View File

@@ -0,0 +1,63 @@
import type { LinkBookmark } from "@markone/core/bookmark"
import { create } from "zustand/react"
import { BookmarkListItemAction } from "./-bookmark-list"
import { router } from "~/router"
enum LayoutMode {
Popup = "Popup",
SideBySide = "SideBySide",
}
enum ActiveDialog {
None = "None",
AddBookmark = "AddBookmark",
DeleteBookmark = "DeleteBookmark",
}
interface BookmarkPageState {
bookmarkToBeDeleted: LinkBookmark | null
layoutMode: LayoutMode
activeDialog: ActiveDialog
handleBookmarkListItemAction: (bookmark: LinkBookmark, action: BookmarkListItemAction) => void
setActiveDialog: (dialog: ActiveDialog) => void
setLayoutMode: (mode: LayoutMode) => void
}
const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
bookmarkCount: 0,
bookmarks: [],
selectedBookmarkId: "",
selectedBookmarkIndex: 0,
isBookmarkItemExpanded: false,
isBookmarkPreviewOpened: false,
bookmarkToBeDeleted: null,
layoutMode: LayoutMode.Popup,
activeDialog: ActiveDialog.None,
actionBarHeight: 0,
handleBookmarkListItemAction(bookmark: LinkBookmark, action: BookmarkListItemAction) {
switch (action) {
case BookmarkListItemAction.Open:
router.navigate({ to: `/bookmarks/${bookmark.id}` })
break
case BookmarkListItemAction.Delete:
set({ bookmarkToBeDeleted: bookmark, activeDialog: ActiveDialog.DeleteBookmark })
break
default:
break
}
},
setActiveDialog(dialog: ActiveDialog) {
set({ activeDialog: dialog })
},
setLayoutMode(mode: LayoutMode) {
set({ layoutMode: mode })
},
}))
export { LayoutMode, ActiveDialog, useBookmarkPageStore }
export type { BookmarkPageState }

View File

@@ -0,0 +1,68 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { useCallback } from "react"
import { useLogOut } from "~/auth"
import { Button } from "~/components/button"
import { useMnemonics } from "~/hooks/use-mnemonics"
import { BookmarkList } from "./-bookmark-list"
import { ActiveDialog, LayoutMode, useBookmarkPageStore } from "./-store"
import { fetchApi, useAuthenticatedQuery } from "~/api"
import { LoadingSpinner } from "~/components/loading-spinner"
import { ActionBar } from "./-action-bar"
export const Route = createFileRoute("/bookmarks/")({
component: RouteComponent,
})
function RouteComponent() {
return (
<main className="w-full flex justify-center">
<BookmarkListPane />
<ActionBar 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">
<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() {
const { data: bookmarks, status } = useAuthenticatedQuery(["bookmarks"], () =>
fetchApi("/bookmarks").then((res) => res.json()),
)
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>
}
}

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react"
import { useEffect, useRef } from "react"
function useMnemonics(
mnemonicMap: Record<string, (event: KeyboardEvent) => void>,
@@ -6,7 +6,6 @@ function useMnemonics(
) {
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
console.log(event.key)
if (!ignore(event)) {
mnemonicMap[event.key]?.(event)
}

View File

@@ -1,13 +1,9 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { RouterProvider, createRouter } from "@tanstack/react-router"
import { RouterProvider } from "@tanstack/react-router"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { router } from "./router"
import "./index.css"
import { routeTree } from "./app/-route-tree.gen"
const router = createRouter({
routeTree,
})
const queryClient = new QueryClient()

View File

@@ -0,0 +1,8 @@
import { createRouter } from "@tanstack/react-router"
import { routeTree } from "./app/-route-tree.gen"
const router = createRouter({
routeTree,
})
export { router }