From 9e6c9a21089af3ad1d7bbf8c4f2884709b32f90b Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Fri, 25 Aug 2023 15:51:28 -0700 Subject: [PATCH] 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) --- components/character/CharacterModal/index.tsx | 6 +- components/common/Alert/index.module.scss | 2 +- components/common/Button/index.module.scss | 13 + components/party/EditPartyModal/index.tsx | 8 +- components/party/Party/index.tsx | 2 + components/party/PartyDropdown/index.tsx | 12 +- .../party/PartyHeader/index.module.scss | 52 +++ components/party/PartyHeader/index.tsx | 92 ++++++ .../PartyVisibilityDialog/index.module.scss | 83 +++++ .../party/PartyVisibilityDialog/index.tsx | 303 ++++++++++++++++++ components/reps/GridRep/index.module.scss | 12 + components/reps/GridRep/index.tsx | 65 ++-- components/weapon/WeaponModal/index.tsx | 6 +- public/icons/Private.svg | 3 + public/icons/Public.svg | 4 + public/icons/Unlisted.svg | 3 + public/locales/en/common.json | 52 ++- public/locales/ja/common.json | 53 ++- styles/themes.scss | 24 ++ styles/variables.scss | 30 +- types/Party.d.ts | 1 + types/index.d.ts | 1 + utils/appState.tsx | 2 + 23 files changed, 789 insertions(+), 40 deletions(-) create mode 100644 components/party/PartyVisibilityDialog/index.module.scss create mode 100644 components/party/PartyVisibilityDialog/index.tsx create mode 100644 public/icons/Private.svg create mode 100644 public/icons/Public.svg create mode 100644 public/icons/Unlisted.svg diff --git a/components/character/CharacterModal/index.tsx b/components/character/CharacterModal/index.tsx index 6a170d6d..09e3622e 100644 --- a/components/character/CharacterModal/index.tsx +++ b/components/character/CharacterModal/index.tsx @@ -270,7 +270,7 @@ const CharacterModal = ({ - + You will lose all changes to{' '} {{ objectName: gridCharacter.object.name[locale] }}{' '} if you continue. @@ -281,9 +281,9 @@ const CharacterModal = ({ } open={alertOpen} - primaryActionText="Close" + primaryActionText={t('alert.unsaved_changes.buttons.confirm')} primaryAction={close} - cancelActionText="Nevermind" + cancelActionText={t('alert.unsaved_changes.buttons.cancel')} cancelAction={() => setAlertOpen(false)} /> ) diff --git a/components/common/Alert/index.module.scss b/components/common/Alert/index.module.scss index 7a4b55a9..30a755e9 100644 --- a/components/common/Alert/index.module.scss +++ b/components/common/Alert/index.module.scss @@ -29,7 +29,7 @@ flex-direction: column; gap: $unit-2x; min-width: 20vw; - max-width: 30vw; + max-width: 32vw; padding: $unit * 4; @include breakpoint(tablet) { diff --git a/components/common/Button/index.module.scss b/components/common/Button/index.module.scss index b61e394d..407af421 100644 --- a/components/common/Button/index.module.scss +++ b/components/common/Button/index.module.scss @@ -46,6 +46,10 @@ flex-grow: 1; } + &.no-shrink { + flex-shrink: 0; + } + &.blended { background: transparent; } @@ -304,6 +308,15 @@ } } + &.notice { + background-color: var(--notice-button-bg); + color: var(--notice-button-text); + + &:hover { + background-color: var(--notice-button-bg-hover); + } + } + &.destructive { background: $error; color: white; diff --git a/components/party/EditPartyModal/index.tsx b/components/party/EditPartyModal/index.tsx index 26103c51..1fed3e8a 100644 --- a/components/party/EditPartyModal/index.tsx +++ b/components/party/EditPartyModal/index.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useRef, useState } from 'react' -import { useRouter } from 'next/router' import { useSnapshot } from 'valtio' import { Trans, useTranslation } from 'react-i18next' import classNames from 'classnames' @@ -20,7 +19,6 @@ import SegmentedControl from '~components/common/SegmentedControl' import Segment from '~components/common/Segment' import SwitchTableField from '~components/common/SwitchTableField' import TableField from '~components/common/TableField' -import Textarea from '~components/common/Textarea' import capitalizeFirstLetter from '~utils/capitalizeFirstLetter' import type { DetailsObject } from 'types' @@ -384,7 +382,7 @@ const EditPartyModal = ({ - + You will lose all changes to your party{' '} {{ @@ -399,9 +397,9 @@ const EditPartyModal = ({ } open={alertOpen} - primaryActionText="Close" + primaryActionText={t('alert.unsaved_changes.buttons.confirm')} primaryAction={close} - cancelActionText="Nevermind" + cancelActionText={t('alert.unsaved_changes.buttons.cancel')} cancelAction={() => setAlertOpen(false)} /> ) diff --git a/components/party/Party/index.tsx b/components/party/Party/index.tsx index 2333c345..58998e08 100644 --- a/components/party/Party/index.tsx +++ b/components/party/Party/index.tsx @@ -169,6 +169,7 @@ const Party = (props: Props) => { if (details.guidebook1_id) payload.guidebook1_id = details.guidebook1_id if (details.guidebook2_id) payload.guidebook2_id = details.guidebook2_id if (details.guidebook3_id) payload.guidebook3_id = details.guidebook3_id + if (details.visibility) payload.visibility = details.visibility if (getLocalId()) payload.local_id = getLocalId() if (Object.keys(payload).length >= 1) return { party: payload } @@ -292,6 +293,7 @@ const Party = (props: Props) => { appState.party.favorited = team.favorited appState.party.remix = team.remix appState.party.remixes = team.remixes + appState.party.visibility = team.visibility appState.party.sourceParty = team.source_party appState.party.created_at = team.created_at appState.party.updated_at = team.updated_at diff --git a/components/party/PartyDropdown/index.tsx b/components/party/PartyDropdown/index.tsx index 52a2a8ca..eadea44e 100644 --- a/components/party/PartyDropdown/index.tsx +++ b/components/party/PartyDropdown/index.tsx @@ -1,5 +1,5 @@ // Libraries -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { useRouter } from 'next/router' import { useSnapshot } from 'valtio' import { useTranslation } from 'next-i18next' @@ -33,12 +33,14 @@ interface Props { editable: boolean deleteTeamCallback: () => void remixTeamCallback: () => void + teamVisibilityCallback: () => void } const PartyDropdown = ({ editable, deleteTeamCallback, remixTeamCallback, + teamVisibilityCallback, }: Props) => { // Localization const { t } = useTranslation('common') @@ -81,6 +83,11 @@ const PartyDropdown = ({ // Methods: Event handlers + // Dialogs / Visibility + function visibilityCallback() { + teamVisibilityCallback() + } + // Alerts / Delete team function openDeleteTeamAlert() { setDeleteAlertOpen(true) @@ -125,6 +132,9 @@ const PartyDropdown = ({ const items = ( <> + + {t('dropdown.party.visibility')} + {t('dropdown.party.copy')} diff --git a/components/party/PartyHeader/index.module.scss b/components/party/PartyHeader/index.module.scss index 892feb5f..9874d093 100644 --- a/components/party/PartyHeader/index.module.scss +++ b/components/party/PartyHeader/index.module.scss @@ -18,6 +18,58 @@ } } + .notice { + align-items: center; + background: var(--notice-bg); + border-radius: $card-corner; + display: flex; + gap: $unit-2x; + font-size: $font-regular; + padding: $unit-4x; + overflow: hidden; + + @include breakpoint(small-tablet) { + flex-direction: column; + gap: $unit; + padding: $unit-2x; + } + + p { + color: var(--notice-text); + flex-grow: 1; + } + + .icon { + align-items: center; + background-color: var(--notice-button-bg); + border-radius: $full-corner; + display: flex; + justify-content: center; + height: $unit-6x; + width: $unit-6x; + flex-shrink: 0; + + svg { + fill: var(--notice-text); + width: $unit-3x; + height: $unit-3x; + } + } + + .buttons { + justify-content: flex-end; + display: flex; + flex-shrink: 0; + gap: $unit; + + @include breakpoint(small-tablet) { + flex-direction: column; + justify-content: center; + width: 100%; + } + } + } + .details { box-sizing: border-box; display: block; diff --git a/components/party/PartyHeader/index.tsx b/components/party/PartyHeader/index.tsx index 4c889045..f113ed64 100644 --- a/components/party/PartyHeader/index.tsx +++ b/components/party/PartyHeader/index.tsx @@ -19,10 +19,14 @@ import { formatTimeAgo } from '~utils/timeAgo' import RemixTeamAlert from '~components/dialogs/RemixTeamAlert' import RemixedToast from '~components/toasts/RemixedToast' +import PartyVisibilityDialog from '~components/party/PartyVisibilityDialog' +import UrlCopiedToast from '~components/toasts/UrlCopiedToast' import EditIcon from '~public/icons/Edit.svg' import RemixIcon from '~public/icons/Remix.svg' import SaveIcon from '~public/icons/Save.svg' +import PrivateIcon from '~public/icons/Private.svg' +import UnlistedIcon from '~public/icons/Unlisted.svg' import type { DetailsObject } from 'types' @@ -50,8 +54,10 @@ const PartyHeader = (props: Props) => { // State: Component const [detailsOpen, setDetailsOpen] = useState(false) + const [copyToastOpen, setCopyToastOpen] = useState(false) const [remixAlertOpen, setRemixAlertOpen] = useState(false) const [remixToastOpen, setRemixToastOpen] = useState(false) + const [visibilityDialogOpen, setVisibilityDialogOpen] = useState(false) const userClass = classNames({ [styles.user]: true, @@ -122,12 +128,29 @@ const PartyHeader = (props: Props) => { setDetailsOpen(open) } + // Dialogs: Visibility + function visibilityDialogCallback() { + setVisibilityDialogOpen(true) + } + + function handleVisibilityDialogChange(open: boolean) { + setVisibilityDialogOpen(open) + } + // Actions: Remix team function remixTeamCallback() { setRemixToastOpen(true) props.remixCallback() } + // Actions: Copy URL + function copyToClipboard() { + if (router.asPath.split('/')[1] === 'p') { + navigator.clipboard.writeText(window.location.href) + setCopyToastOpen(true) + } + } + // Alerts: Remix team function openRemixTeamAlert() { setRemixAlertOpen(true) @@ -146,6 +169,15 @@ const PartyHeader = (props: Props) => { setRemixToastOpen(false) } + // Toasts / Copy URL + function handleCopyToastOpenChanged(open: boolean) { + setCopyToastOpen(!open) + } + + function handleCopyToastCloseClicked() { + setCopyToastOpen(false) + } + // Rendering const userBlock = (username?: string, picture?: string, element?: string) => { @@ -298,6 +330,50 @@ const PartyHeader = (props: Props) => { ) } + // Render: Notice + const unlistedNotice = ( +
+
+ +
+

{t('party.notices.unlisted')}

+
+
+
+ ) + + const privateNotice = ( +
+
+ +
+

{t('party.notices.private')}

+
+
+
+ ) + // Render: Buttons const saveButton = () => { return ( @@ -358,6 +434,8 @@ const PartyHeader = (props: Props) => { return ( <>
+ {party.visibility == 2 && unlistedNotice} + {party.visibility == 3 && privateNotice}
@@ -399,6 +477,7 @@ const PartyHeader = (props: Props) => { editable={props.editable} deleteTeamCallback={props.deleteCallback} remixTeamCallback={props.remixCallback} + teamVisibilityCallback={visibilityDialogCallback} /> )}
@@ -412,6 +491,13 @@ const PartyHeader = (props: Props) => {
{renderTokens()}
+ + { onOpenChange={handleRemixToastOpenChanged} onCloseClick={handleRemixToastCloseClicked} /> + + ) } 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..cfe3f34f --- /dev/null +++ b/components/party/PartyVisibilityDialog/index.tsx @@ -0,0 +1,303 @@ +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 +} + +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 diff --git a/components/reps/GridRep/index.module.scss b/components/reps/GridRep/index.module.scss index 86555e34..e3680ac6 100644 --- a/components/reps/GridRep/index.module.scss +++ b/components/reps/GridRep/index.module.scss @@ -241,6 +241,18 @@ gap: calc($unit / 2); } + .icon { + display: flex; + align-items: center; + justify-content: center; + width: $unit * 2.5; + height: $unit * 2.5; + + svg { + fill: var(--text-tertiary); + } + } + button svg { width: 14px; height: 14px; diff --git a/components/reps/GridRep/index.tsx b/components/reps/GridRep/index.tsx index 7f27892c..3c364e5e 100644 --- a/components/reps/GridRep/index.tsx +++ b/components/reps/GridRep/index.tsx @@ -10,8 +10,11 @@ import { accountState } from '~utils/accountState' import { formatTimeAgo } from '~utils/timeAgo' import Button from '~components/common/Button' +import Tooltip from '~components/common/Tooltip' import SaveIcon from '~public/icons/Save.svg' +import PrivateIcon from '~public/icons/Private.svg' +import UnlistedIcon from '~public/icons/Unlisted.svg' import ShieldIcon from '~public/icons/Shield.svg' import styles from './index.module.scss' @@ -472,6 +475,48 @@ const GridRep = ({ party, loading, onClick, onSave }: Props) => { ) + const favoriteButton = ( + +