From aabd7de207d7cbdecc9298246e6b50efe1b7a64e Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 23 Aug 2023 23:42:52 -0700 Subject: [PATCH 1/2] Implement experimental GridRep (#368) https://github.com/jedmund/hensei-web/assets/383021/d18f68f4-a14a-45a8-81b1-1addb5bd6ed1 This adds an experimental GridRep feature for testing. There are indicator bars underneath the grid preview on desktop that when hovered over, shows the user a peek into the other views of the team. I have qualms about this but I'm pushing it to production so that myself and others can play with it more. --- components/party/PartyFooter/index.tsx | 11 +- .../reps/CharacterRep/index.module.scss | 12 +- components/reps/GridRep/index.module.scss | 161 ++++++- components/reps/GridRep/index.tsx | 440 ++++++++++++++---- pages/[username].tsx | 11 +- pages/saved.tsx | 11 +- pages/teams.tsx | 11 +- styles/themes.scss | 12 + styles/variables.scss | 18 + 9 files changed, 559 insertions(+), 128 deletions(-) diff --git a/components/party/PartyFooter/index.tsx b/components/party/PartyFooter/index.tsx index 9ae4e432..a028a0d1 100644 --- a/components/party/PartyFooter/index.tsx +++ b/components/party/PartyFooter/index.tsx @@ -260,16 +260,7 @@ const PartyFooter = (props: Props) => { return partySnapshot?.remixes.map((party, i) => { return ( .weaponGrid { + .gridContainer { + aspect-ratio: 2/0.95; + width: 100%; + } + + .characterGrid { + aspect-ratio: 2/0.95; + display: flex; + justify-content: space-between; + + .protagonist { + border-width: 1px; + border-style: solid; + + &.fire { + background: var(--fire-portrait-bg); + border-color: var(--fire-bg); + } + + &.water { + background: var(--water-portrait-bg); + border-color: var(--water-bg); + } + + &.wind { + background: var(--wind-portrait-bg); + border-color: var(--wind-bg); + } + + &.earth { + background: var(--earth-portrait-bg); + border-color: var(--earth-bg); + } + + &.light { + background: var(--light-portrait-bg); + border-color: var(--light-bg); + } + + &.dark { + background: var(--dark-portrait-bg); + border-color: var(--dark-bg); + } + + &.empty { + background: var(--card-bg); + } + } + + .grid { + background: var(--background); + border-radius: $item-corner-small; + aspect-ratio: 69/142; + list-style: none; + height: calc(100% - $unit-half); + + img { + border-radius: $item-corner-small; + width: 100%; + } + } + } + + .weaponGrid { aspect-ratio: 2/0.95; display: grid; grid-template-columns: 1fr 3.36fr; /* left column takes up 1 fraction, right column takes up 3 fractions */ @@ -54,7 +125,7 @@ .weapon { background: var(--unit-bg); - border-radius: 4px; + border-radius: $item-corner-small; } .mainhand.weapon { @@ -91,6 +162,51 @@ } } + .summonGrid { + aspect-ratio: 2/0.94; + display: flex; + gap: $unit; + justify-content: space-between; + + .summon, + .mainSummon { + background: var(--background); + border-radius: $item-corner-small; + + img { + border-radius: $item-corner-small; + width: 100%; + } + } + + .mainSummon { + aspect-ratio: 56/97; + display: grid; + grid-column: 1 / 2; /* spans one column */ + } + + .summons { + display: grid; /* make the right-images container a grid */ + grid-template-columns: repeat( + 2, + 1fr + ); /* create 3 columns, each taking up 1 fraction */ + grid-template-rows: repeat( + 2, + 1fr + ); /* create 3 rows, each taking up 1 fraction */ + gap: $unit; + aspect-ratio: 83/100; + // column-gap: $unit; + // row-gap: $unit-2x; + } + + .summon { + aspect-ratio: 184 / 138; + display: grid; + } + } + .details { display: flex; flex-direction: column; @@ -104,6 +220,7 @@ padding-bottom: 1px; text-overflow: ellipsis; white-space: nowrap; + min-height: 24px; max-width: 258px; // Can we not do this? &.empty { @@ -157,6 +274,7 @@ } time { + line-height: 18px; white-space: nowrap; } @@ -234,4 +352,41 @@ } } } + + .indicators { + display: flex; + flex-direction: row; + gap: $unit; + margin-top: $unit * -1; + margin-bottom: $unit-fourth; + justify-content: center; + opacity: 0; + + @include breakpoint(phone) { + display: none; + } + + li { + flex-grow: 1; + text-indent: -9999px; + padding: $unit 0; + + .indicator { + transition: background-color 0.12s ease-in-out; + height: $unit; + border-radius: $unit-half; + background-color: var(--button-contained-bg-hover); + } + + span { + text-indent: -9999px; + position: absolute; + } + + &:hover .indicator, + &.active .indicator { + background-color: var(--text-secondary); + } + } + } } diff --git a/components/reps/GridRep/index.tsx b/components/reps/GridRep/index.tsx index 84e9b86e..7f27892c 100644 --- a/components/reps/GridRep/index.tsx +++ b/components/reps/GridRep/index.tsx @@ -16,23 +16,15 @@ import ShieldIcon from '~public/icons/Shield.svg' import styles from './index.module.scss' interface Props { - shortcode: string - id: string - name: string - raid: Raid - grid: GridWeapon[] - user?: User - fullAuto: boolean - autoGuard: boolean - favorited: boolean + party: Party loading: boolean - createdAt: Date onClick: (shortcode: string) => void onSave?: (partyId: string, favorited: boolean) => void } -const GridRep = (props: Props) => { +const GridRep = ({ party, loading, onClick, onSave }: Props) => { const numWeapons: number = 9 + const numSummons: number = 6 const { account } = useSnapshot(accountState) @@ -42,27 +34,42 @@ const GridRep = (props: Props) => { router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' const [visible, setVisible] = useState(false) + const [currentView, setCurrentView] = useState< + 'characters' | 'weapons' | 'summons' + >('weapons') + const [mainhand, setMainhand] = useState() const [weapons, setWeapons] = useState>({}) - const [grid, setGrid] = useState>({}) + const [weaponGrid, setWeaponGrid] = useState>({}) + const [characters, setCharacters] = useState>({}) + const [characterGrid, setCharacterGrid] = useState>( + {} + ) + const [mainSummon, setMainSummon] = useState() + const [friendSummon, setFriendSummon] = useState() + const [summons, setSummons] = useState>({}) + const [summonGrid, setSummonGrid] = useState>({}) - const gridRepStyles = classNames({ + // Style construction + + const gridRepClasses = classNames({ [styles.gridRep]: true, [styles.visible]: visible, [styles.hidden]: !visible, }) + const titleClass = classNames({ - empty: !props.name, + empty: !party.name, }) const raidClass = classNames({ [styles.raid]: true, - [styles.empty]: !props.raid, + [styles.empty]: !party.raid, }) const userClass = classNames({ [styles.user]: true, - [styles.empty]: !props.user, + [styles.empty]: !party.user, }) const mainhandClasses = classNames({ @@ -75,8 +82,22 @@ const GridRep = (props: Props) => { [styles.grid]: true, }) + const protagonistClasses = classNames({ + [styles.protagonist]: true, + [styles.grid]: true, + [styles[`${numberToElement()}`]]: true, + [styles.empty]: !party.job || party.job.id === '-1', + }) + + const characterClasses = classNames({ + [styles.character]: true, + [styles.grid]: true, + }) + + // Hooks + useEffect(() => { - if (props.loading) { + if (loading) { setVisible(false) } else { const timeout = setTimeout(() => { @@ -84,7 +105,7 @@ const GridRep = (props: Props) => { }, 150) return () => clearTimeout(timeout) } - }, [props.loading]) + }, [loading]) useEffect(() => { setVisible(false) // Trigger fade out @@ -99,7 +120,7 @@ const GridRep = (props: Props) => { const gridWeapons = Array(numWeapons) let foundMainhand = false - for (const [key, value] of Object.entries(props.grid)) { + for (const [key, value] of Object.entries(party.weapons)) { if (value.position == -1) { setMainhand(value.object) foundMainhand = true @@ -114,18 +135,74 @@ const GridRep = (props: Props) => { } setWeapons(newWeapons) - setGrid(gridWeapons) - }, [props.grid]) + setWeaponGrid(gridWeapons) + }, [party]) - function navigate() { - props.onClick(props.shortcode) + useEffect(() => { + const newCharacters = Array(3) + const gridCharacters = Array(3) + + if (party.characters) { + for (const [key, value] of Object.entries(party.characters)) { + if (value.position != null) { + newCharacters[value.position] = value.object + gridCharacters[value.position] = value + } + } + + setCharacters(newCharacters) + setCharacterGrid(gridCharacters) + } + }, [party]) + + useEffect(() => { + const newSummons = Array(numSummons) + const gridSummons = Array(numSummons) + + if (party.summons) { + for (const [key, value] of Object.entries(party.summons)) { + if (value.main) { + setMainSummon(value) + } else if (value.friend) { + setFriendSummon(value) + } else if (!value.main && !value.friend && value.position != null) { + newSummons[value.position] = value.object + gridSummons[value.position] = value + } + } + + setSummons(newSummons) + setSummonGrid(gridSummons) + } + }, [party]) + + // Convert element to string + function numberToElement() { + switch (mainhand?.element || weaponGrid[0]?.element) { + case 1: + return 'wind' + case 2: + return 'fire' + case 3: + return 'water' + case 4: + return 'earth' + case 5: + return 'dark' + case 6: + return 'light' + default: + return '' + } } + // Methods: Image generation + function generateMainhandImage() { let url = '' if (mainhand) { - const weapon = Object.values(props.grid).find( + const weapon = Object.values(party.weapons).find( (w) => w && w.object.id === mainhand.id ) @@ -136,18 +213,18 @@ const GridRep = (props: Props) => { } } - return mainhand && props.grid[0] ? ( + return mainhand && party.weapons[0] ? ( {mainhand.name[locale]} ) : ( '' ) } - function generateGridImage(position: number) { + function generateWeaponGridImage(position: number) { let url = '' const weapon = weapons[position] - const gridWeapon = grid[position] + const gridWeapon = weaponGrid[position] if (weapon && gridWeapon) { if (weapon.element == 0 && gridWeapon.element) { @@ -164,19 +241,163 @@ const GridRep = (props: Props) => { ) } + function generateMCImage() { + let source = '' + + if (party.job) { + const slug = party.job.name.en.replaceAll(' ', '-').toLowerCase() + const gender = party.user?.gender == 1 ? 'b' : 'a' + source = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-portraits/${slug}_${gender}.png` + } + + return ( + party.job && + party.job.id !== '-1' && ( + {party.job + ) + ) + } + + function generateCharacterGridImage(position: number) { + let url = '' + + const gridCharacter = characterGrid[position] + const character = characters[position] + + if (character && gridCharacter) { + // Change the image based on the uncap level + let suffix = '01' + if (gridCharacter.transcendence_step > 0) suffix = '04' + else if (gridCharacter.uncap_level >= 5) suffix = '03' + else if (gridCharacter.uncap_level > 2) suffix = '02' + + if (gridCharacter.object.granblue_id === '3030182000') { + let element = 1 + if (mainhand && mainhand.element) { + element = mainhand.element + } + + suffix = `${suffix}_0${element}` + } + + const url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-main/${character.granblue_id}_${suffix}.jpg` + + return ( + characters[position] && ( + {characters[position]?.name[locale]} + ) + ) + } + } + + function generateMainSummonImage(position: 'main' | 'friend') { + let url = '' + + const upgradedSummons = [ + '2040094000', + '2040100000', + '2040080000', + '2040098000', + '2040090000', + '2040084000', + '2040003000', + '2040056000', + '2040020000', + '2040034000', + '2040028000', + '2040027000', + '2040046000', + '2040047000', + ] + + const summon = position === 'main' ? mainSummon : friendSummon + + if (summon) { + // Change the image based on the uncap level + let suffix = '' + if (summon.object.uncap.xlb && summon.uncap_level == 6) { + if (summon.transcendence_step >= 1 && summon.transcendence_step < 5) { + suffix = '_03' + } else if (summon.transcendence_step === 5) { + suffix = '_04' + } + } else if ( + upgradedSummons.indexOf(summon.object.granblue_id.toString()) != -1 && + summon.uncap_level == 5 + ) { + suffix = '_02' + } + + url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-main/${summon.object.granblue_id}${suffix}.jpg` + } + + return summon && {summon.object.name[locale]} + } + + function generateSummonGridImage(position: number) { + let url = '' + + const gridSummon = summonGrid[position] + const summon = gridSummon?.object + + const upgradedSummons = [ + '2040094000', + '2040100000', + '2040080000', + '2040098000', + '2040090000', + '2040084000', + '2040003000', + '2040056000', + '2040020000', + '2040034000', + '2040028000', + '2040027000', + '2040046000', + '2040047000', + ] + + if (summon && gridSummon) { + // Change the image based on the uncap level + let suffix = '' + if (gridSummon.object.uncap.xlb && gridSummon.uncap_level == 6) { + if ( + gridSummon.transcendence_step >= 1 && + gridSummon.transcendence_step < 5 + ) { + suffix = '_03' + } else if (gridSummon.transcendence_step === 5) { + suffix = '_04' + } + } else if ( + upgradedSummons.indexOf(summon.granblue_id.toString()) != -1 && + gridSummon.uncap_level == 5 + ) { + suffix = '_02' + } + + url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${summon.granblue_id}${suffix}.jpg` + } + return ( + summons[position] && ( + {summons[position]?.name[locale]} + ) + ) + } + function sendSaveData() { - if (props.onSave) props.onSave(props.id, props.favorited) + if (onSave) onSave(party.id, party.favorited) } const userImage = () => { - if (props.user && props.user.avatar) { + if (party.user && party.user.avatar) { return ( {props.user.avatar.picture} ) } else @@ -194,63 +415,95 @@ const GridRep = (props: Props) => { const attribution = () => ( {userImage()} - {props.user ? props.user.username : t('no_user')} + {party.user ? party.user.username : t('no_user')} ) - function fullAutoString() { - const fullAutoElement = ( - - {` · ${t('party.details.labels.full_auto')}`} - - ) + const renderWeaponGrid = ( +
+
{generateMainhandImage()}
- const autoGuardElement = ( - - - - ) +
    + {Array.from(Array(numWeapons)).map((x, i) => { + return ( +
  • + {generateWeaponGridImage(i)} +
  • + ) + })} +
+
+ ) - return ( -
- {fullAutoElement} - {props.autoGuard ? autoGuardElement : ''} + const renderCharacterGrid = ( +
+
{generateMCImage()}
+ {Array.from(Array(3)).map((x, i) => { + return ( +
  • + {generateCharacterGridImage(i)} +
  • + ) + })} +
    + ) + + const renderSummonGrid = ( +
    +
    {generateMainSummonImage('main')}
    +
      + {Array.from(Array(numSummons)).map((x, i) => { + return ( +
    • + {generateSummonGridImage(i)} +
    • + ) + })} +
    +
    + {generateMainSummonImage('friend')}
    - ) - } +
    + ) const detailsWithUsername = (

    - {props.name ? props.name : t('no_title')} + {party.name ? party.name : t('no_title')}

    - {props.raid ? props.raid.name[locale] : t('no_raid')} + {party.raid ? party.raid.name[locale] : t('no_raid')} - {props.fullAuto && ( + {party.full_auto && ( {` · ${t('party.details.labels.full_auto')}`} )} - {props.raid && props.raid.group.extra && ( + {party.raid && party.raid.group.extra && ( {` · EX`} )}
    {account.authorized && - ((props.user && account.user && account.user.id !== props.user.id) || - !props.user) ? ( + ((party.user && account.user && account.user.id !== party.user.id) || + !party.user) ? (
    ) - return ( - - - {detailsWithUsername} -
    -
    {generateMainhandImage()}
    + function changeView(view: 'characters' | 'weapons' | 'summons') { + setCurrentView(view) + } -
      - {Array.from(Array(numWeapons)).map((x, i) => { - return ( -
    • - {generateGridImage(i)} -
    • - ) - })} -
    -
    -
    + return ( + changeView('weapons')} + > + {detailsWithUsername} +
    + {currentView === 'characters' + ? renderCharacterGrid + : currentView === 'summons' + ? renderSummonGrid + : renderWeaponGrid} +
    +
      +
    • changeView('characters')} + > +
      + Characters +
    • +
    • changeView('weapons')} + > +
      + Weapons +
    • +
    • changeView('summons')} + > +
      + Summons +
    • +
    ) } diff --git a/pages/[username].tsx b/pages/[username].tsx index 1b7a976c..697abbdf 100644 --- a/pages/[username].tsx +++ b/pages/[username].tsx @@ -255,16 +255,7 @@ const ProfileRoute: React.FC = ({ return parties.map((party, i) => { return ( = ({ return parties.map((party, i) => { return ( = ({ return parties.map((party, i) => { return ( Date: Fri, 25 Aug 2023 15:51:28 -0700 Subject: [PATCH 2/2] 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 = ( + +