From 6e0973d406eb216d9efd0073575d05c70e7915ae Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Fri, 25 Aug 2023 14:47:13 -0700 Subject: [PATCH] Implement PartyVisibilityDialog This dialog allows the user to change the visibility of their party. --- .../PartyVisibilityDialog/index.module.scss | 83 +++++ .../party/PartyVisibilityDialog/index.tsx | 306 ++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 components/party/PartyVisibilityDialog/index.module.scss create mode 100644 components/party/PartyVisibilityDialog/index.tsx diff --git a/components/party/PartyVisibilityDialog/index.module.scss b/components/party/PartyVisibilityDialog/index.module.scss new file mode 100644 index 00000000..4d0c9ed1 --- /dev/null +++ b/components/party/PartyVisibilityDialog/index.module.scss @@ -0,0 +1,83 @@ +.content { + display: flex; + flex-direction: column; + gap: $unit-4x; + padding: 0 $unit-4x $unit-2x; + + .description { + color: var(--text-primary); + font-size: $font-regular; + } + + .radioGroup { + display: flex; + flex-direction: column; + gap: $unit-2x; + + .radioSet { + display: flex; + gap: $unit; + + .radioItem { + align-items: center; + background: var(--radio-button-bg); + border-radius: $full-corner; + border: none; + display: flex; + border: 2px solid transparent; + box-sizing: border-box; + justify-content: center; + height: $unit-4x; + width: $unit-4x; + min-height: $unit-4x; + min-width: $unit-4x; + + &:focus { + outline: 2px solid var(--radio-active-bg); + + &:hover { + outline-color: var(--radio-active-bg-hover); + } + } + + [data-state='checked'] { + background-color: var(--radio-active-bg); + border-radius: $full-corner; + display: block; + height: $unit-2x; + width: $unit-2x; + } + + &[data-state='checked']:hover [data-state='checked'] { + background-color: var(--radio-active-bg-hover); + } + + &:hover { + background: var(--radio-button-bg-hover); + cursor: pointer; + } + } + + label { + display: flex; + flex-direction: column; + gap: $unit-half; + + &:hover { + cursor: pointer; + } + + h4 { + color: var(--text-primary); + font-size: $font-regular; + font-weight: $bold; + } + + p { + color: var(--text-tertiary); + font-size: $font-small; + } + } + } + } +} diff --git a/components/party/PartyVisibilityDialog/index.tsx b/components/party/PartyVisibilityDialog/index.tsx new file mode 100644 index 00000000..a0f2b2de --- /dev/null +++ b/components/party/PartyVisibilityDialog/index.tsx @@ -0,0 +1,306 @@ +import React, { useEffect, useRef, useState } from 'react' +import { useSnapshot } from 'valtio' +import { Trans, useTranslation } from 'react-i18next' +import classNames from 'classnames' +import debounce from 'lodash.debounce' + +import * as RadioGroup from '@radix-ui/react-radio-group' +import Alert from '~components/common/Alert' +import Button from '~components/common/Button' +import { Dialog, DialogTrigger } from '~components/common/Dialog' +import DialogHeader from '~components/common/DialogHeader' +import DialogFooter from '~components/common/DialogFooter' +import DialogContent from '~components/common/DialogContent' + +import capitalizeFirstLetter from '~utils/capitalizeFirstLetter' +import type { DetailsObject } from 'types' +import type { DialogProps } from '@radix-ui/react-dialog' +import type { JSONContent } from '@tiptap/core' + +import { appState } from '~utils/appState' + +import styles from './index.module.scss' + +interface Props extends DialogProps { + open: boolean + value: 1 | 2 | 3 + onOpenChange?: (open: boolean) => void + updateParty: (details: DetailsObject) => Promise +} + +const EditPartyModal = ({ + open, + value, + updateParty, + onOpenChange, + ...props +}: Props) => { + // Set up translation + const { t } = useTranslation('common') + + // Set up reactive state + const { party } = useSnapshot(appState) + + // Refs + const headerRef = React.createRef() + const topContainerRef = React.createRef() + const footerRef = React.createRef() + const radioItemRef = [ + React.createRef(), + React.createRef(), + React.createRef(), + ] + + // States: Component + const [alertOpen, setAlertOpen] = useState(false) + const [errors, setErrors] = useState<{ [key: string]: string }>({ + name: '', + description: '', + }) + + // States: Data + const [visibility, setVisibility] = useState(1) + + // Hooks + useEffect(() => { + setVisibility(party.visibility) + }, [value]) + + // Methods: Event handlers (Dialog) + function handleOpenChange() { + if (hasBeenModified() && open) { + setAlertOpen(true) + } else if (!hasBeenModified() && open) { + close() + } else { + if (onOpenChange) onOpenChange(true) + } + } + + function close() { + setAlertOpen(false) + setVisibility(party.visibility) + if (onOpenChange) onOpenChange(false) + } + + function onEscapeKeyDown(event: KeyboardEvent) { + event.preventDefault() + handleOpenChange() + } + + function onOpenAutoFocus(event: Event) { + event.preventDefault() + } + + // Methods: Event handlers (Fields) + function handleValueChange(value: string) { + const newVisibility = parseInt(value) + setVisibility(newVisibility) + } + + // Handlers + function handleScroll(event: React.UIEvent) { + const scrollTop = event.currentTarget.scrollTop + const scrollHeight = event.currentTarget.scrollHeight + const clientHeight = event.currentTarget.clientHeight + + if (topContainerRef && topContainerRef.current) + manipulateHeaderShadow(topContainerRef.current, scrollTop) + + if (footerRef && footerRef.current) + manipulateFooterShadow( + footerRef.current, + scrollTop, + scrollHeight, + clientHeight + ) + } + + function manipulateHeaderShadow(header: HTMLDivElement, scrollTop: number) { + const boxShadowBase = '0 2px 8px' + const maxValue = 50 + + if (scrollTop >= 0) { + const input = scrollTop > maxValue ? maxValue : scrollTop + + const boxShadowOpacity = mapRange(input, 0, maxValue, 0.0, 0.16) + const borderOpacity = mapRange(input, 0, maxValue, 0.0, 0.24) + + header.style.boxShadow = `${boxShadowBase} rgba(0, 0, 0, ${boxShadowOpacity})` + header.style.borderBottomColor = `rgba(0, 0, 0, ${borderOpacity})` + } + } + + function manipulateFooterShadow( + footer: HTMLDivElement, + scrollTop: number, + scrollHeight: number, + clientHeight: number + ) { + const boxShadowBase = '0 -2px 8px' + const minValue = scrollHeight - 200 + const currentScroll = scrollTop + clientHeight + + if (currentScroll >= minValue) { + const input = currentScroll < minValue ? minValue : currentScroll + + const boxShadowOpacity = mapRange( + input, + minValue, + scrollHeight, + 0.16, + 0.0 + ) + const borderOpacity = mapRange(input, minValue, scrollHeight, 0.24, 0.0) + + footer.style.boxShadow = `${boxShadowBase} rgba(0, 0, 0, ${boxShadowOpacity})` + footer.style.borderTopColor = `rgba(0, 0, 0, ${borderOpacity})` + } + } + + const calculateFooterShadow = debounce(() => { + const boxShadowBase = '0 -2px 8px' + const scrollable = document.querySelector(`.${styles.scrollable}`) + const footer = footerRef + + if (footer && footer.current) { + if (scrollable) { + if (scrollable.clientHeight >= scrollable.scrollHeight) { + footer.current.style.boxShadow = `${boxShadowBase} rgba(0, 0, 0, 0)` + footer.current.style.borderTopColor = `rgba(0, 0, 0, 0)` + } else { + footer.current.style.boxShadow = `${boxShadowBase} rgba(0, 0, 0, 0.16)` + footer.current.style.borderTopColor = `rgba(0, 0, 0, 0.24)` + } + } else { + footer.current.style.boxShadow = `${boxShadowBase} rgba(0, 0, 0, 0)` + footer.current.style.borderTopColor = `rgba(0, 0, 0, 0)` + } + } + }, 100) + + useEffect(() => { + window.addEventListener('resize', calculateFooterShadow) + calculateFooterShadow() + + return () => { + window.removeEventListener('resize', calculateFooterShadow) + } + }, [calculateFooterShadow]) + + function mapRange( + value: number, + low1: number, + high1: number, + low2: number, + high2: number + ) { + return low2 + ((high2 - low2) * (value - low1)) / (high1 - low1) + } + + // Methods: Modification checking + function hasBeenModified() { + return visibility !== party.visibility + } + + // Methods: Data methods + async function updateDetails(event: React.MouseEvent) { + const details: DetailsObject = { + visibility: visibility, + } + + await updateParty(details) + if (onOpenChange) onOpenChange(false) + } + + // Methods: Rendering methods + function renderRadioItem(value: string, label: string) { + return ( +
+ + + + +
+ ) + } + + const confirmationAlert = ( + setAlertOpen(false)} + /> + ) + + return ( + <> + {confirmationAlert} + + {props.children} + + + +
+

+ {t('modals.team_visibility.description')} +

+ + {renderRadioItem('1', 'public')} + {renderRadioItem('2', 'unlisted')} + {renderRadioItem('3', 'private')} + +
+ + onOpenChange && onOpenChange(false)} + key="cancel" + text={t('buttons.cancel')} + />, +
+ + ) +} + +export default EditPartyModal