From 83981120651808bb525ece937782ba3f4fe3a965 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 31 May 2023 03:25:45 -0700 Subject: [PATCH 1/5] 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: Wed, 31 May 2023 03:26:11 -0700 Subject: [PATCH 2/5] Update the updates page with new items (#306) (#307) --- 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 3/5] 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: Thu, 8 Jun 2023 12:21:00 -0700 Subject: [PATCH 4/5] Deploy content update (#309) * Update the updates page with new items (#306) * 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 5/5] 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 ( + +
+ + + ) + } + + const readOnly = () => { + return
{renderTokens()}
+ } + + return ( + <> +
+
+
+
+

+ {name ? name : t('no_title')} +

+ {party.remix && party.sourceParty ? ( + +
+
+ {renderUserBlock()} + {party.raid ? linkedRaidBlock(party.raid) : ''} + {party.created_at != '' ? ( + + ) : ( + '' + )} +
+
+ {party.editable ? ( +
+
+ ) : ( +
+ {saveButton()} + {remixButton()} +
+ )} +
+ {readOnly()} + {editable()} +
+ + ) +} + +export default PartyHeader diff --git a/components/party/PartySegmentedControl/index.scss b/components/party/PartySegmentedControl/index.scss index a6fd6c50..c58ebcc8 100644 --- a/components/party/PartySegmentedControl/index.scss +++ b/components/party/PartySegmentedControl/index.scss @@ -22,7 +22,12 @@ width: 100%; } + @include breakpoint(phone) { + padding: 0; + } + .SegmentedControl { + gap: $unit; flex-grow: 1; // prettier-ignore @@ -31,6 +36,7 @@ and (max-height: 920px) and (-webkit-min-device-pixel-ratio: 2) { flex-grow: 1; + gap: 0; width: 100%; display: grid; grid-template-columns: auto auto auto; diff --git a/components/party/PartySegmentedControl/index.tsx b/components/party/PartySegmentedControl/index.tsx index 1c807dfb..b84caaf4 100644 --- a/components/party/PartySegmentedControl/index.tsx +++ b/components/party/PartySegmentedControl/index.tsx @@ -5,18 +5,20 @@ import { useTranslation } from 'next-i18next' import { appState } from '~utils/appState' import SegmentedControl from '~components/common/SegmentedControl' -import Segment from '~components/common/Segment' -import ToggleSwitch from '~components/common/ToggleSwitch' import { GridType } from '~utils/enums' import './index.scss' import classNames from 'classnames' +import RepSegment from '~components/reps/RepSegment' +import CharacterRep from '~components/reps/CharacterRep' +import { accountState } from '~utils/accountState' +import WeaponRep from '~components/reps/WeaponRep' +import SummonRep from '~components/reps/SummonRep' interface Props { selectedTab: GridType onClick: (event: React.ChangeEvent) => void - onCheckboxChange: (event: React.ChangeEvent) => void } const PartySegmentedControl = (props: Props) => { @@ -47,17 +49,56 @@ const PartySegmentedControl = (props: Props) => { } } - const extraToggle = ( -
- Extra - -
- ) + const characterSegment = () => { + return ( + + + + ) + } + + const weaponSegment = () => { + { + return ( + + + + ) + } + } + + const summonSegment = () => { + return ( + + + + ) + } return (
{ })} > - - {t('party.segmented_control.characters')} - - - - {t('party.segmented_control.weapons')} - - - - {t('party.segmented_control.summons')} - + {characterSegment()} + {weaponSegment()} + {summonSegment()} - - {(() => { - if (party.editable && props.selectedTab == GridType.Weapon) { - return extraToggle - } - })()}
) } diff --git a/components/reps/CharacterRep/index.scss b/components/reps/CharacterRep/index.scss new file mode 100644 index 00000000..acaf25ab --- /dev/null +++ b/components/reps/CharacterRep/index.scss @@ -0,0 +1,75 @@ +.CharacterRep { + aspect-ratio: 2/0.99; + border-radius: $card-corner; + grid-gap: $unit-half; /* add a gap of 8px between grid items */ + height: $rep-height; + + .Character { + background: var(--card-bg); + border-radius: 4px; + } + + .GridCharacters { + display: grid; /* make the right-images container a grid */ + grid-template-columns: repeat( + 4, + 1fr + ); /* create 3 columns, each taking up 1 fraction */ + gap: $unit-half; + } + + .Grid.Character { + aspect-ratio: 16 / 33; + box-sizing: border-box; + display: grid; + overflow: hidden; + + &.MC { + border-color: transparent; + border-width: 1px; + border-style: solid; + aspect-ratio: 32 / 66; + + img { + position: relative; + width: 100%; + height: 100%; + } + + &.fire { + background: var(--fire-hover-bg); + border-color: var(--fire-bg); + } + + &.water { + background: var(--water-hover-bg); + border-color: var(--water-bg); + } + + &.wind { + background: var(--wind-hover-bg); + border-color: var(--wind-bg); + } + + &.earth { + background: var(--earth-hover-bg); + border-color: var(--earth-bg); + } + + &.light { + background: var(--light-hover-bg); + border-color: var(--light-bg); + } + + &.dark { + background: var(--dark-hover-bg); + border-color: var(--dark-bg); + } + } + } + + .Grid.Character img[src*='jpg'] { + border-radius: 4px; + width: 100%; + } +} diff --git a/components/reps/CharacterRep/index.tsx b/components/reps/CharacterRep/index.tsx new file mode 100644 index 00000000..8d53442e --- /dev/null +++ b/components/reps/CharacterRep/index.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' +import 'fix-date' + +import './index.scss' + +interface Props { + job?: Job + gender?: number + element?: number + grid: GridArray +} + +const CHARACTERS_COUNT = 3 + +const CharacterRep = (props: Props) => { + // Localization for alt tags + const router = useRouter() + const { t } = useTranslation('common') + const locale = + router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' + + // Component state + const [characters, setCharacters] = useState>({}) + const [grid, setGrid] = useState>({}) + + // On grid update + useEffect(() => { + const newCharacters = Array(CHARACTERS_COUNT) + const gridCharacters = Array(CHARACTERS_COUNT) + + if (props.grid) { + for (const [key, value] of Object.entries(props.grid)) { + if (value) { + newCharacters[value.position] = value.object + gridCharacters[value.position] = value + } + } + } + + setCharacters(newCharacters) + setGrid(gridCharacters) + }, [props.grid]) + + // Convert element to string + function numberToElement() { + switch (props.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 generateMCImage() { + let source = '' + + if (props.job) { + const slug = props.job.name.en.replaceAll(' ', '-').toLowerCase() + const gender = props.gender == 1 ? 'b' : 'a' + source = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-portraits/${slug}_${gender}.png` + } + + return props.job && props.job.id !== '-1' ? ( + {props.job + ) : ( + '' + ) + } + + function generateGridImage(position: number) { + let url = '' + + const character = characters[position] + const gridCharacter = grid[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 (character.element == 0) { + url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-main/${character.granblue_id}_${props.element}.jpg` + } else { + url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-main/${character.granblue_id}_${suffix}.jpg` + } + } + + return characters[position] ? ( + {characters[position]?.name[locale]} + ) : ( + '' + ) + } + + // Render + return ( +
+
    +
  • + {generateMCImage()} +
  • + {Array.from(Array(CHARACTERS_COUNT)).map((x, i) => { + return ( +
  • + {generateGridImage(i)} +
  • + ) + })} +
+
+ ) +} + +export default CharacterRep diff --git a/components/reps/RepSegment/index.scss b/components/reps/RepSegment/index.scss new file mode 100644 index 00000000..e6cedb46 --- /dev/null +++ b/components/reps/RepSegment/index.scss @@ -0,0 +1,73 @@ +.RepSegment { + border-radius: $card-corner; + color: $grey-55; + cursor: pointer; + font-size: 1.4rem; + font-weight: $normal; + min-width: 100px; + + &:hover label { + background: var(--button-bg); + color: var(--text-primary); + + .Wrapper .Rep { + opacity: 1; + } + } + + & input { + display: none; + + &:checked + label { + background: var(--button-bg); + color: var(--text-primary); + + .Rep { + opacity: 1; + } + } + } + + & label { + border-radius: $card-corner; + display: block; + font-size: $font-small; + font-weight: $medium; + text-align: center; + white-space: nowrap; + overflow: hidden; + padding: $unit; + padding-bottom: $unit * 1.5; + text-overflow: ellipsis; + cursor: pointer; + + &:before { + background: #fff; + } + + @include breakpoint(phone) { + border-radius: 100px; + padding-bottom: $unit; + } + + .Wrapper { + display: flex; + flex-direction: column; + gap: $unit; + + .Rep { + transition: $duration-opacity-fade opacity ease-in; + opacity: 0.5; + } + } + } + + @include breakpoint(phone) { + min-width: initial; + width: 100%; + + .Rep { + display: none; + } + } +} diff --git a/components/reps/RepSegment/index.tsx b/components/reps/RepSegment/index.tsx new file mode 100644 index 00000000..dbe473f4 --- /dev/null +++ b/components/reps/RepSegment/index.tsx @@ -0,0 +1,34 @@ +import React, { PropsWithChildren } from 'react' + +import './index.scss' + +interface Props { + controlGroup: string + inputName: string + name: string + selected: boolean + onClick: (event: React.ChangeEvent) => void +} + +const RepSegment = ({ children, ...props }: PropsWithChildren) => { + return ( +
+ + +
+ ) +} + +export default RepSegment diff --git a/components/reps/SummonRep/index.scss b/components/reps/SummonRep/index.scss new file mode 100644 index 00000000..58e4a4cf --- /dev/null +++ b/components/reps/SummonRep/index.scss @@ -0,0 +1,45 @@ +.SummonRep { + aspect-ratio: 2/1.045; + border-radius: $card-corner; + display: grid; + grid-template-columns: 1fr 2.25fr; /* left column takes up 1 fraction, right column takes up 3 fractions */ + grid-gap: $unit-half; /* add a gap of 8px between grid items */ + height: $rep-height; + + .Summon { + background: var(--card-bg); + border-radius: 4px; + } + + .Main.Summon { + aspect-ratio: 56/97; + display: grid; + grid-column: 1 / 2; /* spans one column */ + } + + .GridSummons { + 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-half; + // column-gap: $unit; + // row-gap: $unit-2x; + } + + .Grid.Summon { + aspect-ratio: 184 / 138; + display: grid; + } + + .Main.Summon img[src*='jpg'], + .Grid.Summon img[src*='jpg'] { + border-radius: 4px; + width: 100%; + } +} diff --git a/components/reps/SummonRep/index.tsx b/components/reps/SummonRep/index.tsx new file mode 100644 index 00000000..a1a377d5 --- /dev/null +++ b/components/reps/SummonRep/index.tsx @@ -0,0 +1,172 @@ +import React, { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' +import 'fix-date' + +import './index.scss' + +interface Props { + grid: { + mainSummon: GridSummon | undefined + friendSummon: GridSummon | undefined + allSummons: GridArray + } +} + +const SUMMONS_COUNT = 4 + +const SummonRep = (props: Props) => { + // Localization for alt tags + const router = useRouter() + const { t } = useTranslation('common') + const locale = + router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' + + // Component state + const [mainSummon, setMainSummon] = useState() + const [summons, setSummons] = useState>({}) + const [grid, setGrid] = useState>({}) + + // On grid update + useEffect(() => { + const newSummons = Array(SUMMONS_COUNT) + const gridSummons = Array(SUMMONS_COUNT) + + if (props.grid.mainSummon) { + setMainSummon(props.grid.mainSummon) + } + + if (props.grid.allSummons) { + for (const [key, value] of Object.entries(props.grid.allSummons)) { + if (value) { + newSummons[value.position] = value.object + gridSummons[value.position] = value + } + } + } + + setSummons(newSummons) + setGrid(gridSummons) + }, [props.grid]) + + // Methods: Image generation + function generateMainImage() { + let url = '' + + const upgradedSummons = [ + '2040094000', + '2040100000', + '2040080000', + '2040098000', + '2040090000', + '2040084000', + '2040003000', + '2040056000', + '2040020000', + '2040034000', + '2040028000', + '2040027000', + '2040046000', + '2040047000', + ] + + if (mainSummon) { + // Change the image based on the uncap level + let suffix = '' + if (mainSummon.object.uncap.xlb && mainSummon.uncap_level == 6) { + if ( + mainSummon.transcendence_step >= 1 && + mainSummon.transcendence_step < 5 + ) { + suffix = '_03' + } else if (mainSummon.transcendence_step === 5) { + suffix = '_04' + } + } else if ( + upgradedSummons.indexOf(mainSummon.object.granblue_id.toString()) != + -1 && + mainSummon.uncap_level == 5 + ) { + suffix = '_02' + } + + url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-main/${mainSummon.object.granblue_id}${suffix}.jpg` + } + + return mainSummon ? ( + {mainSummon.object.name[locale]} + ) : ( + '' + ) + } + + function generateGridImage(position: number) { + let url = '' + + const summon = summons[position] + const gridSummon = grid[position] + + 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]} + ) : ( + '' + ) + } + + // Render + return ( +
+
{generateMainImage()}
+
    + {Array.from(Array(SUMMONS_COUNT)).map((x, i) => { + return ( +
  • + {generateGridImage(i + 1)} +
  • + ) + })} +
+
+ ) +} + +export default SummonRep diff --git a/components/reps/WeaponRep/index.scss b/components/reps/WeaponRep/index.scss new file mode 100644 index 00000000..65f6b487 --- /dev/null +++ b/components/reps/WeaponRep/index.scss @@ -0,0 +1,45 @@ +.WeaponRep { + aspect-ratio: 2/0.955; + border-radius: $card-corner; + display: grid; + grid-template-columns: 1fr 3.39fr; /* left column takes up 1 fraction, right column takes up 3 fractions */ + grid-gap: $unit-half; /* add a gap of 8px between grid items */ + height: $rep-height; + + .Weapon { + background: var(--card-bg); + border-radius: 4px; + } + + .Mainhand.Weapon { + aspect-ratio: 73/153; + display: grid; + grid-column: 1 / 2; /* spans one column */ + } + + .GridWeapons { + display: grid; /* make the right-images container a grid */ + grid-template-columns: repeat( + 3, + 1fr + ); /* create 3 columns, each taking up 1 fraction */ + grid-template-rows: repeat( + 3, + 1fr + ); /* create 3 rows, each taking up 1 fraction */ + gap: $unit-half; + // column-gap: $unit; + // row-gap: $unit-2x; + } + + .Grid.Weapon { + aspect-ratio: 280 / 160; + display: grid; + } + + .Mainhand.Weapon img[src*='jpg'], + .Grid.Weapon img[src*='jpg'] { + border-radius: 4px; + width: 100%; + } +} diff --git a/components/reps/WeaponRep/index.tsx b/components/reps/WeaponRep/index.tsx new file mode 100644 index 00000000..4cecdd29 --- /dev/null +++ b/components/reps/WeaponRep/index.tsx @@ -0,0 +1,106 @@ +import React, { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' +import 'fix-date' + +import './index.scss' + +interface Props { + grid: { + mainWeapon: GridWeapon | undefined + allWeapons: GridArray + } +} + +const WEAPONS_COUNT = 9 + +const WeaponRep = (props: Props) => { + // Localization for alt tags + const router = useRouter() + const { t } = useTranslation('common') + const locale = + router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' + + // Component state + const [mainhand, setMainhand] = useState() + const [weapons, setWeapons] = useState>({}) + const [grid, setGrid] = useState>({}) + + // On grid update + useEffect(() => { + const newWeapons = Array(WEAPONS_COUNT) + const gridWeapons = Array(WEAPONS_COUNT) + + if (props.grid.mainWeapon) { + setMainhand(props.grid.mainWeapon) + } else { + setMainhand(undefined) + } + + if (props.grid.allWeapons) { + for (const [key, value] of Object.entries(props.grid.allWeapons)) { + if (value) { + newWeapons[value.position] = value.object + gridWeapons[value.position] = value + } + } + } + + setWeapons(newWeapons) + setGrid(gridWeapons) + }, [props.grid]) + + // Methods: Image generation + function generateMainhandImage() { + let url = '' + + if (mainhand && mainhand.object) { + if (mainhand.object.element == 0 && mainhand.element) { + url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${mainhand.object.granblue_id}_${mainhand.element}.jpg` + } else { + url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${mainhand.object.granblue_id}.jpg` + } + } + + return mainhand ? {mainhand.object.name[locale]} : '' + } + + function generateGridImage(position: number) { + let url = '' + + const weapon = weapons[position] + const gridWeapon = grid[position] + + if (weapon && gridWeapon) { + if (weapon.element == 0 && gridWeapon.element) { + url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}_${gridWeapon.element}.jpg` + } else { + url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}.jpg` + } + } + + return weapons[position] ? ( + {weapons[position]?.name[locale]} + ) : ( + '' + ) + } + + // Render + return ( +
+
{generateMainhandImage()}
+
    + {Array.from(Array(WEAPONS_COUNT)).map((x, i) => { + return ( +
  • + {generateGridImage(i)} +
  • + ) + })} +
+
+ ) +} + +export default WeaponRep diff --git a/components/search/SearchModal/index.scss b/components/search/SearchModal/index.scss index 3d3638f7..37c517f3 100644 --- a/components/search/SearchModal/index.scss +++ b/components/search/SearchModal/index.scss @@ -61,6 +61,11 @@ #Results { margin: 0; padding: 0 ($unit * 1.5); + padding-bottom: $unit * 1.5; + + // Infinite scroll + overflow-y: auto; + max-height: 500px; @include breakpoint(phone) { max-height: inherit; diff --git a/components/search/SearchModal/index.tsx b/components/search/SearchModal/index.tsx index a35184c2..09cca333 100644 --- a/components/search/SearchModal/index.tsx +++ b/components/search/SearchModal/index.tsx @@ -19,6 +19,7 @@ import CharacterResult from '~components/character/CharacterResult' import WeaponResult from '~components/weapon/WeaponResult' import SummonResult from '~components/summon/SummonResult' import JobSkillResult from '~components/job/JobSkillResult' +import GuidebookResult from '~components/extra/GuidebookResult' import type { DialogProps } from '@radix-ui/react-dialog' import type { SearchableObject, SearchableObjectArray } from '~types' @@ -31,7 +32,7 @@ interface Props extends DialogProps { placeholderText: string fromPosition: number job?: Job - object: 'weapons' | 'characters' | 'summons' | 'job_skills' + object: 'weapons' | 'characters' | 'summons' | 'job_skills' | 'guidebooks' } const SearchModal = (props: Props) => { @@ -184,7 +185,7 @@ const SearchModal = (props: Props) => { } else if (open && currentPage == 1) { fetchResults({ replace: true }) } - }, [currentPage]) + }, [open, currentPage]) useEffect(() => { // Filters changed @@ -219,6 +220,17 @@ const SearchModal = (props: Props) => { } }, [query]) + useEffect(() => { + if (open && props.object === 'guidebooks') { + setCurrentPage(1) + fetchResults({ replace: true }) + } + }, [query, open]) + + function incrementPage() { + setCurrentPage(currentPage + 1) + } + function renderResults() { let jsx @@ -235,12 +247,15 @@ const SearchModal = (props: Props) => { case 'job_skills': jsx = renderJobSkillSearchResults(results) break + case 'guidebooks': + jsx = renderGuidebookSearchResults(results) + break } return ( 0 ? results.length : 0} - next={() => setCurrentPage(currentPage + 1)} + next={incrementPage} hasMore={totalPages > currentPage} scrollableTarget="Results" loader={
Loading...
} @@ -334,6 +349,27 @@ const SearchModal = (props: Props) => { return jsx } + function renderGuidebookSearchResults(results: { [key: string]: any }) { + let jsx: React.ReactNode + + const castResults: Guidebook[] = results as Guidebook[] + if (castResults && Object.keys(castResults).length > 0) { + jsx = castResults.map((result: Guidebook) => { + return ( + { + storeRecentResult(result) + }} + /> + ) + }) + } + + return jsx + } + function openChange() { if (open) { setQuery('') @@ -365,6 +401,7 @@ const SearchModal = (props: Props) => { diff --git a/components/summon/SummonGrid/index.scss b/components/summon/SummonGrid/index.scss index 830cfee2..5f575ff9 100644 --- a/components/summon/SummonGrid/index.scss +++ b/components/summon/SummonGrid/index.scss @@ -1,6 +1,6 @@ #SummonGrid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)) 2fr; + grid-template-columns: 1.17fr 2fr 1.17fr; gap: $unit-3x; justify-content: center; margin: 0 auto; diff --git a/components/summon/SummonGrid/index.tsx b/components/summon/SummonGrid/index.tsx index 75f2b089..3a72028b 100644 --- a/components/summon/SummonGrid/index.tsx +++ b/components/summon/SummonGrid/index.tsx @@ -452,8 +452,8 @@ const SummonGrid = (props: Props) => {
{mainSummonElement} - {friendSummonElement} {summonGridElement} + {friendSummonElement}
{subAuraSummonElement} diff --git a/components/toasts/RemixedToast/index.tsx b/components/toasts/RemixedToast/index.tsx new file mode 100644 index 00000000..cf112d84 --- /dev/null +++ b/components/toasts/RemixedToast/index.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import Toast from '~components/common/Toast' +import { Trans, useTranslation } from 'next-i18next' + +import './index.scss' + +interface Props { + partyName: string + open: boolean + onActionClick?: () => void + onOpenChange: (open: boolean) => void + onCloseClick: () => void +} + +const RemixedToast = ({ + partyName, + open, + onOpenChange, + onCloseClick, +}: Props) => { + const { t } = useTranslation('common') + + // Methods: Event handlers + function handleOpenChange() { + onOpenChange(open) + } + + function handleCloseClick() { + onCloseClick() + } + + return ( + + You remixed {{ title: partyName }} + + } + onOpenChange={handleOpenChange} + onCloseClick={handleCloseClick} + /> + ) +} + +export default RemixedToast diff --git a/components/about/UpdateToast/index.scss b/components/toasts/UpdateToast/index.scss similarity index 100% rename from components/about/UpdateToast/index.scss rename to components/toasts/UpdateToast/index.scss diff --git a/components/about/UpdateToast/index.tsx b/components/toasts/UpdateToast/index.tsx similarity index 100% rename from components/about/UpdateToast/index.tsx rename to components/toasts/UpdateToast/index.tsx diff --git a/components/toasts/UrlCopiedToast/index.tsx b/components/toasts/UrlCopiedToast/index.tsx new file mode 100644 index 00000000..60ffea89 --- /dev/null +++ b/components/toasts/UrlCopiedToast/index.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import Toast from '~components/common/Toast' + +import './index.scss' +import { useTranslation } from 'next-i18next' + +interface Props { + open: boolean + onActionClick?: () => void + onOpenChange: (open: boolean) => void + onCloseClick: () => void +} + +const UrlCopiedToast = ({ open, onOpenChange, onCloseClick }: Props) => { + const { t } = useTranslation('common') + + // Methods: Event handlers + function handleOpenChange() { + onOpenChange(open) + } + + function handleCloseClick() { + onCloseClick() + } + + return ( + + ) +} + +export default UrlCopiedToast diff --git a/components/weapon/ExtraWeapons/index.scss b/components/weapon/ExtraWeapons/index.scss deleted file mode 100644 index d4cc6d5b..00000000 --- a/components/weapon/ExtraWeapons/index.scss +++ /dev/null @@ -1,59 +0,0 @@ -.ExtraGrid.Weapons { - background: var(--extra-purple-bg); - border-radius: $card-corner; - box-sizing: border-box; - display: grid; - grid-template-columns: 1.42fr 3fr; - justify-content: center; - margin: 20px auto; - max-width: calc($grid-width + 20px); - padding: $unit-2x $unit-2x $unit-2x 0; - position: relative; - left: $unit; - - @include breakpoint(tablet) { - left: auto; - max-width: auto; - width: 100%; - } - - @include breakpoint(phone) { - display: flex; - gap: $unit-2x; - padding: $unit-2x; - flex-direction: column; - } - - & > span { - color: var(--extra-purple-text); - display: flex; - align-items: center; - flex-grow: 1; - justify-content: center; - line-height: 1.2; - font-weight: 500; - text-align: center; - } - - #ExtraWeapons { - display: grid; - gap: $unit-3x; - grid-template-columns: repeat(3, minmax(0, 1fr)); - - @include breakpoint(tablet) { - gap: $unit-2x; - } - - @include breakpoint(phone) { - gap: $unit; - } - } - - .WeaponUnit .WeaponImage { - background: var(--extra-purple-card-bg); - } - - .WeaponUnit .WeaponImage .icon svg { - fill: var(--extra-purple-secondary); - } -} diff --git a/components/weapon/ExtraWeapons/index.tsx b/components/weapon/ExtraWeapons/index.tsx deleted file mode 100644 index 6c1ec552..00000000 --- a/components/weapon/ExtraWeapons/index.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react' -import { useTranslation } from 'next-i18next' -import WeaponUnit from '~components/weapon/WeaponUnit' - -import type { SearchableObject } from '~types' - -import './index.scss' - -// Props -interface Props { - grid: GridArray - editable: boolean - found?: boolean - offset: number - removeWeapon: (id: string) => void - updateObject: (object: SearchableObject, position: number) => void - updateUncap: (id: string, position: number, uncap: number) => void -} - -const ExtraWeapons = (props: Props) => { - const numWeapons: number = 3 - const { t } = useTranslation('common') - - return ( -
- {t('extra_weapons')} -
    - {Array.from(Array(numWeapons)).map((x, i) => { - return ( -
  • - -
  • - ) - })} -
-
- ) -} - -export default ExtraWeapons diff --git a/components/weapon/WeaponGrid/index.scss b/components/weapon/WeaponGrid/index.scss index 7d04123d..8fec4f82 100644 --- a/components/weapon/WeaponGrid/index.scss +++ b/components/weapon/WeaponGrid/index.scss @@ -49,7 +49,7 @@ } } - li { - list-style: none; + li:not(.Empty) { + // aspect-ratio: 1 / 1.035; } } diff --git a/components/weapon/WeaponGrid/index.tsx b/components/weapon/WeaponGrid/index.tsx index 36ba8843..44133528 100644 --- a/components/weapon/WeaponGrid/index.tsx +++ b/components/weapon/WeaponGrid/index.tsx @@ -6,10 +6,13 @@ import { useTranslation } from 'next-i18next' import { AxiosError, AxiosResponse } from 'axios' import debounce from 'lodash.debounce' +import classNames from 'classnames' import Alert from '~components/common/Alert' import WeaponUnit from '~components/weapon/WeaponUnit' -import ExtraWeapons from '~components/weapon/ExtraWeapons' +import ExtraWeaponsGrid from '~components/extra/ExtraWeaponsGrid' +import ExtraContainer from '~components/extra/ExtraContainer' +import GuidebooksGrid from '~components/extra/GuidebooksGrid' import WeaponConflictModal from '~components/weapon/WeaponConflictModal' import api from '~utils/api' @@ -24,8 +27,11 @@ interface Props { new: boolean editable: boolean weapons?: GridWeapon[] + guidebooks?: GuidebookList createParty: (details: DetailsObject) => Promise pushHistory?: (path: string) => void + updateExtra: (enabled: boolean) => void + updateGuidebook: (book: Guidebook | undefined, position: number) => void } const WeaponGrid = (props: Props) => { @@ -115,6 +121,13 @@ const WeaponGrid = (props: Props) => { } } + function receiveGuidebookFromSearch( + object: SearchableObject, + position: number + ) { + props.updateGuidebook(object as Guidebook, position) + } + async function handleWeaponResponse(data: any) { if (data.hasOwnProperty('conflicts')) { if (data.incoming) setIncoming(data.incoming) @@ -236,6 +249,10 @@ const WeaponGrid = (props: Props) => { } } + async function removeGuidebook(position: number) { + props.updateGuidebook(undefined, position) + } + // Methods: Updating uncap level // Note: Saves, but debouncing is not working properly async function saveUncap(id: string, position: number, uncapLevel: number) { @@ -318,6 +335,12 @@ const WeaponGrid = (props: Props) => { setPreviousUncapValues(newPreviousValues) } + // Methods: Convenience + const displayExtraContainer = + props.editable || + appState.party.extra || + Object.values(appState.party.guidebooks).every((el) => el === undefined) + // Render: JSX components const mainhandElement = ( { ) const weaponGridElement = Array.from(Array(numWeapons)).map((x, i) => { + const itemClasses = classNames({ + Empty: appState.grid.weapons.allWeapons[i] === undefined, + }) + return ( -
  • +
  • { ) }) - const extraGridElement = ( - + const extraElement = ( + + + + ) const conflictModal = () => { @@ -409,9 +447,7 @@ const WeaponGrid = (props: Props) => {
      {weaponGridElement}
  • - {(() => { - return party.extra ? extraGridElement : '' - })()} + {displayExtraContainer ? extraElement : ''} ) } diff --git a/components/weapon/WeaponUnit/index.scss b/components/weapon/WeaponUnit/index.scss index 12ec3378..4a625318 100644 --- a/components/weapon/WeaponUnit/index.scss +++ b/components/weapon/WeaponUnit/index.scss @@ -176,6 +176,8 @@ } .WeaponName { + font-size: $font-name; + line-height: 1.2; @include breakpoint(phone) { font-size: $font-tiny; } diff --git a/public/icons/Ellipsis.svg b/public/icons/Ellipsis.svg new file mode 100644 index 00000000..5c8a642b --- /dev/null +++ b/public/icons/Ellipsis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/placeholders/placeholder-guidebook.png b/public/images/placeholders/placeholder-guidebook.png new file mode 100644 index 0000000000000000000000000000000000000000..9d8e1ccb89dabe88c89b1c66381dd0512143b680 GIT binary patch literal 361 zcmeAS@N?(olHy`uVBq!ia0vp^6M#5{gAGV#y6)T#q&N#aB8wRqxP?KOkzv*x37{Zj zage(c!@6@aFM%AEbVpxD28NCO+>g30 u4e8JHew2u@FiqreQ4;8MP#K1dPYl@}EZS~&?lA*Hg2B_(&t;ucLK6UL@KtO8 literal 0 HcmV?d00001 diff --git a/public/locales/en/common.json b/public/locales/en/common.json index d6c0ce1b..9ebe1e98 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -102,6 +102,9 @@ "description": "The page you're looking for couldn't be found", "button": "Create a new party" }, + "validation": { + "guidebooks": "You cannot equip more than one of each Sephira Guidebook" + }, "unauthorized": "You don't have permission to perform that action" }, "proficiencies": { @@ -232,6 +235,11 @@ "clear": "Clear filters" } }, + "guidebooks": { + "buttons": { + "remove": "Remove guidebook" + } + }, "login": { "title": "Log in", "buttons": { @@ -248,6 +256,17 @@ "password": "Password" } }, + "remix_team": { + "title": "Remix team", + "description": { + "creator": "You're already the creator of {{name}}. Are you sure you want to make a copy by remixing it?", + "viewer": "Remixing a team makes a copy of it in your account so you can make your own changes.\n\nWould you like to remix {{name}}?" + }, + "buttons": { + "confirm": "Yes, remix team", + "cancel": "Nevermind" + } + }, "settings": { "title": "Account Settings", "labels": { @@ -383,7 +402,8 @@ "weapon": "Search for a weapon...", "summon": "Search for a summon...", "character": "Search for a character...", - "job_skill": "Search job skills..." + "job_skill": "Search job skills...", + "guidebook": "Search guidebooks..." } }, "teams": { @@ -448,6 +468,7 @@ "source": "Go to original team" }, "extra_weapons": "Additional Weapons", + "sephira_guidebooks": "Sephira Guidebooks", "equipped": "Equipped", "coming_soon": "Coming Soon", "new_party": "New party", diff --git a/public/locales/ja/common.json b/public/locales/ja/common.json index bc1503d7..ea1e5675 100644 --- a/public/locales/ja/common.json +++ b/public/locales/ja/common.json @@ -72,6 +72,9 @@ "description": "探しているページは見つかりませんでした", "button": "新しい編成を作成" }, + "validation": { + "guidebooks": "セフィラ導本を複数個装備することはできません" + }, "unauthorized": "行ったアクションを実行する権限がありません" }, "header": { @@ -232,6 +235,11 @@ "clear": "保存したフィルターをクリア" } }, + "guidebooks": { + "buttons": { + "remove": "導本を削除する" + } + }, "login": { "title": "ログイン", "buttons": { @@ -248,6 +256,17 @@ "password": "パスワード" } }, + "remix_team": { + "title": "編成をリミックス", + "description": { + "creator": "既に{{name}}の作家のため, 本当にリミックスでコピーを作成しますか?", + "viewer": "編成をリミックスすると変更をするために自アカウントにコピーを作成します。{{name}}をリミックスをしますか?" + }, + "buttons": { + "confirm": "リミックス", + "cancel": "キャンセル" + } + }, "settings": { "title": "アカウント設定", "labels": { @@ -384,7 +403,8 @@ "weapon": "武器を検索...", "summon": "召喚石を検索...", "character": "キャラを検索...", - "job_skill": "ジョブのスキルを検索..." + "job_skill": "ジョブのスキルを検索...", + "guidebook": "導本を検索..." } }, "teams": { @@ -450,6 +470,7 @@ }, "equipped": "装備した", "extra_weapons": "Additional Weapons", + "sephira_guidebooks": "セフィラ導本", "coming_soon": "開発中", "new_party": "新しい編成", "no_accessory": "{{accessory}}は装備していません", diff --git a/styles/variables.scss b/styles/variables.scss index 4bb5a75c..fffa1e9b 100644 --- a/styles/variables.scss +++ b/styles/variables.scss @@ -14,6 +14,7 @@ $tablet: 768px; $phone: 375px; $grid-width: 720px; +$rep-height: 111px; // Legacy $medium-screen: 768px; @@ -23,6 +24,7 @@ $unit: 8px; $unit-fourth: calc($unit / 4); $unit-half: calc($unit / 2); +$unit-three-fourth: calc($unit / 4) * 3; $unit-2x: $unit * 2; $unit-3x: $unit * 3; $unit-4x: $unit * 4; @@ -97,7 +99,7 @@ $wind-text-00: #023e28; $wind-text-10: #006a43; $wind-text-20: #1dc688; $wind-bg-00: #4cffbf55; -$wind-bg-10: #4cffbf; +$wind-bg-10: #00b551; $wind-bg-20: #cdffed; $fire-text-00: #3f0202; @@ -125,7 +127,7 @@ $light-text-00: #433d02; $light-text-10: #716500; $light-text-20: #c5b20c; $light-bg-00: #ffed4c55; -$light-bg-10: #ffed4c; +$light-bg-10: #d9c50f; $light-bg-20: #fffacd; $dark-text-00: #260134; @@ -328,6 +330,7 @@ $bold: 600; $font-tiny: 11px; $font-small: 13px; $font-button: 15px; +$font-name: 15px; $font-regular: 16px; $font-medium: 18px; $font-large: 21px; @@ -351,6 +354,7 @@ $hover-shadow: rgba(0, 0, 0, 0.08) 0px 0px 14px; $duration-modal-open: 0.48s; $duration-color-fade: 0.24s; $duration-zoom: 0.18s; +$duration-opacity-fade: 0.12s; // Gradients $hero--gradient--light: linear-gradient( diff --git a/types/Guidebook.d.ts b/types/Guidebook.d.ts new file mode 100644 index 00000000..093e3dc5 --- /dev/null +++ b/types/Guidebook.d.ts @@ -0,0 +1,14 @@ +interface Guidebook { + id: string + granblue_id: string + name: { + [key: string]: string + en: string + jp: string + } + description: { + [key: string]: string + en: string + jp: string + } +} diff --git a/types/Party.d.ts b/types/Party.d.ts index c42fe769..4e86c21e 100644 --- a/types/Party.d.ts +++ b/types/Party.d.ts @@ -1,4 +1,4 @@ -type JobSkillObject = { +type JobSkillList = { [key: number]: JobSkill | undefined 0: JobSkill | undefined 1: JobSkill | undefined @@ -6,6 +6,13 @@ type JobSkillObject = { 3: JobSkill | undefined } +type GuidebookList = { + [key: number]: Guidebook | undefined + 0: Guidebook | undefined + 1: Guidebook | undefined + 2: Guidebook | undefined +} + interface Party { id: string name: string @@ -22,10 +29,11 @@ interface Party { job: Job master_level?: number ultimate_mastery?: number - job_skills: JobSkillObject + job_skills: JobSkillList accessory: JobAccessory shortcode: string extra: boolean + guidebooks: GuidebookList favorited: boolean characters: Array weapons: Array diff --git a/types/index.d.ts b/types/index.d.ts index fd93e089..307c3026 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,4 +1,9 @@ -export type SearchableObject = Character | Weapon | Summon | JobSkill +export type SearchableObject = + | Character + | Weapon + | Summon + | JobSkill + | Guidebook export type SearchableObjectArray = (Character | Weapon | Summon | JobSkill)[] export type JobSkillObject = { [key: number]: JobSkill | undefined @@ -21,7 +26,7 @@ export type PaginationObject = { } export type DetailsObject = { - [key: string]: boolean | number | string | Raid | undefined + [key: string]: boolean | number | string | string[] | Raid | undefined fullAuto?: boolean autoGuard?: boolean chargeAttack?: boolean @@ -34,6 +39,7 @@ export type DetailsObject = { raid?: Raid job?: Job extra?: boolean + guidebooks?: string[] } export type ExtendedMastery = { diff --git a/utils/appState.tsx b/utils/appState.tsx index 29f41bf4..de2b94b7 100644 --- a/utils/appState.tsx +++ b/utils/appState.tsx @@ -55,6 +55,7 @@ interface AppState { turnCount?: number chainCount?: number extra: boolean + guidebooks: GuidebookList user: User | undefined favorited: boolean remix: boolean @@ -116,6 +117,11 @@ export const initialAppState: AppState = { chainCount: undefined, element: 0, extra: false, + guidebooks: { + 0: undefined, + 1: undefined, + 2: undefined, + }, user: undefined, favorited: false, remix: false,