260 lines
7.5 KiB
TypeScript
260 lines
7.5 KiB
TypeScript
|
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 { Button } from "~/components/button"
|
||
|
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||
|
import { DialogKind, useCollectionPageStore } from "./-store"
|
||
|
import { useStore } from "zustand"
|
||
|
|
||
|
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}
|
||
|
/>
|
||
|
))
|
||
|
)
|
||
|
}
|
||
|
|
||
|
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,
|
||
|
})}
|
||
|
onMouseEnter={onItemHover}
|
||
|
>
|
||
|
<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> </span>
|
||
|
<span className={isItemExpanded ? "rotate-90" : ""}>></span>
|
||
|
<span> </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"> </span>
|
||
|
</div>
|
||
|
</div>
|
||
|
) : null}
|
||
|
</div>
|
||
|
</li>
|
||
|
)
|
||
|
},
|
||
|
)
|
||
|
|
||
|
export { CollectionList }
|
||
|
export type { CollectionListState }
|