Files
TheGame/packages/web/components/EditableGridLayout.tsx
2024-08-13 19:04:31 -04:00

354 lines
10 KiB
TypeScript

import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import {
Box,
BoxProps,
ButtonGroup,
CloseIcon,
ConfirmModal,
EditIcon,
Flex,
MetaButton,
RepeatClockIcon,
ResponsiveText,
useBreakpointValue,
useToast,
} from '@metafam/ds';
import { Maybe } from '@metafam/utils';
import deepEquals from 'deep-equal';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Layout, Layouts, Responsive, WidthProvider } from 'react-grid-layout';
import {
BoxMetadata,
BoxType,
BoxTypes,
createBoxKey,
DisplayOutput,
gridSX,
LayoutData,
} from 'utils/boxTypes';
import {
addBoxToLayouts,
disableAddBox,
enableAddBox,
GRID_ROW_HEIGHT,
isSameLayouts,
MULTIPLE_ALLOWED_BOXES,
removeBoxFromLayouts,
updatedLayouts,
} from 'utils/layoutHelpers';
import { AddBoxSection } from '#components/Section/AddBoxSection';
import { GuildFragment, Player } from '#graphql/autogen/hasura-sdk';
import { useBoxHeights } from '#lib/hooks/useBoxHeights';
import { errorHandler } from '#utils/errorHandler';
const ResponsiveGridLayout = WidthProvider(Responsive);
type Props = React.PropsWithChildren<{
player?: Player;
guild?: GuildFragment;
savedLayoutData: LayoutData;
defaultLayoutData: LayoutData;
persisting: boolean;
persistLayoutData: (layoutData: LayoutData) => Promise<void>;
showEditButton: boolean;
allBoxOptions: BoxType[];
ens?: string;
displayComponent: DisplayOutput;
}> &
BoxProps;
export const EditableGridLayout: React.FC<Props> = ({
children,
player,
guild,
persisting,
defaultLayoutData,
savedLayoutData,
showEditButton,
persistLayoutData,
allBoxOptions,
displayComponent: DisplaySection,
ens,
...props
}) => {
const [saving, setSaving] = useState(false);
const [exitAlertCancel, setExitAlertCancel] = useState<boolean>(false);
const [exitAlertReset, setExitAlertReset] = useState<boolean>(false);
const [changed, setChanged] = useState(false);
const [editing, setEditing] = useState(false);
const itemsRef = useRef<Array<Maybe<HTMLElement>>>([]);
const heights = useBoxHeights(itemsRef.current);
const mobile = useBreakpointValue({ base: true, sm: false });
const toast = useToast();
const [currentLayoutData, setCurrentLayoutData] =
useState<LayoutData>(savedLayoutData);
const { layoutItems: currentLayoutItems, layouts: currentLayouts } =
currentLayoutData;
useEffect(() => {
itemsRef.current = itemsRef.current.slice(0, currentLayoutItems.length);
}, [currentLayoutItems]);
useEffect(() => {
const layouts = updatedLayouts(currentLayouts, heights, editing);
if (!deepEquals(layouts, currentLayouts)) {
setCurrentLayoutData(({ layoutItems }) => ({
layouts,
layoutItems,
}));
}
}, [currentLayouts, heights, editing]);
const handleReset = useCallback(() => {
setCurrentLayoutData(enableAddBox(defaultLayoutData));
setChanged(true);
setExitAlertReset(false);
}, [defaultLayoutData]);
const handleCancel = useCallback(() => {
setCurrentLayoutData(savedLayoutData);
setEditing(false);
setExitAlertCancel(false);
}, [savedLayoutData]);
const isDefaultLayout = useMemo(
() => isSameLayouts(defaultLayoutData, currentLayoutData),
[currentLayoutData, defaultLayoutData],
);
const toggleEditLayout = useCallback(async () => {
try {
let layoutData = defaultLayoutData;
if (editing) {
setSaving(true);
layoutData = disableAddBox(currentLayoutData);
await persistLayoutData(layoutData);
} else {
layoutData = enableAddBox(currentLayoutData);
}
setCurrentLayoutData(layoutData);
setEditing((e) => !e);
setChanged(false);
} catch (err) {
toast({
title: 'Error',
description: `Unable to save layout. Error: ${(err as Error).message}`,
status: 'error',
isClosable: true,
});
errorHandler(err as Error);
} finally {
setSaving(false);
}
}, [editing, currentLayoutData, persistLayoutData, defaultLayoutData, toast]);
const handleLayoutChange = useCallback(
(_items: Array<Layout>, layouts: Layouts) => {
const newData = { layouts, layoutItems: currentLayoutItems };
// automatic height adjustments dirty `changed`
setChanged(
(already) =>
already || (editing && !isSameLayouts(currentLayoutData, newData)),
);
setCurrentLayoutData(newData);
},
[currentLayoutData, currentLayoutItems, editing],
);
const onRemoveBox = useCallback(
(boxKey: string): void => {
const layoutData = {
layouts: removeBoxFromLayouts(currentLayouts, boxKey),
layoutItems: currentLayoutItems.filter((item) => item.key !== boxKey),
};
setCurrentLayoutData(layoutData);
setChanged(true);
},
[currentLayouts, currentLayoutItems],
);
const onAddBox = useCallback(
(type: BoxType, metadata: BoxMetadata): void => {
const key = createBoxKey(type, metadata);
if (currentLayoutItems.find((item) => item.key === key)) {
return;
}
const layoutData = {
layouts: addBoxToLayouts(currentLayouts, type, metadata),
layoutItems: [...currentLayoutItems, { type, metadata, key }],
};
setCurrentLayoutData(layoutData);
setChanged(true);
},
[currentLayouts, currentLayoutItems],
);
const availableBoxes = useMemo(
() =>
allBoxOptions.filter(
(box) =>
!currentLayoutItems.map(({ type }) => type).includes(box) ||
MULTIPLE_ALLOWED_BOXES.includes(box),
),
[currentLayoutItems, allBoxOptions],
);
return (
<Box
width="100%"
height="min-content"
sx={gridSX}
maxW="96rem"
px={4}
{...props}
>
{showEditButton && (
<ButtonGroup
w="full"
mb={4}
justifyContent={children ? 'space-between' : 'end'}
variant="ghost"
zIndex={10}
isAttached
size={mobile ? 'xs' : 'md'}
>
{children}
{editing && !isDefaultLayout && (
<MetaButton
aria-label="Reset Layout"
_hover={{ background: 'purple.600' }}
textTransform="uppercase"
px={[8, 12]}
letterSpacing="0.1em"
onClick={() => setExitAlertReset(true)}
leftIcon={mobile ? undefined : <RepeatClockIcon />}
>
Reset
</MetaButton>
)}
{editing && (
<MetaButton
aria-label="Cancel Layout Edit"
colorScheme="purple"
_hover={{ background: 'purple.600' }}
textTransform="uppercase"
px={[9, 12]}
letterSpacing="0.1em"
onClick={() => setExitAlertCancel(true)}
leftIcon={mobile ? undefined : <CloseIcon />}
>
Cancel
</MetaButton>
)}
<ConfirmModal
isOpen={exitAlertReset}
onNope={() => setExitAlertReset(false)}
onYep={handleReset}
header="Reset the layout to its default?"
/>
<ConfirmModal
isOpen={exitAlertCancel}
onNope={() => setExitAlertCancel(false)}
onYep={handleCancel}
header="Cancel editing the layout?"
/>
{(!editing || changed) && (
<MetaButton
aria-label="Edit Layout"
borderColor="transparent"
background="rgba(17, 17, 17, 0.9)"
_hover={{ color: 'white' }}
variant="outline"
textTransform="uppercase"
px={[5, 12]}
letterSpacing="0.1em"
bg="transparent"
color={editing ? 'red.400' : 'pinkShadeOne'}
leftIcon={mobile ? undefined : <EditIcon />}
transition="color 0.2s ease"
isLoading={saving || persisting}
onClick={toggleEditLayout}
>
<ResponsiveText
content={{
base: editing ? 'Save' : 'Edit Layout ',
md: `${editing ? 'Save' : 'Edit'} Layout`,
}}
/>
</MetaButton>
)}
</ButtonGroup>
)}
<Box w="100%" pb="1rem" h="min-content">
<ResponsiveGridLayout
onLayoutChange={handleLayoutChange}
layouts={currentLayouts}
breakpoints={{ lg: 1180, md: 900, sm: 0 }}
cols={{ lg: 3, md: 2, sm: 1 }}
rowHeight={GRID_ROW_HEIGHT}
isDraggable={!!editing}
isResizable={!!editing}
margin={[30, 30]}
containerPadding={{
sm: [0, 20],
lg: [0, 30],
}}
>
{currentLayoutItems.map(({ key, type, metadata }, i) => (
<Flex
boxShadow={editing ? 'lg' : 'md'}
bg="#FFFFFF0A"
backdropFilter="blur(7px)"
borderTop="1px solid #2B2244"
borderLeft="1px solid #2B2244"
overflow="hidden"
borderRadius="lg"
transition="boxShadow 0.2s 0.3s ease"
id={key}
key={key}
>
{type === BoxTypes.ADD_NEW_BOX ? (
<AddBoxSection
boxes={availableBoxes}
previewComponent={DisplaySection ?? null}
{...{ player, guild, onAddBox }}
ref={(e: Maybe<HTMLElement>) => {
itemsRef.current[i] = e;
}}
/>
) : (
<DisplaySection
{...{
type,
metadata,
player,
guild,
editing,
onRemoveBox,
ens,
}}
ref={(e: Maybe<HTMLElement>) => {
itemsRef.current[i] = e;
}}
/>
)}
</Flex>
))}
</ResponsiveGridLayout>
</Box>
</Box>
);
};