import type { Bookmark } from "@markone/core" import { Link } from "@tanstack/react-router" import clsx from "clsx" import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react" import { twMerge } from "tailwind-merge" import { createStore, useStore } from "zustand" import { subscribeWithSelector } from "zustand/middleware" import { useBookmarkTags } from "~/bookmark/api" import { Button } from "~/components/button" import { LoadingSpinner } from "~/components/loading-spinner" import { useMnemonics } from "~/hooks/use-mnemonics" import { DialogKind, useBookmarkPageStore } from "./-store" const LONG_PRESS_DELAY_MS = 500 enum BookmarkListItemAction { Open = "Open", Edit = "Edit", Delete = "Delete", CopyLink = "CopyLink", } type SelectionChangeCallback = (bookmark: Bookmark) => void type ItemActionCallback = (bookmark: Bookmark, action: BookmarkListItemAction) => void interface BookmarkListProps { bookmarks: Bookmark[] selectedBookmarkId?: string alwaysExpandItem: boolean onSelectionChange?: SelectionChangeCallback onItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void className?: string } interface CreateStoreOptions { bookmarks: Bookmark[] selectedBookmarkId?: string alwaysExpandItem: boolean onItemAction: ItemActionCallback } interface BookmarkListState { bookmarks: Bookmark[] selectedIndex: number selectedBookmarkId: string alwaysExpandItem: boolean isItemExpanded: boolean onItemAction: ItemActionCallback setBookmarks: (bookmarks: Bookmark[]) => void setSelectedIndex: (index: number) => void setSelectedBookmarkId: (id: string) => void setIsItemExpanded: (expanded: boolean) => void } type BookmarkListStore = ReturnType const BookmarkListStoreContext = createContext(null) function createBookmarkListStore({ bookmarks, selectedBookmarkId, alwaysExpandItem, onItemAction, }: CreateStoreOptions) { let _selectedBookmarkId = selectedBookmarkId if (!_selectedBookmarkId && bookmarks.length > 0) { _selectedBookmarkId = bookmarks[0].id } return createStore()( subscribeWithSelector((set) => ({ bookmarks, alwaysExpandItem, selectedIndex: selectedBookmarkId ? bookmarks.findIndex((bookmark) => bookmark.id === selectedBookmarkId) : 0, selectedBookmarkId: _selectedBookmarkId ?? "", isItemExpanded: alwaysExpandItem, onItemAction, setBookmarks(bookmarks: Bookmark[]) { set({ bookmarks, selectedBookmarkId: bookmarks.length > 0 ? bookmarks[0].id : "", selectedIndex: 0 }) }, 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(selector: (state: BookmarkListState) => T): T { const store = useBookmarkListStoreContext() return useStore(store, selector) } function BookmarkList({ bookmarks, selectedBookmarkId, alwaysExpandItem, onSelectionChange, onItemAction, className, }: BookmarkListProps) { const storeRef = useRef(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! store.getState().setBookmarks(bookmarks) }, [bookmarks]) 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 ( <_BookmarkList className={className} /> ) } 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, c: (event) => { console.log("event", event) if (event.ctrlKey || event.metaKey) { event.preventDefault() copyBookmarkLink() } }, e: editItem, }, { ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []), }, ) async function copyBookmarkLink() { const { bookmarks, selectedIndex, onItemAction } = store.getState() onItemAction(bookmarks[selectedIndex], BookmarkListItemAction.CopyLink) } 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) } } function editItem() { const { bookmarks, selectedIndex, onItemAction } = store.getState() onItemAction(bookmarks[selectedIndex], BookmarkListItemAction.Edit) } return (
) }) function ListContainer() { const bookmarks = useBookmarkListStore((state) => state.bookmarks) const selectedItemId = useBookmarkListStore((state) => state.selectedBookmarkId) return bookmarks.length === 0 ? (

You have not saved any bookmark!

) : ( bookmarks.map((bookmark, i) => ( )) ) } const BookmarkListItem = memo( ({ bookmark, index, selected }: { bookmark: Bookmark; 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 setSelectedBookmarkId = useBookmarkListStore((state) => state.setSelectedBookmarkId) const setIsItemExpanded = useBookmarkListStore((state) => state.setIsItemExpanded) const onItemAction = useBookmarkListStore((state) => state.onItemAction) const longPressTimerRef = useRef | null>(null) const isLongPressActive = useRef(false) useEffect(() => { if (selected) { store.getState().setSelectedIndex(index) } }, [selected, index, store]) function handleTouchStart() { isLongPressActive.current = true longPressTimerRef.current = setTimeout(() => { if (isLongPressActive.current) { setSelectedBookmarkId(bookmark.id) setIsItemExpanded(true) } }, LONG_PRESS_DELAY_MS) } function handleTouchEnd() { isLongPressActive.current = false if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current) } } useEffect( () => () => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current) } }, [], ) function deleteItem() { onItemAction(bookmark, BookmarkListItemAction.Delete) } function copyItemLink() { onItemAction(bookmark, BookmarkListItemAction.CopyLink) } function editItem() { onItemAction(bookmark, BookmarkListItemAction.Edit) } function onItemHover() { if (!store.getState().isItemExpanded) { setSelectedBookmarkId(bookmark.id) } } return (
  • {bookmark.title}

    {url.host}

    {isBookmarkItemExpanded && selected ? ( <>
     
    ) : null}
  • ) }, ) function BookmarkTagList({ bookmark }: { bookmark: Bookmark }) { const { data: tags, status } = useBookmarkTags(bookmark) switch (status) { case "pending": return case "success": return (
    {tags.map((tag) => ( #{tag.name} ))}
    ) case "error": return null } } export { BookmarkList, BookmarkListItemAction }