hensei-web/components/party/PartyVisibilityDialog/index.tsx
Justin Edmund 9e6c9a2108
Implement party visibility (#369)
Parties can now be set to be private or unlisted. Private parties cannot
be shared with anyone while Unlisted parties can be seen by those with
the link.

We implemented a dialog to change visibility, notices to let users know
if a party isn't public, and icons on the GridRep so users can see at a
glance which of their parties has different visibility on their profile.

![CleanShot 2023-08-25 at 15 50
10@2x](https://github.com/jedmund/hensei-web/assets/383021/488b7fe2-497a-48f3-982a-d603c0a34539)

![CleanShot 2023-08-25 at 15 49
45@2x](https://github.com/jedmund/hensei-web/assets/383021/675523f6-d158-4019-8c1a-cf87b48501f9)

![CleanShot 2023-08-25 at 15 50
49@2x](https://github.com/jedmund/hensei-web/assets/383021/419a3b06-f083-4c9e-b4fb-ea70669513fd)
2023-08-25 15:51:28 -07:00

303 lines
8.6 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'react-i18next'
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 type { DetailsObject } from 'types'
import type { DialogProps } from '@radix-ui/react-dialog'
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<any>
}
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<HTMLDivElement>()
const topContainerRef = React.createRef<HTMLDivElement>()
const footerRef = React.createRef<HTMLDivElement>()
const radioItemRef = [
React.createRef<HTMLButtonElement>(),
React.createRef<HTMLButtonElement>(),
React.createRef<HTMLButtonElement>(),
]
// 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<HTMLDivElement, 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 (
<div className={styles.radioSet}>
<RadioGroup.Item
className={styles.radioItem}
value={value}
id={label}
tabIndex={parseInt(value)}
ref={radioItemRef[parseInt(value)]}
>
<RadioGroup.Indicator className={styles.radioIndicator} />
</RadioGroup.Item>
<label htmlFor={label}>
<h4>{t(`modals.team_visibility.options.${label}`)}</h4>
<p>{t(`modals.team_visibility.descriptions.${label}`)}</p>
</label>
</div>
)
}
const confirmationAlert = (
<Alert
message={t('modals.team_visibility.alerts.unsaved_changes.message')}
open={alertOpen}
primaryActionText={t(
'modals.team_visibility.alerts.unsaved_changes.buttons.confirm'
)}
primaryAction={close}
cancelActionText={t(
'modals.team_visibility.alerts.unsaved_changes.buttons.cancel'
)}
cancelAction={() => setAlertOpen(false)}
/>
)
return (
<>
{confirmationAlert}
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{props.children}</DialogTrigger>
<DialogContent
className="changeVisibility"
onEscapeKeyDown={onEscapeKeyDown}
onOpenAutoFocus={onOpenAutoFocus}
>
<DialogHeader
title={t('modals.team_visibility.title')}
ref={headerRef}
/>
<div className={styles.content}>
<p className={styles.description}>
{t('modals.team_visibility.description')}
</p>
<RadioGroup.Root
className={styles.radioGroup}
defaultValue={`${visibility}`}
aria-label={t('modals.team_visibility.label')}
onValueChange={handleValueChange}
>
{renderRadioItem('1', 'public')}
{renderRadioItem('2', 'unlisted')}
{renderRadioItem('3', 'private')}
</RadioGroup.Root>
</div>
<DialogFooter
ref={footerRef}
rightElements={[
<Button
bound={true}
onClick={() => onOpenChange && onOpenChange(false)}
key="cancel"
text={t('buttons.cancel')}
/>,
<Button
bound={true}
key="confirm"
onClick={updateDetails}
text={t('modals.team_visibility.buttons.confirm')}
/>,
]}
/>
</DialogContent>
</Dialog>
</>
)
}
export default EditPartyModal