411 lines
12 KiB
TypeScript
411 lines
12 KiB
TypeScript
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<typeof createBookmarkListStore>
|
|
|
|
const BookmarkListStoreContext = createContext<BookmarkListStore | null>(null)
|
|
|
|
function createBookmarkListStore({
|
|
bookmarks,
|
|
selectedBookmarkId,
|
|
alwaysExpandItem,
|
|
onItemAction,
|
|
}: CreateStoreOptions) {
|
|
let _selectedBookmarkId = selectedBookmarkId
|
|
if (!_selectedBookmarkId && bookmarks.length > 0) {
|
|
_selectedBookmarkId = bookmarks[0].id
|
|
}
|
|
|
|
return createStore<BookmarkListState>()(
|
|
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<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!
|
|
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 (
|
|
<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,
|
|
|
|
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 (
|
|
<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: 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<ReturnType<typeof setTimeout> | 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 (
|
|
<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={onItemHover}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchEnd={handleTouchEnd}
|
|
onTouchCancel={handleTouchEnd}
|
|
>
|
|
<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)
|
|
}}
|
|
>
|
|
<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 })}
|
|
>
|
|
{bookmark.title}
|
|
</Link>
|
|
<p className="opacity-80 text-sm">{url.host}</p>
|
|
{isBookmarkItemExpanded && selected ? (
|
|
<>
|
|
<BookmarkTagList bookmark={bookmark} />
|
|
<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">
|
|
<div className="flex space-x-2">
|
|
<Button variant="light" className="text-sm" onClick={copyItemLink}>
|
|
<span>COPY LINK</span>
|
|
</Button>
|
|
<Button variant="light" className="text-sm" onClick={editItem}>
|
|
<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 BookmarkTagList({ bookmark }: { bookmark: Bookmark }) {
|
|
const { data: tags, status } = useBookmarkTags(bookmark)
|
|
switch (status) {
|
|
case "pending":
|
|
return <LoadingSpinner />
|
|
case "success":
|
|
return (
|
|
<div className="flex flex-row flex-wrap space-x-2">
|
|
{tags.map((tag) => (
|
|
<Link key={tag.id} to={`/bookmarks?tags=${tag.name}`} className="underline">
|
|
#{tag.name}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)
|
|
case "error":
|
|
return null
|
|
}
|
|
}
|
|
|
|
export { BookmarkList, BookmarkListItemAction }
|