refactor: recreate collection and global hotkey contexts as store (using zustand)

This commit is contained in:
Sonny
2024-11-06 23:06:21 +01:00
committed by Sonny
parent 8bd87b5bba
commit 861906d29b
27 changed files with 247 additions and 148 deletions

View File

@@ -10,8 +10,8 @@ import {
import ModalWrapper from '~/components/common/modal/_modal_wrapper';
import TextEllipsis from '~/components/common/text_ellipsis';
import useClickOutside from '~/hooks/use_click_outside';
import useGlobalHotkeys from '~/hooks/use_global_hotkeys';
import useShortcut from '~/hooks/use_shortcut';
import { useGlobalHotkeysStore } from '~/store/global_hotkeys_store';
interface ModalProps {
title?: string;
@@ -32,7 +32,7 @@ export default function Modal({
close,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const { setGlobalHotkeysEnabled } = useGlobalHotkeys();
const { setGlobalHotkeysEnabled } = useGlobalHotkeysStore();
useClickOutside(modalRef, close);
useShortcut('ESCAPE_KEY', close, { disableGlobalCheck: true });

View File

@@ -4,7 +4,7 @@ import CollectionHeader from '~/components/dashboard/collection/header/collectio
import LinkList from '~/components/dashboard/link/link_list';
import { NoCollection } from '~/components/dashboard/link/no_item';
import Footer from '~/components/footer/footer';
import useActiveCollection from '~/hooks/use_active_collection';
import { useActiveCollection } from '~/store/collection_store';
export interface CollectionHeaderProps {
showButtons: boolean;

View File

@@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import TextEllipsis from '~/components/common/text_ellipsis';
import useActiveCollection from '~/hooks/use_active_collection';
import { useActiveCollection } from '~/store/collection_store';
const CollectionDescriptionStyle = styled.div({
width: '100%',

View File

@@ -6,7 +6,7 @@ import { CollectionHeaderProps } from '~/components/dashboard/collection/collect
import CollectionControls from '~/components/dashboard/collection/header/collection_controls';
import CollectionDescription from '~/components/dashboard/collection/header/collection_description';
import VisibilityBadge from '~/components/visibilty/visibilty';
import useActiveCollection from '~/hooks/use_active_collection';
import { useActiveCollection } from '~/store/collection_store';
const paddingLeft = '1.25em';
const paddingRight = '1.65em';

View File

@@ -5,8 +5,8 @@ import { useEffect, useRef } from 'react';
import { AiFillFolderOpen, AiOutlineFolder } from 'react-icons/ai';
import TextEllipsis from '~/components/common/text_ellipsis';
import { Item } from '~/components/dashboard/side_nav/nav_item';
import useActiveCollection from '~/hooks/use_active_collection';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
import { CollectionWithLinks } from '~/types/app';
const CollectionItemStyle = styled(Item, {

View File

@@ -2,9 +2,8 @@ import styled from '@emotion/styled';
import { useTranslation } from 'react-i18next';
import CollectionItem from '~/components/dashboard/collection/list/collection_item';
import CollectionListContainer from '~/components/dashboard/collection/list/collection_list_container';
import useActiveCollection from '~/hooks/use_active_collection';
import useCollections from '~/hooks/use_collections';
import useShortcut from '~/hooks/use_shortcut';
import { useActiveCollection, useCollections } from '~/store/collection_store';
const SideMenu = styled.nav(({ theme }) => ({
height: '100%',

View File

@@ -1,17 +1,15 @@
import { Link as InertiaLink } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { ActionIcon, Menu } from '@mantine/core';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BsThreeDotsVertical } from 'react-icons/bs';
import { FaRegEye } from 'react-icons/fa';
import { GoPencil } from 'react-icons/go';
import { IoTrashOutline } from 'react-icons/io5';
import { MdFavorite, MdFavoriteBorder } from 'react-icons/md';
import useActiveCollection from '~/hooks/use_active_collection';
import useCollections from '~/hooks/use_collections';
import { onFavorite } from '~/lib/favorite';
import { appendCollectionId, appendLinkId } from '~/lib/navigation';
import { useFavorites } from '~/store/collection_store';
import { Link } from '~/types/app';
interface LinksControlsProps {
@@ -22,34 +20,9 @@ export default function LinkControls({
link,
showGoToCollection = false,
}: LinksControlsProps) {
const { collections, setCollections } = useCollections();
const { setActiveCollection } = useActiveCollection();
const { toggleFavorite } = useFavorites();
const { t } = useTranslation('common');
const toggleFavorite = useCallback(
(linkId: Link['id']) => {
let linkIndex = 0;
const collectionIndex = collections.findIndex(({ links }) => {
const lIndex = links.findIndex((l) => l.id === linkId);
if (lIndex !== -1) {
linkIndex = lIndex;
}
return lIndex !== -1;
});
const collectionLink = collections[collectionIndex].links[linkIndex];
const collectionsCopy = [...collections];
collectionsCopy[collectionIndex].links[linkIndex] = {
...collectionLink,
favorite: !collectionLink.favorite,
};
setCollections(collectionsCopy);
setActiveCollection(collectionsCopy[collectionIndex]);
},
[collections, setCollections]
);
const onFavoriteCallback = () => toggleFavorite(link.id);
return (
<Menu withinPortal shadow="md" width={200}>

View File

@@ -2,8 +2,8 @@ import styled from '@emotion/styled';
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { useTranslation } from 'react-i18next';
import useActiveCollection from '~/hooks/use_active_collection';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
import { fadeIn } from '~/styles/keyframes';
const NoCollectionStyle = styled.div({

View File

@@ -7,11 +7,10 @@ import Modal from '~/components/common/modal/modal';
import NoSearchResult from '~/components/dashboard/search/no_search_result';
import SearchResultList from '~/components/dashboard/search/search_result_list';
import { GOOGLE_SEARCH_URL } from '~/constants';
import useActiveCollection from '~/hooks/use_active_collection';
import useCollections from '~/hooks/use_collections';
import useToggle from '~/hooks/use_modal';
import useShortcut from '~/hooks/use_shortcut';
import { makeRequest } from '~/lib/request';
import { useActiveCollection, useCollections } from '~/store/collection_store';
import { SearchResult } from '~/types/search';
const SearchInput = styled.input(({ theme }) => ({

View File

@@ -4,7 +4,7 @@ import { AiOutlineFolder } from 'react-icons/ai';
import Legend from '~/components/common/legend';
import TextEllipsis from '~/components/common/text_ellipsis';
import LinkFavicon from '~/components/dashboard/link/link_favicon';
import useCollections from '~/hooks/use_collections';
import { useCollections } from '~/store/collection_store';
import {
SearchResult,
SearchResultCollection,

View File

@@ -1,11 +1,9 @@
import styled from '@emotion/styled';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { AiFillStar, AiOutlineStar } from 'react-icons/ai';
import { DropdownItemButton } from '~/components/common/dropdown/dropdown_item';
import useActiveCollection from '~/hooks/use_active_collection';
import useCollections from '~/hooks/use_collections';
import { onFavorite } from '~/lib/favorite';
import { useFavorites } from '~/store/collection_store';
import { Link } from '~/types/app';
const StarItem = styled(DropdownItemButton)(({ theme }) => ({
@@ -13,34 +11,9 @@ const StarItem = styled(DropdownItemButton)(({ theme }) => ({
}));
export default function FavoriteDropdownItem({ link }: { link: Link }) {
const { collections, setCollections } = useCollections();
const { setActiveCollection } = useActiveCollection();
const { toggleFavorite } = useFavorites();
const { t } = useTranslation();
const toggleFavorite = useCallback(
(linkId: Link['id']) => {
let linkIndex = 0;
const collectionIndex = collections.findIndex(({ links }) => {
const lIndex = links.findIndex((l) => l.id === linkId);
if (lIndex !== -1) {
linkIndex = lIndex;
}
return lIndex !== -1;
});
const collectionLink = collections[collectionIndex].links[linkIndex];
const collectionsCopy = [...collections];
collectionsCopy[collectionIndex].links[linkIndex] = {
...collectionLink,
favorite: !collectionLink.favorite,
};
setCollections(collectionsCopy);
setActiveCollection(collectionsCopy[collectionIndex]);
},
[collections, setCollections]
);
const onFavoriteCallback = () => toggleFavorite(link.id);
return (
<StarItem

View File

@@ -2,7 +2,7 @@ import styled from '@emotion/styled';
import { useTranslation } from 'react-i18next';
import FavoriteListContainer from '~/components/dashboard/side_nav/favorite/favorite_container';
import FavoriteItem from '~/components/dashboard/side_nav/favorite/favorite_item';
import useFavorites from '~/hooks/use_favorites';
import { useFavorites } from '~/store/collection_store';
const FavoriteLabel = styled.p(({ theme }) => ({
color: theme.colors.grey,

View File

@@ -8,10 +8,10 @@ import FavoriteList from '~/components/dashboard/side_nav/favorite/favorite_list
import { Item, ItemLink } from '~/components/dashboard/side_nav/nav_item';
import UserCard from '~/components/dashboard/side_nav/user_card';
import ModalSettings from '~/components/settings/settings_modal';
import useActiveCollection from '~/hooks/use_active_collection';
import useUser from '~/hooks/use_user';
import { rgba } from '~/lib/color';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
const SideMenu = styled.nav(({ theme }) => ({
height: '100%',

View File

@@ -0,0 +1,9 @@
import { useEffect } from 'react';
export const useDisableOverflow = () =>
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = 'auto';
};
}, []);

View File

@@ -1,5 +0,0 @@
import { useContext } from 'react';
import { ActiveCollectionContext } from '~/contexts/active_collection_context';
const useActiveCollection = () => useContext(ActiveCollectionContext);
export default useActiveCollection;

View File

@@ -1,5 +0,0 @@
import { useContext } from 'react';
import CollectionsContext from '~/contexts/collections_context';
const useCollections = () => useContext(CollectionsContext);
export default useCollections;

View File

@@ -1,6 +1,6 @@
import KEYS from '#constants/keys';
import { useHotkeys } from 'react-hotkeys-hook';
import useGlobalHotkeys from '~/hooks/use_global_hotkeys';
import { useGlobalHotkeysStore } from '~/store/global_hotkeys_store';
type ShortcutOptions = {
enabled?: boolean;
@@ -15,7 +15,7 @@ export default function useShortcut(
disableGlobalCheck: false,
}
) {
const { globalHotkeysEnabled } = useGlobalHotkeys();
const { globalHotkeysEnabled } = useGlobalHotkeysStore();
return useHotkeys(
KEYS[key],
(event) => {

View File

@@ -3,8 +3,8 @@ import { route } from '@izzyjs/route/client';
import { Text } from '@mantine/core';
import { useEffect, useRef } from 'react';
import { AiFillFolderOpen, AiOutlineFolder } from 'react-icons/ai';
import useActiveCollection from '~/hooks/use_active_collection';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
import { CollectionWithLinks } from '~/types/app';
import classes from './collection_item.module.css';

View File

@@ -1,9 +1,8 @@
import { Box, ScrollArea, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import useActiveCollection from '~/hooks/use_active_collection';
import useCollections from '~/hooks/use_collections';
import useShortcut from '~/hooks/use_shortcut';
import CollectionItem from '~/mantine/components/dashboard/collection/item/collection_item';
import { useActiveCollection, useCollections } from '~/store/collection_store';
import styles from './collection_list.module.css';
export default function CollectionList() {

View File

@@ -6,8 +6,8 @@ import { BsThreeDotsVertical } from 'react-icons/bs';
import { GoPencil } from 'react-icons/go';
import { IoIosAddCircleOutline } from 'react-icons/io';
import { IoTrashOutline } from 'react-icons/io5';
import useActiveCollection from '~/hooks/use_active_collection';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
interface DashboardHeaderProps {
navbar: {

View File

@@ -1,7 +1,7 @@
import { Box, Group, ScrollArea, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import useFavorites from '~/hooks/use_favorites';
import { FavoriteItem } from '~/mantine/components/dashboard/favorite/item/favorite_item';
import { useFavorites } from '~/store/collection_store';
import styles from './favorite_list.module.css';
export function FavoriteList() {

View File

@@ -2,8 +2,8 @@ import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { Anchor, Box, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import useActiveCollection from '~/hooks/use_active_collection';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
import styles from './no_link.module.css';
export function NoLink() {

View File

@@ -1,15 +1,23 @@
import { router } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { AppShell, ScrollArea, Stack } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useEffect } from 'react';
import DashboardProviders from '~/components/dashboard/dashboard_provider';
import LinkItem from '~/components/dashboard/link/link_item';
import { MantineFooter } from '~/components/footer/mantine_footer';
import { useDisableOverflow } from '~/hooks/mantine/use_disable_overflow';
import useShortcut from '~/hooks/use_shortcut';
import { appendCollectionId } from '~/lib/navigation';
import { DashboardAside } from '~/mantine/components/dashboard/dashboard_aside';
import { DashboardHeader } from '~/mantine/components/dashboard/dashboard_header';
import { DashboardNavbar } from '~/mantine/components/dashboard/dashboard_navbar';
import { NoLink } from '~/mantine/components/dashboard/link/no_link';
import { MantineDashboardLayout } from '~/mantine/layouts/mantine_dashboard_layout';
import {
useActiveCollection,
useCollectionsSetter,
} from '~/store/collection_store';
import { useGlobalHotkeysStore } from '~/store/global_hotkeys_store';
import { CollectionWithLinks } from '~/types/app';
import classes from './dashboard.module.css';
@@ -24,71 +32,91 @@ export default function MantineDashboard(props: Readonly<DashboardPageProps>) {
const [openedAside, { toggle: toggleAside, close: closeAside }] =
useDisclosure();
const { activeCollection } = useActiveCollection();
const { _setCollections, setActiveCollection } = useCollectionsSetter();
const { globalHotkeysEnabled } = useGlobalHotkeysStore();
useShortcut('ESCAPE_KEY', () => {
closeNavbar();
closeAside();
});
useDisableOverflow();
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = 'auto';
};
_setCollections(props.collections);
setActiveCollection(props.activeCollection);
}, []);
useShortcut(
'OPEN_CREATE_LINK_KEY',
() =>
router.visit(
appendCollectionId(route('link.create-form').url, activeCollection?.id)
),
{
enabled: globalHotkeysEnabled,
}
);
useShortcut(
'OPEN_CREATE_COLLECTION_KEY',
() => router.visit(route('collection.create-form').url),
{
enabled: globalHotkeysEnabled,
}
);
return (
<MantineDashboardLayout>
<DashboardProviders {...props}>
<div className={classes.app_wrapper}>
<AppShell
layout="alt"
header={{ height: 50 }}
navbar={{
width: 300,
breakpoint: 'sm',
collapsed: { mobile: !openedNavbar },
}}
aside={{
width: 300,
breakpoint: 'md',
collapsed: { mobile: !openedAside },
}}
classNames={{
aside: classes.ml_custom_class,
footer: classes.ml_custom_class,
navbar: classes.ml_custom_class,
header: classes.ml_custom_class,
}}
className={classes.app_shell}
>
<DashboardHeader
navbar={{ opened: openedNavbar, toggle: toggleNavbar }}
aside={{ opened: openedAside, toggle: toggleAside }}
/>
<DashboardNavbar isOpen={openedNavbar} toggle={toggleNavbar} />
<AppShell.Main>
{props.activeCollection.links.length > 0 ? (
<ScrollArea
h="calc(100vh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))"
p="md"
>
<Stack gap="xs">
{props.activeCollection.links.map((link) => (
<LinkItem key={link.id} link={link} showUserControls />
))}
</Stack>
</ScrollArea>
) : (
<NoLink key={props.activeCollection.id} />
)}
</AppShell.Main>
<DashboardAside isOpen={openedAside} toggle={toggleAside} />
<AppShell.Footer pl="xs" pr="xs">
<MantineFooter />
</AppShell.Footer>
</AppShell>
</div>
</DashboardProviders>
<div className={classes.app_wrapper}>
<AppShell
layout="alt"
header={{ height: 50 }}
navbar={{
width: 300,
breakpoint: 'sm',
collapsed: { mobile: !openedNavbar },
}}
aside={{
width: 300,
breakpoint: 'md',
collapsed: { mobile: !openedAside },
}}
classNames={{
aside: classes.ml_custom_class,
footer: classes.ml_custom_class,
navbar: classes.ml_custom_class,
header: classes.ml_custom_class,
}}
className={classes.app_shell}
>
<DashboardHeader
navbar={{ opened: openedNavbar, toggle: toggleNavbar }}
aside={{ opened: openedAside, toggle: toggleAside }}
/>
<DashboardNavbar isOpen={openedNavbar} toggle={toggleNavbar} />
<AppShell.Main>
{activeCollection?.links && activeCollection.links.length > 0 ? (
<ScrollArea
h="calc(100vh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))"
p="md"
>
<Stack gap="xs">
{activeCollection?.links.map((link) => (
<LinkItem key={link.id} link={link} showUserControls />
))}
</Stack>
</ScrollArea>
) : (
<NoLink key={activeCollection?.id} />
)}
</AppShell.Main>
<DashboardAside isOpen={openedAside} toggle={toggleAside} />
<AppShell.Footer pl="xs" pr="xs">
<MantineFooter />
</AppShell.Footer>
</AppShell>
</div>
</MantineDashboardLayout>
);
}

View File

@@ -0,0 +1,91 @@
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import { CollectionWithLinks, Link, LinkWithCollection } from '~/types/app';
type Collections = CollectionWithLinks[];
interface CollectionStore {
collections: Collections;
_setCollections: (collections: Collections) => void;
activeCollection: CollectionWithLinks | null;
setActiveCollection: (collection: CollectionWithLinks) => void;
favorites: LinkWithCollection[];
toggleFavorite: (link: Link['id']) => void;
}
export const useCollectionStore = create<CollectionStore>((set, get) => ({
collections: [],
_setCollections: (collections) => {
const favorites = getFavoriteLinks(collections);
set({ collections, favorites });
},
activeCollection: null,
setActiveCollection: (collection) => set({ activeCollection: collection }),
favorites: [],
toggleFavorite: (linkId) => {
const { collections } = get();
let linkIndex = 0;
const collectionIndex = collections.findIndex(({ links }) => {
const lIndex = links.findIndex((l) => l.id === linkId);
if (lIndex !== -1) {
linkIndex = lIndex;
}
return lIndex !== -1;
});
const collectionLink = collections[collectionIndex].links[linkIndex];
const collectionsCopy = [...collections];
collectionsCopy[collectionIndex].links[linkIndex] = {
...collectionLink,
favorite: !collectionLink.favorite,
};
set({
collections: collectionsCopy,
activeCollection: collectionsCopy[collectionIndex],
favorites: getFavoriteLinks(collectionsCopy),
});
},
}));
export const useActiveCollection = () =>
useCollectionStore(
useShallow((state) => ({
activeCollection: state.activeCollection,
setActiveCollection: state.setActiveCollection,
}))
);
export const useCollections = () =>
useCollectionStore(
useShallow((state) => ({ collections: state.collections }))
);
export const useFavorites = () =>
useCollectionStore(
useShallow((state) => ({
favorites: state.favorites,
toggleFavorite: state.toggleFavorite,
}))
);
export function useCollectionsSetter() {
const { _setCollections, setActiveCollection } = useCollectionStore();
return { _setCollections, setActiveCollection };
}
function getFavoriteLinks(collections: Collections) {
return collections.reduce((acc, collection) => {
collection.links.forEach((link) => {
if (link.favorite) {
const newLink: LinkWithCollection = { ...link, collection };
acc.push(newLink);
}
});
return acc;
}, [] as LinkWithCollection[]);
}

View File

@@ -0,0 +1,11 @@
import { create } from 'zustand';
interface GlobalHotkeysStore {
globalHotkeysEnabled: boolean;
setGlobalHotkeysEnabled: (value: boolean) => void;
}
export const useGlobalHotkeysStore = create<GlobalHotkeysStore>((set) => ({
globalHotkeysEnabled: true,
setGlobalHotkeysEnabled: (value) => set({ globalHotkeysEnabled: value }),
}));

View File

@@ -6,7 +6,7 @@
"scripts": {
"start": "node bin/server.js",
"build": "node ace build",
"dev": "node ace serve --hmr",
"dev": "node ace serve --watch",
"test": "node ace test",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write --parser typescript '**/*.{ts,tsx}'",
@@ -106,7 +106,8 @@
"react-select": "^5.8.2",
"react-swipeable": "^7.0.1",
"react-toggle": "^4.1.3",
"reflect-metadata": "^0.2.2"
"reflect-metadata": "^0.2.2",
"zustand": "^5.0.1"
},
"hotHook": {
"boundaries": [

26
pnpm-lock.yaml generated
View File

@@ -125,6 +125,9 @@ importers:
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
zustand:
specifier: ^5.0.1
version: 5.0.1(@types/react@18.3.12)(react@18.3.1)
devDependencies:
'@adonisjs/assembler':
specifier: ^7.8.2
@@ -5115,6 +5118,24 @@ packages:
engines: {node: '>=8.0.0'}
hasBin: true
zustand@5.0.1:
resolution: {integrity: sha512-pRET7Lao2z+n5R/HduXMio35TncTlSW68WsYBq2Lg1ASspsNGjpwLAsij3RpouyV6+kHMwwwzP0bZPD70/Jx/w==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@adonisjs/ace@13.3.0':
@@ -10181,3 +10202,8 @@ snapshots:
validator: 13.12.0
optionalDependencies:
commander: 9.5.0
zustand@5.0.1(@types/react@18.3.12)(react@18.3.1):
optionalDependencies:
'@types/react': 18.3.12
react: 18.3.1