From 83981120651808bb525ece937782ba3f4fe3a965 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 31 May 2023 03:25:45 -0700 Subject: [PATCH 1/3] Update the updates page with new items (#306) --- components/about/UpdatesPage/index.tsx | 42 ++++++++++++++++++++++++++ public/locales/en/updates.json | 8 +++++ public/locales/ja/updates.json | 8 +++++ 3 files changed, 58 insertions(+) diff --git a/components/about/UpdatesPage/index.tsx b/components/about/UpdatesPage/index.tsx index 668b3cb3..96a4cca1 100644 --- a/components/about/UpdatesPage/index.tsx +++ b/components/about/UpdatesPage/index.tsx @@ -56,6 +56,48 @@ const UpdatesPage = () => { return (

{common('about.segmented_control.updates')}

+ + + + Date: Thu, 8 Jun 2023 12:19:39 -0700 Subject: [PATCH 2/3] Add Nier and Estarriola uncaps (#308) * Update the updates page with new items (#306) (#307) * Update .gitignore * Add Nier and Estarriola uncaps * Fix uncaps treated as new characters --- .gitignore | 1 + components/about/UpdatesPage/index.tsx | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index 663dcea0..e3403051 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ public/images/ax* public/images/accessory* public/images/mastery* public/images/updates* +public/images/guidebooks* # Typescript v1 declaration files typings/ diff --git a/components/about/UpdatesPage/index.tsx b/components/about/UpdatesPage/index.tsx index 96a4cca1..53319b4e 100644 --- a/components/about/UpdatesPage/index.tsx +++ b/components/about/UpdatesPage/index.tsx @@ -56,6 +56,14 @@ const UpdatesPage = () => { return (

{common('about.segmented_control.updates')}

+ Date: Fri, 16 Jun 2023 18:49:55 -0700 Subject: [PATCH 3/3] Redesigned team navigation (#310) * Add ellipsis icon * Reduce size of tokens * Move UpdateToast to toasts folder * Update variables.scss * Add reps for grid objects These reps act like the existing PartyRep except for Characters and Summons, as well as a new component just for Weapons. They only render the grid of objects and nothing else. Eventually PartyRep will use WeaponRep * Added RepSegment This is a Character, Weapon or Summon rep wrapped with an input and label for use in a SegmentedControl * Modify PartySegmentedControl to use RepSegments This will not work on mobile yet, where it should gracefully degrade to a normal SegmentedControl with only text * Extract URL copied and Remixed toasts into files * Extract delete team alert into a file Also, to support this: * Added `Destructive` class to Button * Added `primaryActionClassName` prop to Alert * Added an alert for when remixing teams * Began refactoring PartyDetails into several files * PartyHeader will live at the top, above the new segmented control * PartyDetails stays below, only showing remixed teams and the description * PartyDropdown handles the new ... menu * Remove duplicated code This is description and remix code that is still in `PartyDetails` * Small fixes for weapon grid * Add placeholder image for guidebooks * Add localizations * Add Guidebook type and update other types * Update gitignore Don't commit guidebook images * Indicate if a dialog is scrollable We had broken paging in the infinite scroll component. Turning off "scrolling" at the dialog levels fixes it without adding scrollbars in environments that persistently show them * Add ExtraContainer This is the purple container that will contain additional weapons and sephira guidebooks * Move ExtraWeapons to ExtraWeaponsGrid And put it in ExtraContainer * Added GuidebooksGrid and GuidebookUnit These are the display components for Guidebooks in the WeaponGrid * Visual adjustments to summon grid * Add Empty class to weapons when unit is unfilled * Implement GuidebooksGrid in WeaponGrid * Remove extra switch * Remove old dependencies and props * Implement searching for/adding guidebooks to party * Update styles * Fix dependency * Properly determine when extra container should display * Change to 1-indexing for guidebooks * Add support for removing guidebooks * Display guidebook validation error * Move read only buttons to PartyHeader Also broke up tokens and made them easier to render * Add guidebooks to DetailsObject * Remove preview when on mobile sizes --- components/Header/index.tsx | 42 -- components/Layout/index.tsx | 2 +- components/common/Alert/index.scss | 1 + components/common/Alert/index.tsx | 7 +- components/common/Button/index.scss | 9 + components/common/DialogContent/index.scss | 8 +- components/common/DialogContent/index.tsx | 15 +- .../common/DropdownMenuContent/index.scss | 13 +- components/common/SegmentedControl/index.scss | 12 +- components/common/Token/index.scss | 6 +- components/dialogs/DeleteTeamAlert/index.tsx | 35 + components/dialogs/RemixTeamAlert/index.tsx | 57 ++ components/extra/ExtraContainer/index.scss | 50 ++ components/extra/ExtraContainer/index.tsx | 11 + components/extra/ExtraWeaponsGrid/index.scss | 47 ++ components/extra/ExtraWeaponsGrid/index.tsx | 95 +++ components/extra/GuidebookResult/index.scss | 37 + components/extra/GuidebookResult/index.tsx | 32 + components/extra/GuidebookUnit/index.scss | 109 +++ components/extra/GuidebookUnit/index.tsx | 201 +++++ components/extra/GuidebooksGrid/index.scss | 45 ++ components/extra/GuidebooksGrid/index.tsx | 95 +++ components/party/Party/index.scss | 8 + components/party/Party/index.tsx | 107 ++- components/party/PartyDetails/index.tsx | 573 +-------------- components/party/PartyDropdown/index.scss | 0 components/party/PartyDropdown/index.tsx | 197 +++++ components/party/PartyHeader/index.scss | 394 ++++++++++ components/party/PartyHeader/index.tsx | 693 ++++++++++++++++++ .../party/PartySegmentedControl/index.scss | 6 + .../party/PartySegmentedControl/index.tsx | 104 +-- components/reps/CharacterRep/index.scss | 75 ++ components/reps/CharacterRep/index.tsx | 132 ++++ components/reps/RepSegment/index.scss | 73 ++ components/reps/RepSegment/index.tsx | 34 + components/reps/SummonRep/index.scss | 45 ++ components/reps/SummonRep/index.tsx | 172 +++++ components/reps/WeaponRep/index.scss | 45 ++ components/reps/WeaponRep/index.tsx | 106 +++ components/search/SearchModal/index.scss | 5 + components/search/SearchModal/index.tsx | 43 +- components/summon/SummonGrid/index.scss | 2 +- components/summon/SummonGrid/index.tsx | 2 +- components/toasts/RemixedToast/index.tsx | 49 ++ .../{about => toasts}/UpdateToast/index.scss | 0 .../{about => toasts}/UpdateToast/index.tsx | 0 components/toasts/UrlCopiedToast/index.tsx | 39 + components/weapon/ExtraWeapons/index.scss | 59 -- components/weapon/ExtraWeapons/index.tsx | 48 -- components/weapon/WeaponGrid/index.scss | 4 +- components/weapon/WeaponGrid/index.tsx | 64 +- components/weapon/WeaponUnit/index.scss | 2 + public/icons/Ellipsis.svg | 5 + .../placeholders/placeholder-guidebook.png | Bin 0 -> 361 bytes public/locales/en/common.json | 23 +- public/locales/ja/common.json | 23 +- styles/variables.scss | 8 +- types/Guidebook.d.ts | 14 + types/Party.d.ts | 12 +- types/index.d.ts | 10 +- utils/appState.tsx | 6 + 61 files changed, 3317 insertions(+), 794 deletions(-) create mode 100644 components/dialogs/DeleteTeamAlert/index.tsx create mode 100644 components/dialogs/RemixTeamAlert/index.tsx create mode 100644 components/extra/ExtraContainer/index.scss create mode 100644 components/extra/ExtraContainer/index.tsx create mode 100644 components/extra/ExtraWeaponsGrid/index.scss create mode 100644 components/extra/ExtraWeaponsGrid/index.tsx create mode 100644 components/extra/GuidebookResult/index.scss create mode 100644 components/extra/GuidebookResult/index.tsx create mode 100644 components/extra/GuidebookUnit/index.scss create mode 100644 components/extra/GuidebookUnit/index.tsx create mode 100644 components/extra/GuidebooksGrid/index.scss create mode 100644 components/extra/GuidebooksGrid/index.tsx create mode 100644 components/party/PartyDropdown/index.scss create mode 100644 components/party/PartyDropdown/index.tsx create mode 100644 components/party/PartyHeader/index.scss create mode 100644 components/party/PartyHeader/index.tsx create mode 100644 components/reps/CharacterRep/index.scss create mode 100644 components/reps/CharacterRep/index.tsx create mode 100644 components/reps/RepSegment/index.scss create mode 100644 components/reps/RepSegment/index.tsx create mode 100644 components/reps/SummonRep/index.scss create mode 100644 components/reps/SummonRep/index.tsx create mode 100644 components/reps/WeaponRep/index.scss create mode 100644 components/reps/WeaponRep/index.tsx create mode 100644 components/toasts/RemixedToast/index.tsx rename components/{about => toasts}/UpdateToast/index.scss (100%) rename components/{about => toasts}/UpdateToast/index.tsx (100%) create mode 100644 components/toasts/UrlCopiedToast/index.tsx delete mode 100644 components/weapon/ExtraWeapons/index.scss delete mode 100644 components/weapon/ExtraWeapons/index.tsx create mode 100644 public/icons/Ellipsis.svg create mode 100644 public/images/placeholders/placeholder-guidebook.png create mode 100644 types/Guidebook.d.ts diff --git a/components/Header/index.tsx b/components/Header/index.tsx index 4fac2dea..b40746d6 100644 --- a/components/Header/index.tsx +++ b/components/Header/index.tsx @@ -296,25 +296,6 @@ const Header = () => { } // Rendering: Buttons - const saveButton = () => { - return ( - -
-
- {renderUserBlock()} - {party.raid ? linkedRaidBlock(party.raid) : ''} - {party.created_at != '' ? ( - - ) : ( - '' - )} -
-
- {party.editable ? ( -
-
- ) : ( - '' - )} - {readOnly()} {editable()} - - {deleteAlert()} {remixes && remixes.length > 0 ? remixSection() : ''} diff --git a/components/party/PartyDropdown/index.scss b/components/party/PartyDropdown/index.scss new file mode 100644 index 00000000..e69de29b diff --git a/components/party/PartyDropdown/index.tsx b/components/party/PartyDropdown/index.tsx new file mode 100644 index 00000000..189f867c --- /dev/null +++ b/components/party/PartyDropdown/index.tsx @@ -0,0 +1,197 @@ +// Libraries +import React, { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import { subscribe, useSnapshot } from 'valtio' +import { Trans, useTranslation } from 'next-i18next' +import Link from 'next/link' +import classNames from 'classnames' + +// Dependencies: Common +import Button from '~components/common/Button' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, +} from '~components/common/DropdownMenuContent' + +// Dependencies: Toasts +import RemixedToast from '~components/toasts/RemixedToast' +import UrlCopiedToast from '~components/toasts/UrlCopiedToast' + +// Dependencies: Alerts +import DeleteTeamAlert from '~components/dialogs/DeleteTeamAlert' +import RemixTeamAlert from '~components/dialogs/RemixTeamAlert' + +// Dependencies: Utils +import api from '~utils/api' +import { accountState } from '~utils/accountState' +import { appState } from '~utils/appState' +import { getLocalId } from '~utils/localId' +import { retrieveLocaleCookies } from '~utils/retrieveCookies' +import { setEditKey, storeEditKey } from '~utils/userToken' + +// Dependencies: Icons +import EllipsisIcon from '~public/icons/Ellipsis.svg' + +// Dependencies: Props +interface Props { + editable: boolean + deleteTeamCallback: () => void + remixTeamCallback: () => void +} + +const PartyDropdown = ({ + editable, + deleteTeamCallback, + remixTeamCallback, +}: Props) => { + // Localization + const { t } = useTranslation('common') + + // Router + const router = useRouter() + const locale = + router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' + const localeData = retrieveLocaleCookies() + + const [open, setOpen] = useState(false) + + const [deleteAlertOpen, setDeleteAlertOpen] = useState(false) + const [remixAlertOpen, setRemixAlertOpen] = useState(false) + + const [copyToastOpen, setCopyToastOpen] = useState(false) + const [remixToastOpen, setRemixToastOpen] = useState(false) + + const [name, setName] = useState('') + const [originalName, setOriginalName] = useState('') + + // Snapshots + const { account } = useSnapshot(accountState) + const { party: partySnapshot } = useSnapshot(appState) + + // Subscribe to app state to listen for party name and + // unsubscribe when component is unmounted + const unsubscribe = subscribe(appState, () => { + const newName = + appState.party && appState.party.name ? appState.party.name : '' + setName(newName) + }) + + useEffect(() => () => unsubscribe(), []) + + // Methods: Event handlers (Buttons) + function handleButtonClicked() { + setOpen(!open) + } + + // Methods: Event handlers (Menus) + function handleOpenChange(open: boolean) { + setOpen(open) + } + + function closeMenu() { + setOpen(false) + } + + // Method: Actions + function copyToClipboard() { + if (router.asPath.split('/')[1] === 'p') { + navigator.clipboard.writeText(window.location.href) + setCopyToastOpen(true) + } + } + + // Methods: Event handlers + + // Alerts / Delete team + function openDeleteTeamAlert() { + setDeleteAlertOpen(true) + } + + function handleDeleteTeamAlertChange(open: boolean) { + setDeleteAlertOpen(open) + } + + // Alerts / Remix team + function openRemixTeamAlert() { + setRemixAlertOpen(true) + } + + function handleRemixTeamAlertChange(open: boolean) { + setRemixAlertOpen(open) + } + + // Toasts / Copy URL + function handleCopyToastOpenChanged(open: boolean) { + setCopyToastOpen(open) + } + + function handleCopyToastCloseClicked() { + setCopyToastOpen(false) + } + + // Toasts / Remix team + function handleRemixToastOpenChanged(open: boolean) { + setRemixToastOpen(open) + } + + function handleRemixToastCloseClicked() { + setRemixToastOpen(false) + } + + const editableItems = () => { + return ( + <> + + + Copy link to team + + + Remix team + + + + + Delete team + + + + ) + } + + return ( + <> + + + + + + + ) +} + +export default PartyDropdown diff --git a/components/party/PartyHeader/index.scss b/components/party/PartyHeader/index.scss new file mode 100644 index 00000000..f51ba741 --- /dev/null +++ b/components/party/PartyHeader/index.scss @@ -0,0 +1,394 @@ +.DetailsWrapper { + display: flex; + flex-direction: column; + gap: $unit-2x; + margin: $unit-4x auto 0 auto; + max-width: $grid-width; + + @include breakpoint(phone) { + .Button:not(.IconButton) { + justify-content: center; + width: 100%; + + .Text { + width: auto; + } + } + } + + .PartyDetails { + box-sizing: border-box; + display: none; + margin: 0 auto $unit-2x; + max-width: $unit * 94; + overflow: hidden; + width: 100%; + + @include breakpoint(phone) { + padding: 0 $unit; + } + + &.Visible { + // margin-bottom: $unit-12x; + } + + &.Editable { + gap: $unit; + + &.Visible { + display: grid; + } + + fieldset { + display: block; + width: 100%; + + textarea { + min-height: $unit * 22; + width: 100%; + } + } + + .SelectTrigger { + padding: $unit-2x; + width: 100%; + } + + .DetailToggleGroup { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: $unit; + + @include breakpoint(phone) { + grid-template-columns: 1fr; + } + + .ToggleSection, + .InputSection { + align-items: center; + display: flex; + background: var(--card-bg); + border-radius: $input-corner; + + & > label { + align-items: center; + display: flex; + font-size: $font-regular; + gap: $unit; + grid-template-columns: 2fr 1fr; + justify-content: space-between; + width: 100%; + + & > span { + flex-grow: 1; + } + } + } + + .ToggleSection { + padding: ($unit * 1.5) $unit-2x; + } + + .InputSection { + padding: $unit-half $unit-2x; + padding-right: $unit-half; + + .Input { + border-radius: 7px; + } + + div.Input { + align-items: center; + border: 2px solid transparent; + box-sizing: border-box; + display: flex; + padding: $unit; + + &:has(> input:focus) { + border: 2px solid $blue; + outline: none; + } + + & > input { + background: transparent; + border: none; + padding: $unit 0; + text-align: right; + width: 2rem; + + &:focus { + outline: none; + border: none; + } + } + } + + label { + display: flex; + justify-content: space-between; + + span { + flex-grow: 1; + } + + .Input { + border-radius: 7px; + max-width: 10rem; + } + + div { + display: flex; + flex-direction: row; + gap: $unit-half; + justify-content: right; + } + } + } + } + + .bottom { + display: flex; + flex-direction: row; + gap: $unit; + + @include breakpoint(phone) { + flex-direction: column; + width: 100%; + } + + .left { + flex-grow: 1; + } + + .right { + display: flex; + flex-direction: row; + gap: $unit; + + @include breakpoint(phone) { + .Button { + flex-grow: 1; + } + } + } + } + } + + &.ReadOnly { + box-sizing: border-box; + line-height: 1.4; + white-space: pre-wrap; + + &.Visible { + display: block; + } + + a:hover { + text-decoration: underline; + } + + p { + font-size: $font-regular; + line-height: $font-regular * 1.2; + white-space: pre-line; + } + + .Tokens { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: $unit; + margin-bottom: $unit-2x; + } + + .YoutubeWrapper { + background-color: var(--card-bg); + border-radius: $card-corner; + margin: $unit 0; + position: relative; + display: block; + contain: content; + background-position: center center; + background-size: cover; + cursor: pointer; + width: 60%; + height: 60%; + + @include breakpoint(tablet) { + width: 100%; + height: 100%; + } + + /* gradient */ + &::before { + content: ''; + display: block; + position: absolute; + top: 0; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADGCAYAAAAT+OqFAAAAdklEQVQoz42QQQ7AIAgEF/T/D+kbq/RWAlnQyyazA4aoAB4FsBSA/bFjuF1EOL7VbrIrBuusmrt4ZZORfb6ehbWdnRHEIiITaEUKa5EJqUakRSaEYBJSCY2dEstQY7AuxahwXFrvZmWl2rh4JZ07z9dLtesfNj5q0FU3A5ObbwAAAABJRU5ErkJggg==); + background-position: top; + background-repeat: repeat-x; + height: 60px; + padding-bottom: 50px; + width: 100%; + transition: all 0.2s cubic-bezier(0, 0, 0.2, 1); + } + + /* responsive iframe with a 16:9 aspect ratio + thanks https://css-tricks.com/responsive-iframes/ + */ + &::after { + content: ''; + display: block; + padding-bottom: calc(100% / (16 / 9)); + } + + &:hover > .PlayerButton { + opacity: 1; + } + + & > iframe { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + } + + /* Play button */ + & > .PlayerButton { + background: none; + border: none; + background-image: url('/icons/youtube.svg'); + width: 68px; + height: 68px; + opacity: 0.8; + transition: all 0.2s cubic-bezier(0, 0, 0.2, 1); + } + + & > .PlayerButton, + & > .PlayerButton:before { + position: absolute; + top: 50%; + left: 50%; + transform: translate3d(-50%, -50%, 0); + } + + /* Post-click styles */ + &.lyt-activated { + cursor: unset; + } + &.lyt-activated::before, + &.lyt-activated > .PlayerButton { + opacity: 0; + pointer-events: none; + } + } + } + } + + .PartyInfo { + box-sizing: border-box; + display: flex; + flex-direction: row; + gap: $unit; + margin: 0 auto; + max-width: $unit * 94; + width: 100%; + + @include breakpoint(phone) { + flex-direction: column; + gap: $unit; + padding: 0 $unit; + } + + & > .Right { + display: flex; + gap: $unit-half; + } + + & > .Left { + flex-grow: 1; + + .Header { + align-items: center; + display: flex; + gap: $unit; + margin-bottom: $unit; + + h1 { + font-size: $font-xlarge; + font-weight: $normal; + text-align: left; + color: var(--text-primary); + + &.empty { + color: var(--text-secondary); + } + } + } + + .attribution { + align-items: center; + display: flex; + flex-direction: row; + + & > div { + align-items: center; + display: inline-flex; + font-size: $font-small; + height: 26px; + } + + time { + font-size: $font-small; + } + + a:visited:not(.fire):not(.water):not(.wind):not(.earth):not(.dark):not( + .light + ) { + color: var(--text-primary); + } + + a:hover:not(.fire):not(.water):not(.wind):not(.earth):not(.dark):not( + .light + ) { + color: $blue; + } + + & > *:not(:last-child):after { + content: ' · '; + margin: 0 calc($unit / 2); + } + } + } + + .user { + align-items: center; + display: inline-flex; + gap: calc($unit / 2); + margin-top: 1px; + + img, + .no-user { + $diameter: 24px; + + border-radius: calc($diameter / 2); + height: $diameter; + width: $diameter; + } + + img.gran { + background-color: #cee7fe; + } + + img.djeeta { + background-color: #ffe1fe; + } + + .no-user { + background: $grey-80; + } + } + } +} diff --git a/components/party/PartyHeader/index.tsx b/components/party/PartyHeader/index.tsx new file mode 100644 index 00000000..e45a67c7 --- /dev/null +++ b/components/party/PartyHeader/index.tsx @@ -0,0 +1,693 @@ +import React, { useEffect, useState, ChangeEvent, KeyboardEvent } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useSnapshot } from 'valtio' +import { useTranslation } from 'next-i18next' +import classNames from 'classnames' + +import Button from '~components/common/Button' +import CharLimitedFieldset from '~components/common/CharLimitedFieldset' +import DurationInput from '~components/common/DurationInput' +import Input from '~components/common/Input' +import RaidDropdown from '~components/RaidDropdown' +import Switch from '~components/common/Switch' +import Tooltip from '~components/common/Tooltip' +import Token from '~components/common/Token' + +import PartyDropdown from '~components/party/PartyDropdown' + +import { accountState } from '~utils/accountState' +import { appState, initialAppState } from '~utils/appState' +import { formatTimeAgo } from '~utils/timeAgo' + +import CheckIcon from '~public/icons/Check.svg' +import EditIcon from '~public/icons/Edit.svg' +import RemixIcon from '~public/icons/Remix.svg' +import SaveIcon from '~public/icons/Save.svg' + +import type { DetailsObject } from 'types' + +import './index.scss' +import api from '~utils/api' + +// Props +interface Props { + party?: Party + new: boolean + editable: boolean + deleteCallback: () => void + remixCallback: () => void + updateCallback: (details: DetailsObject) => void +} + +const PartyHeader = (props: Props) => { + const { party, raids } = useSnapshot(appState) + + const { t } = useTranslation('common') + const router = useRouter() + const locale = router.locale || 'en' + + const { party: partySnapshot } = useSnapshot(appState) + + const nameInput = React.createRef() + const descriptionInput = React.createRef() + + const [open, setOpen] = useState(false) + const [name, setName] = useState('') + const [alertOpen, setAlertOpen] = useState(false) + + const [chargeAttack, setChargeAttack] = useState(true) + const [fullAuto, setFullAuto] = useState(false) + const [autoGuard, setAutoGuard] = useState(false) + + const [buttonCount, setButtonCount] = useState(undefined) + const [chainCount, setChainCount] = useState(undefined) + const [turnCount, setTurnCount] = useState(undefined) + const [clearTime, setClearTime] = useState(0) + + const [raidSlug, setRaidSlug] = useState('') + + const readOnlyClasses = classNames({ + PartyDetails: true, + ReadOnly: true, + Visible: !open, + }) + + const editableClasses = classNames({ + PartyDetails: true, + Editable: true, + Visible: open, + }) + + const userClass = classNames({ + user: true, + empty: !party.user, + }) + + const linkClass = classNames({ + wind: party && party.element == 1, + fire: party && party.element == 2, + water: party && party.element == 3, + earth: party && party.element == 4, + dark: party && party.element == 5, + light: party && party.element == 6, + }) + + const [errors, setErrors] = useState<{ [key: string]: string }>({ + name: '', + description: '', + }) + + useEffect(() => { + if (props.party) { + setName(props.party.name) + setAutoGuard(props.party.auto_guard) + setFullAuto(props.party.full_auto) + setChargeAttack(props.party.charge_attack) + setClearTime(props.party.clear_time) + if (props.party.turn_count) setTurnCount(props.party.turn_count) + if (props.party.button_count) setButtonCount(props.party.button_count) + if (props.party.chain_count) setChainCount(props.party.chain_count) + } + }, [props.party]) + + // Subscribe to router changes and reset state + // if the new route is a new team + useEffect(() => { + router.events.on('routeChangeStart', (url, { shallow }) => { + if (url === '/new' || url === '/') { + const party = initialAppState.party + + setName(party.name ? party.name : '') + setAutoGuard(party.autoGuard) + setFullAuto(party.fullAuto) + setChargeAttack(party.chargeAttack) + setClearTime(party.clearTime) + setTurnCount(party.turnCount) + setButtonCount(party.buttonCount) + setChainCount(party.chainCount) + } + }) + }, []) + + function handleInputChange(event: React.ChangeEvent) { + event.preventDefault() + + const { name, value } = event.target + setName(value) + + let newErrors = errors + setErrors(newErrors) + } + + function handleChargeAttackChanged(checked: boolean) { + setChargeAttack(checked) + } + + function handleFullAutoChanged(checked: boolean) { + setFullAuto(checked) + } + + function handleAutoGuardChanged(checked: boolean) { + setAutoGuard(checked) + } + + function handleClearTimeInput(value: number) { + if (!isNaN(value)) setClearTime(value) + } + + function handleTurnCountInput(event: React.ChangeEvent) { + const value = parseInt(event.currentTarget.value) + if (!isNaN(value)) setTurnCount(value) + } + + function handleButtonCountInput(event: ChangeEvent) { + const value = parseInt(event.currentTarget.value) + if (!isNaN(value)) setButtonCount(value) + } + + function handleChainCountInput(event: ChangeEvent) { + const value = parseInt(event.currentTarget.value) + if (!isNaN(value)) setChainCount(value) + } + + function handleInputKeyDown(event: KeyboardEvent) { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + // Allow the key to be processed normally + return + } + + // Get the current value + const input = event.currentTarget + let value = event.currentTarget.value + + // Check if the key that was pressed is the backspace key + if (event.key === 'Backspace') { + // Remove the colon if the value is "12:" + if (value.length === 4) { + value = value.slice(0, -1) + } + + // Allow the backspace key to be processed normally + input.value = value + return + } + + // Check if the key that was pressed is the tab key + if (event.key === 'Tab') { + // Allow the tab key to be processed normally + return + } + + // Get the character that was entered and check if it is numeric + const char = parseInt(event.key) + const isNumber = !isNaN(char) + + // Check if the character should be accepted or rejected + const numberValue = parseInt(`${value}${char}`) + const minValue = parseInt(event.currentTarget.min) + const maxValue = parseInt(event.currentTarget.max) + + if (!isNumber || numberValue < minValue || numberValue > maxValue) { + // Reject the character if it isn't a number, + // or if it exceeds the min and max values + event.preventDefault() + } + } + + function toggleDetails() { + // Enabling this code will make live updates not work, + // but I'm not sure why it's here, so we're not going to remove it. + + // if (name !== party.name) { + // const resetName = party.name ? party.name : '' + // setName(resetName) + // if (nameInput.current) nameInput.current.value = resetName + // } + setOpen(!open) + } + + function receiveRaid(slug?: string) { + if (slug) setRaidSlug(slug) + } + + function switchValue(value: boolean) { + if (value) return 'on' + else return 'off' + } + + // Actions: Favorites + function toggleFavorite() { + if (appState.party.favorited) unsaveFavorite() + else saveFavorite() + } + + function saveFavorite() { + if (appState.party.id) + api.saveTeam({ id: appState.party.id }).then((response) => { + if (response.status == 201) appState.party.favorited = true + }) + else console.error('Failed to save team: No party ID') + } + + function unsaveFavorite() { + if (appState.party.id) + api.unsaveTeam({ id: appState.party.id }).then((response) => { + if (response.status == 200) appState.party.favorited = false + }) + else console.error('Failed to unsave team: No party ID') + } + + function updateDetails(event: React.MouseEvent) { + const descriptionValue = descriptionInput.current?.value + const raid = raids.find((raid) => raid.slug === raidSlug) + + const details: DetailsObject = { + fullAuto: fullAuto, + autoGuard: autoGuard, + chargeAttack: chargeAttack, + clearTime: clearTime, + buttonCount: buttonCount, + turnCount: turnCount, + chainCount: chainCount, + name: name, + description: descriptionValue, + raid: raid, + } + + props.updateCallback(details) + toggleDetails() + } + + // Methods: Navigation + function goTo(shortcode?: string) { + if (shortcode) router.push(`/p/${shortcode}`) + } + + const userImage = (picture?: string, element?: string) => { + if (picture && element) + return ( + {picture} + ) + else + return ( + {t('no_user')} + ) + } + + const userBlock = (username?: string, picture?: string, element?: string) => { + return ( +
+ {userImage(picture, element)} + {username ? username : t('no_user')} +
+ ) + } + + const renderUserBlock = () => { + let username, picture, element + if (accountState.account.authorized && props.new) { + username = accountState.account.user?.username + picture = accountState.account.user?.avatar.picture + element = accountState.account.user?.avatar.element + } else if (party.user && !props.new) { + username = party.user.username + picture = party.user.avatar.picture + element = party.user.avatar.element + } + + if (username && picture && element) { + return linkedUserBlock(username, picture, element) + } else if (!props.new) { + return userBlock() + } + } + + const linkedUserBlock = ( + username?: string, + picture?: string, + element?: string + ) => { + return ( + + ) + } + + const linkedRaidBlock = (raid: Raid) => { + return ( + + ) + } + + // Render: Tokens + const chargeAttackToken = ( + + {`${t('party.details.labels.charge_attack')} ${ + chargeAttack ? 'On' : 'Off' + }`} + + ) + + const fullAutoToken = ( + + {`${t('party.details.labels.full_auto')} ${fullAuto ? 'On' : 'Off'}`} + + ) + + const autoGuardToken = ( + + {`${t('party.details.labels.auto_guard')} ${autoGuard ? 'On' : 'Off'}`} + + ) + + const turnCountToken = ( + + {t('party.details.turns.with_count', { + count: turnCount, + })} + + ) + + const buttonChainToken = () => { + if (buttonCount || chainCount) { + let string = '' + + if (buttonCount && buttonCount > 0) { + string += `${buttonCount}b` + } + + if (!buttonCount && chainCount && chainCount > 0) { + string += `0${t('party.details.suffix.buttons')}${chainCount}${t( + 'party.details.suffix.chains' + )}` + } else if (buttonCount && chainCount && chainCount > 0) { + string += `${chainCount}${t('party.details.suffix.chains')}` + } else if (buttonCount && !chainCount) { + string += `0${t('party.details.suffix.chains')}` + } + + return {string} + } + } + + const clearTimeToken = () => { + const minutes = Math.floor(clearTime / 60) + const seconds = clearTime - minutes * 60 + + let string = '' + if (minutes > 0) + string = `${minutes}${t('party.details.suffix.minutes')} ${seconds}${t( + 'party.details.suffix.seconds' + )}` + else string = `${seconds}${t('party.details.suffix.seconds')}` + + return {string} + } + + function renderTokens() { + return ( +
+ {chargeAttackToken} + {fullAutoToken} + {autoGuardToken} + {turnCount ? turnCountToken : ''} + {clearTime > 0 ? clearTimeToken() : ''} + {buttonChainToken()} +
+ ) + } + + // Render: Buttons + const saveButton = () => { + return ( + +