move bookmark preview to its own route
This commit is contained in:
376
packages/web/src/app/bookmarks/-bookmark-list.tsx
Normal file
376
packages/web/src/app/bookmarks/-bookmark-list.tsx
Normal 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> </span>
|
||||
<span className={isBookmarkItemExpanded ? "rotate-90" : ""}>></span>
|
||||
<span> </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"> </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 }
|
Reference in New Issue
Block a user