From c7e08362029175108b5bcff11c492c4cedc9e92d Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sat, 4 Feb 2023 23:46:24 -0800 Subject: [PATCH] February 2023 Update (#158) --- .env.sample | 6 +- .gitignore | 3 + README.md | 81 ++- README.png | Bin 0 -> 4035 bytes components/AboutHead/index.tsx | 53 ++ components/AboutModal/index.scss | 114 ---- components/AboutModal/index.tsx | 195 ------ components/AboutPage/index.scss | 76 +++ components/AboutPage/index.tsx | 175 +++++ components/AccountModal/index.scss | 14 +- components/AccountModal/index.tsx | 548 +++++++-------- components/Alert/index.scss | 48 +- components/Alert/index.tsx | 19 +- components/AwakeningSelect/index.tsx | 228 ++----- components/AxSelect/index.tsx | 22 +- components/Button/index.scss | 74 ++- components/Button/index.tsx | 57 +- components/ChangelogModal/index.scss | 15 - components/ChangelogModal/index.tsx | 55 -- components/ChangelogUnit/index.scss | 17 + components/ChangelogUnit/index.tsx | 94 +++ components/CharacterConflictModal/index.tsx | 74 ++- components/CharacterGrid/index.tsx | 224 ++++++- components/CharacterHovercard/index.scss | 68 ++ components/CharacterHovercard/index.tsx | 247 +++++-- components/CharacterModal/index.scss | 78 +++ components/CharacterModal/index.tsx | 307 +++++++++ components/CharacterUnit/index.scss | 47 +- components/CharacterUnit/index.tsx | 359 ++++++++-- components/ContextMenu/index.scss | 6 + components/ContextMenu/index.tsx | 36 + components/ContextMenuItem/index.scss | 11 + components/ContextMenuItem/index.tsx | 30 + components/Dialog/index.scss | 211 ------ components/Dialog/index.tsx | 68 +- components/DialogContent/index.scss | 287 ++++++++ components/DialogContent/index.tsx | 144 ++++ .../index.scss | 22 +- components/DropdownMenuContent/index.tsx | 40 ++ components/DurationInput/index.scss | 35 + components/DurationInput/index.tsx | 147 ++-- components/ErrorSection/index.scss | 22 + components/ErrorSection/index.tsx | 48 ++ components/ExtendedMasterySelect/index.scss | 17 + components/ExtendedMasterySelect/index.tsx | 165 +++++ components/ExtraSummons/index.tsx | 4 + components/ExtraWeapons/index.tsx | 2 + components/GridRep/index.scss | 2 +- components/GridRep/index.tsx | 36 +- components/Header/index.scss | 16 +- components/Header/index.tsx | 607 ++++++++++++++--- components/HeaderMenu/index.tsx | 180 ----- components/Hovercard/index.scss | 95 +++ components/Hovercard/index.tsx | 31 + components/Input/index.scss | 4 +- components/JobAccessoryItem/index.scss | 52 ++ components/JobAccessoryItem/index.tsx | 34 + components/JobAccessoryPopover/index.scss | 67 ++ components/JobAccessoryPopover/index.tsx | 152 +++++ components/JobDropdown/index.tsx | 15 +- components/JobImage/index.scss | 80 +++ components/JobImage/index.tsx | 114 ++++ components/JobSection/index.scss | 73 +- components/JobSection/index.tsx | 80 ++- components/JobSkillResult/index.tsx | 2 +- components/JobSkillSearchFilterBar/index.tsx | 1 + components/Layout/index.scss | 15 + components/Layout/index.tsx | 68 +- components/LoginModal/index.scss | 15 +- components/LoginModal/index.tsx | 112 ++-- components/NewHead/index.tsx | 32 + components/Overlay/index.scss | 8 + components/Party/index.tsx | 136 +++- components/PartyDetails/index.scss | 140 +++- components/PartyDetails/index.tsx | 628 +++++++++++------- components/PartyHead/index.tsx | 74 +++ components/PartySegmentedControl/index.tsx | 14 +- components/PopoverContent/index.scss | 10 + components/PopoverContent/index.tsx | 44 ++ components/ProfileHead/index.tsx | 60 ++ components/RaidDropdown/index.tsx | 2 +- components/RingSelect/index.scss | 5 + components/RingSelect/index.tsx | 150 +++++ components/RoadmapModal/index.tsx | 92 --- .../{RoadmapModal => RoadmapPage}/index.scss | 70 +- components/RoadmapPage/index.tsx | 56 ++ components/SavedHead/index.tsx | 26 + components/SearchFilter/index.scss | 1 + components/SearchModal/index.scss | 24 +- components/SearchModal/index.tsx | 40 +- components/Select/index.scss | 17 +- components/Select/index.tsx | 33 +- components/SelectItem/index.scss | 8 + components/SelectItem/index.tsx | 10 +- components/SelectTableField/index.scss | 57 +- components/SelectTableField/index.tsx | 11 +- components/SelectWithInput/index.scss | 29 + components/SelectWithInput/index.tsx | 201 ++++++ components/SignupModal/index.scss | 35 +- components/SignupModal/index.tsx | 144 ++-- components/SummonGrid/index.tsx | 249 ++++++- components/SummonHovercard/index.tsx | 101 +-- components/SummonUnit/index.scss | 17 +- components/SummonUnit/index.tsx | 267 ++++++-- components/Switch/index.scss | 6 +- components/TeamsHead/index.tsx | 38 ++ components/Toast/index.scss | 60 ++ components/Toast/index.tsx | 52 ++ components/Token/index.scss | 24 +- components/Tooltip/index.scss | 13 + components/Tooltip/index.tsx | 39 ++ components/TranscendenceFragment/index.scss | 83 +++ components/TranscendenceFragment/index.tsx | 49 ++ components/TranscendencePopover/index.scss | 24 + components/TranscendencePopover/index.tsx | 91 +++ components/TranscendenceStar/index.scss | 108 +++ components/TranscendenceStar/index.tsx | 118 ++++ components/UncapIndicator/index.scss | 4 + components/UncapIndicator/index.tsx | 104 ++- components/UncapStar/index.tsx | 14 +- components/UpdateToast/index.scss | 11 + components/UpdateToast/index.tsx | 75 +++ components/UpdatesPage/index.scss | 135 ++++ components/UpdatesPage/index.tsx | 278 ++++++++ components/WeaponConflictModal/index.tsx | 70 +- components/WeaponGrid/index.scss | 4 + components/WeaponGrid/index.tsx | 146 ++-- components/WeaponHovercard/index.scss | 26 +- components/WeaponHovercard/index.tsx | 176 +++-- components/WeaponKeySelect/index.tsx | 1 + components/WeaponModal/index.scss | 29 +- components/WeaponModal/index.tsx | 115 ++-- components/WeaponSearchFilterBar/index.tsx | 2 +- components/WeaponUnit/index.scss | 24 +- components/WeaponUnit/index.tsx | 512 ++++++++------ {utils => data}/awakening.tsx | 68 +- utils/axData.tsx => data/ax.tsx | 81 ++- utils/Element.tsx => data/elements.tsx | 8 + {utils => data}/jobGroups.tsx | 0 data/overMastery.tsx | 383 +++++++++++ {utils => data}/raidGroups.tsx | 0 {utils => data}/skillGroups.tsx | 0 {utils => data}/weaponKeyGroups.tsx | 0 {utils => data}/weaponSeries.tsx | 0 hooks/useLockedBody.tsx | 56 ++ next.config.js | 54 +- package-lock.json | 337 ++++++++++ package.json | 8 + pages/[username].tsx | 286 ++++---- pages/_app.tsx | 82 ++- pages/about.tsx | 157 +++++ pages/new/index.tsx | 229 +++++-- pages/p/[party].tsx | 276 ++++---- pages/saved.tsx | 221 +++--- pages/teams.tsx | 231 ++++--- public/favicon.ico | Bin 25931 -> 0 bytes public/icons/Manatura.svg | 3 + public/icons/Remix.svg | 4 + public/icons/Shield.svg | 3 + public/icons/perpetuity/empty.svg | 49 ++ public/icons/perpetuity/filled.svg | 53 ++ public/icons/transcendence/0/stage-0.png | Bin 0 -> 770 bytes public/icons/transcendence/0/stage-0@2x.png | Bin 0 -> 2108 bytes public/icons/transcendence/0/stage-0@3x.png | Bin 0 -> 3953 bytes .../icons/transcendence/1/stage-1-hover.png | Bin 0 -> 755 bytes .../transcendence/1/stage-1-hover@2x.png | Bin 0 -> 2010 bytes .../transcendence/1/stage-1-hover@3x.png | Bin 0 -> 3697 bytes public/icons/transcendence/1/stage-1.png | Bin 0 -> 765 bytes public/icons/transcendence/1/stage-1@2x.png | Bin 0 -> 2088 bytes public/icons/transcendence/1/stage-1@3x.png | Bin 0 -> 3890 bytes .../icons/transcendence/2/stage-2-hover.png | Bin 0 -> 729 bytes .../transcendence/2/stage-2-hover@2x.png | Bin 0 -> 1885 bytes .../transcendence/2/stage-2-hover@3x.png | Bin 0 -> 3464 bytes public/icons/transcendence/2/stage-2.png | Bin 0 -> 761 bytes public/icons/transcendence/2/stage-2@2x.png | Bin 0 -> 2095 bytes public/icons/transcendence/2/stage-2@3x.png | Bin 0 -> 3878 bytes .../icons/transcendence/3/stage-3-hover.png | Bin 0 -> 720 bytes .../transcendence/3/stage-3-hover@2x.png | Bin 0 -> 1799 bytes .../transcendence/3/stage-3-hover@3x.png | Bin 0 -> 3264 bytes public/icons/transcendence/3/stage-3.png | Bin 0 -> 768 bytes public/icons/transcendence/3/stage-3@2x.png | Bin 0 -> 2100 bytes public/icons/transcendence/3/stage-3@3x.png | Bin 0 -> 3861 bytes .../icons/transcendence/4/stage-4-hover.png | Bin 0 -> 694 bytes .../transcendence/4/stage-4-hover@2x.png | Bin 0 -> 1665 bytes .../transcendence/4/stage-4-hover@3x.png | Bin 0 -> 2984 bytes public/icons/transcendence/4/stage-4.png | Bin 0 -> 770 bytes public/icons/transcendence/4/stage-4@2x.png | Bin 0 -> 2077 bytes public/icons/transcendence/4/stage-4@3x.png | Bin 0 -> 3814 bytes .../icons/transcendence/5/stage-5-hover.png | Bin 0 -> 668 bytes .../transcendence/5/stage-5-hover@2x.png | Bin 0 -> 1525 bytes .../transcendence/5/stage-5-hover@3x.png | Bin 0 -> 2689 bytes public/icons/transcendence/5/stage-5.png | Bin 0 -> 766 bytes public/icons/transcendence/5/stage-5@2x.png | Bin 0 -> 2073 bytes public/icons/transcendence/5/stage-5@3x.png | Bin 0 -> 3757 bytes .../interactive/interactive-base.png | Bin 0 -> 11369 bytes .../interactive/interactive-base@2x.png | Bin 0 -> 33820 bytes .../interactive/interactive-base@3x.png | Bin 0 -> 66076 bytes .../interactive/interactive-piece.png | Bin 0 -> 1524 bytes .../interactive/interactive-piece@2x.png | Bin 0 -> 3972 bytes .../interactive/interactive-piece@3x.png | Bin 0 -> 7585 bytes public/images/about-hero.jpg | Bin 0 -> 128185 bytes public/images/favicon.png | Bin 0 -> 4381 bytes public/locales/en/about.json | 72 ++ public/locales/en/common.json | 97 ++- public/locales/en/roadmap.json | 44 -- public/locales/en/updates.json | 87 +++ public/locales/ja/about.json | 72 ++ public/locales/ja/common.json | 97 ++- public/locales/ja/roadmap.json | 44 -- public/locales/ja/updates.json | 87 +++ public/profile/npc.png | Bin 0 -> 100862 bytes public/profile/npc@2x.png | Bin 0 -> 105329 bytes styles/globals.scss | 179 +++-- styles/keyframes.scss | 102 +++ styles/mixins.scss | 35 +- styles/themes.scss | 42 +- styles/variables.scss | 93 ++- types/AppUpdate.d.ts | 5 + types/GridCharacter.d.ts | 4 + types/GridSummon.d.ts | 1 + types/{AxSkill.d.ts => ItemSkill.d.ts} | 6 +- types/Job.d.ts | 3 + types/JobAccessory.d.ts | 11 + types/Party.d.ts | 5 + types/Summon.d.ts | 3 + types/TeamElement.d.ts | 1 + types/User.d.ts | 3 +- types/index.d.ts | 53 ++ utils/accountState.tsx | 7 +- utils/api.tsx | 23 +- utils/appState.tsx | 34 +- utils/capitalizeFirstLetter.tsx | 3 + utils/changeLanguage.tsx | 5 +- utils/elementEmoji.tsx | 14 + utils/elementalizeAetherialMastery.tsx | 54 ++ utils/emptyStates.tsx | 4 + utils/enums.tsx | 7 +- utils/extractFilters.tsx | 2 +- utils/fetchLatestVersion.tsx | 10 + utils/generateTitle.tsx | 2 +- utils/getElementForParty.tsx | 8 + utils/groupWeaponKeys.tsx | 2 +- utils/jobsWithAccessories.tsx | 4 + utils/localId.tsx | 9 + utils/mapWeaponSeries.tsx | 2 +- utils/reportError.tsx | 20 + utils/setUserToken.tsx | 19 - utils/stateValues.tsx | 2 +- utils/userToken.tsx | 40 ++ 249 files changed, 11948 insertions(+), 3724 deletions(-) create mode 100644 README.png create mode 100644 components/AboutHead/index.tsx delete mode 100644 components/AboutModal/index.scss delete mode 100644 components/AboutModal/index.tsx create mode 100644 components/AboutPage/index.scss create mode 100644 components/AboutPage/index.tsx delete mode 100644 components/ChangelogModal/index.scss delete mode 100644 components/ChangelogModal/index.tsx create mode 100644 components/ChangelogUnit/index.scss create mode 100644 components/ChangelogUnit/index.tsx create mode 100644 components/CharacterModal/index.scss create mode 100644 components/CharacterModal/index.tsx create mode 100644 components/ContextMenu/index.scss create mode 100644 components/ContextMenu/index.tsx create mode 100644 components/ContextMenuItem/index.scss create mode 100644 components/ContextMenuItem/index.tsx create mode 100644 components/DialogContent/index.scss create mode 100644 components/DialogContent/index.tsx rename components/{HeaderMenu => DropdownMenuContent}/index.scss (89%) create mode 100644 components/DropdownMenuContent/index.tsx create mode 100644 components/ErrorSection/index.scss create mode 100644 components/ErrorSection/index.tsx create mode 100644 components/ExtendedMasterySelect/index.scss create mode 100644 components/ExtendedMasterySelect/index.tsx delete mode 100644 components/HeaderMenu/index.tsx create mode 100644 components/Hovercard/index.scss create mode 100644 components/Hovercard/index.tsx create mode 100644 components/JobAccessoryItem/index.scss create mode 100644 components/JobAccessoryItem/index.tsx create mode 100644 components/JobAccessoryPopover/index.scss create mode 100644 components/JobAccessoryPopover/index.tsx create mode 100644 components/JobImage/index.scss create mode 100644 components/JobImage/index.tsx create mode 100644 components/Layout/index.scss create mode 100644 components/NewHead/index.tsx create mode 100644 components/PartyHead/index.tsx create mode 100644 components/PopoverContent/index.scss create mode 100644 components/PopoverContent/index.tsx create mode 100644 components/ProfileHead/index.tsx create mode 100644 components/RingSelect/index.scss create mode 100644 components/RingSelect/index.tsx delete mode 100644 components/RoadmapModal/index.tsx rename components/{RoadmapModal => RoadmapPage}/index.scss (69%) create mode 100644 components/RoadmapPage/index.tsx create mode 100644 components/SavedHead/index.tsx create mode 100644 components/SelectWithInput/index.scss create mode 100644 components/SelectWithInput/index.tsx create mode 100644 components/TeamsHead/index.tsx create mode 100644 components/Toast/index.scss create mode 100644 components/Toast/index.tsx create mode 100644 components/Tooltip/index.scss create mode 100644 components/Tooltip/index.tsx create mode 100644 components/TranscendenceFragment/index.scss create mode 100644 components/TranscendenceFragment/index.tsx create mode 100644 components/TranscendencePopover/index.scss create mode 100644 components/TranscendencePopover/index.tsx create mode 100644 components/TranscendenceStar/index.scss create mode 100644 components/TranscendenceStar/index.tsx create mode 100644 components/UpdateToast/index.scss create mode 100644 components/UpdateToast/index.tsx create mode 100644 components/UpdatesPage/index.scss create mode 100644 components/UpdatesPage/index.tsx rename {utils => data}/awakening.tsx (50%) rename utils/axData.tsx => data/ax.tsx (88%) rename utils/Element.tsx => data/elements.tsx (80%) rename {utils => data}/jobGroups.tsx (100%) create mode 100644 data/overMastery.tsx rename {utils => data}/raidGroups.tsx (100%) rename {utils => data}/skillGroups.tsx (100%) rename {utils => data}/weaponKeyGroups.tsx (100%) rename {utils => data}/weaponSeries.tsx (100%) create mode 100644 hooks/useLockedBody.tsx create mode 100644 pages/about.tsx delete mode 100644 public/favicon.ico create mode 100644 public/icons/Manatura.svg create mode 100644 public/icons/Remix.svg create mode 100644 public/icons/Shield.svg create mode 100644 public/icons/perpetuity/empty.svg create mode 100644 public/icons/perpetuity/filled.svg create mode 100644 public/icons/transcendence/0/stage-0.png create mode 100644 public/icons/transcendence/0/stage-0@2x.png create mode 100644 public/icons/transcendence/0/stage-0@3x.png create mode 100644 public/icons/transcendence/1/stage-1-hover.png create mode 100644 public/icons/transcendence/1/stage-1-hover@2x.png create mode 100644 public/icons/transcendence/1/stage-1-hover@3x.png create mode 100644 public/icons/transcendence/1/stage-1.png create mode 100644 public/icons/transcendence/1/stage-1@2x.png create mode 100644 public/icons/transcendence/1/stage-1@3x.png create mode 100644 public/icons/transcendence/2/stage-2-hover.png create mode 100644 public/icons/transcendence/2/stage-2-hover@2x.png create mode 100644 public/icons/transcendence/2/stage-2-hover@3x.png create mode 100644 public/icons/transcendence/2/stage-2.png create mode 100644 public/icons/transcendence/2/stage-2@2x.png create mode 100644 public/icons/transcendence/2/stage-2@3x.png create mode 100644 public/icons/transcendence/3/stage-3-hover.png create mode 100644 public/icons/transcendence/3/stage-3-hover@2x.png create mode 100644 public/icons/transcendence/3/stage-3-hover@3x.png create mode 100644 public/icons/transcendence/3/stage-3.png create mode 100644 public/icons/transcendence/3/stage-3@2x.png create mode 100644 public/icons/transcendence/3/stage-3@3x.png create mode 100644 public/icons/transcendence/4/stage-4-hover.png create mode 100644 public/icons/transcendence/4/stage-4-hover@2x.png create mode 100644 public/icons/transcendence/4/stage-4-hover@3x.png create mode 100644 public/icons/transcendence/4/stage-4.png create mode 100644 public/icons/transcendence/4/stage-4@2x.png create mode 100644 public/icons/transcendence/4/stage-4@3x.png create mode 100644 public/icons/transcendence/5/stage-5-hover.png create mode 100644 public/icons/transcendence/5/stage-5-hover@2x.png create mode 100644 public/icons/transcendence/5/stage-5-hover@3x.png create mode 100644 public/icons/transcendence/5/stage-5.png create mode 100644 public/icons/transcendence/5/stage-5@2x.png create mode 100644 public/icons/transcendence/5/stage-5@3x.png create mode 100644 public/icons/transcendence/interactive/interactive-base.png create mode 100644 public/icons/transcendence/interactive/interactive-base@2x.png create mode 100644 public/icons/transcendence/interactive/interactive-base@3x.png create mode 100644 public/icons/transcendence/interactive/interactive-piece.png create mode 100644 public/icons/transcendence/interactive/interactive-piece@2x.png create mode 100644 public/icons/transcendence/interactive/interactive-piece@3x.png create mode 100644 public/images/about-hero.jpg create mode 100644 public/images/favicon.png create mode 100644 public/locales/en/about.json delete mode 100644 public/locales/en/roadmap.json create mode 100644 public/locales/en/updates.json create mode 100644 public/locales/ja/about.json delete mode 100644 public/locales/ja/roadmap.json create mode 100644 public/locales/ja/updates.json create mode 100644 public/profile/npc.png create mode 100644 public/profile/npc@2x.png create mode 100644 styles/keyframes.scss create mode 100644 types/AppUpdate.d.ts rename types/{AxSkill.d.ts => ItemSkill.d.ts} (63%) create mode 100644 types/JobAccessory.d.ts create mode 100644 utils/capitalizeFirstLetter.tsx create mode 100644 utils/elementEmoji.tsx create mode 100644 utils/elementalizeAetherialMastery.tsx create mode 100644 utils/fetchLatestVersion.tsx create mode 100644 utils/getElementForParty.tsx create mode 100644 utils/jobsWithAccessories.tsx create mode 100644 utils/localId.tsx create mode 100644 utils/reportError.tsx delete mode 100644 utils/setUserToken.tsx create mode 100644 utils/userToken.tsx diff --git a/.env.sample b/.env.sample index 5cdcdb82..e6f68af6 100644 --- a/.env.sample +++ b/.env.sample @@ -5,4 +5,8 @@ NODE_PATH='src/' # Don't add a trailing slash to these URLs. REACT_APP_SIERO_API_URL='' REACT_APP_SIERO_OAUTH_URL='' -REACT_APP_SIERO_IMG_URL='' \ No newline at end of file +REACT_APP_SIERO_IMG_URL='' + +# You will have to use a Google account to acquire a Youtube API key +# or embeds will not work! +NEXT_PUBLIC_YOUTUBE_API_KEY='' diff --git a/.gitignore b/.gitignore index 29947561..663dcea0 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,9 @@ public/images/chara* public/images/job* public/images/awakening* public/images/ax* +public/images/accessory* +public/images/mastery* +public/images/updates* # Typescript v1 declaration files typings/ diff --git a/README.md b/README.md index c87e0421..038da0cb 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,42 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +![Header image for hensei-web](README.png) + +# hensei-web + +**hensei-web** is the frontend for [granblue.team](https://app.granblue.team/), an app for saving and sharing teams for [Granblue Fantasy](https://game.granbluefantasy.jp). ## Getting Started -First, run the development server: +First, you have to set up your environment file. You should start with [.env.sample](https://github.com/jedmund/hensei-web/blob/staging/.env.sample), but here are some gotchas: + +#### App URLs + +Don't add a trailing slash to these URLs! +The API will run on port 3000 by default, but make sure to change these to match your instance of the API. + +``` +NEXT_PUBLIC_SIERO_API_URL='http://127.0.0.1:3000/api/v1' +NEXT_PUBLIC_SIERO_OAUTH_URL='http://127.0.0.1:3000/oauth' +``` + +#### Asset URLs + +Next.js serves all assets out of the /public directory. In development we utilize this for all assets, but in production, you will want to host these images on a cloud storage provider like Amazon S3. Once you have that set up and you're running in a production environment, change this to the full bucket URL. + +``` +NEXT_PUBLIC_SIERO_IMG_URL='/images' +``` + +#### Dependencies + +Once your `.env` is all set up, install all dependencies: + +```bash +npm install +# or +yarn install +``` + +Then, run the development server with: ```bash npm run dev @@ -10,25 +44,28 @@ npm run dev yarn dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## Assets -You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. +The [hensei-api](https://github.com/jedmund/hensei-api) repository has tasks that will help you get assets, although some were crafted or renamed by hand. The front-end expects this folder structure inside of the `images` folder: -[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. - -The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +``` +root +├─ accessory-grid/ +├─ accessory-square/ +├─ awakening/ +├─ ax/ +├─ chara-main/ +├─ chara-grid/ +├─ chara-square/ +├─ jobs/ +├─ job-icons/ +├─ job-skills/ +├─ mastery/ +├─ summon-main/ +├─ summon-grid/ +├─ summon-square/ +├─ updates/ +├─ weapon-main/ +├─ weapon-grid/ +├─ weapon-square/ +``` diff --git a/README.png b/README.png new file mode 100644 index 0000000000000000000000000000000000000000..eff6dd5664c95502ec32293d84601a8ce4c6ce38 GIT binary patch literal 4035 zcmeHKdo-XJ{_1)k7dF&nU zr!*?K=z z(yTQMeiryz*jqpl3BN?*y%2)nCu|Q`xL{#p992hM_RHwr;_JHSJB!faR0d=YhU^H&#H9`MqQ?+GZKZ8M6Ir@c()>9xFH%wzs$QCnudAW@j^ktMo0A znn{hJCI%_*^3IZX##A&tW}se0p4EnGKeqTuG!_*!g;5NRjEsE9ZoeGQ%*?zm3irH& zzgqoc4sCUfkG97^%P(0%boHyjBb4m=i$b}B+vTogSX=i1w)O^ial4CJDXqD zNVFzH(-!k^VWd+Ebldf9$`7^hx%Y|g2M!F+Rcwb4Sj&pjrTy7v0LvlAX^75jF>!rN= z!l8dM9#PJFFcoe(ku}BPa5g@|kg}#)TU(#@s?>SM&vbNlb~43psPGea4nE$Q<#26g z;YsA$vukoK9JU{?Afme$&Ch zp)vP!e^t8m+>T=7yg`*85|b4Q-m}^0JuCXY3O~{xSj&NwEf0!BxIHE&TZ$70jWd|0 z5t+EqG^M(^#d^^ywEn}cuCDn$B-=TDKcksYnnpKkQamNQvBEsx!O@X{h?0;*+_pC% z>8!y*yH8DhwKB5-*bB4A3g%?qc}Y9bi-93;8JT)je(Gn zc;DcHp`Gw(E-N2w^AGx})~$Xqc$v`|9LwqA`wtySXXS#d6lju_n!Xl+n2B}a&iL_L zX5$V4`Mxx%n}^{H(bF;+3`Tc#NCSdcBK2p{XjT`JIj(!jYI3*h&tw z&XsMH)x88&`^@2Z$jM_6ZzRGxkapZ!_k6XSyORR@jVa9b_x>{Ts|!xqD;;w)-K?7j zZE59g6cy9ybY@0``@Pa7@_qO03(_beXPA6y#D#!b=Wj3wyjQ`)HRNBYMm3{e;djBK zrNDSrCa8YNy|rs=_70FL^w~^jp;NA=^jK#FTt)%?VzGE*VDdWPj>cGW;J3cAyAj!Vt=FpO zb)UFZySUDuL+qt9Db(ZymQ|~vE(VBZsxt7^AER@#TY?S(v+b$-^L;it#z-^i&=^1@ zS31|ya%A=OzP^_qEa%gC^6^3;`oLQ(DsqO4@w#?P8MSARp21R3R?Ws1TDCV~5Th-k zm-CVzZWSZb4mbMm-il06PGb#HWZF8%1gpun>HwlXq|dUv6|{1&mf?|+hH*I)$|Lg% zKIwPB#>%Pqcu}b*(N2{&C@r%|P*ejB<3l>(PsJ=9Ng!Jfk+Dt(Y+x=!Kv%oEy6#)5 zvn~?=|7La!z-kY=NACV)_g+__FOgdh3coVp@g&*vG=CQ@q^m!8#XLQvg3iS`&Yem8 z`g%)TM1+Asyd8=s@-h7>0N4-gh1Y(SoYS2nQ+0V0Sds_tnM9tG6d+V$X zS2OO#wEERm0rB$=7hJYD8dlJ+=4@u!^)m*gO+a|dw>&8Z;*>yJBofa|F#YdUoGuFw z3L;e*T#WtBhsttzj&sneCzWGA--2rPANBHdqz_5$oTm9mDx$s zzX;wUzSC%Mr|0*P%jM)_QMD30=B_0EK8P%=BA8aATm^5)-B@ zd-np3wG0Uk=8wAIkH%0>J;O0A zhF~v_39cRIcIOWu+o?*zTbGK#1Tx$IIOZ}ACmNpt{#C>=cifoL?S=X{T>k0V#AS8X zQ{Z)|IEkHhbaqkO3>%9WTT-Y~QZ=pE(K|Xi%9((BkL6C}NSu&soR@AO(_FNygh1cp zGQNPz4QG+rIrgfkUWufp5$)ySG1Y7`M`=J?_q2wqL(yDf16WUXd#1(I(miCf1!LN` zTrk?%{)6{~dJv~$HTh-fq%3~bG5^D^MaR&@b{{Tp^K0L2IYO97z%pY91cGxMvBn6y ze{&pKR_WoZTGz{_vJ43fN-e7aebe~uEL%K!g_%fuG_#H zaz<^J4(W5J7rd5ABMOf^mjYImKN(I|`N~_v^KmBSYIi*8cn<4nXJ0IcTi=Gt+iGx{ z5@f|tZ@uDlZL97}vfb>Y7CUQ(RZQ7LY zQ;n)^Iq#xI8y+5hivOotyZG)*yPujw?{h?iL`Hh5(F|o2=DZI&+Q3@V9G)^gUxkK; zyPqp#KgB09`wV|Yh6bJUAtaUlzWmZ70#QxXgM>lB)JS{QHjmr%hgn&_9H(tK`IU=v zmHQ4PzWi+#u`p+UQR}m=?(Wq7V05hE?wbSxLH3)#3)B;}Z(qPb%WLB30~$W_$oK5v zItyL4McPwpYVPMIFjBgFRG|)FR^aUuF4yY69!CFPFTdd^h~&$%KRj3&05AaAS~(mj J*?;Q#-vL%?WDx)W literal 0 HcmV?d00001 diff --git a/components/AboutHead/index.tsx b/components/AboutHead/index.tsx new file mode 100644 index 00000000..0aa45ec7 --- /dev/null +++ b/components/AboutHead/index.tsx @@ -0,0 +1,53 @@ +import React, { useEffect, useState } from 'react' +import Head from 'next/head' +import { useTranslation } from 'next-i18next' + +interface Props { + page: string +} + +const AboutHead = ({ page }: Props) => { + // Import translations + const { t } = useTranslation('common') + + // State + const [currentPage, setCurrentPage] = useState('about') + + // Hooks + useEffect(() => { + setCurrentPage(page) + }, [page]) + + return ( + + {/* HTML */} + {t(`page.titles.${currentPage}`)} + + + + + {/* OpenGraph */} + + + + + + {/* Twitter */} + + + + + + ) +} + +export default AboutHead diff --git a/components/AboutModal/index.scss b/components/AboutModal/index.scss deleted file mode 100644 index a13fea89..00000000 --- a/components/AboutModal/index.scss +++ /dev/null @@ -1,114 +0,0 @@ -.DialogWrapper { - position: fixed; - background: none; - border: 0; - inset: 0; - top: 0; - display: grid; - place-items: center; - min-height: 100vh; - min-width: 100vw; - overflow-y: auto; - color: inherit; -} - -.About.Dialog { - top: 0; - animation: none; - transform: translate(-50%, 0); - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - margin-top: $unit-10x; - - @include breakpoint(phone) { - border-radius: 0; - transform: none; - margin: 0; - } - - section { - margin-bottom: $unit; - - h2 { - margin-bottom: $unit * 3; - } - } - - .DialogDescription { - font-size: $font-regular; - line-height: 1.24; - margin-bottom: $unit; - - &:last-of-type { - margin-bottom: 0; - } - } - - .Links { - display: grid; - gap: $unit; - margin: $unit-2x 0; - } - - div.LinkItem { - margin-top: $unit-2x; - } - - .LinkItem { - $diameter: $unit-6x; - border: 1px solid var(--link-item-bg); - border-radius: $card-corner; - - &:hover { - background-color: var(--link-item-bg); - - svg { - fill: var(--link-item-image-color-hover); - } - } - - a { - display: flex; - padding: $unit-2x; - - &:hover { - text-decoration: none; - } - - .Left { - align-items: center; - display: flex; - gap: $unit-2x; - flex-grow: 1; - } - - svg { - fill: var(--link-item-image-color); - width: $diameter; - height: auto; - - &.ShareIcon { - width: $unit-4x; - } - } - } - - h3 { - font-weight: $bold; - } - } -} - -.ScrollingOverlay { - background: rgba(0 0 0 / 0.5); - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: grid; - place-items: center; - overflow-y: auto; - z-index: 40; - padding-top: 10%; -} diff --git a/components/AboutModal/index.tsx b/components/AboutModal/index.tsx deleted file mode 100644 index e7842c29..00000000 --- a/components/AboutModal/index.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import React from 'react' -import Link from 'next/link' -import { useTranslation } from 'next-i18next' -import * as Dialog from '@radix-ui/react-dialog' - -import CrossIcon from '~public/icons/Cross.svg' -import ShareIcon from '~public/icons/Share.svg' -import DiscordIcon from '~public/icons/discord.svg' -import GithubIcon from '~public/icons/github.svg' - -import './index.scss' - -const AboutModal = () => { - const { t } = useTranslation('common') - - return ( - - -
  • - {t('modals.about.title')} -
  • -
    - - -
    - event.preventDefault()} - > -
    - - {t('menu.about')} - - - - - - -
    - -
    - - Granblue.team is a tool to save and share team comps for{' '} - - Granblue Fantasy. - - - - Start adding to a team and a URL will be created for you to - share wherever you like, no account needed. - - - However, if you do make an account, you can save any teams you - find for future reference and keep all of your teams together - in one place. - -
    - -
    - Feedback - - This is an evolving project so feedback and suggestions are - greatly appreciated! - - - If you have a feature request, would like to report a bug, or - are enjoying the tool and want to say thanks, come hang out in - Discord! - - -
    - -
    - Credits - - Granblue.team was built by{' '} - - @jedmund - {' '} - with a lot of help from{' '} - - @lalalalinna - {' '} - and{' '} - - @tarngerine - - . - - - Many thanks also go to Disinfect, Slipper, Jif, Bless, - 9highwind, and everyone else in{' '} - - Fireplace - {' '} - that helped with bug testing and feature requests. (P.S. - We're recruiting!) And yoey, but he won't join our - crew. - -
    - -
    - - Contributing - - - This app is open source and licensed under{' '} - - GNU AGPLv3 - - . Plainly, that means you can download the source, modify it, - and redistribute it if you attribute this project, use the - same license, and keep it open source. You can contribute on - Github. - - -
    -
    -
    -
    -
    -
    - ) -} - -export default AboutModal diff --git a/components/AboutPage/index.scss b/components/AboutPage/index.scss new file mode 100644 index 00000000..1dad165b --- /dev/null +++ b/components/AboutPage/index.scss @@ -0,0 +1,76 @@ +.About.PageContent { + $width: 520px; + padding-bottom: $unit-12x; + + section { + display: flex; + flex-direction: column; + position: relative; + gap: $unit-2x; + z-index: 5; + + .Hero { + position: absolute; + width: 40vw; + height: 80vh; + right: -18vw; + top: $unit-4x * -1; + z-index: 1; + background-image: var(--hero-gradient), url('/images/about-hero.jpg'); + + @include breakpoint(tablet) { + right: -14vw; + width: 60vw; + } + + @include breakpoint(phone) { + right: $unit-2x * -1; + width: 80vw; + + &::before { + content: ' '; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--hero-gradient-overlay); + z-index: 3; + } + } + } + + p { + font-size: $font-medium; + max-width: $width; + line-height: 1.35; + z-index: 2; + } + + h2 { + font-weight: $bold; + font-size: $font-medium; + margin: 0; + max-width: $width; + z-index: 2; + } + } + .Links { + display: grid; + gap: $unit; + margin: $unit-2x 0; + } + + div.LinkItem { + margin-top: $unit-2x; + } + + .LinkItem { + max-width: calc($width / 3 * 2); + + @include breakpoint(phone) { + max-width: inherit; + width: 100%; + } + } +} diff --git a/components/AboutPage/index.tsx b/components/AboutPage/index.tsx new file mode 100644 index 00000000..14307f98 --- /dev/null +++ b/components/AboutPage/index.tsx @@ -0,0 +1,175 @@ +import React from 'react' +import Link from 'next/link' +import { Trans, useTranslation } from 'next-i18next' + +import ShareIcon from '~public/icons/Share.svg' +import DiscordIcon from '~public/icons/discord.svg' +import GithubIcon from '~public/icons/github.svg' + +import './index.scss' + +interface Props {} + +const AboutPage: React.FC = (props: Props) => { + const { t: common } = useTranslation('common') + const { t: about } = useTranslation('about') + + return ( +
    +

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

    +
    +

    + + Granblue.team is a tool to save and share team compositions for{' '} + + Granblue Fantasy + + , a social RPG from Cygames. + +

    +

    {about('about.explanation.0')}

    +

    {about('about.explanation.1')}

    +
    +
    + +
    +

    {about('about.feedback.title')}

    +

    {about('about.feedback.explanation')}

    +

    {about('about.feedback.solicit')}

    + +
    + +
    +

    {about('about.credits.title')}

    +

    + + Granblue.team was built and is maintained by{' '} + + @jedmund + + . + +

    +

    + + Many thanks to{' '} + + @lalalalinna + {' '} + and{' '} + + @tarngerine + + , who both provided a lot of help and advice as I was ramping up. + +

    +

    + + Many thanks also go to everyone in{' '} + + Fireplace + {' '} + and the granblue-tools Discord for all of their help with with bug + testing, feature requests, and moral support. (P.S. We're + recruiting!) + +

    +
    + +
    +

    {about('about.contributing.title')}

    + +

    {about('about.contributing.explanation')}

    + +
    +
    +

    {about('about.license.title')}

    +

    + + This app is licensed under{' '} + + GNU AGPLv3 + + . + +

    +

    {about('about.license.explanation')}

    +
    +
    +

    {about('about.copyright.title')}

    +

    {about('about.copyright.explanation')}

    +
    +
    + ) +} + +export default AboutPage diff --git a/components/AccountModal/index.scss b/components/AccountModal/index.scss index d77eed85..47a61c56 100644 --- a/components/AccountModal/index.scss +++ b/components/AccountModal/index.scss @@ -1,13 +1,19 @@ -.Account.Dialog { +.Account.DialogContent { display: flex; flex-direction: column; - gap: $unit * 2; + gap: $unit-2x; width: $unit * 64; + overflow-y: hidden; - form { + .Fields { display: flex; flex-direction: column; - gap: $unit * 2; + gap: $unit-2x; + padding: 0 $unit-4x; + + @include breakpoint(phone) { + gap: $unit-4x; + } } .DialogDescription { diff --git a/components/AccountModal/index.tsx b/components/AccountModal/index.tsx index c5da21ef..72518faa 100644 --- a/components/AccountModal/index.tsx +++ b/components/AccountModal/index.tsx @@ -2,14 +2,15 @@ import React, { useEffect, useState } from 'react' import { getCookie, setCookie } from 'cookies-next' import { useRouter } from 'next/router' import { useTranslation } from 'next-i18next' +import { useTheme } from 'next-themes' import { Dialog, DialogClose, - DialogContent, DialogTitle, DialogTrigger, } from '~components/Dialog' +import DialogContent from '~components/DialogContent' import Button from '~components/Button' import SelectItem from '~components/SelectItem' import PictureSelectItem from '~components/PictureSelectItem' @@ -23,7 +24,6 @@ import { pictureData } from '~utils/pictureData' import CrossIcon from '~public/icons/Cross.svg' import './index.scss' -import { useTheme } from 'next-themes' type StateVariables = { [key: string]: boolean @@ -34,288 +34,312 @@ type StateVariables = { } interface Props { + open: boolean username?: string picture?: string gender?: number language?: string theme?: string private?: boolean + onOpenChange?: (open: boolean) => void } -const AccountModal = (props: Props) => { - // Localization - const { t } = useTranslation('common') - const router = useRouter() - const locale = - router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' +const AccountModal = React.forwardRef( + function AccountModal(props: Props, forwardedRef) { + // Localization + const { t } = useTranslation('common') + const router = useRouter() + const locale = + router.locale && ['en', 'ja'].includes(router.locale) + ? router.locale + : 'en' - // useEffect only runs on the client, so now we can safely show the UI - const [mounted, setMounted] = useState(false) - const { theme: appTheme, setTheme: setAppTheme } = useTheme() + // useEffect only runs on the client, so now we can safely show the UI + const [mounted, setMounted] = useState(false) + const { theme: appTheme, setTheme: setAppTheme } = useTheme() - // Cookies - const accountCookie = getCookie('account') - const userCookie = getCookie('user') + // Cookies + const accountCookie = getCookie('account') + const userCookie = getCookie('user') - const cookieData = { - account: accountCookie ? JSON.parse(accountCookie as string) : undefined, - user: userCookie ? JSON.parse(userCookie as string) : undefined, - } - - // UI State - const [open, setOpen] = useState(false) - const [selectOpenState, setSelectOpenState] = useState({ - picture: false, - gender: false, - language: false, - theme: false, - }) - - // Values - const [username, setUsername] = useState(props.username || '') - const [picture, setPicture] = useState(props.picture || '') - const [language, setLanguage] = useState(props.language || '') - const [gender, setGender] = useState(props.gender || 0) - const [theme, setTheme] = useState(props.theme || 'system') - // const [privateProfile, setPrivateProfile] = useState(false) - - // Setup - const [pictureOpen, setPictureOpen] = useState(false) - const [genderOpen, setGenderOpen] = useState(false) - const [languageOpen, setLanguageOpen] = useState(false) - const [themeOpen, setThemeOpen] = useState(false) - - // UI management - function openChange(open: boolean) { - setOpen(open) - } - - function openSelect(name: 'picture' | 'gender' | 'language' | 'theme') { - setPictureOpen(name === 'picture' ? !pictureOpen : false) - setGenderOpen(name === 'gender' ? !genderOpen : false) - setLanguageOpen(name === 'language' ? !languageOpen : false) - setThemeOpen(name === 'theme' ? !themeOpen : false) - } - - // Event handlers - function handlePictureChange(value: string) { - setPicture(value) - } - - function handleLanguageChange(value: string) { - setLanguage(value) - } - - function handleGenderChange(value: string) { - setGender(parseInt(value)) - } - - function handleThemeChange(value: string) { - setTheme(value) - setAppTheme(value) - } - - function onEscapeKeyDown(event: KeyboardEvent) { - if (pictureOpen || genderOpen || languageOpen || themeOpen) { - return event.preventDefault() - } else { - setOpen(false) - } - } - - // API calls - function update(event: React.FormEvent) { - event.preventDefault() - - const object = { - user: { - picture: picture, - element: pictureData.find((i) => i.filename === picture)?.element, - language: language, - gender: gender, - theme: theme, - // private: privateProfile, - }, + const cookieData = { + account: accountCookie ? JSON.parse(accountCookie as string) : undefined, + user: userCookie ? JSON.parse(userCookie as string) : undefined, } - if (accountState.account.user) { - api.endpoints.users - .update(accountState.account.user?.id, object) - .then((response) => { - const user = response.data - - const cookieObj = { - picture: user.avatar.picture, - element: user.avatar.element, - gender: user.gender, - language: user.language, - theme: user.theme, - } - - setCookie('user', cookieObj, { path: '/' }) - - accountState.account.user = { - id: user.id, - username: user.username, - picture: user.avatar.picture, - element: user.avatar.element, - language: user.language, - theme: user.theme, - gender: user.gender, - } - - setOpen(false) - changeLanguage(router, user.language) - }) - } - } - - // Views - const pictureOptions = pictureData - .sort((a, b) => (a.name.en > b.name.en ? 1 : -1)) - .map((item, i) => { - return ( - - {item.name[locale]} - - ) + // UI State + const [open, setOpen] = useState(false) + const [selectOpenState, setSelectOpenState] = useState({ + picture: false, + gender: false, + language: false, + theme: false, }) - const pictureField = () => ( - openSelect('picture')} - onChange={handlePictureChange} - onClose={() => setPictureOpen(false)} - imageAlt={t('modals.settings.labels.image_alt')} - imageClass={pictureData.find((i) => i.filename === picture)?.element} - imageSrc={[`/profile/${picture}.png`, `/profile/${picture}@2x.png 2x`]} - value={picture} - > - {pictureOptions} - - ) + // Values + const [username, setUsername] = useState(props.username || '') + const [picture, setPicture] = useState(props.picture || '') + const [language, setLanguage] = useState(props.language || '') + const [gender, setGender] = useState(props.gender || 0) + const [theme, setTheme] = useState(props.theme || 'system') + // const [privateProfile, setPrivateProfile] = useState(false) - const genderField = () => ( - openSelect('gender')} - onChange={handleGenderChange} - onClose={() => setGenderOpen(false)} - value={`${gender}`} - > - - {t('modals.settings.gender.gran')} - - - {t('modals.settings.gender.djeeta')} - - - ) + // Setup + const [pictureOpen, setPictureOpen] = useState(false) + const [genderOpen, setGenderOpen] = useState(false) + const [languageOpen, setLanguageOpen] = useState(false) + const [themeOpen, setThemeOpen] = useState(false) - const languageField = () => ( - openSelect('language')} - onChange={handleLanguageChange} - onClose={() => setLanguageOpen(false)} - value={language} - > - - {t('modals.settings.language.english')} - - - {t('modals.settings.language.japanese')} - - - ) + // Refs + const headerRef = React.createRef() + const footerRef = React.createRef() - const themeField = () => ( - openSelect('theme')} - onChange={handleThemeChange} - onClose={() => setThemeOpen(false)} - value={theme} - > - - {t('modals.settings.theme.system')} - - - {t('modals.settings.theme.light')} - - - {t('modals.settings.theme.dark')} - - - ) + useEffect(() => { + setOpen(props.open) + }, [props.open]) - useEffect(() => { - setMounted(true) - }, []) + // UI management + function openChange(open: boolean) { + if (props.onOpenChange) props.onOpenChange(open) + setOpen(open) + } - if (!mounted) { - return null - } + function openSelect(name: 'picture' | 'gender' | 'language' | 'theme') { + setPictureOpen(name === 'picture' ? !pictureOpen : false) + setGenderOpen(name === 'gender' ? !genderOpen : false) + setLanguageOpen(name === 'language' ? !languageOpen : false) + setThemeOpen(name === 'theme' ? !themeOpen : false) + } - return ( - - -
  • - {t('menu.settings')} -
  • -
    - {}} - onEscapeKeyDown={onEscapeKeyDown} + // Event handlers + function handlePictureChange(value: string) { + setPicture(value) + } + + function handleLanguageChange(value: string) { + setLanguage(value) + } + + function handleGenderChange(value: string) { + setGender(parseInt(value)) + } + + function handleThemeChange(value: string) { + setTheme(value) + setAppTheme(value) + } + + function onEscapeKeyDown(event: KeyboardEvent) { + if (pictureOpen || genderOpen || languageOpen || themeOpen) { + return event.preventDefault() + } else { + setOpen(false) + } + } + + // API calls + function update(event: React.FormEvent) { + event.preventDefault() + + const object = { + user: { + picture: picture, + element: pictureData.find((i) => i.filename === picture)?.element, + language: language, + gender: gender, + theme: theme, + // private: privateProfile, + }, + } + + if (accountState.account.user) { + api.endpoints.users + .update(accountState.account.user?.id, object) + .then((response) => { + const user = response.data + + const cookieObj = { + avatar: { + picture: user.avatar.picture, + element: user.avatar.element, + }, + gender: user.gender, + language: user.language, + theme: user.theme, + } + + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + 60) + setCookie('user', cookieObj, { path: '/', expires: expiresAt }) + + accountState.account.user = { + id: user.id, + username: user.username, + granblueId: '', + avatar: { + picture: user.avatar.picture, + element: user.avatar.element, + }, + language: user.language, + theme: user.theme, + gender: user.gender, + } + + setOpen(false) + if (props.onOpenChange) props.onOpenChange(false) + changeLanguage(router, user.language) + }) + } + } + + // Views + const pictureOptions = pictureData + .sort((a, b) => (a.name.en > b.name.en ? 1 : -1)) + .map((item, i) => { + return ( + + {item.name[locale]} + + ) + }) + + const pictureField = () => ( + openSelect('picture')} + onChange={handlePictureChange} + onClose={() => setPictureOpen(false)} + imageAlt={t('modals.settings.labels.image_alt')} + imageClass={pictureData.find((i) => i.filename === picture)?.element} + imageSrc={[`/profile/${picture}.png`, `/profile/${picture}@2x.png 2x`]} + value={picture} > -
    -
    - - {t('modals.settings.title')} - - @{username} -
    - - - - - -
    + {pictureOptions} +
    + ) -
    - {pictureField()} - {genderField()} - {languageField()} - {themeField()} -
    - ) -} + const genderField = () => ( + openSelect('gender')} + onChange={handleGenderChange} + onClose={() => setGenderOpen(false)} + value={`${gender}`} + > + + {t('modals.settings.gender.gran')} + + + {t('modals.settings.gender.djeeta')} + + + ) + + const languageField = () => ( + openSelect('language')} + onChange={handleLanguageChange} + onClose={() => setLanguageOpen(false)} + value={language} + > + + {t('modals.settings.language.english')} + + + {t('modals.settings.language.japanese')} + + + ) + + const themeField = () => ( + openSelect('theme')} + onChange={handleThemeChange} + onClose={() => setThemeOpen(false)} + value={theme} + > + + {t('modals.settings.theme.system')} + + + {t('modals.settings.theme.light')} + + + {t('modals.settings.theme.dark')} + + + ) + + useEffect(() => { + setMounted(true) + }, []) + + if (!mounted) { + return null + } + + return ( + + {}} + onEscapeKeyDown={onEscapeKeyDown} + > +
    +
    + + {t('modals.settings.title')} + + @{username} +
    + + + + + +
    + +
    +
    + {pictureField()} + {genderField()} + {languageField()} + {themeField()} +
    +
    +
    +
    +
    +
    + ) + } +) export default AccountModal diff --git a/components/Alert/index.scss b/components/Alert/index.scss index d8fdc1ea..aba79f93 100644 --- a/components/Alert/index.scss +++ b/components/Alert/index.scss @@ -2,51 +2,43 @@ align-items: center; display: flex; justify-content: center; - position: absolute; + position: fixed; height: 100vh; width: 100vw; top: 0; left: 0; - z-index: 21; + z-index: 31; } .Alert { - background: $grey-100; + animation: $duration-modal-open cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal none + running openModalDesktop; + background: var(--dialog-bg); border-radius: $unit; display: flex; flex-direction: column; - gap: $unit; - min-width: $unit * 20; - max-width: $unit * 40; + gap: $unit-2x; + min-width: 20vw; + max-width: 30vw; padding: $unit * 4; + @include breakpoint(phone) { + max-width: inherit; + width: 60vw; + } + .description { font-size: $font-regular; - line-height: 1.26; + line-height: 1.4; + + strong { + font-weight: $bold; + } } .buttons { + display: flex; align-self: flex-end; - } - - .Button { - font-size: $font-regular; - padding: ($unit * 1.5) ($unit * 2); - margin-top: $unit * 2; - - &.btn-disabled { - background: $grey-90; - color: $grey-70; - cursor: not-allowed; - } - - &:not(.btn-disabled) { - background: $grey-90; - color: $grey-50; - - &:hover { - background: $grey-80; - } - } + gap: $unit; } } diff --git a/components/Alert/index.tsx b/components/Alert/index.tsx index 21a1a1a7..8315495d 100644 --- a/components/Alert/index.tsx +++ b/components/Alert/index.tsx @@ -3,12 +3,13 @@ import * as AlertDialog from '@radix-ui/react-alert-dialog' import './index.scss' import Button from '~components/Button' +import Overlay from '~components/Overlay' // Props interface Props { open: boolean title?: string - message: string + message: string | React.ReactNode primaryAction?: () => void primaryActionText?: string cancelAction: () => void @@ -22,20 +23,29 @@ const Alert = (props: Props) => {
    - {props.title ? Error : ''} + {props.title ? ( + {props.title} + ) : ( + '' + )} {props.message}
    + ) diff --git a/components/AwakeningSelect/index.tsx b/components/AwakeningSelect/index.tsx index 2debb197..ec90f169 100644 --- a/components/AwakeningSelect/index.tsx +++ b/components/AwakeningSelect/index.tsx @@ -1,198 +1,94 @@ -import React, { ForwardedRef, useEffect, useState } from 'react' -import { useRouter } from 'next/router' -import { useTranslation } from 'next-i18next' +import React, { useEffect, useState } from 'react' +import cloneDeep from 'lodash.clonedeep' -import Input from '~components/LabelledInput' -import Select from '~components/Select' -import SelectItem from '~components/SelectItem' - -import classNames from 'classnames' - -import { weaponAwakening, characterAwakening } from '~utils/awakening' -import type { Awakening } from '~utils/awakening' +import SelectWithInput from '~components/SelectWithInput' +import { weaponAwakening, characterAwakening } from '~data/awakening' import './index.scss' interface Props { object: 'character' | 'weapon' - awakeningType?: number - awakeningLevel?: number - onOpenChange: (open: boolean) => void + type?: number + level?: number + onOpenChange?: (open: boolean) => void sendValidity: (isValid: boolean) => void sendValues: (type: number, level: number) => void } const AwakeningSelect = (props: Props) => { - const router = useRouter() - const locale = - router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' - const { t } = useTranslation('common') - - const [open, setOpen] = useState(false) - - // Refs - const awakeningLevelInput = React.createRef() - - // States - const [awakeningType, setAwakeningType] = useState(-1) + // Data states + const [awakeningType, setAwakeningType] = useState( + props.object === 'weapon' ? 0 : 1 + ) const [awakeningLevel, setAwakeningLevel] = useState(1) - const [maxValue, setMaxValue] = useState(1) + // Data + const chooseDataset = () => { + let list: ItemSkill[] = [] - const [error, setError] = useState('') + switch (props.object) { + case 'character': + list = characterAwakening + break + case 'weapon': + // WARNING: Clonedeep is masking a deeper error + // which is running this method every time this component is rerendered + // causing multiple "No awakening" items to be added + const awakening = cloneDeep(weaponAwakening) + awakening.unshift({ + id: 0, + name: { + en: 'No awakening', + ja: '覚醒なし', + }, + slug: 'no-awakening', + minValue: 0, + maxValue: 0, + fractional: false, + }) + list = awakening + break + } - // Classes - const inputClasses = classNames({ - Bound: true, - Hidden: awakeningType === -1, - }) - - const errorClasses = classNames({ - errors: true, - visible: error !== '', - }) - - // Set max value based on object type - useEffect(() => { - if (props.object === 'character') setMaxValue(9) - else if (props.object === 'weapon') setMaxValue(15) - }, [props.object]) + return list + } // Set default awakening and level based on object type useEffect(() => { - let defaultAwakening = 0 - if (props.object === 'weapon') defaultAwakening = -1 + const defaultAwakening = props.object === 'weapon' ? 0 : 1 + const type = props.type != undefined ? props.type : defaultAwakening - setAwakeningType( - props.awakeningType != undefined ? props.awakeningType : defaultAwakening - ) - setAwakeningLevel(props.awakeningLevel ? props.awakeningLevel : 1) - }, [props.object, props.awakeningType, props.awakeningLevel]) - - // Send awakening type and level when changed - useEffect(() => { - props.sendValues(awakeningType, awakeningLevel) - }, [props.sendValues, awakeningType, awakeningLevel]) + setAwakeningType(type) + setAwakeningLevel(props.level ? props.level : 1) + }, [props.object, props.type, props.level]) // Send validity of form when awakening level changes useEffect(() => { - props.sendValidity(awakeningLevel > 0 && error === '') - }, [props.sendValidity, awakeningLevel, error]) + props.sendValidity(awakeningLevel > 0) + }, [props.sendValidity, awakeningLevel]) // Classes - function changeOpen() { - setOpen(!open) - props.onOpenChange(!open) + function changeOpen(open: boolean) { + if (props.onOpenChange) props.onOpenChange(open) } - function onClose() { - props.onOpenChange(false) - } - - function generateOptions(object: 'character' | 'weapon') { - let options: Awakening[] = [] - if (object === 'character') options = characterAwakening - else if (object === 'weapon') options = weaponAwakening - else return - - let optionElements: React.ReactNode[] = options.map((awakening, i) => { - return ( - - {awakening.name[locale]} - - ) - }) - - if (object === 'weapon') { - optionElements?.unshift( - - {t('awakening.no_type')} - - ) - } - - return optionElements - } - - function handleSelectChange(rawValue: string) { - const value = parseInt(rawValue) - setAwakeningType(value) - } - - function handleInputChange(event: React.ChangeEvent) { - const value = parseFloat(event.target.value) - if (handleLevelError(value)) setAwakeningLevel(value) - } - - function handleLevelError(value: number) { - let error = '' - if (value < 1) { - error = t('awakening.errors.value_too_low', { - minValue: 1, - }) - } else if (value > maxValue) { - error = t('awakening.errors.value_too_high', { - maxValue: maxValue, - }) - } else if (value % 1 != 0) { - error = t('awakening.errors.value_not_whole') - } else if (!value || value <= 0) { - error = t('awakening.errors.value_empty') - } else { - error = '' - } - - setError(error) - - return error.length === 0 - } - - const rangeString = (object: 'character' | 'weapon') => { - let minValue = 1 - let maxValue = 1 - - if (object === 'weapon') { - minValue = 1 - maxValue = 15 - } else if (object === 'character') { - minValue = 1 - maxValue = 9 - } else return - - return `${minValue}~${maxValue}` + function handleValueChange(type: number, level: number) { + setAwakeningType(type) + setAwakeningLevel(level) + props.sendValues(type, level) } return ( -
    -
    -
    - - - -
    -

    {error}

    -
    +
    +
    ) } diff --git a/components/AxSelect/index.tsx b/components/AxSelect/index.tsx index 01c9c17e..99ab9c7e 100644 --- a/components/AxSelect/index.tsx +++ b/components/AxSelect/index.tsx @@ -7,7 +7,7 @@ import SelectItem from '~components/SelectItem' import classNames from 'classnames' -import { axData } from '~utils/axData' +import ax from '~data/ax' import './index.scss' @@ -155,7 +155,7 @@ const AXSelect = (props: Props) => { if (props.currentSkills[0].modifier > -1 && primaryAxValueInput.current) { const modifier = props.currentSkills[0].modifier - const axSkill = axData[props.axType - 1][modifier] + const axSkill = ax[props.axType - 1][modifier] setupInput(axSkill, primaryAxValueInput.current) } } @@ -169,7 +169,7 @@ const AXSelect = (props: Props) => { props.currentSkills[1].modifier != null ) { const firstSkill = props.currentSkills[0] - const primaryAxSkill = axData[props.axType - 1][firstSkill.modifier] + const primaryAxSkill = ax[props.axType - 1][firstSkill.modifier] const secondaryAxSkill = findSecondaryAxSkill( primaryAxSkill, props.currentSkills[1] @@ -185,7 +185,7 @@ const AXSelect = (props: Props) => { } function findSecondaryAxSkill( - axSkill: AxSkill | undefined, + axSkill: ItemSkill | undefined, skillAtIndex: SimpleAxSkill ) { if (axSkill) @@ -213,7 +213,7 @@ const AXSelect = (props: Props) => { } function generateOptions(modifierSet: number) { - const axOptions = axData[props.axType - 1] + const axOptions = ax[props.axType - 1] let axOptionElements: React.ReactNode[] = [] if (modifierSet == 0) { @@ -264,7 +264,7 @@ const AXSelect = (props: Props) => { secondaryAxModifierSelect.current && secondaryAxValueInput.current ) { - setupInput(axData[props.axType - 1][value], primaryAxValueInput.current) + setupInput(ax[props.axType - 1][value], primaryAxValueInput.current) setPrimaryAxValue(0) primaryAxValueInput.current.value = '' @@ -280,7 +280,7 @@ const AXSelect = (props: Props) => { const value = parseInt(rawValue) setSecondaryAxModifier(value) - const primaryAxSkill = axData[props.axType - 1][primaryAxModifier] + const primaryAxSkill = ax[props.axType - 1][primaryAxModifier] const currentAxSkill = primaryAxSkill.secondary ? primaryAxSkill.secondary.find((skill) => skill.id == value) : undefined @@ -304,7 +304,7 @@ const AXSelect = (props: Props) => { } function handlePrimaryErrors(value: number) { - const primaryAxSkill = axData[props.axType - 1][primaryAxModifier] + const primaryAxSkill = ax[props.axType - 1][primaryAxModifier] let newErrors = { ...errors } if (value < primaryAxSkill.minValue) { @@ -333,7 +333,7 @@ const AXSelect = (props: Props) => { } function handleSecondaryErrors(value: number) { - const primaryAxSkill = axData[props.axType - 1][primaryAxModifier] + const primaryAxSkill = ax[props.axType - 1][primaryAxModifier] let newErrors = { ...errors } if (primaryAxSkill.secondary) { @@ -373,7 +373,7 @@ const AXSelect = (props: Props) => { return newErrors.axValue2.length === 0 } - function setupInput(ax: AxSkill | undefined, element: HTMLInputElement) { + function setupInput(ax: ItemSkill | undefined, element: HTMLInputElement) { if (ax) { const rangeString = `${ax.minValue}~${ax.maxValue}${ax.suffix || ''}` @@ -410,6 +410,7 @@ const AXSelect = (props: Props) => { onOpenChange={() => openSelect(1)} onValueChange={handleAX1SelectChange} triggerClass="modal" + overlayVisible={false} > {generateOptions(0)} @@ -439,6 +440,7 @@ const AXSelect = (props: Props) => { onValueChange={handleAX2SelectChange} triggerClass="modal" ref={secondaryAxModifierSelect} + overlayVisible={false} > {generateOptions(1)} diff --git a/components/Button/index.scss b/components/Button/index.scss index 4c6415e5..e0527b66 100644 --- a/components/Button/index.scss +++ b/components/Button/index.scss @@ -8,6 +8,8 @@ font-size: $font-button; font-weight: $normal; gap: 6px; + transition: 0.18s opacity ease-in-out; + user-select: none; &:hover, &.Blended:hover, @@ -30,6 +32,24 @@ background: transparent; } + &.IconButton.medium { + height: inherit; + padding: $unit-half; + + &:hover { + background: none; + } + + .Text { + font-size: $font-small; + font-weight: $bold; + + @include breakpoint(phone) { + display: none; + } + } + } + &.Contained { background: var(--button-contained-bg); @@ -42,10 +62,10 @@ stroke: #ff4d4d; } - &.Active.Save { + &.Save { color: #ff4d4d; - .Accessory svg { + &.Active .Accessory svg { fill: #ff4d4d; stroke: #ff4d4d; } @@ -61,6 +81,14 @@ } } + &.Options { + box-shadow: 0px 1px 3px rgb(0 0 0 / 14%); + position: absolute; + left: 8px; + top: 8px; + z-index: 3; + } + &:disabled { background-color: var(--button-bg-disabled); color: var(--button-text-disabled); @@ -81,6 +109,17 @@ padding: $unit * 1.5; } + @include breakpoint(phone) { + &.destructive { + background: $error; + color: $grey-100; + + .Accessory svg { + fill: $grey-100; + } + } + } + &.destructive:hover { background: $error; color: $grey-100; @@ -90,24 +129,27 @@ } } - &.save:hover { - color: #ff4d4d; - + &.Save { .Accessory svg { - fill: #ff4d4d; - stroke: #ff4d4d; + fill: none; + stroke: var(--button-text); } - } - &.save.Active { - color: #ff4d4d; + &.Saved { + color: #ff4d4d; + + .Accessory svg { + fill: #ff4d4d; + stroke: none; + } + } &:hover { - color: darken(#ff4d4d, 30); + color: #ff4d4d; - .icon svg { - fill: darken(#ff4d4d, 30); - stroke: darken(#ff4d4d, 30); + .Accessory svg { + fill: none; + stroke: #ff4d4d; } } } @@ -129,6 +171,10 @@ display: flex; + &.Arrow { + margin-top: $unit-half; + } + svg { fill: var(--button-text); height: $dimension; diff --git a/components/Button/index.tsx b/components/Button/index.tsx index e87e7ff7..8594c129 100644 --- a/components/Button/index.tsx +++ b/components/Button/index.tsx @@ -8,7 +8,10 @@ interface Props React.ButtonHTMLAttributes, HTMLButtonElement > { - accessoryIcon?: React.ReactNode + leftAccessoryIcon?: React.ReactNode + leftAccessoryClassName?: string + rightAccessoryIcon?: React.ReactNode + rightAccessoryClassName?: string active?: boolean blended?: boolean contained?: boolean @@ -24,22 +27,45 @@ const defaultProps = { } const Button = React.forwardRef(function button( - { accessoryIcon, active, blended, contained, buttonSize, text, ...props }, + { + leftAccessoryIcon, + leftAccessoryClassName, + rightAccessoryIcon, + rightAccessoryClassName, + active, + blended, + contained, + buttonSize, + text, + ...props + }, forwardedRef ) { - const classes = classNames( - { - Button: true, - Active: active, - Blended: blended, - Contained: contained, - }, - buttonSize, - props.className - ) + const classes = classNames(buttonSize, props.className, { + Button: true, + Active: active, + Blended: blended, + Contained: contained, + }) - const hasAccessory = () => { - if (accessoryIcon) return {accessoryIcon} + const leftAccessoryClasses = classNames(leftAccessoryClassName, { + Accessory: true, + Left: true, + }) + + const rightAccessoryClasses = classNames(rightAccessoryClassName, { + Accessory: true, + Right: true, + }) + + const hasLeftAccessory = () => { + if (leftAccessoryIcon) + return {leftAccessoryIcon} + } + + const hasRightAccessory = () => { + if (rightAccessoryIcon) + return {rightAccessoryIcon} } const hasText = () => { @@ -48,8 +74,9 @@ const Button = React.forwardRef(function button( return ( ) }) diff --git a/components/ChangelogModal/index.scss b/components/ChangelogModal/index.scss deleted file mode 100644 index 67aac905..00000000 --- a/components/ChangelogModal/index.scss +++ /dev/null @@ -1,15 +0,0 @@ -h3.version { - color: $blue; - font-weight: $medium; - font-size: $font-medium; - margin-bottom: $unit; -} - -.notes { - color: var(--text-primary); - list-style-type: disc; - - li { - margin-bottom: $unit-half; - } -} diff --git a/components/ChangelogModal/index.tsx b/components/ChangelogModal/index.tsx deleted file mode 100644 index 2991f8fa..00000000 --- a/components/ChangelogModal/index.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react' -import { useTranslation } from 'next-i18next' -import * as Dialog from '@radix-ui/react-dialog' - -import CrossIcon from '~public/icons/Cross.svg' - -import './index.scss' - -const ChangelogModal = () => { - const { t } = useTranslation('common') - - return ( - - -
  • - {t('modals.changelog.title')} -
  • -
    - - event.preventDefault()} - > -
    - - {t('menu.changelog')} - - - - - - -
    - -
    - -

    1.0

    -
      -
    • First release!
    • -
    • Content update - Mid-December 2022 Flash Gala
    • -
    • You can embed Youtube videos now
    • -
    • Better clicking - right-click and open in a new tab
    • -
    • Manually set dark mode in Account Settings
    • -
    • Lots of bugs squashed
    • -
    -
    -
    -
    - -
    -
    - ) -} - -export default ChangelogModal diff --git a/components/ChangelogUnit/index.scss b/components/ChangelogUnit/index.scss new file mode 100644 index 00000000..6c6a0e8c --- /dev/null +++ b/components/ChangelogUnit/index.scss @@ -0,0 +1,17 @@ +.ChangelogUnit { + display: flex; + flex-direction: column; + gap: $unit; + + img { + border-radius: $input-corner; + width: 100%; + } + + h4 { + font-size: $font-small; + font-weight: $medium; + text-align: center; + line-height: 1.4; + } +} diff --git a/components/ChangelogUnit/index.tsx b/components/ChangelogUnit/index.tsx new file mode 100644 index 00000000..98fdb87b --- /dev/null +++ b/components/ChangelogUnit/index.tsx @@ -0,0 +1,94 @@ +import { useRouter } from 'next/router' +import React, { useEffect, useState } from 'react' +import api from '~utils/api' + +import './index.scss' + +interface Props { + id: string + type: 'character' | 'summon' | 'weapon' + image?: '01' | '02' | '03' | '04' +} + +const defaultProps = { + active: false, + blended: false, + contained: false, + buttonSize: 'medium' as const, + image: '01', +} + +const ChangelogUnit = ({ id, type, image }: Props) => { + // Router + const router = useRouter() + const locale = + router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' + + // State + const [item, setItem] = useState() + + // Hooks + useEffect(() => { + fetch() + }, []) + + async function fetch() { + switch (type) { + case 'character': + const character = await fetchCharacter() + setItem(character.data) + break + + case 'weapon': + const weapon = await fetchWeapon() + setItem(weapon.data) + break + + case 'summon': + const summon = await fetchSummon() + setItem(summon.data) + break + } + } + + async function fetchCharacter() { + return api.endpoints.characters.getOne({ id: id }) + } + + async function fetchWeapon() { + return api.endpoints.weapons.getOne({ id: id }) + } + + async function fetchSummon() { + return api.endpoints.summons.getOne({ id: id }) + } + + const imageUrl = () => { + let src = '' + + switch (type) { + case 'character': + src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-grid/${id}_${image}.jpg` + break + case 'weapon': + src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${id}.jpg` + break + case 'summon': + src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${id}.jpg` + break + } + + return src + } + + return ( +
    + {item +

    {item ? item.name[locale] : ''}

    +
    + ) +} + +ChangelogUnit.defaultProps = defaultProps + +export default ChangelogUnit diff --git a/components/CharacterConflictModal/index.tsx b/components/CharacterConflictModal/index.tsx index b14fed31..535d6305 100644 --- a/components/CharacterConflictModal/index.tsx +++ b/components/CharacterConflictModal/index.tsx @@ -2,7 +2,8 @@ import React, { useEffect, useState } from 'react' import { useRouter } from 'next/router' import { Trans, useTranslation } from 'next-i18next' -import { Dialog, DialogContent } from '~components/Dialog' +import { Dialog } from '~components/Dialog' +import DialogContent from '~components/DialogContent' import Button from '~components/Button' import Overlay from '~components/Overlay' @@ -29,6 +30,9 @@ const CharacterConflictModal = (props: Props) => { // States const [open, setOpen] = useState(false) + // Refs + const footerRef = React.createRef() + useEffect(() => { setOpen(props.open) }, [setOpen, props.open]) @@ -71,43 +75,53 @@ const CharacterConflictModal = (props: Props) => { return ( event.preventDefault()} onEscapeKeyDown={close} > -

    - -

    -
    -
      - {props.conflictingCharacters?.map((character, i) => ( -
    • +
      +

      + +

      +
      +
        + {props.conflictingCharacters?.map((character, i) => ( +
      • + {character.object.name[locale]} + {character.object.name[locale]} +
      • + ))} +
      + +
      +
      {character.object.name[locale]} - {character.object.name[locale]} -
    • - ))} -
    - -
    -
    - {props.incomingCharacter?.name[locale]} - {props.incomingCharacter?.name[locale]} + {props.incomingCharacter?.name[locale]} +
    -
    -
    +
    +
    +
    +
    diff --git a/components/CharacterGrid/index.tsx b/components/CharacterGrid/index.tsx index 48cc0ca8..9cd2d54f 100644 --- a/components/CharacterGrid/index.tsx +++ b/components/CharacterGrid/index.tsx @@ -2,8 +2,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { getCookie } from 'cookies-next' import { useSnapshot } from 'valtio' +import { useTranslation } from 'next-i18next' -import { AxiosResponse } from 'axios' +import { AxiosError, AxiosResponse } from 'axios' import debounce from 'lodash.debounce' import Alert from '~components/Alert' @@ -15,13 +16,13 @@ import type { DetailsObject, JobSkillObject, SearchableObject } from '~types' import api from '~utils/api' import { appState } from '~utils/appState' -import { accountState } from '~utils/accountState' import './index.scss' // Props interface Props { new: boolean + editable: boolean characters?: GridCharacter[] createParty: (details?: DetailsObject) => Promise pushHistory?: (path: string) => void @@ -31,15 +32,21 @@ const CharacterGrid = (props: Props) => { // Constants const numCharacters: number = 5 + // Localization + const { t } = useTranslation('common') + // Cookies const cookie = getCookie('account') const accountData: AccountCookie = cookie ? JSON.parse(cookie as string) : null + // Set up state for error handling + const [axiosError, setAxiosError] = useState() + const [errorAlertOpen, setErrorAlertOpen] = useState(false) + // Set up state for view management const { party, grid } = useSnapshot(appState) - const [slug, setSlug] = useState() const [modalOpen, setModalOpen] = useState(false) // Set up state for conflict management @@ -55,27 +62,23 @@ const CharacterGrid = (props: Props) => { 2: undefined, 3: undefined, }) + const [jobAccessory, setJobAccessory] = useState() const [errorMessage, setErrorMessage] = useState('') - // Create a temporary state to store previous character uncap values + // Create a temporary state to store previous weapon uncap values and transcendence stages const [previousUncapValues, setPreviousUncapValues] = useState<{ [key: number]: number | undefined }>({}) - // Set the editable flag only on first load - useEffect(() => { - // If user is logged in and matches - if ( - (accountData && party.user && accountData.userId === party.user.id) || - props.new - ) - appState.party.editable = true - else appState.party.editable = false - }, [props.new, accountData, party]) + const [previousTranscendenceStages, setPreviousTranscendenceStages] = + useState<{ + [key: number]: number | undefined + }>({}) useEffect(() => { setJob(appState.party.job) setJobSkills(appState.party.jobSkills) + setJobAccessory(appState.party.accessory) }, [appState]) // Initialize an array of current uncap values for each characters @@ -101,10 +104,18 @@ const CharacterGrid = (props: Props) => { .catch((error) => console.error(error)) }) } else { - if (party.editable) + if (props.editable) saveCharacter(party.id, character, position) .then((response) => handleCharacterResponse(response.data)) - .catch((error) => console.error(error)) + .catch((error) => { + const axiosError = error as AxiosError + const response = axiosError.response + + if (response) { + setErrorAlertOpen(true) + setAxiosError(response) + } + }) } } @@ -171,8 +182,17 @@ const CharacterGrid = (props: Props) => { setIncoming(undefined) } + async function removeCharacter(id: string) { + try { + const response = await api.endpoints.grid_characters.destroy({ id: id }) + appState.grid.characters[response.data.position] = undefined + } catch (error) { + console.error(error) + } + } + // Methods: Saving job and job skills - const saveJob = async function (job?: Job) { + async function saveJob(job?: Job) { const payload = { party: { job_id: job ? job.id : -1, @@ -200,8 +220,8 @@ const CharacterGrid = (props: Props) => { } } - const saveJobSkill = function (skill: JobSkill, position: number) { - if (party.id && appState.party.editable) { + function saveJobSkill(skill: JobSkill, position: number) { + if (party.id && props.editable) { const positionedKey = `skill${position}_id` let skillObject: { @@ -239,6 +259,24 @@ const CharacterGrid = (props: Props) => { } } + async function saveAccessory(accessory: JobAccessory) { + const payload = { + party: { + accessory_id: accessory.id, + }, + } + + if (appState.party.id) { + const response = await api.endpoints.parties.update( + appState.party.id, + payload + ) + const team = response.data.party + setJobAccessory(team.accessory) + appState.party.accessory = team.accessory + } + } + // Methods: Helpers function characterUncapLevel(character: Character) { let uncapLevel @@ -260,6 +298,7 @@ const CharacterGrid = (props: Props) => { // Note: Saves, but debouncing is not working properly async function saveUncap(id: string, position: number, uncapLevel: number) { storePreviousUncapValue(position) + storePreviousTranscendenceStage(position) try { if (uncapLevel != previousUncapValues[position]) @@ -271,11 +310,17 @@ const CharacterGrid = (props: Props) => { // Revert optimistic UI updateUncapLevel(position, previousUncapValues[position]) + updateTranscendenceStage(position, previousTranscendenceStages[position]) // Remove optimistic key - let newPreviousValues = { ...previousUncapValues } - delete newPreviousValues[position] - setPreviousUncapValues(newPreviousValues) + let newPreviousTranscendenceStages = { ...previousTranscendenceStages } + let newPreviousUncapValues = { ...previousUncapValues } + + delete newPreviousTranscendenceStages[position] + delete newPreviousUncapValues[position] + + setPreviousTranscendenceStages(newPreviousTranscendenceStages) + setPreviousUncapValues(newPreviousUncapValues) } } @@ -284,26 +329,26 @@ const CharacterGrid = (props: Props) => { position: number, uncapLevel: number ) { - if ( - party.user && - accountState.account.user && - party.user.id === accountState.account.user.id - ) { - memoizeAction(id, position, uncapLevel) + if (props.editable) { + memoizeUncapAction(id, position, uncapLevel) // Optimistically update UI updateUncapLevel(position, uncapLevel) + + if (uncapLevel < 6) { + updateTranscendenceStage(position, 0) + } } } - const memoizeAction = useCallback( + const memoizeUncapAction = useCallback( (id: string, position: number, uncapLevel: number) => { - debouncedAction(id, position, uncapLevel) + debouncedUncapAction(id, position, uncapLevel) }, [props, previousUncapValues] ) - const debouncedAction = useMemo( + const debouncedUncapAction = useMemo( () => debounce((id, position, number) => { saveUncap(id, position, number) @@ -332,11 +377,119 @@ const CharacterGrid = (props: Props) => { } } + // Methods: Updating transcendence stage + // Note: Saves, but debouncing is not working properly + async function saveTranscendence( + id: string, + position: number, + stage: number + ) { + storePreviousUncapValue(position) + storePreviousTranscendenceStage(position) + + const payload = { + character: { + uncap_level: stage > 0 ? 6 : 5, + transcendence_step: stage, + }, + } + + try { + if (stage != previousTranscendenceStages[position]) + await api.endpoints.grid_characters + .update(id, payload) + .then((response) => { + storeGridCharacter(response.data) + }) + } catch (error) { + console.error(error) + + // Revert optimistic UI + updateUncapLevel(position, previousUncapValues[position]) + updateTranscendenceStage(position, previousTranscendenceStages[position]) + + // Remove optimistic key + let newPreviousTranscendenceStages = { ...previousTranscendenceStages } + let newPreviousUncapValues = { ...previousUncapValues } + + delete newPreviousTranscendenceStages[position] + delete newPreviousUncapValues[position] + + setPreviousTranscendenceStages(newPreviousTranscendenceStages) + setPreviousUncapValues(newPreviousUncapValues) + } + } + + function initiateTranscendenceUpdate( + id: string, + position: number, + stage: number + ) { + if (props.editable) { + memoizeTranscendenceAction(id, position, stage) + + // Optimistically update UI + updateTranscendenceStage(position, stage) + + if (stage > 0) { + updateUncapLevel(position, 6) + } + } + } + + const memoizeTranscendenceAction = useCallback( + (id: string, position: number, stage: number) => { + debouncedTranscendenceAction(id, position, stage) + }, + [props, previousTranscendenceStages] + ) + + const debouncedTranscendenceAction = useMemo( + () => + debounce((id, position, number) => { + saveTranscendence(id, position, number) + }, 500), + [props, saveTranscendence] + ) + + const updateTranscendenceStage = ( + position: number, + stage: number | undefined + ) => { + const character = appState.grid.characters[position] + if (character && stage !== undefined) { + character.transcendence_step = stage + appState.grid.characters[position] = character + } + } + + function storePreviousTranscendenceStage(position: number) { + // Save the current value in case of an unexpected result + let newPreviousValues = { ...previousUncapValues } + + if (grid.characters[position]) { + newPreviousValues[position] = grid.characters[position]?.uncap_level + setPreviousTranscendenceStages(newPreviousValues) + } + } + function cancelAlert() { setErrorMessage('') } // Render: JSX components + const errorAlert = () => { + return ( + setErrorAlertOpen(false)} + cancelActionText={t('buttons.confirm')} + /> + ) + } + return (
    { {
  • ) })}
    + {errorAlert()} ) } diff --git a/components/CharacterHovercard/index.scss b/components/CharacterHovercard/index.scss index e69de29b..1f1045dc 100644 --- a/components/CharacterHovercard/index.scss +++ b/components/CharacterHovercard/index.scss @@ -0,0 +1,68 @@ +.Character.HovercardContent { + .title .Image { + position: relative; + + .Perpetuity { + position: absolute; + background-image: url('/icons/perpetuity/filled.svg'); + background-size: $unit-3x $unit-3x; + z-index: 20; + top: $unit-half * -1; + right: $unit-3x; + width: $unit-3x; + height: $unit-3x; + } + } + + .Mastery { + display: flex; + flex-direction: column; + gap: $unit; + + ul { + display: flex; + flex-direction: column; + gap: $unit-half; + + .ExtendedMastery { + align-items: center; + display: flex; + gap: $unit-half; + + img { + width: $unit-3x; + } + + strong { + font-weight: $bold; + } + } + } + } + + .Awakening { + display: flex; + flex-direction: column; + gap: $unit; + + & > div { + align-items: center; + display: flex; + gap: $unit-half; + + img { + width: $unit-3x; + } + + strong { + font-weight: $bold; + } + } + } + + // .Footer { + // position: sticky; + // bottom: 0; + // left: 0; + // } +} diff --git a/components/CharacterHovercard/index.tsx b/components/CharacterHovercard/index.tsx index 0f9d16ac..76eb2c4d 100644 --- a/components/CharacterHovercard/index.tsx +++ b/components/CharacterHovercard/index.tsx @@ -2,16 +2,29 @@ import React from 'react' import { useRouter } from 'next/router' import { useTranslation } from 'next-i18next' -import * as HoverCard from '@radix-ui/react-hover-card' - +import { + Hovercard, + HovercardContent, + HovercardTrigger, +} from '~components/Hovercard' +import Button from '~components/Button' import WeaponLabelIcon from '~components/WeaponLabelIcon' import UncapIndicator from '~components/UncapIndicator' +import { + overMastery, + aetherialMastery, + permanentMastery, +} from '~data/overMastery' +import { characterAwakening } from '~data/awakening' +import { ExtendedMastery } from '~types' + import './index.scss' interface Props { gridCharacter: GridCharacter children: React.ReactNode + onTriggerClick: () => void } interface KeyNames { @@ -43,10 +56,19 @@ const CharacterHovercard = (props: Props) => { ] const tintElement = Element[props.gridCharacter.object.element] - const wikiUrl = `https://gbf.wiki/${props.gridCharacter.object.name.en.replaceAll( - ' ', - '_' - )}` + + function goTo() { + const urlSafeName = props.gridCharacter.object.name.en.replaceAll(' ', '_') + const url = `https://gbf.wiki/${urlSafeName}` + + window.open(url, '_blank') + } + + const perpetuity = () => { + if (props.gridCharacter && props.gridCharacter.perpetuity) { + return + } + } function characterImage() { let imgSrc = '' @@ -66,59 +88,194 @@ const CharacterHovercard = (props: Props) => { return imgSrc } + function masteryElement(dictionary: ItemSkill[], mastery: ExtendedMastery) { + const canonicalMastery = dictionary.find( + (item) => item.id === mastery.modifier + ) + + if (canonicalMastery) { + return ( +
  • + {canonicalMastery.name[locale]} + + {canonicalMastery.name[locale]}  + {`+${mastery.strength}${canonicalMastery.suffix}`} + +
  • + ) + } + } + + const overMasterySection = () => { + if (props.gridCharacter && props.gridCharacter.over_mastery) { + return ( +
    +
    + {t('modals.characters.subtitles.ring')} +
    +
      + {[...Array(4)].map((e, i) => { + const ringIndex = i + 1 + const ringStat: ExtendedMastery = + props.gridCharacter.over_mastery[i] + if (ringStat && ringStat.modifier && ringStat.modifier > 0) { + if (ringIndex === 1 || ringIndex === 2) { + return masteryElement(overMastery.a, ringStat) + } else if (ringIndex === 3) { + return masteryElement(overMastery.b, ringStat) + } else { + return masteryElement(overMastery.c, ringStat) + } + } + })} +
    +
    + ) + } + } + + const aetherialMasterySection = () => { + if ( + props.gridCharacter && + props.gridCharacter.aetherial_mastery && + props.gridCharacter.aetherial_mastery.modifier > 0 + ) { + return ( +
    +
    + {t('modals.characters.subtitles.earring')} +
    +
      + {masteryElement( + aetherialMastery, + props.gridCharacter.aetherial_mastery + )} +
    +
    + ) + } + } + + const permanentMasterySection = () => { + if (props.gridCharacter && props.gridCharacter.perpetuity) { + return ( +
    +
    + {t('modals.characters.subtitles.permanent')} +
    +
      + {[...Array(4)].map((e, i) => { + return masteryElement(permanentMastery, { + modifier: i + 1, + strength: permanentMastery[i].maxValue, + }) + })} +
    +
    + ) + } + } + + const awakeningSection = () => { + const gridAwakening = props.gridCharacter.awakening + const awakening = characterAwakening.find( + (awakening) => awakening.id === gridAwakening?.type + ) + + if (gridAwakening && awakening) { + return ( +
    +
    + {t('modals.characters.subtitles.awakening')} +
    +
    + {gridAwakening.type > 1 ? ( + {awakening.name[locale]} + ) : ( + '' + )} + + {`${awakening.name[locale]}`}  + {`Lv${gridAwakening.level}`} + +
    +
    + ) + } + } + + const wikiButton = ( +