mirror of
https://github.com/filecoin-project/slate.git
synced 2024-11-09 20:28:29 +03:00
feat(Upload/Popup): add popup component
This commit is contained in:
parent
8727358958
commit
1108e1aa15
@ -1,126 +1,400 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as System from "~/components/system";
|
|
||||||
import * as Styles from "~/common/styles";
|
import * as Styles from "~/common/styles";
|
||||||
import * as Strings from "~/common/strings";
|
import * as System from "~/components/system";
|
||||||
import * as FileUtilities from "~/common/file-utilities";
|
import * as Constants from "~/common/constants";
|
||||||
|
import * as SVG from "~/common/svg";
|
||||||
|
|
||||||
|
import { useUploadContext } from "~/components/core/Upload/Provider";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { css } from "@emotion/react";
|
import { css } from "@emotion/react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { Match, Switch } from "~/components/utility/Switch";
|
||||||
import { Show } from "~/components/utility/Show";
|
import { Show } from "~/components/utility/Show";
|
||||||
|
import { useHover } from "~/common/hooks";
|
||||||
|
|
||||||
import FilePlaceholder from "~/components/core/ObjectPreview/placeholders/File";
|
import DataMeter from "~/components/core/DataMeter";
|
||||||
import { clamp } from "lodash";
|
import BlobObjectPreview from "~/components/core/BlobObjectPreview";
|
||||||
import { useEventListener } from "~/common/hooks";
|
/* -------------------------------------------------------------------------------------------------
|
||||||
|
* Popup
|
||||||
|
* -----------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
const STYLES_POPUP = (theme) => css`
|
const STYLES_POPUP_WRAPPER = (theme) => css`
|
||||||
${Styles.CONTAINER_CENTERED};
|
|
||||||
flex-direction: column;
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
width: 100%;
|
bottom: 24px;
|
||||||
height: 100vh;
|
right: 24px;
|
||||||
top: 0px;
|
z-index: ${theme.zindex.sidebar};
|
||||||
left: 0;
|
@media (max-width: ${theme.sizes.mobile}px) {
|
||||||
background-color: ${theme.semantic.bgWhite};
|
right: 50%;
|
||||||
|
transform: translateX(50%);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const STYLES_DISMISS_BUTTON = (theme) => css`
|
||||||
|
${Styles.BUTTON_RESET};
|
||||||
|
position: absolute;
|
||||||
|
right: -8px;
|
||||||
|
top: -8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid ${theme.semantic.borderGrayLight4};
|
||||||
|
color: ${theme.semantic.textGrayDark};
|
||||||
|
|
||||||
|
background-color: ${theme.semantic.bgWhite};
|
||||||
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
|
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
|
||||||
-webkit-backdrop-filter: blur(75px);
|
-webkit-backdrop-filter: blur(75px);
|
||||||
backdrop-filter: blur(75px);
|
backdrop-filter: blur(75px);
|
||||||
background-color: ${theme.semantic.bgBlurWhiteOP};
|
background-color: ${theme.semantic.bgBlurWhiteOP};
|
||||||
}
|
}
|
||||||
`;
|
|
||||||
|
|
||||||
const STYLES_PLACEHOLDER = css`
|
|
||||||
width: 64px;
|
|
||||||
height: 80px;
|
|
||||||
svg {
|
svg {
|
||||||
height: 100%;
|
display: block;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function Popup() {
|
const STYLES_POPUP_CONTENT = css`
|
||||||
const DEFAULT_DROPPING_STATE = {
|
border-radius: 12px;
|
||||||
isDroppingFiles: false,
|
overflow: hidden;
|
||||||
totalFilesDropped: undefined,
|
`;
|
||||||
};
|
|
||||||
|
|
||||||
const timerRef = React.useRef();
|
const useUploadPopup = ({ totalFilesSummary }) => {
|
||||||
|
const [{ isFinished }, { resetUploadState }] = useUploadContext();
|
||||||
|
const [popupState, setPopupState] = React.useState({
|
||||||
|
isVisible: false,
|
||||||
|
isSummaryExpanded: false,
|
||||||
|
});
|
||||||
|
|
||||||
const [{ isDroppingFiles, totalFilesDropped }, setDroppingState] =
|
// NOTE(amine): popup handlers
|
||||||
React.useState(DEFAULT_DROPPING_STATE);
|
const showUploadPopup = () => setPopupState((prev) => ({ ...prev, isVisible: true }));
|
||||||
|
const hideUploadPopup = () => setPopupState((prev) => ({ ...prev, isVisible: false }));
|
||||||
|
const expandUploadSummary = () => setPopupState({ isVisible: true, isSummaryExpanded: true });
|
||||||
|
const collapseUploadSummary = () => setPopupState({ isVisible: true, isSummaryExpanded: false });
|
||||||
|
|
||||||
const handleDragOver = (e) => {
|
const timeoutRef = React.useRef();
|
||||||
e.preventDefault();
|
//NOTE(amine): show the upload summary, then automatically collapse the upload summary after 3 seconds
|
||||||
// NOTE(amine): Hack to hide the popup if the user drags files outside of the app
|
const isStarted = totalFilesSummary.total > 0;
|
||||||
clearTimeout(timerRef.current);
|
React.useEffect(() => {
|
||||||
timerRef.current = setTimeout(() => {
|
if (!isStarted) return;
|
||||||
setDroppingState(DEFAULT_DROPPING_STATE);
|
expandUploadSummary();
|
||||||
}, 100);
|
timeoutRef.current = setTimeout(collapseUploadSummary, 3000);
|
||||||
};
|
}, [isStarted]);
|
||||||
|
|
||||||
const handleDragEnter = async (e) => {
|
/**
|
||||||
e.preventDefault();
|
* NOTE(amine): show the upload summary when a file fails to upload,
|
||||||
const { files } = await FileUtilities.formatDroppedFiles({
|
* then automatically collapse the upload summary after 3 seconds
|
||||||
dataTransfer: e.dataTransfer,
|
*/
|
||||||
|
const isSummaryExpandedRef = React.useRef();
|
||||||
|
isSummaryExpandedRef.current = popupState.isSummaryExpanded;
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isSummaryExpandedRef.current || totalFilesSummary.failed === 0) return;
|
||||||
|
expandUploadSummary();
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = setTimeout(collapseUploadSummary, 3000);
|
||||||
|
}, [totalFilesSummary.failed]);
|
||||||
|
|
||||||
|
// NOTE(amine): show the upload summary when upload finishes
|
||||||
|
const totalFilesSummaryRef = React.useRef();
|
||||||
|
totalFilesSummaryRef.current = totalFilesSummary;
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isFinished) return;
|
||||||
|
//NOTE(amine): if all the upload items have been canceled, hide the upload popup
|
||||||
|
if (totalFilesSummaryRef.current.total === 0) {
|
||||||
|
hideUploadPopup();
|
||||||
|
resetUploadState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
expandUploadSummary();
|
||||||
|
}, [isFinished]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOTE(amine): the upload summary is set to automatically collapse when the upload starts and when a file fails to upload.
|
||||||
|
* Let's cancel those effects when the user hovers over the summary
|
||||||
|
*/
|
||||||
|
const cancelAutoCollapseOnMouseEnter = () => clearTimeout(timeoutRef.current);
|
||||||
|
return [
|
||||||
|
popupState,
|
||||||
|
{
|
||||||
|
showUploadPopup,
|
||||||
|
hideUploadPopup,
|
||||||
|
expandUploadSummary,
|
||||||
|
collapseUploadSummary,
|
||||||
|
cancelAutoCollapseOnMouseEnter,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const useUploadSummary = ({ fileLoading }) =>
|
||||||
|
React.useMemo(() => {
|
||||||
|
let totalFilesSummary = { failed: 0, duplicate: 0, saved: 0, total: 0 };
|
||||||
|
const uploadSummary = Object.entries(fileLoading).map(([, file]) => {
|
||||||
|
totalFilesSummary["total"]++;
|
||||||
|
if (file.status === "saving") return { ...file, filename: file.name };
|
||||||
|
totalFilesSummary[file.status]++;
|
||||||
|
return { ...file, filename: file.name };
|
||||||
});
|
});
|
||||||
setDroppingState({ totalFilesDropped: files.length || undefined, isDroppingFiles: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEventListener("dragenter", handleDragEnter, []);
|
const statusOrder = {
|
||||||
useEventListener("dragover", handleDragOver, []);
|
failed: 1,
|
||||||
|
saving: 2,
|
||||||
|
duplicate: 3,
|
||||||
|
saved: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalFilesSummary,
|
||||||
|
uploadSummary: uploadSummary.sort(
|
||||||
|
(a, b) => statusOrder[a.status] - statusOrder[b.status] || a.createdAt - b.createdAt
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}, [fileLoading]);
|
||||||
|
|
||||||
|
export function Popup() {
|
||||||
|
const [{ isFinished, fileLoading }, { resetUploadState }] = useUploadContext();
|
||||||
|
const { uploadSummary, totalFilesSummary } = useUploadSummary({ fileLoading });
|
||||||
|
|
||||||
|
const [isHovered, { handleOnMouseEnter, handleOnMouseLeave }] = useHover();
|
||||||
|
|
||||||
|
const [
|
||||||
|
popupState,
|
||||||
|
{ hideUploadPopup, expandUploadSummary, collapseUploadSummary, cancelAutoCollapseOnMouseEnter },
|
||||||
|
] = useUploadPopup({ totalFilesSummary });
|
||||||
|
|
||||||
|
if (!popupState.isVisible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<div
|
||||||
{isDroppingFiles ? (
|
css={STYLES_POPUP_WRAPPER}
|
||||||
<motion.div
|
onMouseEnter={handleOnMouseEnter}
|
||||||
initial={{ opacity: 0 }}
|
onMouseLeave={handleOnMouseLeave}
|
||||||
animate={{ opacity: 1 }}
|
>
|
||||||
exit={{ opacity: 0 }}
|
<div css={[STYLES_POPUP_CONTENT]}>
|
||||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
<AnimatePresence>
|
||||||
css={STYLES_POPUP}
|
{popupState.isSummaryExpanded ? (
|
||||||
>
|
<motion.div
|
||||||
<DroppedFilesPlaceholder totalFilesDropped={totalFilesDropped} />
|
initial={{ y: 400 }}
|
||||||
<div style={{ marginTop: 64 }}>
|
animate={{ y: 0 }}
|
||||||
<System.H3 as="p" style={{ textAlign: "center" }}>
|
exit={{ y: 400 }}
|
||||||
Dropping {totalFilesDropped}{" "}
|
transition={{ type: "spring", stiffness: 170, damping: 26 }}
|
||||||
{totalFilesDropped ? Strings.pluralize("file", totalFilesDropped) : "files"} to save
|
onMouseEnter={cancelAutoCollapseOnMouseEnter}
|
||||||
to Slate
|
>
|
||||||
</System.H3>
|
<Summary uploadSummary={uploadSummary} />
|
||||||
<Show when={!totalFilesDropped || totalFilesDropped > 200}>
|
</motion.div>
|
||||||
<System.H5 as="p" color="textGrayDark">
|
) : null}
|
||||||
(we recommend uploading 200 files at a time)
|
</AnimatePresence>
|
||||||
</System.H5>
|
<Header
|
||||||
</Show>
|
totalFilesSummary={totalFilesSummary}
|
||||||
</div>
|
popupState={popupState}
|
||||||
</motion.div>
|
expandUploadSummary={expandUploadSummary}
|
||||||
) : null}
|
collapseUploadSummary={collapseUploadSummary}
|
||||||
</AnimatePresence>
|
/>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const DroppedFilesPlaceholder = ({ totalFilesDropped = 3 }) => {
|
|
||||||
const marginRight = clamp(totalFilesDropped - 1, 0, 2) * 8;
|
|
||||||
return (
|
|
||||||
<div style={{ position: "relative", right: marginRight }}>
|
|
||||||
<div css={STYLES_PLACEHOLDER}>
|
|
||||||
<FilePlaceholder />
|
|
||||||
</div>
|
</div>
|
||||||
<Show when={totalFilesDropped >= 2}>
|
<Show when={isHovered && isFinished}>
|
||||||
<div
|
<button css={STYLES_DISMISS_BUTTON} onClick={() => (hideUploadPopup(), resetUploadState())}>
|
||||||
style={{ position: "absolute", top: -15, right: -16, zIndex: -1 }}
|
<SVG.Dismiss width={16} />
|
||||||
css={STYLES_PLACEHOLDER}
|
</button>
|
||||||
>
|
|
||||||
<FilePlaceholder />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={totalFilesDropped >= 3}>
|
|
||||||
<div
|
|
||||||
style={{ position: "absolute", top: 2 * -15, right: 2 * -16, zIndex: -2 }}
|
|
||||||
css={STYLES_PLACEHOLDER}
|
|
||||||
>
|
|
||||||
<FilePlaceholder />
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------------------------------
|
||||||
|
* Popup Header
|
||||||
|
* -----------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
const STYLES_POPUP_HEADER = (theme) => css`
|
||||||
|
color: ${theme.semantic.textGrayDark};
|
||||||
|
width: 264px;
|
||||||
|
padding: 9px 12px 7px;
|
||||||
|
background-color: ${theme.semantic.bgWhite};
|
||||||
|
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
|
||||||
|
-webkit-backdrop-filter: blur(75px);
|
||||||
|
backdrop-filter: blur(75px);
|
||||||
|
background-color: ${theme.semantic.bgBlurWhiteOP};
|
||||||
|
}
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: border-radius 0.5s ease-in-out;
|
||||||
|
border: 1px solid ${theme.semantic.bgGrayLight};
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const STYLES_RESET_BORDER_TOP = css`
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0px 0px 12px 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function Header({ totalFilesSummary, popupState, expandUploadSummary, collapseUploadSummary }) {
|
||||||
|
const [{ isFinished, totalBytesUploaded, totalBytes }, { retryAll }] = useUploadContext();
|
||||||
|
|
||||||
|
const uploadProgress = Math.floor((totalBytesUploaded / totalBytes) * 100);
|
||||||
|
|
||||||
|
if (isFinished && totalFilesSummary.failed > 0) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
css={[STYLES_POPUP_HEADER, STYLES_RESET_BORDER_TOP, Styles.HORIZONTAL_CONTAINER_CENTERED]}
|
||||||
|
>
|
||||||
|
<SVG.AlertTriangle style={{ color: Constants.system.red }} />
|
||||||
|
<System.P2 style={{ marginLeft: 12 }}>{totalFilesSummary.failed} failed</System.P2>
|
||||||
|
<button
|
||||||
|
css={Styles.BUTTON_RESET}
|
||||||
|
style={{ marginLeft: "auto", color: Constants.system.blue }}
|
||||||
|
onClick={retryAll}
|
||||||
|
>
|
||||||
|
<span css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
|
||||||
|
<SVG.RotateCcw width={16} />
|
||||||
|
<System.P2 style={{ marginLeft: 4 }}>Retry failed</System.P2>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFinished) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
css={[STYLES_POPUP_HEADER, STYLES_RESET_BORDER_TOP, Styles.HORIZONTAL_CONTAINER_CENTERED]}
|
||||||
|
>
|
||||||
|
<SVG.CheckCircle style={{ color: Constants.system.green }} />
|
||||||
|
<System.P2 style={{ marginLeft: 12 }}>
|
||||||
|
{totalFilesSummary.saved + totalFilesSummary.duplicate} saved
|
||||||
|
</System.P2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ y: 10, opacity: 0 }}
|
||||||
|
css={[
|
||||||
|
Styles.BUTTON_RESET,
|
||||||
|
STYLES_POPUP_HEADER,
|
||||||
|
popupState.isSummaryExpanded && STYLES_RESET_BORDER_TOP,
|
||||||
|
]}
|
||||||
|
aria-label="Upload Summary"
|
||||||
|
onClick={popupState.isSummaryExpanded ? collapseUploadSummary : expandUploadSummary}
|
||||||
|
>
|
||||||
|
<span css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
|
||||||
|
<System.P2 color="textBlack" style={{ width: "5ch" }}>
|
||||||
|
{uploadProgress}%
|
||||||
|
</System.P2>
|
||||||
|
<DataMeter
|
||||||
|
bytes={totalBytesUploaded}
|
||||||
|
maximumBytes={totalBytes}
|
||||||
|
style={{
|
||||||
|
width: 164,
|
||||||
|
marginLeft: 8,
|
||||||
|
backgroundColor: Constants.semantic.bgGrayLight,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div initial={null} animate={{ rotateX: popupState.isSummaryExpanded ? 180 : 0 }}>
|
||||||
|
<SVG.ChevronUp style={{ marginLeft: 24, display: "block" }} />
|
||||||
|
</motion.div>
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------------------------------
|
||||||
|
* Popup Summary
|
||||||
|
* -----------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
const STYLES_SUMMARY = (theme) => css`
|
||||||
|
position: relative;
|
||||||
|
background-color: ${theme.system.white};
|
||||||
|
max-height: 312px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
border: 1px solid ${theme.semantic.bgGrayLight};
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 12px 12px 0px 0px;
|
||||||
|
|
||||||
|
// NOTE(amine): fix alignment issue caused by inline display
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const STYLES_PREVIEW_WRAPPER = css`
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const STYLES_SUMMARY_ACTION = css`
|
||||||
|
${Styles.BUTTON_RESET};
|
||||||
|
position: relative;
|
||||||
|
width: 32;
|
||||||
|
height: 32;
|
||||||
|
right: -16;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function Summary({ uploadSummary }) {
|
||||||
|
const [, { retry, cancel }] = useUploadContext();
|
||||||
|
return (
|
||||||
|
<div css={STYLES_SUMMARY}>
|
||||||
|
{uploadSummary.map((file) => (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
style={{ padding: "9px 12px 11px" }}
|
||||||
|
css={Styles.HORIZONTAL_CONTAINER_CENTERED}
|
||||||
|
>
|
||||||
|
<div css={STYLES_PREVIEW_WRAPPER}>
|
||||||
|
<BlobObjectPreview file={file} placeholderRatio={2.4} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginLeft: 12, maxWidth: 164 }}>
|
||||||
|
<System.H5 nbrOflines={1} as="p">
|
||||||
|
{file.name}
|
||||||
|
</System.H5>
|
||||||
|
<Switch fallback={<System.H5>Saved</System.H5>}>
|
||||||
|
<Match when={file.status === "saving"}>
|
||||||
|
<DataMeter
|
||||||
|
bytes={file.loaded}
|
||||||
|
maximumBytes={file.total}
|
||||||
|
style={{ maxWidth: 84, marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={file.status === "failed"}>
|
||||||
|
<System.P3 color="red">failed</System.P3>
|
||||||
|
</Match>
|
||||||
|
<Match when={file.status === "duplicate"}>
|
||||||
|
<System.P3 color="green">already saved</System.P3>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginLeft: "auto" }}>
|
||||||
|
<Switch fallback={<SVG.CheckCircle style={{ color: Constants.system.green }} />}>
|
||||||
|
<Match when={file.status === "saving"}>
|
||||||
|
<button
|
||||||
|
css={STYLES_SUMMARY_ACTION}
|
||||||
|
style={{ color: Constants.semantic.textGray }}
|
||||||
|
onClick={() => cancel({ fileKey: file.id })}
|
||||||
|
>
|
||||||
|
<SVG.XCircle />
|
||||||
|
</button>
|
||||||
|
</Match>
|
||||||
|
<Match when={file.status === "failed"}>
|
||||||
|
<button
|
||||||
|
css={STYLES_SUMMARY_ACTION}
|
||||||
|
style={{ color: Constants.system.blue }}
|
||||||
|
onClick={() => retry({ fileKey: file.id })}
|
||||||
|
>
|
||||||
|
<SVG.RotateCcw width={16} />
|
||||||
|
</button>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<System.Divider height={1} color="bgLight" />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user