create generic list component

This commit is contained in:
2025-06-01 00:39:47 +01:00
parent 4bc5630922
commit 6625451d47
5 changed files with 516 additions and 639 deletions

View File

@@ -1,112 +1,125 @@
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 { memo, useCallback } 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 { List } from "~/components/list"
import { DialogKind, useBookmarkPageStore } from "./-store"
const LONG_PRESS_DELAY_MS = 500
enum BookmarkListItemAction {
export 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
onSelectionChange?: (bookmark: Bookmark) => void
onItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void
className?: string
}
interface CreateStoreOptions {
bookmarks: Bookmark[]
selectedBookmarkId?: string
alwaysExpandItem: boolean
onItemAction: ItemActionCallback
}
const BookmarkTagList = memo(function BookmarkTagList({ bookmark }: { bookmark: Bookmark }) {
const { data: tags } = useBookmarkTags(bookmark)
interface BookmarkListState {
bookmarks: Bookmark[]
selectedIndex: number
selectedBookmarkId: string
alwaysExpandItem: boolean
isItemExpanded: boolean
return tags ? (
<div className="flex flex-wrap gap-1 pt-2">
{tags.map((tag) => (
<span key={tag.id} className="text-xs bg-stone-100 text-stone-900 px-2 py-0.5 rounded">
{tag.name}
</span>
))}
</div>
) : null
})
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,
const BookmarkListItem = memo(function BookmarkListItem({
bookmark,
isSelected,
isExpanded,
onSelect,
onExpand,
onItemAction,
}: CreateStoreOptions) {
let _selectedBookmarkId = selectedBookmarkId
if (!_selectedBookmarkId && bookmarks.length > 0) {
_selectedBookmarkId = bookmarks[0].id
}
alwaysExpandItem,
}: {
bookmark: Bookmark
isSelected: boolean
isExpanded: boolean
onSelect: () => void
onExpand: () => void
onItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void
alwaysExpandItem: boolean
}) {
const url = new URL(bookmark.url)
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 })
},
})),
return (
<div
className={clsx("group flex flex-row justify-start py-2", {
"bg-teal-600 text-stone-100": isExpanded,
"text-teal-600": isSelected && !isExpanded,
})}
>
<button
type="button"
disabled={alwaysExpandItem || !isSelected}
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
invisible: !isSelected,
})}
onClick={onExpand}
>
<span className="sr-only">Options for this bookmark</span>
<span>&nbsp;</span>
<span className={isExpanded ? "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: isSelected })}
>
{bookmark.title}
</Link>
<p className="opacity-80 text-sm">{url.host}</p>
{isExpanded ? (
<>
<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={() => onItemAction(bookmark, BookmarkListItemAction.CopyLink)}
>
<span>COPY LINK</span>
</Button>
<Button
variant="light"
className="text-sm"
onClick={() => onItemAction(bookmark, BookmarkListItemAction.Edit)}
>
<span className="underline">E</span>DIT
</Button>
<Button
variant="light"
className="text-sm"
onClick={() => onItemAction(bookmark, BookmarkListItemAction.Delete)}
>
<span className="underline">D</span>ELETE
</Button>
<span className="-ml-2">&nbsp;</span>
</div>
</div>
</>
) : null}
</div>
</div>
)
}
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,
@@ -116,295 +129,38 @@ function BookmarkList({
onItemAction,
className,
}: BookmarkListProps) {
const storeRef = useRef<BookmarkListStore | null>(null)
if (!storeRef.current) {
storeRef.current = createBookmarkListStore({ bookmarks, selectedBookmarkId, alwaysExpandItem, onItemAction })
}
const handleSelect = useCallback(
(bookmark: Bookmark) => {
onSelectionChange?.(bookmark)
},
[onSelectionChange],
)
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])
const handleExpand = useCallback((bookmark: Bookmark) => {
// No-op since expansion is handled by the List component
}, [])
return (
<BookmarkListStoreContext value={storeRef.current}>
<_BookmarkList className={className} />
</BookmarkListStoreContext>
<List
items={bookmarks}
selectedItemId={selectedBookmarkId}
alwaysExpandItem={alwaysExpandItem}
onSelectionChange={handleSelect}
className={className}
emptyMessage="No bookmarks found!"
renderItem={({ item: bookmark, isSelected, isExpanded, onSelect, onExpand }) => (
<BookmarkListItem
bookmark={bookmark}
isSelected={isSelected}
isExpanded={isExpanded}
onSelect={onSelect}
onExpand={onExpand}
onItemAction={onItemAction}
alwaysExpandItem={alwaysExpandItem}
/>
)}
/>
)
}
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>&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 })}
>
{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">&nbsp;</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 }
export { BookmarkList }

View File

@@ -1,259 +1,105 @@
import { create } from "zustand/react"
import { subscribeWithSelector } from "zustand/middleware"
import type { Collection } from "@markone/core"
import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react"
import { clsx } from "clsx"
import { memo } from "react"
import { Button } from "~/components/button"
import { useMnemonics } from "~/hooks/use-mnemonics"
import { DialogKind, useCollectionPageStore } from "./-store"
import { useStore } from "zustand"
import { Link } from "~/components/link"
import { List } from "~/components/list"
export enum CollectionListItemAction {
Delete = "Delete",
Edit = "Edit",
}
type ItemActionCallback = (collection: Collection, action: CollectionListItemAction) => void
interface CollectionListProps {
collections: Collection[]
className?: string
}
interface CreateStoreOptions {
collections: Collection[]
}
interface CollectionListState {
collections: Collection[]
selectedIndex: number
selectedCollectionId: string
isItemExpanded: boolean
setCollections: (collections: Collection[]) => void
setSelectedIndex: (index: number) => void
setSelectedCollectionId: (id: string) => void
setIsItemExpanded: (expanded: boolean) => void
}
type CollectionListStore = ReturnType<typeof createCollectionListStore>
const CollectionListStoreContext = createContext<CollectionListStore | null>(null)
function createCollectionListStore({ collections }: CreateStoreOptions) {
return create<CollectionListState>()(
subscribeWithSelector((set) => ({
collections,
selectedIndex: 0,
selectedCollectionId: collections.length > 0 ? collections[0].id : "",
isItemExpanded: false,
setCollections(collections: Collection[]) {
set({ collections, selectedCollectionId: collections.length > 0 ? collections[0].id : "", selectedIndex: 0 })
},
setSelectedIndex(index: number) {
set({ selectedIndex: index })
},
setSelectedCollectionId(id: string) {
set({ selectedCollectionId: id })
},
setIsItemExpanded(expanded: boolean) {
set({ isItemExpanded: expanded })
},
})),
)
}
function useCollectionListStoreContext() {
const store = useContext(CollectionListStoreContext)
if (!store) throw new Error("CollectionListStoreContext not found")
return store
}
function useCollectionListStore<T>(selector: (state: CollectionListState) => T): T {
const store = useCollectionListStoreContext()
return useStore(store, selector)
}
function CollectionList({ collections, className }: CollectionListProps) {
const storeRef = useRef<CollectionListStore | null>(null)
if (!storeRef.current) {
storeRef.current = createCollectionListStore({ collections })
}
useEffect(() => {
// biome-ignore lint/style/noNonNullAssertion: storeRef.current is already set above, so cant be null
const store = storeRef.current!
store.getState().setCollections(collections)
}, [collections])
return (
<CollectionListStoreContext.Provider value={storeRef.current}>
<_CollectionList className={className} />
</CollectionListStoreContext.Provider>
)
}
const _CollectionList = memo(({ className }: { className?: string }) => {
const store = useCollectionListStoreContext()
const handleCollectionListAction = useCollectionPageStore((state) => state.handleCollectionListAction)
useMnemonics(
{
j: selectNextItem,
ArrowDown: selectNextItem,
k: selectPrevItem,
ArrowUp: selectPrevItem,
h: collapseItem,
ArrowLeft: collapseItem,
l: expandItem,
ArrowRight: expandItem,
d: deleteItem,
e: editItem,
},
{
ignore: useCallback(() => useCollectionPageStore.getState().dialog.kind !== DialogKind.None, []),
},
)
function deleteItem() {
const { collections, selectedIndex } = store.getState()
handleCollectionListAction(collections[selectedIndex], CollectionListItemAction.Delete)
}
function editItem() {
const { collections, selectedIndex } = store.getState()
handleCollectionListAction(collections[selectedIndex], CollectionListItemAction.Edit)
}
function selectPrevItem() {
const { collections, selectedIndex, setSelectedCollectionId } = store.getState()
const prevIndex = selectedIndex - 1
if (prevIndex >= 0) {
setSelectedCollectionId(collections[prevIndex].id)
}
}
function selectNextItem() {
const { collections, selectedIndex, setSelectedCollectionId } = store.getState()
const nextIndex = selectedIndex + 1
if (nextIndex < collections.length) {
setSelectedCollectionId(collections[nextIndex].id)
}
}
function expandItem() {
store.getState().setIsItemExpanded(true)
}
function collapseItem() {
store.getState().setIsItemExpanded(false)
}
return (
<ul className={clsx("flex flex-col -mt-2", className)}>
<ListContainer />
</ul>
)
})
function ListContainer() {
const collections = useCollectionListStore((state) => state.collections)
const selectedItemId = useCollectionListStore((state) => state.selectedCollectionId)
return collections.length === 0 ? (
<p>You have not created any collections!</p>
) : (
collections.map((collection, index) => (
<CollectionListItem
key={collection.id}
collection={collection}
selected={collection.id === selectedItemId}
index={index}
/>
))
)
onItemAction: (collection: Collection, action: CollectionListItemAction) => void
}
const CollectionListItem = memo(
({ collection, selected, index }: { collection: Collection; selected: boolean; index: number }) => {
const store = useCollectionListStoreContext()
const isItemExpanded = useCollectionListStore((state) => state.isItemExpanded)
const setSelectedCollectionId = useCollectionListStore((state) => state.setSelectedCollectionId)
const setIsItemExpanded = useCollectionListStore((state) => state.setIsItemExpanded)
const handleCollectionListAction = useCollectionPageStore((state) => state.handleCollectionListAction)
useEffect(() => {
if (selected) {
store.getState().setSelectedIndex(index)
}
}, [selected, index, store])
function onItemHover() {
if (!store.getState().isItemExpanded) {
setSelectedCollectionId(collection.id)
}
}
function deleteItem() {
handleCollectionListAction(collection, CollectionListItemAction.Delete)
}
function editItem() {
handleCollectionListAction(collection, CollectionListItemAction.Edit)
}
return (
<li
className={clsx("group flex flex-row justify-start py-2", {
"bg-teal-600 text-stone-100": isItemExpanded && selected,
"text-teal-600": selected && !isItemExpanded,
({
collection,
isSelected,
isExpanded,
onSelect,
onExpand,
onItemAction,
}: {
collection: Collection
isSelected: boolean
isExpanded: boolean
onSelect: () => void
onExpand: () => void
onItemAction: (collection: Collection, action: CollectionListItemAction) => void
}) => (
<div
className={clsx("group flex flex-row justify-start py-2", {
"bg-teal-600 text-stone-100": isExpanded,
"text-teal-600": isSelected && !isExpanded,
})}
>
<button
type="button"
disabled={!isSelected}
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
invisible: !isSelected,
})}
onMouseEnter={onItemHover}
onClick={onExpand}
>
<button
type="button"
disabled={!selected}
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
invisible: !selected,
})}
onClick={() => {
setIsItemExpanded(!isItemExpanded)
}}
>
<span className="sr-only">Options for this collection</span>
<span>&nbsp;</span>
<span className={isItemExpanded ? "rotate-90" : ""}>&gt;</span>
<span>&nbsp;</span>
</button>
<div className="flex flex-col w-full">
<div className="block w-full text-start font-bold">{collection.name}</div>
<p className="opacity-80 text-sm">{collection.description}</p>
{isItemExpanded && 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">
<div className="flex space-x-2">
<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">&nbsp;</span>
</div>
</div>
) : null}
<span className="sr-only">Options for this collection</span>
<span>&nbsp;</span>
<span className={isExpanded ? "rotate-90" : ""}>&gt;</span>
<span>&nbsp;</span>
</button>
<div className="flex flex-col w-full">
<div className="block w-full text-start font-bold">
<Link href="#">{collection.name}</Link>
</div>
</li>
)
},
<p className="opacity-80 text-sm">{collection.description}</p>
{isExpanded ? (
<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={() => onItemAction(collection, CollectionListItemAction.Edit)}
>
<span className="underline">E</span>DIT
</Button>
<Button
variant="light"
className="text-sm"
onClick={() => onItemAction(collection, CollectionListItemAction.Delete)}
>
<span className="underline">D</span>ELETE
</Button>
<span className="-ml-2">&nbsp;</span>
</div>
</div>
) : null}
</div>
</div>
),
)
function CollectionList({ collections, className, onItemAction }: CollectionListProps) {
return (
<List
className="-mt-2"
items={collections}
emptyMessage="No collections found!"
renderItem={({ item: collection, isSelected, isExpanded, onSelect, onExpand }) => (
<CollectionListItem
collection={collection}
isSelected={isSelected}
isExpanded={isExpanded}
onSelect={onSelect}
onExpand={onExpand}
onItemAction={onItemAction}
/>
)}
/>
)
}
export { CollectionList }
export type { CollectionListState }

View File

@@ -57,13 +57,14 @@ function CollectionsPane() {
function CollectionsContainer() {
const { data: collections, status } = useCollections()
const handleCollectionListAction = useCollectionPageStore((state) => state.handleCollectionListAction)
switch (status) {
case "success":
return collections.length === 0 ? (
<p>You have not created any collections!</p>
) : (
<CollectionList collections={collections} />
<CollectionList collections={collections} onItemAction={handleCollectionListAction} />
)
case "pending":

View File

@@ -1,32 +0,0 @@
import type { Collection } from "@markone/core"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { UnauthenticatedError, fetchApi } from "~/api"
function useUpdateCollection(collection: Collection) {
const queryClient = useQueryClient()
const navigate = useNavigate()
return useMutation({
mutationFn: (body: { title: string; description: string }) =>
fetchApi(`/collections/${collection.id}`, {
method: "PATCH",
body: JSON.stringify(body),
}).then((res) => (res.status === 204 ? collection : res.json())),
onError: (error) => {
if (error instanceof UnauthenticatedError) {
navigate({ to: "/login", replace: true })
}
},
onSuccess: (updatedCollection: Collection | undefined) => {
if (updatedCollection) {
queryClient.setQueryData(["collections"], (collections: Collection[]) =>
collections ? collections.map((it) => (it.id === updatedCollection.id ? updatedCollection : it)) : [updatedCollection],
)
queryClient.setQueryData(["collections", updatedCollection.id], updatedCollection)
}
},
})
}
export { useUpdateCollection }

View File

@@ -0,0 +1,306 @@
import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react"
import { createStore, useStore } from "zustand"
import { subscribeWithSelector } from "zustand/middleware"
import { clsx } from "clsx"
import { useMnemonics } from "~/hooks/use-mnemonics"
interface ListData {
id: string
}
interface ListState<T extends ListData> {
items: T[]
selectedIndex: number
selectedItemId: string
isItemExpanded: boolean
alwaysExpandItem: boolean
setItems: (items: T[]) => void
setSelectedIndex: (index: number) => void
setSelectedItemId: (id: string) => void
setIsItemExpanded: (expanded: boolean) => void
}
type ListStore<T extends ListData> = ReturnType<typeof createListStore<T>>
const ListStoreContext = createContext<ListStore<ListData> | null>(null)
interface ListProps<T extends ListData> {
items: T[]
selectedItemId?: string
alwaysExpandItem?: boolean
onSelectionChange?: (item: T) => void
renderItem: (props: {
item: T
isSelected: boolean
isExpanded: boolean
onSelect: () => void
onExpand: () => void
}) => React.ReactNode
className?: string
emptyMessage?: string
}
function List<T extends ListData>({
items,
selectedItemId,
alwaysExpandItem = false,
onSelectionChange,
renderItem,
className,
emptyMessage = "No items found!",
}: ListProps<T>) {
const storeRef = useRef<ListStore<T> | null>(null)
if (!storeRef.current) {
storeRef.current = createListStore({ items, selectedItemId, alwaysExpandItem })
}
useEffect(() => {
const store = storeRef.current
if (!store) return
store.getState().setItems(items)
}, [items])
useEffect(() => {
const store = storeRef.current
if (!store) return
if (selectedItemId !== store.getState().selectedItemId && selectedItemId) {
store.getState().setSelectedItemId(selectedItemId)
}
}, [selectedItemId])
useEffect(() => {
const store = storeRef.current
if (!store) return
const unsub = store.subscribe(
(state) => state,
({ items, selectedIndex }) => {
onSelectionChange?.(items[selectedIndex])
},
{
equalityFn: (stateA, stateB) => stateA.selectedIndex === stateB.selectedIndex,
},
)
return () => {
unsub()
}
}, [onSelectionChange])
return (
<ListStoreContext.Provider value={storeRef.current as unknown as ListStore<ListData>}>
<MemoizedList className={className} renderItem={renderItem} emptyMessage={emptyMessage} />
</ListStoreContext.Provider>
)
}
function createListStore<T extends ListData>({
items,
selectedItemId,
alwaysExpandItem = false,
}: {
items: T[]
selectedItemId?: string
alwaysExpandItem?: boolean
}) {
let _selectedItemId = selectedItemId
if (!_selectedItemId && items.length > 0) {
_selectedItemId = items[0].id
}
return createStore<ListState<T>>()(
subscribeWithSelector((set) => ({
items,
alwaysExpandItem,
selectedIndex: selectedItemId ? items.findIndex((item) => item.id === selectedItemId) : 0,
selectedItemId: _selectedItemId ?? "",
isItemExpanded: alwaysExpandItem,
setItems(items: T[]) {
set({
items,
selectedItemId: items.length > 0 ? items[0].id : "",
selectedIndex: 0,
})
},
setSelectedIndex(index: number) {
set({ selectedIndex: index })
},
setSelectedItemId(id: string) {
set({ selectedItemId: id })
},
setIsItemExpanded(expanded: boolean) {
set({ isItemExpanded: expanded })
},
})),
)
}
function useListStoreContext<T extends ListData>() {
const store = useContext(ListStoreContext)
if (!store) throw new Error("ListStoreContext not found")
return store as unknown as ListStore<T>
}
function useListStore<T extends ListData, R>(selector: (state: ListState<T>) => R): R {
const store = useListStoreContext<T>()
return useStore(store, selector)
}
function ListItem<T extends ListData>({
item,
index,
isSelected,
isExpanded,
onSelect,
onExpand,
renderItem,
}: {
item: T
index: number
isSelected: boolean
isExpanded: boolean
onSelect: (id: string) => void
onExpand: (expanded: boolean) => void
renderItem: (props: {
item: T
isSelected: boolean
isExpanded: boolean
onSelect: () => void
onExpand: () => void
}) => React.ReactNode
}) {
const setSelectedIndex = useListStore((state) => state.setSelectedIndex)
const isItemExpanded = useListStore((state) => state.isItemExpanded)
useEffect(() => {
if (isSelected) {
setSelectedIndex(index)
}
}, [isSelected, index, setSelectedIndex])
const handleSelect = useCallback(() => {
onSelect(item.id)
}, [item.id, onSelect])
const handleExpand = useCallback(() => {
onExpand(!isExpanded)
}, [isExpanded, onExpand])
const handleMouseEnter = useCallback(() => {
if (!isItemExpanded) {
onSelect(item.id)
}
}, [isItemExpanded, item.id, onSelect])
return (
<li onMouseEnter={handleMouseEnter}>
{renderItem({
item,
isSelected,
isExpanded,
onSelect: handleSelect,
onExpand: handleExpand,
})}
</li>
)
}
const MemoizedListItem = memo(ListItem) as typeof ListItem
function _List<T extends ListData>({
className,
renderItem,
emptyMessage,
}: {
className?: string
renderItem: (props: {
item: T
isSelected: boolean
isExpanded: boolean
onSelect: () => void
onExpand: () => void
}) => React.ReactNode
emptyMessage: string
}) {
const store = useListStoreContext<T>()
const items = useListStore<T, T[]>((state) => state.items)
const selectedItemId = useListStore<T, string>((state) => state.selectedItemId)
const isItemExpanded = useListStore<T, boolean>((state) => state.isItemExpanded)
const setSelectedItemId = useListStore<T, (id: string) => void>((state) => state.setSelectedItemId)
const setIsItemExpanded = useListStore<T, (expanded: boolean) => void>((state) => state.setIsItemExpanded)
const shortcuts = {
j: selectNextItem,
ArrowDown: selectNextItem,
k: selectPrevItem,
ArrowUp: selectPrevItem,
l: expandItem,
ArrowRight: expandItem,
h: collapseItem,
ArrowLeft: collapseItem,
}
useMnemonics(shortcuts, {
ignore: useCallback(() => false, []),
})
function selectNextItem() {
const { items, selectedIndex, setSelectedItemId } = store.getState()
const nextIndex = selectedIndex + 1
if (nextIndex < items.length) {
setSelectedItemId(items[nextIndex].id)
}
}
function selectPrevItem() {
const { items, selectedIndex, setSelectedItemId } = store.getState()
const prevIndex = selectedIndex - 1
if (prevIndex >= 0) {
setSelectedItemId(items[prevIndex].id)
}
}
function expandItem() {
store.getState().setIsItemExpanded(true)
}
function collapseItem() {
const state = store.getState()
if (!state.alwaysExpandItem) {
store.getState().setIsItemExpanded(false)
}
}
return (
<ul className={clsx("flex flex-col", className)}>
{items.length === 0 ? (
<p>{emptyMessage}</p>
) : (
items.map((item, index) => {
const isSelected = item.id === selectedItemId
const isExpanded = isItemExpanded && isSelected
return (
<MemoizedListItem
key={item.id}
item={item}
index={index}
isSelected={isSelected}
isExpanded={isExpanded}
onSelect={setSelectedItemId}
onExpand={setIsItemExpanded}
renderItem={renderItem}
/>
)
})
)}
</ul>
)
}
const MemoizedList = memo(_List) as typeof _List
export { List }
export type { ListData, ListProps }