mirror of
https://github.com/MetaFam/TheGame.git
synced 2026-01-12 08:08:25 -05:00
354 lines
10 KiB
TypeScript
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>
|
|
);
|
|
};
|