February 2023 Update (#158)
This commit is contained in:
parent
c0890e6e96
commit
c7e0836202
249 changed files with 11948 additions and 3724 deletions
|
|
@ -6,3 +6,7 @@ NODE_PATH='src/'
|
||||||
REACT_APP_SIERO_API_URL=''
|
REACT_APP_SIERO_API_URL=''
|
||||||
REACT_APP_SIERO_OAUTH_URL=''
|
REACT_APP_SIERO_OAUTH_URL=''
|
||||||
REACT_APP_SIERO_IMG_URL=''
|
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=''
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -53,6 +53,9 @@ public/images/chara*
|
||||||
public/images/job*
|
public/images/job*
|
||||||
public/images/awakening*
|
public/images/awakening*
|
||||||
public/images/ax*
|
public/images/ax*
|
||||||
|
public/images/accessory*
|
||||||
|
public/images/mastery*
|
||||||
|
public/images/updates*
|
||||||
|
|
||||||
# Typescript v1 declaration files
|
# Typescript v1 declaration files
|
||||||
typings/
|
typings/
|
||||||
|
|
|
||||||
81
README.md
81
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).
|

|
||||||
|
|
||||||
|
# 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
|
## 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
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
@ -10,25 +44,28 @@ npm run dev
|
||||||
yarn 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`.
|
```
|
||||||
|
root
|
||||||
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.
|
├─ accessory-grid/
|
||||||
|
├─ accessory-square/
|
||||||
## Learn More
|
├─ awakening/
|
||||||
|
├─ ax/
|
||||||
To learn more about Next.js, take a look at the following resources:
|
├─ chara-main/
|
||||||
|
├─ chara-grid/
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
├─ chara-square/
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
├─ jobs/
|
||||||
|
├─ job-icons/
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
├─ job-skills/
|
||||||
|
├─ mastery/
|
||||||
## Deploy on Vercel
|
├─ summon-main/
|
||||||
|
├─ summon-grid/
|
||||||
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.
|
├─ summon-square/
|
||||||
|
├─ updates/
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
├─ weapon-main/
|
||||||
|
├─ weapon-grid/
|
||||||
|
├─ weapon-square/
|
||||||
|
```
|
||||||
|
|
|
||||||
BIN
README.png
Normal file
BIN
README.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
53
components/AboutHead/index.tsx
Normal file
53
components/AboutHead/index.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Head>
|
||||||
|
{/* HTML */}
|
||||||
|
<title>{t(`page.titles.${currentPage}`)}</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content={t(`page.descriptions.${currentPage}`)}
|
||||||
|
/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/images/favicon.png" />
|
||||||
|
|
||||||
|
{/* OpenGraph */}
|
||||||
|
<meta property="og:title" content={t(`page.titles.${currentPage}`)} />
|
||||||
|
<meta property="og:description" content={t('page.descriptions.about')} />
|
||||||
|
<meta
|
||||||
|
property="og:url"
|
||||||
|
content={`https://app.granblue.team/${currentPage}`}
|
||||||
|
/>
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
|
||||||
|
{/* Twitter */}
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:domain" content="app.granblue.team" />
|
||||||
|
<meta name="twitter:title" content={t(`page.titles.${currentPage}`)} />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content={t(`page.descriptions.${currentPage}`)}
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AboutHead
|
||||||
|
|
@ -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%;
|
|
||||||
}
|
|
||||||
|
|
@ -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 (
|
|
||||||
<Dialog.Root>
|
|
||||||
<Dialog.Trigger asChild>
|
|
||||||
<li className="MenuItem">
|
|
||||||
<span>{t('modals.about.title')}</span>
|
|
||||||
</li>
|
|
||||||
</Dialog.Trigger>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay className="ScrollingOverlay">
|
|
||||||
<div className="DialogWrapper">
|
|
||||||
<Dialog.Content
|
|
||||||
className="About Dialog"
|
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
<div className="DialogHeader">
|
|
||||||
<Dialog.Title className="DialogTitle">
|
|
||||||
{t('menu.about')}
|
|
||||||
</Dialog.Title>
|
|
||||||
<Dialog.Close className="DialogClose" asChild>
|
|
||||||
<span>
|
|
||||||
<CrossIcon />
|
|
||||||
</span>
|
|
||||||
</Dialog.Close>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<Dialog.Description className="DialogDescription">
|
|
||||||
Granblue.team is a tool to save and share team comps for{' '}
|
|
||||||
<a
|
|
||||||
href="https://game.granbluefantasy.jp"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Granblue Fantasy.
|
|
||||||
</a>
|
|
||||||
</Dialog.Description>
|
|
||||||
<Dialog.Description className="DialogDescription">
|
|
||||||
Start adding to a team and a URL will be created for you to
|
|
||||||
share wherever you like, no account needed.
|
|
||||||
</Dialog.Description>
|
|
||||||
<Dialog.Description className="DialogDescription">
|
|
||||||
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.
|
|
||||||
</Dialog.Description>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<Dialog.Title className="DialogTitle">Feedback</Dialog.Title>
|
|
||||||
<Dialog.Description className="DialogDescription">
|
|
||||||
This is an evolving project so feedback and suggestions are
|
|
||||||
greatly appreciated!
|
|
||||||
</Dialog.Description>
|
|
||||||
<Dialog.Description className="DialogDescription">
|
|
||||||
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!
|
|
||||||
</Dialog.Description>
|
|
||||||
<div className="LinkItem">
|
|
||||||
<Link href="https://discord.gg/qyZ5hGdPC8">
|
|
||||||
<a
|
|
||||||
href="https://discord.gg/qyZ5hGdPC8"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<div className="Left">
|
|
||||||
<DiscordIcon />
|
|
||||||
<h3>granblue-tools</h3>
|
|
||||||
</div>
|
|
||||||
<ShareIcon className="ShareIcon" />
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<Dialog.Title className="DialogTitle">Credits</Dialog.Title>
|
|
||||||
<Dialog.Description className="DialogDescription">
|
|
||||||
Granblue.team was built by{' '}
|
|
||||||
<a
|
|
||||||
href="https://twitter.com/jedmund"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
@jedmund
|
|
||||||
</a>{' '}
|
|
||||||
with a lot of help from{' '}
|
|
||||||
<a
|
|
||||||
href="https://twitter.com/lalalalinna"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
@lalalalinna
|
|
||||||
</a>{' '}
|
|
||||||
and{' '}
|
|
||||||
<a
|
|
||||||
href="https://twitter.com/tarngerine"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
@tarngerine
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</Dialog.Description>
|
|
||||||
<Dialog.Description className="DialogDescription">
|
|
||||||
Many thanks also go to Disinfect, Slipper, Jif, Bless,
|
|
||||||
9highwind, and everyone else in{' '}
|
|
||||||
<a
|
|
||||||
href="https://game.granbluefantasy.jp/#guild/detail/1190185"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Fireplace
|
|
||||||
</a>{' '}
|
|
||||||
that helped with bug testing and feature requests. (P.S.
|
|
||||||
We're recruiting!) And yoey, but he won't join our
|
|
||||||
crew.
|
|
||||||
</Dialog.Description>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<Dialog.Title className="DialogTitle">
|
|
||||||
Contributing
|
|
||||||
</Dialog.Title>
|
|
||||||
<Dialog.Description className="DialogDescription">
|
|
||||||
This app is open source and licensed under{' '}
|
|
||||||
<a
|
|
||||||
href="https://choosealicense.com/licenses/agpl-3.0/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
GNU AGPLv3
|
|
||||||
</a>
|
|
||||||
. 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.
|
|
||||||
</Dialog.Description>
|
|
||||||
<ul className="Links">
|
|
||||||
<li className="LinkItem">
|
|
||||||
<Link href="https://github.com/jedmund/hensei-api">
|
|
||||||
<a
|
|
||||||
href="https://github.com/jedmund/hensei-api"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<div className="Left">
|
|
||||||
<GithubIcon />
|
|
||||||
<h3>jedmund/hensei-api</h3>
|
|
||||||
</div>
|
|
||||||
<ShareIcon className="ShareIcon" />
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li className="LinkItem">
|
|
||||||
<Link href="https://github.com/jedmund/hensei-web">
|
|
||||||
<a
|
|
||||||
href="https://github.com/jedmund/hensei-web"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<div className="Left">
|
|
||||||
<GithubIcon />
|
|
||||||
<h3>jedmund/hensei-web</h3>
|
|
||||||
</div>
|
|
||||||
<ShareIcon className="ShareIcon" />
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</Dialog.Content>
|
|
||||||
</div>
|
|
||||||
</Dialog.Overlay>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AboutModal
|
|
||||||
76
components/AboutPage/index.scss
Normal file
76
components/AboutPage/index.scss
Normal file
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
components/AboutPage/index.tsx
Normal file
175
components/AboutPage/index.tsx
Normal file
|
|
@ -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: Props) => {
|
||||||
|
const { t: common } = useTranslation('common')
|
||||||
|
const { t: about } = useTranslation('about')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="About PageContent">
|
||||||
|
<h1>{common('about.segmented_control.about')}</h1>
|
||||||
|
<section>
|
||||||
|
<h2>
|
||||||
|
<Trans i18nKey="about:about.subtitle">
|
||||||
|
Granblue.team is a tool to save and share team compositions for{' '}
|
||||||
|
<a
|
||||||
|
href="https://game.granbluefantasy.jp"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Granblue Fantasy
|
||||||
|
</a>
|
||||||
|
, a social RPG from Cygames.
|
||||||
|
</Trans>
|
||||||
|
</h2>
|
||||||
|
<p>{about('about.explanation.0')}</p>
|
||||||
|
<p>{about('about.explanation.1')}</p>
|
||||||
|
<div className="Hero" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>{about('about.feedback.title')}</h2>
|
||||||
|
<p>{about('about.feedback.explanation')}</p>
|
||||||
|
<p>{about('about.feedback.solicit')}</p>
|
||||||
|
<div className="Discord LinkItem">
|
||||||
|
<Link href="https://discord.gg/qyZ5hGdPC8">
|
||||||
|
<a
|
||||||
|
href="https://discord.gg/qyZ5hGdPC8"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<div className="Left">
|
||||||
|
<DiscordIcon />
|
||||||
|
<h3>granblue-tools</h3>
|
||||||
|
</div>
|
||||||
|
<ShareIcon className="ShareIcon" />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>{about('about.credits.title')}</h2>
|
||||||
|
<p>
|
||||||
|
<Trans i18nKey="about:about.credits.maintainer">
|
||||||
|
Granblue.team was built and is maintained by{' '}
|
||||||
|
<a
|
||||||
|
href="https://twitter.com/jedmund"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
@jedmund
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Trans i18nKey="about:about.credits.assistance">
|
||||||
|
Many thanks to{' '}
|
||||||
|
<a
|
||||||
|
href="https://twitter.com/lalalalinna"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
@lalalalinna
|
||||||
|
</a>{' '}
|
||||||
|
and{' '}
|
||||||
|
<a
|
||||||
|
href="https://twitter.com/tarngerine"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
@tarngerine
|
||||||
|
</a>
|
||||||
|
, who both provided a lot of help and advice as I was ramping up.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Trans i18nKey="about:about.credits.support">
|
||||||
|
Many thanks also go to everyone in{' '}
|
||||||
|
<a
|
||||||
|
href="https://game.granbluefantasy.jp/#guild/detail/1190185"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Fireplace
|
||||||
|
</a>{' '}
|
||||||
|
and the granblue-tools Discord for all of their help with with bug
|
||||||
|
testing, feature requests, and moral support. (P.S. We're
|
||||||
|
recruiting!)
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>{about('about.contributing.title')}</h2>
|
||||||
|
|
||||||
|
<p>{about('about.contributing.explanation')}</p>
|
||||||
|
<ul className="Links">
|
||||||
|
<li className="Github LinkItem">
|
||||||
|
<Link href="https://github.com/jedmund/hensei-api">
|
||||||
|
<a
|
||||||
|
href="https://github.com/jedmund/hensei-api"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<div className="Left">
|
||||||
|
<GithubIcon />
|
||||||
|
<h3>jedmund/hensei-api</h3>
|
||||||
|
</div>
|
||||||
|
<ShareIcon className="ShareIcon" />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="Github LinkItem">
|
||||||
|
<Link href="https://github.com/jedmund/hensei-web">
|
||||||
|
<a
|
||||||
|
href="https://github.com/jedmund/hensei-web"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<div className="Left">
|
||||||
|
<GithubIcon />
|
||||||
|
<h3>jedmund/hensei-web</h3>
|
||||||
|
</div>
|
||||||
|
<ShareIcon className="ShareIcon" />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>{about('about.license.title')}</h2>
|
||||||
|
<p>
|
||||||
|
<Trans i18nKey="about:about.license.license">
|
||||||
|
This app is licensed under{' '}
|
||||||
|
<a
|
||||||
|
href="https://choosealicense.com/licenses/agpl-3.0/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
GNU AGPLv3
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<p>{about('about.license.explanation')}</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>{about('about.copyright.title')}</h2>
|
||||||
|
<p>{about('about.copyright.explanation')}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AboutPage
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
.Account.Dialog {
|
.Account.DialogContent {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit * 2;
|
gap: $unit-2x;
|
||||||
width: $unit * 64;
|
width: $unit * 64;
|
||||||
|
overflow-y: hidden;
|
||||||
|
|
||||||
form {
|
.Fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit * 2;
|
gap: $unit-2x;
|
||||||
|
padding: 0 $unit-4x;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
gap: $unit-4x;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.DialogDescription {
|
.DialogDescription {
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,15 @@ import React, { useEffect, useState } from 'react'
|
||||||
import { getCookie, setCookie } from 'cookies-next'
|
import { getCookie, setCookie } from 'cookies-next'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useTranslation } from 'next-i18next'
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '~components/Dialog'
|
} from '~components/Dialog'
|
||||||
|
import DialogContent from '~components/DialogContent'
|
||||||
import Button from '~components/Button'
|
import Button from '~components/Button'
|
||||||
import SelectItem from '~components/SelectItem'
|
import SelectItem from '~components/SelectItem'
|
||||||
import PictureSelectItem from '~components/PictureSelectItem'
|
import PictureSelectItem from '~components/PictureSelectItem'
|
||||||
|
|
@ -23,7 +24,6 @@ import { pictureData } from '~utils/pictureData'
|
||||||
|
|
||||||
import CrossIcon from '~public/icons/Cross.svg'
|
import CrossIcon from '~public/icons/Cross.svg'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import { useTheme } from 'next-themes'
|
|
||||||
|
|
||||||
type StateVariables = {
|
type StateVariables = {
|
||||||
[key: string]: boolean
|
[key: string]: boolean
|
||||||
|
|
@ -34,20 +34,25 @@ type StateVariables = {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
open: boolean
|
||||||
username?: string
|
username?: string
|
||||||
picture?: string
|
picture?: string
|
||||||
gender?: number
|
gender?: number
|
||||||
language?: string
|
language?: string
|
||||||
theme?: string
|
theme?: string
|
||||||
private?: boolean
|
private?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AccountModal = (props: Props) => {
|
const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
||||||
|
function AccountModal(props: Props, forwardedRef) {
|
||||||
// Localization
|
// Localization
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const locale =
|
const locale =
|
||||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
router.locale && ['en', 'ja'].includes(router.locale)
|
||||||
|
? router.locale
|
||||||
|
: 'en'
|
||||||
|
|
||||||
// useEffect only runs on the client, so now we can safely show the UI
|
// useEffect only runs on the client, so now we can safely show the UI
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
@ -85,8 +90,17 @@ const AccountModal = (props: Props) => {
|
||||||
const [languageOpen, setLanguageOpen] = useState(false)
|
const [languageOpen, setLanguageOpen] = useState(false)
|
||||||
const [themeOpen, setThemeOpen] = useState(false)
|
const [themeOpen, setThemeOpen] = useState(false)
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const headerRef = React.createRef<HTMLDivElement>()
|
||||||
|
const footerRef = React.createRef<HTMLDivElement>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(props.open)
|
||||||
|
}, [props.open])
|
||||||
|
|
||||||
// UI management
|
// UI management
|
||||||
function openChange(open: boolean) {
|
function openChange(open: boolean) {
|
||||||
|
if (props.onOpenChange) props.onOpenChange(open)
|
||||||
setOpen(open)
|
setOpen(open)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,26 +159,34 @@ const AccountModal = (props: Props) => {
|
||||||
const user = response.data
|
const user = response.data
|
||||||
|
|
||||||
const cookieObj = {
|
const cookieObj = {
|
||||||
|
avatar: {
|
||||||
picture: user.avatar.picture,
|
picture: user.avatar.picture,
|
||||||
element: user.avatar.element,
|
element: user.avatar.element,
|
||||||
|
},
|
||||||
gender: user.gender,
|
gender: user.gender,
|
||||||
language: user.language,
|
language: user.language,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
}
|
}
|
||||||
|
|
||||||
setCookie('user', cookieObj, { path: '/' })
|
const expiresAt = new Date()
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 60)
|
||||||
|
setCookie('user', cookieObj, { path: '/', expires: expiresAt })
|
||||||
|
|
||||||
accountState.account.user = {
|
accountState.account.user = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
granblueId: '',
|
||||||
|
avatar: {
|
||||||
picture: user.avatar.picture,
|
picture: user.avatar.picture,
|
||||||
element: user.avatar.element,
|
element: user.avatar.element,
|
||||||
|
},
|
||||||
language: user.language,
|
language: user.language,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
gender: user.gender,
|
gender: user.gender,
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
if (props.onOpenChange) props.onOpenChange(false)
|
||||||
changeLanguage(router, user.language)
|
changeLanguage(router, user.language)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -279,17 +301,14 @@ const AccountModal = (props: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={openChange}>
|
<Dialog open={open} onOpenChange={openChange}>
|
||||||
<DialogTrigger asChild>
|
|
||||||
<li className="MenuItem">
|
|
||||||
<span>{t('menu.settings')}</span>
|
|
||||||
</li>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="Account Dialog"
|
className="Account"
|
||||||
|
headerref={headerRef}
|
||||||
|
footerref={footerRef}
|
||||||
onOpenAutoFocus={(event: Event) => {}}
|
onOpenAutoFocus={(event: Event) => {}}
|
||||||
onEscapeKeyDown={onEscapeKeyDown}
|
onEscapeKeyDown={onEscapeKeyDown}
|
||||||
>
|
>
|
||||||
<div className="DialogHeader">
|
<div className="DialogHeader" ref={headerRef}>
|
||||||
<div className="DialogTop">
|
<div className="DialogTop">
|
||||||
<DialogTitle className="SubTitle">
|
<DialogTitle className="SubTitle">
|
||||||
{t('modals.settings.title')}
|
{t('modals.settings.title')}
|
||||||
|
|
@ -304,18 +323,23 @@ const AccountModal = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={update}>
|
<form onSubmit={update}>
|
||||||
|
<div className="Fields">
|
||||||
{pictureField()}
|
{pictureField()}
|
||||||
{genderField()}
|
{genderField()}
|
||||||
{languageField()}
|
{languageField()}
|
||||||
{themeField()}
|
{themeField()}
|
||||||
|
</div>
|
||||||
|
<div className="DialogFooter" ref={footerRef}>
|
||||||
<Button
|
<Button
|
||||||
contained={true}
|
contained={true}
|
||||||
text={t('modals.settings.buttons.confirm')}
|
text={t('modals.settings.buttons.confirm')}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export default AccountModal
|
export default AccountModal
|
||||||
|
|
|
||||||
|
|
@ -2,51 +2,43 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: absolute;
|
position: fixed;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 21;
|
z-index: 31;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Alert {
|
.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;
|
border-radius: $unit;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit;
|
gap: $unit-2x;
|
||||||
min-width: $unit * 20;
|
min-width: 20vw;
|
||||||
max-width: $unit * 40;
|
max-width: 30vw;
|
||||||
padding: $unit * 4;
|
padding: $unit * 4;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
max-width: inherit;
|
||||||
|
width: 60vw;
|
||||||
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
font-size: $font-regular;
|
font-size: $font-regular;
|
||||||
line-height: 1.26;
|
line-height: 1.4;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: $bold;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
|
display: flex;
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
}
|
gap: $unit;
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import Button from '~components/Button'
|
import Button from '~components/Button'
|
||||||
|
import Overlay from '~components/Overlay'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean
|
||||||
title?: string
|
title?: string
|
||||||
message: string
|
message: string | React.ReactNode
|
||||||
primaryAction?: () => void
|
primaryAction?: () => void
|
||||||
primaryActionText?: string
|
primaryActionText?: string
|
||||||
cancelAction: () => void
|
cancelAction: () => void
|
||||||
|
|
@ -22,20 +23,29 @@ const Alert = (props: Props) => {
|
||||||
<AlertDialog.Overlay className="Overlay" onClick={props.cancelAction} />
|
<AlertDialog.Overlay className="Overlay" onClick={props.cancelAction} />
|
||||||
<div className="AlertWrapper">
|
<div className="AlertWrapper">
|
||||||
<AlertDialog.Content className="Alert">
|
<AlertDialog.Content className="Alert">
|
||||||
{props.title ? <AlertDialog.Title>Error</AlertDialog.Title> : ''}
|
{props.title ? (
|
||||||
|
<AlertDialog.Title>{props.title}</AlertDialog.Title>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
<AlertDialog.Description className="description">
|
<AlertDialog.Description className="description">
|
||||||
{props.message}
|
{props.message}
|
||||||
</AlertDialog.Description>
|
</AlertDialog.Description>
|
||||||
<div className="buttons">
|
<div className="buttons">
|
||||||
<AlertDialog.Cancel asChild>
|
<AlertDialog.Cancel asChild>
|
||||||
<Button
|
<Button
|
||||||
|
contained={true}
|
||||||
onClick={props.cancelAction}
|
onClick={props.cancelAction}
|
||||||
text={props.cancelActionText}
|
text={props.cancelActionText}
|
||||||
/>
|
/>
|
||||||
</AlertDialog.Cancel>
|
</AlertDialog.Cancel>
|
||||||
{props.primaryAction ? (
|
{props.primaryAction ? (
|
||||||
<AlertDialog.Action onClick={props.primaryAction}>
|
<AlertDialog.Action asChild>
|
||||||
{props.primaryActionText}
|
<Button
|
||||||
|
contained={true}
|
||||||
|
onClick={props.primaryAction}
|
||||||
|
text={props.primaryActionText}
|
||||||
|
/>
|
||||||
</AlertDialog.Action>
|
</AlertDialog.Action>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
|
|
@ -43,6 +53,7 @@ const Alert = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</AlertDialog.Content>
|
</AlertDialog.Content>
|
||||||
</div>
|
</div>
|
||||||
|
<Overlay open={props.open} visible={true} />
|
||||||
</AlertDialog.Portal>
|
</AlertDialog.Portal>
|
||||||
</AlertDialog.Root>
|
</AlertDialog.Root>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,199 +1,95 @@
|
||||||
import React, { ForwardedRef, useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/router'
|
import cloneDeep from 'lodash.clonedeep'
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
|
|
||||||
import Input from '~components/LabelledInput'
|
import SelectWithInput from '~components/SelectWithInput'
|
||||||
import Select from '~components/Select'
|
import { weaponAwakening, characterAwakening } from '~data/awakening'
|
||||||
import SelectItem from '~components/SelectItem'
|
|
||||||
|
|
||||||
import classNames from 'classnames'
|
|
||||||
|
|
||||||
import { weaponAwakening, characterAwakening } from '~utils/awakening'
|
|
||||||
import type { Awakening } from '~utils/awakening'
|
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
object: 'character' | 'weapon'
|
object: 'character' | 'weapon'
|
||||||
awakeningType?: number
|
type?: number
|
||||||
awakeningLevel?: number
|
level?: number
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void
|
||||||
sendValidity: (isValid: boolean) => void
|
sendValidity: (isValid: boolean) => void
|
||||||
sendValues: (type: number, level: number) => void
|
sendValues: (type: number, level: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AwakeningSelect = (props: Props) => {
|
const AwakeningSelect = (props: Props) => {
|
||||||
const router = useRouter()
|
// Data states
|
||||||
const locale =
|
const [awakeningType, setAwakeningType] = useState(
|
||||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
props.object === 'weapon' ? 0 : 1
|
||||||
const { t } = useTranslation('common')
|
)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
// Refs
|
|
||||||
const awakeningLevelInput = React.createRef<HTMLInputElement>()
|
|
||||||
|
|
||||||
// States
|
|
||||||
const [awakeningType, setAwakeningType] = useState(-1)
|
|
||||||
const [awakeningLevel, setAwakeningLevel] = useState(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':
|
||||||
// Classes
|
list = characterAwakening
|
||||||
const inputClasses = classNames({
|
break
|
||||||
Bound: true,
|
case 'weapon':
|
||||||
Hidden: awakeningType === -1,
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
const errorClasses = classNames({
|
return list
|
||||||
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])
|
|
||||||
|
|
||||||
// Set default awakening and level based on object type
|
// Set default awakening and level based on object type
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let defaultAwakening = 0
|
const defaultAwakening = props.object === 'weapon' ? 0 : 1
|
||||||
if (props.object === 'weapon') defaultAwakening = -1
|
const type = props.type != undefined ? props.type : defaultAwakening
|
||||||
|
|
||||||
setAwakeningType(
|
setAwakeningType(type)
|
||||||
props.awakeningType != undefined ? props.awakeningType : defaultAwakening
|
setAwakeningLevel(props.level ? props.level : 1)
|
||||||
)
|
}, [props.object, props.type, props.level])
|
||||||
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])
|
|
||||||
|
|
||||||
// Send validity of form when awakening level changes
|
// Send validity of form when awakening level changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
props.sendValidity(awakeningLevel > 0 && error === '')
|
props.sendValidity(awakeningLevel > 0)
|
||||||
}, [props.sendValidity, awakeningLevel, error])
|
}, [props.sendValidity, awakeningLevel])
|
||||||
|
|
||||||
// Classes
|
// Classes
|
||||||
function changeOpen() {
|
function changeOpen(open: boolean) {
|
||||||
setOpen(!open)
|
if (props.onOpenChange) props.onOpenChange(open)
|
||||||
props.onOpenChange(!open)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClose() {
|
function handleValueChange(type: number, level: number) {
|
||||||
props.onOpenChange(false)
|
setAwakeningType(type)
|
||||||
}
|
setAwakeningLevel(level)
|
||||||
|
props.sendValues(type, level)
|
||||||
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 (
|
|
||||||
<SelectItem key={i} value={awakening.id}>
|
|
||||||
{awakening.name[locale]}
|
|
||||||
</SelectItem>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (object === 'weapon') {
|
|
||||||
optionElements?.unshift(
|
|
||||||
<SelectItem key={-1} value={-1}>
|
|
||||||
{t('awakening.no_type')}
|
|
||||||
</SelectItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return optionElements
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelectChange(rawValue: string) {
|
|
||||||
const value = parseInt(rawValue)
|
|
||||||
setAwakeningType(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
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}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="AwakeningSelect">
|
<div className="Awakening">
|
||||||
<div className="AwakeningSet">
|
<SelectWithInput
|
||||||
<div className="fields">
|
object={`${props.object}_awakening`}
|
||||||
<Select
|
dataSet={chooseDataset()}
|
||||||
key="awakening_type"
|
selectValue={awakeningType}
|
||||||
value={`${awakeningType}`}
|
inputValue={awakeningLevel}
|
||||||
open={open}
|
onOpenChange={changeOpen}
|
||||||
onValueChange={handleSelectChange}
|
sendValidity={props.sendValidity}
|
||||||
onOpenChange={() => changeOpen()}
|
sendValues={handleValueChange}
|
||||||
onClose={onClose}
|
|
||||||
triggerClass="modal"
|
|
||||||
>
|
|
||||||
{generateOptions(props.object)}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
value={awakeningLevel}
|
|
||||||
className={inputClasses}
|
|
||||||
type="number"
|
|
||||||
placeholder={rangeString(props.object)}
|
|
||||||
min={1}
|
|
||||||
max={maxValue}
|
|
||||||
step="1"
|
|
||||||
onChange={handleInputChange}
|
|
||||||
visible={awakeningType !== -1 ? true : false}
|
|
||||||
ref={awakeningLevelInput}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className={errorClasses}>{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import SelectItem from '~components/SelectItem'
|
||||||
|
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
||||||
import { axData } from '~utils/axData'
|
import ax from '~data/ax'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
|
|
@ -155,7 +155,7 @@ const AXSelect = (props: Props) => {
|
||||||
|
|
||||||
if (props.currentSkills[0].modifier > -1 && primaryAxValueInput.current) {
|
if (props.currentSkills[0].modifier > -1 && primaryAxValueInput.current) {
|
||||||
const modifier = props.currentSkills[0].modifier
|
const modifier = props.currentSkills[0].modifier
|
||||||
const axSkill = axData[props.axType - 1][modifier]
|
const axSkill = ax[props.axType - 1][modifier]
|
||||||
setupInput(axSkill, primaryAxValueInput.current)
|
setupInput(axSkill, primaryAxValueInput.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -169,7 +169,7 @@ const AXSelect = (props: Props) => {
|
||||||
props.currentSkills[1].modifier != null
|
props.currentSkills[1].modifier != null
|
||||||
) {
|
) {
|
||||||
const firstSkill = props.currentSkills[0]
|
const firstSkill = props.currentSkills[0]
|
||||||
const primaryAxSkill = axData[props.axType - 1][firstSkill.modifier]
|
const primaryAxSkill = ax[props.axType - 1][firstSkill.modifier]
|
||||||
const secondaryAxSkill = findSecondaryAxSkill(
|
const secondaryAxSkill = findSecondaryAxSkill(
|
||||||
primaryAxSkill,
|
primaryAxSkill,
|
||||||
props.currentSkills[1]
|
props.currentSkills[1]
|
||||||
|
|
@ -185,7 +185,7 @@ const AXSelect = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function findSecondaryAxSkill(
|
function findSecondaryAxSkill(
|
||||||
axSkill: AxSkill | undefined,
|
axSkill: ItemSkill | undefined,
|
||||||
skillAtIndex: SimpleAxSkill
|
skillAtIndex: SimpleAxSkill
|
||||||
) {
|
) {
|
||||||
if (axSkill)
|
if (axSkill)
|
||||||
|
|
@ -213,7 +213,7 @@ const AXSelect = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateOptions(modifierSet: number) {
|
function generateOptions(modifierSet: number) {
|
||||||
const axOptions = axData[props.axType - 1]
|
const axOptions = ax[props.axType - 1]
|
||||||
|
|
||||||
let axOptionElements: React.ReactNode[] = []
|
let axOptionElements: React.ReactNode[] = []
|
||||||
if (modifierSet == 0) {
|
if (modifierSet == 0) {
|
||||||
|
|
@ -264,7 +264,7 @@ const AXSelect = (props: Props) => {
|
||||||
secondaryAxModifierSelect.current &&
|
secondaryAxModifierSelect.current &&
|
||||||
secondaryAxValueInput.current
|
secondaryAxValueInput.current
|
||||||
) {
|
) {
|
||||||
setupInput(axData[props.axType - 1][value], primaryAxValueInput.current)
|
setupInput(ax[props.axType - 1][value], primaryAxValueInput.current)
|
||||||
setPrimaryAxValue(0)
|
setPrimaryAxValue(0)
|
||||||
primaryAxValueInput.current.value = ''
|
primaryAxValueInput.current.value = ''
|
||||||
|
|
||||||
|
|
@ -280,7 +280,7 @@ const AXSelect = (props: Props) => {
|
||||||
const value = parseInt(rawValue)
|
const value = parseInt(rawValue)
|
||||||
setSecondaryAxModifier(value)
|
setSecondaryAxModifier(value)
|
||||||
|
|
||||||
const primaryAxSkill = axData[props.axType - 1][primaryAxModifier]
|
const primaryAxSkill = ax[props.axType - 1][primaryAxModifier]
|
||||||
const currentAxSkill = primaryAxSkill.secondary
|
const currentAxSkill = primaryAxSkill.secondary
|
||||||
? primaryAxSkill.secondary.find((skill) => skill.id == value)
|
? primaryAxSkill.secondary.find((skill) => skill.id == value)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
@ -304,7 +304,7 @@ const AXSelect = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePrimaryErrors(value: number) {
|
function handlePrimaryErrors(value: number) {
|
||||||
const primaryAxSkill = axData[props.axType - 1][primaryAxModifier]
|
const primaryAxSkill = ax[props.axType - 1][primaryAxModifier]
|
||||||
let newErrors = { ...errors }
|
let newErrors = { ...errors }
|
||||||
|
|
||||||
if (value < primaryAxSkill.minValue) {
|
if (value < primaryAxSkill.minValue) {
|
||||||
|
|
@ -333,7 +333,7 @@ const AXSelect = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSecondaryErrors(value: number) {
|
function handleSecondaryErrors(value: number) {
|
||||||
const primaryAxSkill = axData[props.axType - 1][primaryAxModifier]
|
const primaryAxSkill = ax[props.axType - 1][primaryAxModifier]
|
||||||
let newErrors = { ...errors }
|
let newErrors = { ...errors }
|
||||||
|
|
||||||
if (primaryAxSkill.secondary) {
|
if (primaryAxSkill.secondary) {
|
||||||
|
|
@ -373,7 +373,7 @@ const AXSelect = (props: Props) => {
|
||||||
return newErrors.axValue2.length === 0
|
return newErrors.axValue2.length === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupInput(ax: AxSkill | undefined, element: HTMLInputElement) {
|
function setupInput(ax: ItemSkill | undefined, element: HTMLInputElement) {
|
||||||
if (ax) {
|
if (ax) {
|
||||||
const rangeString = `${ax.minValue}~${ax.maxValue}${ax.suffix || ''}`
|
const rangeString = `${ax.minValue}~${ax.maxValue}${ax.suffix || ''}`
|
||||||
|
|
||||||
|
|
@ -410,6 +410,7 @@ const AXSelect = (props: Props) => {
|
||||||
onOpenChange={() => openSelect(1)}
|
onOpenChange={() => openSelect(1)}
|
||||||
onValueChange={handleAX1SelectChange}
|
onValueChange={handleAX1SelectChange}
|
||||||
triggerClass="modal"
|
triggerClass="modal"
|
||||||
|
overlayVisible={false}
|
||||||
>
|
>
|
||||||
{generateOptions(0)}
|
{generateOptions(0)}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -439,6 +440,7 @@ const AXSelect = (props: Props) => {
|
||||||
onValueChange={handleAX2SelectChange}
|
onValueChange={handleAX2SelectChange}
|
||||||
triggerClass="modal"
|
triggerClass="modal"
|
||||||
ref={secondaryAxModifierSelect}
|
ref={secondaryAxModifierSelect}
|
||||||
|
overlayVisible={false}
|
||||||
>
|
>
|
||||||
{generateOptions(1)}
|
{generateOptions(1)}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
font-size: $font-button;
|
font-size: $font-button;
|
||||||
font-weight: $normal;
|
font-weight: $normal;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
transition: 0.18s opacity ease-in-out;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&.Blended:hover,
|
&.Blended:hover,
|
||||||
|
|
@ -30,6 +32,24 @@
|
||||||
background: transparent;
|
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 {
|
&.Contained {
|
||||||
background: var(--button-contained-bg);
|
background: var(--button-contained-bg);
|
||||||
|
|
||||||
|
|
@ -42,10 +62,10 @@
|
||||||
stroke: #ff4d4d;
|
stroke: #ff4d4d;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.Active.Save {
|
&.Save {
|
||||||
color: #ff4d4d;
|
color: #ff4d4d;
|
||||||
|
|
||||||
.Accessory svg {
|
&.Active .Accessory svg {
|
||||||
fill: #ff4d4d;
|
fill: #ff4d4d;
|
||||||
stroke: #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 {
|
&:disabled {
|
||||||
background-color: var(--button-bg-disabled);
|
background-color: var(--button-bg-disabled);
|
||||||
color: var(--button-text-disabled);
|
color: var(--button-text-disabled);
|
||||||
|
|
@ -81,6 +109,17 @@
|
||||||
padding: $unit * 1.5;
|
padding: $unit * 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
&.destructive {
|
||||||
|
background: $error;
|
||||||
|
color: $grey-100;
|
||||||
|
|
||||||
|
.Accessory svg {
|
||||||
|
fill: $grey-100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.destructive:hover {
|
&.destructive:hover {
|
||||||
background: $error;
|
background: $error;
|
||||||
color: $grey-100;
|
color: $grey-100;
|
||||||
|
|
@ -90,24 +129,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.save:hover {
|
&.Save {
|
||||||
|
.Accessory svg {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--button-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Saved {
|
||||||
color: #ff4d4d;
|
color: #ff4d4d;
|
||||||
|
|
||||||
.Accessory svg {
|
.Accessory svg {
|
||||||
fill: #ff4d4d;
|
fill: #ff4d4d;
|
||||||
stroke: #ff4d4d;
|
stroke: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.save.Active {
|
|
||||||
color: #ff4d4d;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: darken(#ff4d4d, 30);
|
color: #ff4d4d;
|
||||||
|
|
||||||
.icon svg {
|
.Accessory svg {
|
||||||
fill: darken(#ff4d4d, 30);
|
fill: none;
|
||||||
stroke: darken(#ff4d4d, 30);
|
stroke: #ff4d4d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +171,10 @@
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
|
&.Arrow {
|
||||||
|
margin-top: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: var(--button-text);
|
fill: var(--button-text);
|
||||||
height: $dimension;
|
height: $dimension;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ interface Props
|
||||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
HTMLButtonElement
|
HTMLButtonElement
|
||||||
> {
|
> {
|
||||||
accessoryIcon?: React.ReactNode
|
leftAccessoryIcon?: React.ReactNode
|
||||||
|
leftAccessoryClassName?: string
|
||||||
|
rightAccessoryIcon?: React.ReactNode
|
||||||
|
rightAccessoryClassName?: string
|
||||||
active?: boolean
|
active?: boolean
|
||||||
blended?: boolean
|
blended?: boolean
|
||||||
contained?: boolean
|
contained?: boolean
|
||||||
|
|
@ -24,22 +27,45 @@ const defaultProps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
|
const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
|
||||||
{ accessoryIcon, active, blended, contained, buttonSize, text, ...props },
|
{
|
||||||
|
leftAccessoryIcon,
|
||||||
|
leftAccessoryClassName,
|
||||||
|
rightAccessoryIcon,
|
||||||
|
rightAccessoryClassName,
|
||||||
|
active,
|
||||||
|
blended,
|
||||||
|
contained,
|
||||||
|
buttonSize,
|
||||||
|
text,
|
||||||
|
...props
|
||||||
|
},
|
||||||
forwardedRef
|
forwardedRef
|
||||||
) {
|
) {
|
||||||
const classes = classNames(
|
const classes = classNames(buttonSize, props.className, {
|
||||||
{
|
|
||||||
Button: true,
|
Button: true,
|
||||||
Active: active,
|
Active: active,
|
||||||
Blended: blended,
|
Blended: blended,
|
||||||
Contained: contained,
|
Contained: contained,
|
||||||
},
|
})
|
||||||
buttonSize,
|
|
||||||
props.className
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasAccessory = () => {
|
const leftAccessoryClasses = classNames(leftAccessoryClassName, {
|
||||||
if (accessoryIcon) return <span className="Accessory">{accessoryIcon}</span>
|
Accessory: true,
|
||||||
|
Left: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rightAccessoryClasses = classNames(rightAccessoryClassName, {
|
||||||
|
Accessory: true,
|
||||||
|
Right: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasLeftAccessory = () => {
|
||||||
|
if (leftAccessoryIcon)
|
||||||
|
return <span className={leftAccessoryClasses}>{leftAccessoryIcon}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRightAccessory = () => {
|
||||||
|
if (rightAccessoryIcon)
|
||||||
|
return <span className={rightAccessoryClasses}>{rightAccessoryIcon}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasText = () => {
|
const hasText = () => {
|
||||||
|
|
@ -48,8 +74,9 @@ const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button {...props} className={classes} ref={forwardedRef}>
|
<button {...props} className={classes} ref={forwardedRef}>
|
||||||
{hasAccessory()}
|
{hasLeftAccessory()}
|
||||||
{hasText()}
|
{hasText()}
|
||||||
|
{hasRightAccessory()}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 (
|
|
||||||
<Dialog.Root>
|
|
||||||
<Dialog.Trigger asChild>
|
|
||||||
<li className="MenuItem">
|
|
||||||
<span>{t('modals.changelog.title')}</span>
|
|
||||||
</li>
|
|
||||||
</Dialog.Trigger>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Content
|
|
||||||
className="Dialog"
|
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
<div className="DialogHeader">
|
|
||||||
<Dialog.Title className="DialogTitle">
|
|
||||||
{t('menu.changelog')}
|
|
||||||
</Dialog.Title>
|
|
||||||
<Dialog.Close className="DialogClose" asChild>
|
|
||||||
<span>
|
|
||||||
<CrossIcon />
|
|
||||||
</span>
|
|
||||||
</Dialog.Close>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<Dialog.Description className="DialogDescription">
|
|
||||||
<h3 className="version">1.0</h3>
|
|
||||||
<ul className="notes">
|
|
||||||
<li>First release!</li>
|
|
||||||
<li>Content update - Mid-December 2022 Flash Gala</li>
|
|
||||||
<li>You can embed Youtube videos now</li>
|
|
||||||
<li>Better clicking - right-click and open in a new tab</li>
|
|
||||||
<li>Manually set dark mode in Account Settings</li>
|
|
||||||
<li>Lots of bugs squashed</li>
|
|
||||||
</ul>
|
|
||||||
</Dialog.Description>
|
|
||||||
</section>
|
|
||||||
</Dialog.Content>
|
|
||||||
<Dialog.Overlay className="Overlay" />
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ChangelogModal
|
|
||||||
17
components/ChangelogUnit/index.scss
Normal file
17
components/ChangelogUnit/index.scss
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
components/ChangelogUnit/index.tsx
Normal file
94
components/ChangelogUnit/index.tsx
Normal file
|
|
@ -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<Character | Weapon | Summon>()
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="ChangelogUnit" key={id}>
|
||||||
|
<img alt={item ? item.name[locale] : ''} src={imageUrl()} />
|
||||||
|
<h4>{item ? item.name[locale] : ''}</h4>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ChangelogUnit.defaultProps = defaultProps
|
||||||
|
|
||||||
|
export default ChangelogUnit
|
||||||
|
|
@ -2,7 +2,8 @@ import React, { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { Trans, useTranslation } from 'next-i18next'
|
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 Button from '~components/Button'
|
||||||
import Overlay from '~components/Overlay'
|
import Overlay from '~components/Overlay'
|
||||||
|
|
||||||
|
|
@ -29,6 +30,9 @@ const CharacterConflictModal = (props: Props) => {
|
||||||
// States
|
// States
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const footerRef = React.createRef<HTMLDivElement>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpen(props.open)
|
setOpen(props.open)
|
||||||
}, [setOpen, props.open])
|
}, [setOpen, props.open])
|
||||||
|
|
@ -71,10 +75,12 @@ const CharacterConflictModal = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={openChange}>
|
<Dialog open={open} onOpenChange={openChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="Conflict Dialog"
|
className="Conflict"
|
||||||
|
footerref={footerRef}
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
onEscapeKeyDown={close}
|
onEscapeKeyDown={close}
|
||||||
>
|
>
|
||||||
|
<div className="Content">
|
||||||
<p>
|
<p>
|
||||||
<Trans i18nKey="modals.conflict.character"></Trans>
|
<Trans i18nKey="modals.conflict.character"></Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -101,13 +107,21 @@ const CharacterConflictModal = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
</div>
|
||||||
<Button onClick={close} text={t('buttons.cancel')} />
|
<div className="DialogFooter" ref={footerRef}>
|
||||||
|
<div className="Buttons Span">
|
||||||
<Button
|
<Button
|
||||||
|
contained={true}
|
||||||
|
onClick={close}
|
||||||
|
text={t('buttons.cancel')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
contained={true}
|
||||||
onClick={props.resolveConflict}
|
onClick={props.resolveConflict}
|
||||||
text={t('modals.conflict.buttons.confirm')}
|
text={t('modals.conflict.buttons.confirm')}
|
||||||
/>
|
/>
|
||||||
</footer>
|
</div>
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<Overlay open={open} visible={true} />
|
<Overlay open={open} visible={true} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { getCookie } from 'cookies-next'
|
import { getCookie } from 'cookies-next'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
import { AxiosResponse } from 'axios'
|
import { AxiosError, AxiosResponse } from 'axios'
|
||||||
import debounce from 'lodash.debounce'
|
import debounce from 'lodash.debounce'
|
||||||
|
|
||||||
import Alert from '~components/Alert'
|
import Alert from '~components/Alert'
|
||||||
|
|
@ -15,13 +16,13 @@ import type { DetailsObject, JobSkillObject, SearchableObject } from '~types'
|
||||||
|
|
||||||
import api from '~utils/api'
|
import api from '~utils/api'
|
||||||
import { appState } from '~utils/appState'
|
import { appState } from '~utils/appState'
|
||||||
import { accountState } from '~utils/accountState'
|
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
new: boolean
|
new: boolean
|
||||||
|
editable: boolean
|
||||||
characters?: GridCharacter[]
|
characters?: GridCharacter[]
|
||||||
createParty: (details?: DetailsObject) => Promise<Party>
|
createParty: (details?: DetailsObject) => Promise<Party>
|
||||||
pushHistory?: (path: string) => void
|
pushHistory?: (path: string) => void
|
||||||
|
|
@ -31,15 +32,21 @@ const CharacterGrid = (props: Props) => {
|
||||||
// Constants
|
// Constants
|
||||||
const numCharacters: number = 5
|
const numCharacters: number = 5
|
||||||
|
|
||||||
|
// Localization
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
// Cookies
|
// Cookies
|
||||||
const cookie = getCookie('account')
|
const cookie = getCookie('account')
|
||||||
const accountData: AccountCookie = cookie
|
const accountData: AccountCookie = cookie
|
||||||
? JSON.parse(cookie as string)
|
? JSON.parse(cookie as string)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
// Set up state for error handling
|
||||||
|
const [axiosError, setAxiosError] = useState<AxiosResponse>()
|
||||||
|
const [errorAlertOpen, setErrorAlertOpen] = useState(false)
|
||||||
|
|
||||||
// Set up state for view management
|
// Set up state for view management
|
||||||
const { party, grid } = useSnapshot(appState)
|
const { party, grid } = useSnapshot(appState)
|
||||||
const [slug, setSlug] = useState()
|
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
|
||||||
// Set up state for conflict management
|
// Set up state for conflict management
|
||||||
|
|
@ -55,27 +62,23 @@ const CharacterGrid = (props: Props) => {
|
||||||
2: undefined,
|
2: undefined,
|
||||||
3: undefined,
|
3: undefined,
|
||||||
})
|
})
|
||||||
|
const [jobAccessory, setJobAccessory] = useState<JobAccessory>()
|
||||||
const [errorMessage, setErrorMessage] = 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<{
|
const [previousUncapValues, setPreviousUncapValues] = useState<{
|
||||||
[key: number]: number | undefined
|
[key: number]: number | undefined
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
// Set the editable flag only on first load
|
const [previousTranscendenceStages, setPreviousTranscendenceStages] =
|
||||||
useEffect(() => {
|
useState<{
|
||||||
// If user is logged in and matches
|
[key: number]: number | undefined
|
||||||
if (
|
}>({})
|
||||||
(accountData && party.user && accountData.userId === party.user.id) ||
|
|
||||||
props.new
|
|
||||||
)
|
|
||||||
appState.party.editable = true
|
|
||||||
else appState.party.editable = false
|
|
||||||
}, [props.new, accountData, party])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setJob(appState.party.job)
|
setJob(appState.party.job)
|
||||||
setJobSkills(appState.party.jobSkills)
|
setJobSkills(appState.party.jobSkills)
|
||||||
|
setJobAccessory(appState.party.accessory)
|
||||||
}, [appState])
|
}, [appState])
|
||||||
|
|
||||||
// Initialize an array of current uncap values for each characters
|
// Initialize an array of current uncap values for each characters
|
||||||
|
|
@ -101,10 +104,18 @@ const CharacterGrid = (props: Props) => {
|
||||||
.catch((error) => console.error(error))
|
.catch((error) => console.error(error))
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if (party.editable)
|
if (props.editable)
|
||||||
saveCharacter(party.id, character, position)
|
saveCharacter(party.id, character, position)
|
||||||
.then((response) => handleCharacterResponse(response.data))
|
.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)
|
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
|
// Methods: Saving job and job skills
|
||||||
const saveJob = async function (job?: Job) {
|
async function saveJob(job?: Job) {
|
||||||
const payload = {
|
const payload = {
|
||||||
party: {
|
party: {
|
||||||
job_id: job ? job.id : -1,
|
job_id: job ? job.id : -1,
|
||||||
|
|
@ -200,8 +220,8 @@ const CharacterGrid = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveJobSkill = function (skill: JobSkill, position: number) {
|
function saveJobSkill(skill: JobSkill, position: number) {
|
||||||
if (party.id && appState.party.editable) {
|
if (party.id && props.editable) {
|
||||||
const positionedKey = `skill${position}_id`
|
const positionedKey = `skill${position}_id`
|
||||||
|
|
||||||
let skillObject: {
|
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
|
// Methods: Helpers
|
||||||
function characterUncapLevel(character: Character) {
|
function characterUncapLevel(character: Character) {
|
||||||
let uncapLevel
|
let uncapLevel
|
||||||
|
|
@ -260,6 +298,7 @@ const CharacterGrid = (props: Props) => {
|
||||||
// Note: Saves, but debouncing is not working properly
|
// Note: Saves, but debouncing is not working properly
|
||||||
async function saveUncap(id: string, position: number, uncapLevel: number) {
|
async function saveUncap(id: string, position: number, uncapLevel: number) {
|
||||||
storePreviousUncapValue(position)
|
storePreviousUncapValue(position)
|
||||||
|
storePreviousTranscendenceStage(position)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (uncapLevel != previousUncapValues[position])
|
if (uncapLevel != previousUncapValues[position])
|
||||||
|
|
@ -271,11 +310,17 @@ const CharacterGrid = (props: Props) => {
|
||||||
|
|
||||||
// Revert optimistic UI
|
// Revert optimistic UI
|
||||||
updateUncapLevel(position, previousUncapValues[position])
|
updateUncapLevel(position, previousUncapValues[position])
|
||||||
|
updateTranscendenceStage(position, previousTranscendenceStages[position])
|
||||||
|
|
||||||
// Remove optimistic key
|
// Remove optimistic key
|
||||||
let newPreviousValues = { ...previousUncapValues }
|
let newPreviousTranscendenceStages = { ...previousTranscendenceStages }
|
||||||
delete newPreviousValues[position]
|
let newPreviousUncapValues = { ...previousUncapValues }
|
||||||
setPreviousUncapValues(newPreviousValues)
|
|
||||||
|
delete newPreviousTranscendenceStages[position]
|
||||||
|
delete newPreviousUncapValues[position]
|
||||||
|
|
||||||
|
setPreviousTranscendenceStages(newPreviousTranscendenceStages)
|
||||||
|
setPreviousUncapValues(newPreviousUncapValues)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,26 +329,26 @@ const CharacterGrid = (props: Props) => {
|
||||||
position: number,
|
position: number,
|
||||||
uncapLevel: number
|
uncapLevel: number
|
||||||
) {
|
) {
|
||||||
if (
|
if (props.editable) {
|
||||||
party.user &&
|
memoizeUncapAction(id, position, uncapLevel)
|
||||||
accountState.account.user &&
|
|
||||||
party.user.id === accountState.account.user.id
|
|
||||||
) {
|
|
||||||
memoizeAction(id, position, uncapLevel)
|
|
||||||
|
|
||||||
// Optimistically update UI
|
// Optimistically update UI
|
||||||
updateUncapLevel(position, uncapLevel)
|
updateUncapLevel(position, uncapLevel)
|
||||||
|
|
||||||
|
if (uncapLevel < 6) {
|
||||||
|
updateTranscendenceStage(position, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const memoizeAction = useCallback(
|
const memoizeUncapAction = useCallback(
|
||||||
(id: string, position: number, uncapLevel: number) => {
|
(id: string, position: number, uncapLevel: number) => {
|
||||||
debouncedAction(id, position, uncapLevel)
|
debouncedUncapAction(id, position, uncapLevel)
|
||||||
},
|
},
|
||||||
[props, previousUncapValues]
|
[props, previousUncapValues]
|
||||||
)
|
)
|
||||||
|
|
||||||
const debouncedAction = useMemo(
|
const debouncedUncapAction = useMemo(
|
||||||
() =>
|
() =>
|
||||||
debounce((id, position, number) => {
|
debounce((id, position, number) => {
|
||||||
saveUncap(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() {
|
function cancelAlert() {
|
||||||
setErrorMessage('')
|
setErrorMessage('')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render: JSX components
|
// Render: JSX components
|
||||||
|
const errorAlert = () => {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
open={errorAlertOpen}
|
||||||
|
title={axiosError ? `${axiosError.status}` : 'Error'}
|
||||||
|
message={t(`errors.${axiosError?.statusText.toLowerCase()}`)}
|
||||||
|
cancelAction={() => setErrorAlertOpen(false)}
|
||||||
|
cancelActionText={t('buttons.confirm')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Alert
|
<Alert
|
||||||
|
|
@ -349,9 +502,11 @@ const CharacterGrid = (props: Props) => {
|
||||||
<JobSection
|
<JobSection
|
||||||
job={job}
|
job={job}
|
||||||
jobSkills={jobSkills}
|
jobSkills={jobSkills}
|
||||||
editable={party.editable}
|
jobAccessory={jobAccessory}
|
||||||
|
editable={props.editable}
|
||||||
saveJob={saveJob}
|
saveJob={saveJob}
|
||||||
saveSkill={saveJobSkill}
|
saveSkill={saveJobSkill}
|
||||||
|
saveAccessory={saveAccessory}
|
||||||
/>
|
/>
|
||||||
<CharacterConflictModal
|
<CharacterConflictModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
|
|
@ -367,16 +522,19 @@ const CharacterGrid = (props: Props) => {
|
||||||
<li key={`grid_unit_${i}`}>
|
<li key={`grid_unit_${i}`}>
|
||||||
<CharacterUnit
|
<CharacterUnit
|
||||||
gridCharacter={grid.characters[i]}
|
gridCharacter={grid.characters[i]}
|
||||||
editable={party.editable}
|
editable={props.editable}
|
||||||
position={i}
|
position={i}
|
||||||
updateObject={receiveCharacterFromSearch}
|
updateObject={receiveCharacterFromSearch}
|
||||||
updateUncap={initiateUncapUpdate}
|
updateUncap={initiateUncapUpdate}
|
||||||
|
updateTranscendence={initiateTranscendenceUpdate}
|
||||||
|
removeCharacter={removeCharacter}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{errorAlert()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
@ -2,16 +2,29 @@ import React from 'react'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useTranslation } from 'next-i18next'
|
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 WeaponLabelIcon from '~components/WeaponLabelIcon'
|
||||||
import UncapIndicator from '~components/UncapIndicator'
|
import UncapIndicator from '~components/UncapIndicator'
|
||||||
|
|
||||||
|
import {
|
||||||
|
overMastery,
|
||||||
|
aetherialMastery,
|
||||||
|
permanentMastery,
|
||||||
|
} from '~data/overMastery'
|
||||||
|
import { characterAwakening } from '~data/awakening'
|
||||||
|
import { ExtendedMastery } from '~types'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
gridCharacter: GridCharacter
|
gridCharacter: GridCharacter
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
onTriggerClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KeyNames {
|
interface KeyNames {
|
||||||
|
|
@ -43,10 +56,19 @@ const CharacterHovercard = (props: Props) => {
|
||||||
]
|
]
|
||||||
|
|
||||||
const tintElement = Element[props.gridCharacter.object.element]
|
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 <i className="Perpetuity" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function characterImage() {
|
function characterImage() {
|
||||||
let imgSrc = ''
|
let imgSrc = ''
|
||||||
|
|
@ -66,19 +88,154 @@ const CharacterHovercard = (props: Props) => {
|
||||||
return imgSrc
|
return imgSrc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function masteryElement(dictionary: ItemSkill[], mastery: ExtendedMastery) {
|
||||||
|
const canonicalMastery = dictionary.find(
|
||||||
|
(item) => item.id === mastery.modifier
|
||||||
|
)
|
||||||
|
|
||||||
|
if (canonicalMastery) {
|
||||||
return (
|
return (
|
||||||
<HoverCard.Root>
|
<li className="ExtendedMastery" key={canonicalMastery.id}>
|
||||||
<HoverCard.Trigger>{props.children}</HoverCard.Trigger>
|
<img
|
||||||
<HoverCard.Portal>
|
alt={canonicalMastery.name[locale]}
|
||||||
<HoverCard.Content className="Weapon Hovercard">
|
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/mastery/${canonicalMastery.slug}.png`}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{canonicalMastery.name[locale]}</strong>
|
||||||
|
{`+${mastery.strength}${canonicalMastery.suffix}`}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overMasterySection = () => {
|
||||||
|
if (props.gridCharacter && props.gridCharacter.over_mastery) {
|
||||||
|
return (
|
||||||
|
<section className="Mastery">
|
||||||
|
<h5 className={tintElement}>
|
||||||
|
{t('modals.characters.subtitles.ring')}
|
||||||
|
</h5>
|
||||||
|
<ul>
|
||||||
|
{[...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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const aetherialMasterySection = () => {
|
||||||
|
if (
|
||||||
|
props.gridCharacter &&
|
||||||
|
props.gridCharacter.aetherial_mastery &&
|
||||||
|
props.gridCharacter.aetherial_mastery.modifier > 0
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<section className="Mastery">
|
||||||
|
<h5 className={tintElement}>
|
||||||
|
{t('modals.characters.subtitles.earring')}
|
||||||
|
</h5>
|
||||||
|
<ul>
|
||||||
|
{masteryElement(
|
||||||
|
aetherialMastery,
|
||||||
|
props.gridCharacter.aetherial_mastery
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const permanentMasterySection = () => {
|
||||||
|
if (props.gridCharacter && props.gridCharacter.perpetuity) {
|
||||||
|
return (
|
||||||
|
<section className="Mastery">
|
||||||
|
<h5 className={tintElement}>
|
||||||
|
{t('modals.characters.subtitles.permanent')}
|
||||||
|
</h5>
|
||||||
|
<ul>
|
||||||
|
{[...Array(4)].map((e, i) => {
|
||||||
|
return masteryElement(permanentMastery, {
|
||||||
|
modifier: i + 1,
|
||||||
|
strength: permanentMastery[i].maxValue,
|
||||||
|
})
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const awakeningSection = () => {
|
||||||
|
const gridAwakening = props.gridCharacter.awakening
|
||||||
|
const awakening = characterAwakening.find(
|
||||||
|
(awakening) => awakening.id === gridAwakening?.type
|
||||||
|
)
|
||||||
|
|
||||||
|
if (gridAwakening && awakening) {
|
||||||
|
return (
|
||||||
|
<section className="Awakening">
|
||||||
|
<h5 className={tintElement}>
|
||||||
|
{t('modals.characters.subtitles.awakening')}
|
||||||
|
</h5>
|
||||||
|
<div>
|
||||||
|
{gridAwakening.type > 1 ? (
|
||||||
|
<img
|
||||||
|
alt={awakening.name[locale]}
|
||||||
|
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/awakening/character_${gridAwakening.type}.jpg`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
<strong>{`${awakening.name[locale]}`}</strong>
|
||||||
|
{`Lv${gridAwakening.level}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wikiButton = (
|
||||||
|
<Button
|
||||||
|
className={tintElement}
|
||||||
|
text={t('buttons.wiki')}
|
||||||
|
onClick={goTo}
|
||||||
|
contained={true}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Hovercard openDelay={350}>
|
||||||
|
<HovercardTrigger asChild onClick={props.onTriggerClick}>
|
||||||
|
{props.children}
|
||||||
|
</HovercardTrigger>
|
||||||
|
<HovercardContent className="Character" side="top">
|
||||||
<div className="top">
|
<div className="top">
|
||||||
<div className="title">
|
<div className="title">
|
||||||
<h4>{props.gridCharacter.object.name[locale]}</h4>
|
<h4>{props.gridCharacter.object.name[locale]}</h4>
|
||||||
|
<div className="Image">
|
||||||
|
{perpetuity()}
|
||||||
<img
|
<img
|
||||||
alt={props.gridCharacter.object.name[locale]}
|
alt={props.gridCharacter.object.name[locale]}
|
||||||
src={characterImage()}
|
src={characterImage()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="subInfo">
|
<div className="subInfo">
|
||||||
<div className="icons">
|
<div className="icons">
|
||||||
<WeaponLabelIcon
|
<WeaponLabelIcon
|
||||||
|
|
@ -107,18 +264,18 @@ const CharacterHovercard = (props: Props) => {
|
||||||
type="character"
|
type="character"
|
||||||
ulb={props.gridCharacter.object.uncap.ulb || false}
|
ulb={props.gridCharacter.object.uncap.ulb || false}
|
||||||
flb={props.gridCharacter.object.uncap.flb || false}
|
flb={props.gridCharacter.object.uncap.flb || false}
|
||||||
special={false}
|
transcendenceStage={props.gridCharacter.transcendence_step}
|
||||||
|
special={props.gridCharacter.object.special}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{wikiButton}
|
||||||
<a className={`Button ${tintElement}`} href={wikiUrl} target="_new">
|
{awakeningSection()}
|
||||||
{t('buttons.wiki')}
|
{overMasterySection()}
|
||||||
</a>
|
{aetherialMasterySection()}
|
||||||
<HoverCard.Arrow />
|
{permanentMasterySection()}
|
||||||
</HoverCard.Content>
|
</HovercardContent>
|
||||||
</HoverCard.Portal>
|
</Hovercard>
|
||||||
</HoverCard.Root>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
78
components/CharacterModal/index.scss
Normal file
78
components/CharacterModal/index.scss
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
.Character.DialogContent {
|
||||||
|
gap: $unit;
|
||||||
|
min-width: 480px;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
min-width: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.DialogHeader {
|
||||||
|
transition: 0.18s padding-top ease-in-out;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
&.Scrolled {
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
|
box-shadow: 0 1px 12px rgba(0, 0, 0, 0.34);
|
||||||
|
padding-top: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
transition: 0.2s width ease-in-out;
|
||||||
|
width: $unit-6x !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.DialogTitle {
|
||||||
|
font-size: $font-large;
|
||||||
|
}
|
||||||
|
|
||||||
|
.SubTitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mods {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-4x;
|
||||||
|
padding: 0 $unit-4x $unit-2x;
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-half;
|
||||||
|
|
||||||
|
&.inline {
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
color: $grey-55;
|
||||||
|
font-size: $font-small;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
background-color: $grey-90;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Button {
|
||||||
|
font-size: $font-regular;
|
||||||
|
padding: ($unit * 1.5) ($unit-2x);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.btn-disabled {
|
||||||
|
background: $grey-90;
|
||||||
|
color: $grey-70;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
307
components/CharacterModal/index.tsx
Normal file
307
components/CharacterModal/index.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
// Core dependencies
|
||||||
|
import React, {
|
||||||
|
PropsWithChildren,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import { AxiosResponse } from 'axios'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
// UI dependencies
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '~components/Dialog'
|
||||||
|
import DialogContent from '~components/DialogContent'
|
||||||
|
import Button from '~components/Button'
|
||||||
|
import SelectWithInput from '~components/SelectWithInput'
|
||||||
|
import AwakeningSelect from '~components/AwakeningSelect'
|
||||||
|
import RingSelect from '~components/RingSelect'
|
||||||
|
import Switch from '~components/Switch'
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
import api from '~utils/api'
|
||||||
|
import { appState } from '~utils/appState'
|
||||||
|
import { retrieveCookies } from '~utils/retrieveCookies'
|
||||||
|
import elementalizeAetherialMastery from '~utils/elementalizeAetherialMastery'
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const emptyExtendedMastery: ExtendedMastery = {
|
||||||
|
modifier: 0,
|
||||||
|
strength: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles and icons
|
||||||
|
import CrossIcon from '~public/icons/Cross.svg'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import {
|
||||||
|
CharacterOverMastery,
|
||||||
|
ExtendedMastery,
|
||||||
|
GridCharacterObject,
|
||||||
|
} from '~types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
gridCharacter: GridCharacter
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
updateCharacter: (object: GridCharacterObject) => Promise<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const CharacterModal = ({
|
||||||
|
gridCharacter,
|
||||||
|
children,
|
||||||
|
open: modalOpen,
|
||||||
|
onOpenChange,
|
||||||
|
updateCharacter,
|
||||||
|
}: PropsWithChildren<Props>) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const locale =
|
||||||
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
|
// Cookies
|
||||||
|
const cookies = retrieveCookies()
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [formValid, setFormValid] = useState(false)
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const headerRef = React.createRef<HTMLDivElement>()
|
||||||
|
const footerRef = React.createRef<HTMLDivElement>()
|
||||||
|
|
||||||
|
// Classes
|
||||||
|
const headerClasses = classNames({
|
||||||
|
DialogHeader: true,
|
||||||
|
Short: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Callbacks and Hooks
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(modalOpen)
|
||||||
|
}, [modalOpen])
|
||||||
|
|
||||||
|
// Character properties: Perpetuity
|
||||||
|
const [perpetuity, setPerpetuity] = useState(false)
|
||||||
|
|
||||||
|
// Character properties: Ring
|
||||||
|
const [rings, setRings] = useState<CharacterOverMastery>({
|
||||||
|
1: { ...emptyExtendedMastery, modifier: 1 },
|
||||||
|
2: { ...emptyExtendedMastery, modifier: 2 },
|
||||||
|
3: emptyExtendedMastery,
|
||||||
|
4: emptyExtendedMastery,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Character properties: Earrings
|
||||||
|
const [earring, setEarring] = useState<ExtendedMastery>(emptyExtendedMastery)
|
||||||
|
|
||||||
|
// Character properties: Awakening
|
||||||
|
const [awakeningType, setAwakeningType] = useState(0)
|
||||||
|
const [awakeningLevel, setAwakeningLevel] = useState(0)
|
||||||
|
|
||||||
|
// Character properties: Transcendence
|
||||||
|
const [transcendenceStep, setTranscendenceStep] = useState(0)
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
useEffect(() => {
|
||||||
|
if (gridCharacter.aetherial_mastery) {
|
||||||
|
setEarring({
|
||||||
|
modifier: gridCharacter.aetherial_mastery.modifier,
|
||||||
|
strength: gridCharacter.aetherial_mastery.strength,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setAwakeningType(gridCharacter.awakening.type)
|
||||||
|
setAwakeningLevel(gridCharacter.awakening.level)
|
||||||
|
setPerpetuity(gridCharacter.perpetuity)
|
||||||
|
}, [gridCharacter])
|
||||||
|
|
||||||
|
// Prepare the GridWeaponObject to send to the server
|
||||||
|
function prepareObject() {
|
||||||
|
let object: GridCharacterObject = {
|
||||||
|
character: {
|
||||||
|
ring1: {
|
||||||
|
modifier: rings[1].modifier,
|
||||||
|
strength: rings[1].strength,
|
||||||
|
},
|
||||||
|
ring2: {
|
||||||
|
modifier: rings[2].modifier,
|
||||||
|
strength: rings[2].strength,
|
||||||
|
},
|
||||||
|
ring3: {
|
||||||
|
modifier: rings[3].modifier,
|
||||||
|
strength: rings[3].strength,
|
||||||
|
},
|
||||||
|
ring4: {
|
||||||
|
modifier: rings[4].modifier,
|
||||||
|
strength: rings[4].strength,
|
||||||
|
},
|
||||||
|
earring: {
|
||||||
|
modifier: earring.modifier,
|
||||||
|
strength: earring.strength,
|
||||||
|
},
|
||||||
|
awakening: {
|
||||||
|
type: awakeningType,
|
||||||
|
level: awakeningLevel,
|
||||||
|
},
|
||||||
|
transcendence_step: transcendenceStep,
|
||||||
|
perpetuity: perpetuity,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: UI state management
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
setOpen(open)
|
||||||
|
onOpenChange(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Receive data from components
|
||||||
|
function receiveRingValues(overMastery: CharacterOverMastery) {
|
||||||
|
setRings(overMastery)
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiveEarringValues(
|
||||||
|
earringModifier: number,
|
||||||
|
earringStrength: number
|
||||||
|
) {
|
||||||
|
setEarring({
|
||||||
|
modifier: earringModifier,
|
||||||
|
strength: earringStrength,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCheckedChange(checked: boolean) {
|
||||||
|
setPerpetuity(checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateCharacter() {
|
||||||
|
await updateCharacter(prepareObject())
|
||||||
|
|
||||||
|
setOpen(false)
|
||||||
|
if (onOpenChange) onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiveAwakeningValues(type: number, level: number) {
|
||||||
|
setAwakeningType(type)
|
||||||
|
setAwakeningLevel(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiveValidity(isValid: boolean) {
|
||||||
|
setFormValid(isValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ringSelect = () => {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h3>{t('modals.characters.subtitles.ring')}</h3>
|
||||||
|
<RingSelect
|
||||||
|
gridCharacter={gridCharacter}
|
||||||
|
sendValues={receiveRingValues}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const earringSelect = () => {
|
||||||
|
const earringData = elementalizeAetherialMastery(gridCharacter)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h3>{t('modals.characters.subtitles.earring')}</h3>
|
||||||
|
<SelectWithInput
|
||||||
|
object="earring"
|
||||||
|
dataSet={earringData}
|
||||||
|
selectValue={earring.modifier ? earring.modifier : 0}
|
||||||
|
inputValue={earring.strength ? earring.strength : 0}
|
||||||
|
sendValidity={receiveValidity}
|
||||||
|
sendValues={receiveEarringValues}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const awakeningSelect = () => {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h3>{t('modals.characters.subtitles.awakening')}</h3>
|
||||||
|
<AwakeningSelect
|
||||||
|
object="character"
|
||||||
|
type={awakeningType}
|
||||||
|
level={awakeningLevel}
|
||||||
|
sendValidity={receiveValidity}
|
||||||
|
sendValues={receiveAwakeningValues}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const perpetuitySwitch = () => {
|
||||||
|
return (
|
||||||
|
<section className="inline">
|
||||||
|
<h3>{t('modals.characters.subtitles.permanent')}</h3>
|
||||||
|
<Switch onCheckedChange={handleCheckedChange} checked={perpetuity} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
<DialogContent
|
||||||
|
className="Character"
|
||||||
|
headerref={headerRef}
|
||||||
|
footerref={footerRef}
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
onEscapeKeyDown={() => {}}
|
||||||
|
>
|
||||||
|
<div className={headerClasses} ref={headerRef}>
|
||||||
|
<img
|
||||||
|
alt={gridCharacter.object.name[locale]}
|
||||||
|
className="DialogImage"
|
||||||
|
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-square/${gridCharacter.object.granblue_id}_01.jpg`}
|
||||||
|
/>
|
||||||
|
<div className="DialogTop">
|
||||||
|
<DialogTitle className="SubTitle">
|
||||||
|
{t('modals.characters.title')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogTitle className="DialogTitle">
|
||||||
|
{gridCharacter.object.name[locale]}
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogClose className="DialogClose" asChild>
|
||||||
|
<span>
|
||||||
|
<CrossIcon />
|
||||||
|
</span>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mods">
|
||||||
|
{perpetuitySwitch()}
|
||||||
|
{ringSelect()}
|
||||||
|
{earringSelect()}
|
||||||
|
{awakeningSelect()}
|
||||||
|
</div>
|
||||||
|
<div className="DialogFooter" ref={footerRef}>
|
||||||
|
<Button
|
||||||
|
contained={true}
|
||||||
|
onClick={handleUpdateCharacter}
|
||||||
|
disabled={!formValid}
|
||||||
|
text={t('modals.characters.buttons.confirm')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CharacterModal
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
gap: calc($unit / 2);
|
gap: calc($unit / 2);
|
||||||
// min-height: 320px;
|
// min-height: 320px;
|
||||||
// max-width: 200px;
|
// max-width: 200px;
|
||||||
|
position: relative;
|
||||||
margin-bottom: $unit * 4;
|
margin-bottom: $unit * 4;
|
||||||
|
|
||||||
&.editable .CharacterImage:hover {
|
&.editable .CharacterImage:hover {
|
||||||
|
|
@ -22,6 +23,17 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Button {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .Button,
|
||||||
|
.Button.Clicked {
|
||||||
|
pointer-events: initial;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
h3,
|
h3,
|
||||||
ul {
|
ul {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
@ -57,9 +69,11 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: all 0.18s ease-in-out;
|
transition: $duration-zoom all ease-in-out;
|
||||||
height: auto;
|
height: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
-webkit-user-select: none; /* Safari */
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
&:hover .icon svg {
|
&:hover .icon svg {
|
||||||
fill: var(--icon-secondary-hover);
|
fill: var(--icon-secondary-hover);
|
||||||
|
|
@ -72,6 +86,7 @@
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
transition: $duration-color-fade fill ease-in-out;
|
||||||
fill: var(--icon-secondary);
|
fill: var(--icon-secondary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -82,4 +97,34 @@
|
||||||
font-size: $font-tiny;
|
font-size: $font-tiny;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover .Perpetuity.Empty {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Perpetuity {
|
||||||
|
position: absolute;
|
||||||
|
background-image: url('/icons/perpetuity/filled.svg');
|
||||||
|
background-size: $unit-4x $unit-4x;
|
||||||
|
z-index: 20;
|
||||||
|
top: $unit * -1;
|
||||||
|
right: $unit-3x;
|
||||||
|
width: $unit-4x;
|
||||||
|
height: $unit-4x;
|
||||||
|
transition: $duration-zoom opacity ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-image: url('/icons/perpetuity/empty.svg');
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Empty {
|
||||||
|
background-image: url('/icons/perpetuity/empty.svg');
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-image: url('/icons/perpetuity/filled.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,35 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { MouseEvent, useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useTranslation } from 'next-i18next'
|
import { Trans, useTranslation } from 'next-i18next'
|
||||||
import classnames from 'classnames'
|
import { AxiosResponse } from 'axios'
|
||||||
|
import classNames from 'classnames'
|
||||||
import { appState } from '~utils/appState'
|
|
||||||
|
|
||||||
|
import Alert from '~components/Alert'
|
||||||
|
import Button from '~components/Button'
|
||||||
import CharacterHovercard from '~components/CharacterHovercard'
|
import CharacterHovercard from '~components/CharacterHovercard'
|
||||||
|
import CharacterModal from '~components/CharacterModal'
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
} from '~components/ContextMenu'
|
||||||
|
import ContextMenuItem from '~components/ContextMenuItem'
|
||||||
import SearchModal from '~components/SearchModal'
|
import SearchModal from '~components/SearchModal'
|
||||||
import UncapIndicator from '~components/UncapIndicator'
|
import UncapIndicator from '~components/UncapIndicator'
|
||||||
import PlusIcon from '~public/icons/Add.svg'
|
|
||||||
|
|
||||||
import type { SearchableObject } from '~types'
|
import api from '~utils/api'
|
||||||
|
import { appState } from '~utils/appState'
|
||||||
|
|
||||||
|
import PlusIcon from '~public/icons/Add.svg'
|
||||||
|
import SettingsIcon from '~public/icons/Settings.svg'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type {
|
||||||
|
GridCharacterObject,
|
||||||
|
PerpetuityObject,
|
||||||
|
SearchableObject,
|
||||||
|
} from '~types'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
|
|
@ -19,48 +37,151 @@ interface Props {
|
||||||
gridCharacter?: GridCharacter
|
gridCharacter?: GridCharacter
|
||||||
position: number
|
position: number
|
||||||
editable: boolean
|
editable: boolean
|
||||||
|
removeCharacter: (id: string) => void
|
||||||
updateObject: (object: SearchableObject, position: number) => void
|
updateObject: (object: SearchableObject, position: number) => void
|
||||||
updateUncap: (id: string, position: number, uncap: number) => void
|
updateUncap: (id: string, position: number, uncap: number) => void
|
||||||
|
updateTranscendence: (id: string, position: number, stage: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CharacterUnit = (props: Props) => {
|
const CharacterUnit = ({
|
||||||
|
gridCharacter,
|
||||||
|
position,
|
||||||
|
editable,
|
||||||
|
removeCharacter: sendCharacterToRemove,
|
||||||
|
updateObject,
|
||||||
|
updateUncap,
|
||||||
|
updateTranscendence,
|
||||||
|
}: Props) => {
|
||||||
|
// Translations and locale
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
const { party, grid } = useSnapshot(appState)
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const locale =
|
const locale =
|
||||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
|
||||||
|
// State snapshot
|
||||||
|
const { party, grid } = useSnapshot(appState)
|
||||||
|
|
||||||
|
// State: UI
|
||||||
|
const [detailsModalOpen, setDetailsModalOpen] = useState(false)
|
||||||
|
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
||||||
|
const [contextMenuOpen, setContextMenuOpen] = useState(false)
|
||||||
|
const [alertOpen, setAlertOpen] = useState(false)
|
||||||
|
|
||||||
|
// State: Other
|
||||||
const [imageUrl, setImageUrl] = useState('')
|
const [imageUrl, setImageUrl] = useState('')
|
||||||
|
|
||||||
const classes = classnames({
|
// Classes
|
||||||
|
const classes = classNames({
|
||||||
CharacterUnit: true,
|
CharacterUnit: true,
|
||||||
editable: props.editable,
|
editable: editable,
|
||||||
filled: props.gridCharacter !== undefined,
|
filled: gridCharacter !== undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const gridCharacter = props.gridCharacter
|
const buttonClasses = classNames({
|
||||||
|
Options: true,
|
||||||
|
Clicked: contextMenuOpen,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Other
|
||||||
const character = gridCharacter?.object
|
const character = gridCharacter?.object
|
||||||
|
|
||||||
|
// Hooks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
generateImageUrl()
|
generateImageUrl()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Methods: Open layer
|
||||||
|
function openCharacterModal(event: Event) {
|
||||||
|
setDetailsModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSearchModal() {
|
||||||
|
if (editable) setSearchModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRemoveCharacterAlert() {
|
||||||
|
setAlertOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Handle button clicked
|
||||||
|
function handleButtonClicked() {
|
||||||
|
setContextMenuOpen(!contextMenuOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePerpetuityClick() {
|
||||||
|
if (gridCharacter) {
|
||||||
|
let object: PerpetuityObject = {
|
||||||
|
character: { perpetuity: !gridCharacter.perpetuity },
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCharacter(object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Handle open change
|
||||||
|
function handleCharacterModalOpenChange(open: boolean) {
|
||||||
|
setDetailsModalOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchModalOpenChange(open: boolean) {
|
||||||
|
setSearchModalOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContextMenuOpenChange(open: boolean) {
|
||||||
|
if (!open) setContextMenuOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Mutate data
|
||||||
|
|
||||||
|
// Send the GridWeaponObject to the server
|
||||||
|
async function updateCharacter(
|
||||||
|
object: GridCharacterObject | PerpetuityObject
|
||||||
|
) {
|
||||||
|
if (gridCharacter)
|
||||||
|
return await api.endpoints.grid_characters
|
||||||
|
.update(gridCharacter.id, object)
|
||||||
|
.then((response) => processResult(response))
|
||||||
|
.catch((error) => processError(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the server's response to state
|
||||||
|
function processResult(response: AxiosResponse) {
|
||||||
|
const gridCharacter: GridCharacter = response.data
|
||||||
|
appState.grid.characters[gridCharacter.position] = gridCharacter
|
||||||
|
}
|
||||||
|
|
||||||
|
function processError(error: any) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
function passUncapData(uncap: number) {
|
||||||
|
if (gridCharacter) updateUncap(gridCharacter.id, position, uncap)
|
||||||
|
}
|
||||||
|
|
||||||
|
function passTranscendenceData(stage: number) {
|
||||||
|
if (gridCharacter) updateTranscendence(gridCharacter.id, position, stage)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCharacter() {
|
||||||
|
if (gridCharacter) sendCharacterToRemove(gridCharacter.id)
|
||||||
|
setAlertOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Image string generation
|
||||||
function generateImageUrl() {
|
function generateImageUrl() {
|
||||||
let imgSrc = ''
|
let imgSrc = ''
|
||||||
|
|
||||||
if (props.gridCharacter) {
|
if (gridCharacter) {
|
||||||
const character = props.gridCharacter.object!
|
const character = gridCharacter.object!
|
||||||
|
|
||||||
// Change the image based on the uncap level
|
// Change the image based on the uncap level
|
||||||
let suffix = '01'
|
let suffix = '01'
|
||||||
if (props.gridCharacter.uncap_level == 6) suffix = '04'
|
if (gridCharacter.transcendence_step > 0) suffix = '04'
|
||||||
else if (props.gridCharacter.uncap_level == 5) suffix = '03'
|
else if (gridCharacter.uncap_level >= 5) suffix = '03'
|
||||||
else if (props.gridCharacter.uncap_level > 2) suffix = '02'
|
else if (gridCharacter.uncap_level > 2) suffix = '02'
|
||||||
|
|
||||||
// Special casing for Lyria (and Young Cat eventually)
|
// Special casing for Lyria (and Young Cat eventually)
|
||||||
if (props.gridCharacter.object.granblue_id === '3030182000') {
|
if (gridCharacter.object.granblue_id === '3030182000') {
|
||||||
let element = 1
|
let element = 1
|
||||||
if (grid.weapons.mainWeapon && grid.weapons.mainWeapon.element) {
|
if (grid.weapons.mainWeapon && grid.weapons.mainWeapon.element) {
|
||||||
element = grid.weapons.mainWeapon.element
|
element = grid.weapons.mainWeapon.element
|
||||||
|
|
@ -77,15 +198,111 @@ const CharacterUnit = (props: Props) => {
|
||||||
setImageUrl(imgSrc)
|
setImageUrl(imgSrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
function passUncapData(uncap: number) {
|
// Methods: Layer element rendering
|
||||||
if (props.gridCharacter)
|
const characterModal = () => {
|
||||||
props.updateUncap(props.gridCharacter.id, props.position, uncap)
|
if (gridCharacter) {
|
||||||
|
return (
|
||||||
|
<CharacterModal
|
||||||
|
gridCharacter={gridCharacter}
|
||||||
|
open={detailsModalOpen}
|
||||||
|
onOpenChange={handleCharacterModalOpenChange}
|
||||||
|
updateCharacter={updateCharacter}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = (
|
const contextMenu = () => {
|
||||||
<div className="CharacterImage">
|
if (editable && gridCharacter && gridCharacter.id) {
|
||||||
<img alt={character?.name.en} className="grid_image" src={imageUrl} />
|
return (
|
||||||
{props.editable ? (
|
<>
|
||||||
|
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
leftAccessoryIcon={<SettingsIcon />}
|
||||||
|
className={buttonClasses}
|
||||||
|
onClick={handleButtonClicked}
|
||||||
|
/>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent align="start">
|
||||||
|
<ContextMenuItem onSelect={openCharacterModal}>
|
||||||
|
{t('context.modify.character')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={openRemoveCharacterAlert}>
|
||||||
|
{t('context.remove')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
{characterModal()}
|
||||||
|
{removeAlert()}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAlert = () => {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
open={alertOpen}
|
||||||
|
primaryAction={removeCharacter}
|
||||||
|
primaryActionText={t('modals.characters.buttons.remove')}
|
||||||
|
cancelAction={() => setAlertOpen(false)}
|
||||||
|
cancelActionText={t('buttons.cancel')}
|
||||||
|
message={
|
||||||
|
<Trans i18nKey="modals.characters.messages.remove">
|
||||||
|
Are you sure you want to remove{' '}
|
||||||
|
<strong>{{ character: gridCharacter?.object.name[locale] }}</strong>{' '}
|
||||||
|
from your team?
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchModal = () => {
|
||||||
|
if (editable) {
|
||||||
|
return (
|
||||||
|
<SearchModal
|
||||||
|
placeholderText={t('search.placeholders.character')}
|
||||||
|
fromPosition={position}
|
||||||
|
object="characters"
|
||||||
|
open={searchModalOpen}
|
||||||
|
onOpenChange={handleSearchModalOpenChange}
|
||||||
|
send={updateObject}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Core element rendering
|
||||||
|
const perpetuity = () => {
|
||||||
|
if (gridCharacter) {
|
||||||
|
const classes = classNames({
|
||||||
|
Perpetuity: true,
|
||||||
|
Empty: !gridCharacter.perpetuity,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <i className={classes} onClick={handlePerpetuityClick} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = () => {
|
||||||
|
let image = (
|
||||||
|
<img
|
||||||
|
alt={character?.name[locale]}
|
||||||
|
className="grid_image"
|
||||||
|
src={imageUrl}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className="CharacterImage"
|
||||||
|
tabIndex={gridCharacter ? gridCharacter.position * 7 : 0}
|
||||||
|
onClick={openSearchModal}
|
||||||
|
>
|
||||||
|
{image}
|
||||||
|
{editable ? (
|
||||||
<span className="icon">
|
<span className="icon">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -95,27 +312,35 @@ const CharacterUnit = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const editableImage = (
|
return gridCharacter ? (
|
||||||
<SearchModal
|
<CharacterHovercard
|
||||||
placeholderText={t('search.placeholders.character')}
|
gridCharacter={gridCharacter}
|
||||||
fromPosition={props.position}
|
onTriggerClick={openSearchModal}
|
||||||
object="characters"
|
|
||||||
send={props.updateObject}
|
|
||||||
>
|
>
|
||||||
{image}
|
{content}
|
||||||
</SearchModal>
|
</CharacterHovercard>
|
||||||
|
) : (
|
||||||
|
content
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const unitContent = (
|
const unitContent = (
|
||||||
|
<>
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
{props.editable ? editableImage : image}
|
{contextMenu()}
|
||||||
|
{perpetuity()}
|
||||||
|
{image()}
|
||||||
{gridCharacter && character ? (
|
{gridCharacter && character ? (
|
||||||
<UncapIndicator
|
<UncapIndicator
|
||||||
type="character"
|
type="character"
|
||||||
flb={character.uncap.flb || false}
|
flb={character.uncap.flb || false}
|
||||||
ulb={character.uncap.ulb || false}
|
ulb={character.uncap.ulb || false}
|
||||||
uncapLevel={gridCharacter.uncap_level}
|
uncapLevel={gridCharacter.uncap_level}
|
||||||
|
transcendenceStage={gridCharacter.transcendence_step}
|
||||||
|
position={gridCharacter.position}
|
||||||
|
editable={editable}
|
||||||
updateUncap={passUncapData}
|
updateUncap={passUncapData}
|
||||||
|
updateTranscendence={passTranscendenceData}
|
||||||
special={character.special}
|
special={character.special}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -123,15 +348,11 @@ const CharacterUnit = (props: Props) => {
|
||||||
)}
|
)}
|
||||||
<h3 className="CharacterName">{character?.name[locale]}</h3>
|
<h3 className="CharacterName">{character?.name[locale]}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
{searchModal()}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
const withHovercard = (
|
return unitContent
|
||||||
<CharacterHovercard gridCharacter={gridCharacter!}>
|
|
||||||
{unitContent}
|
|
||||||
</CharacterHovercard>
|
|
||||||
)
|
|
||||||
|
|
||||||
return gridCharacter && !props.editable ? withHovercard : unitContent
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CharacterUnit
|
export default CharacterUnit
|
||||||
|
|
|
||||||
6
components/ContextMenu/index.scss
Normal file
6
components/ContextMenu/index.scss
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.ContextMenu {
|
||||||
|
background: var(--menu-bg);
|
||||||
|
border-radius: $input-corner;
|
||||||
|
padding: $unit 0;
|
||||||
|
margin-top: $unit-fourth;
|
||||||
|
}
|
||||||
36
components/ContextMenu/index.tsx
Normal file
36
components/ContextMenu/index.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React from 'react'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props
|
||||||
|
extends React.DetailedHTMLProps<
|
||||||
|
React.DialogHTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> {
|
||||||
|
align?: 'start' | 'center' | 'end'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContextMenuContent = React.forwardRef<HTMLDivElement, Props>(
|
||||||
|
function ContextMenu({ children, ...props }, forwardedRef) {
|
||||||
|
const classes = classNames(
|
||||||
|
{
|
||||||
|
ContextMenu: true,
|
||||||
|
},
|
||||||
|
props.className
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content className={classes} {...props} ref={forwardedRef}>
|
||||||
|
{children}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ContextMenu = DropdownMenu.Root
|
||||||
|
export const ContextMenuGroup = DropdownMenu.Group
|
||||||
|
export const ContextMenuTrigger = DropdownMenu.Trigger
|
||||||
11
components/ContextMenuItem/index.scss
Normal file
11
components/ContextMenuItem/index.scss
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
.ContextItem {
|
||||||
|
color: var(--menu-text);
|
||||||
|
font-size: $font-regular;
|
||||||
|
padding: ($unit * 1.5) $unit-2x;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--menu-bg-item-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
components/ContextMenuItem/index.tsx
Normal file
30
components/ContextMenuItem/index.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import React from 'react'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import { DropdownMenuItem } from '@radix-ui/react-dropdown-menu'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string
|
||||||
|
onSelect?: (event: Event) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContextMenuItem = React.forwardRef<HTMLDivElement, Props>(
|
||||||
|
function ContextMenu({ children, ...props }, forwardedRef) {
|
||||||
|
const classes = classNames(
|
||||||
|
{
|
||||||
|
ContextItem: true,
|
||||||
|
},
|
||||||
|
props.className
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem className={classes} onSelect={props.onSelect}>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default ContextMenuItem
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
.Dialog {
|
|
||||||
$multiplier: 4;
|
|
||||||
|
|
||||||
animation: 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal none running
|
|
||||||
openModalDesktop;
|
|
||||||
background: var(--dialog-bg);
|
|
||||||
border-radius: $card-corner;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit * $multiplier;
|
|
||||||
height: auto;
|
|
||||||
min-width: $unit * 48;
|
|
||||||
min-height: $unit-12x;
|
|
||||||
min-width: 580px;
|
|
||||||
padding: $unit * $multiplier;
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
z-index: 40;
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
animation: 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal forwards running
|
|
||||||
openModalMobile;
|
|
||||||
min-width: inherit;
|
|
||||||
min-height: 80vh;
|
|
||||||
transform: initial;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
height: auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
.left {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
|
||||||
gap: $unit;
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: $font-small;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogClose {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: $error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: $grey-50;
|
|
||||||
float: right;
|
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogTitle {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: $font-xlarge;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: $font-xlarge;
|
|
||||||
font-weight: $medium;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogTop {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
|
||||||
gap: calc($unit / 2);
|
|
||||||
|
|
||||||
.SubTitle {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: $font-small;
|
|
||||||
font-weight: $medium;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogDescription {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Conflict.Dialog {
|
|
||||||
$weapon-diameter: 14rem;
|
|
||||||
|
|
||||||
& > p {
|
|
||||||
line-height: 1.2;
|
|
||||||
max-width: 400px;
|
|
||||||
|
|
||||||
strong {
|
|
||||||
font-weight: $bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:lang(ja) {
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.weapon,
|
|
||||||
.character {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit;
|
|
||||||
text-align: center;
|
|
||||||
width: $weapon-diameter;
|
|
||||||
font-weight: $medium;
|
|
||||||
|
|
||||||
img {
|
|
||||||
border-radius: 1rem;
|
|
||||||
width: $weapon-diameter;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Diagram {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
align-items: flex-start;
|
|
||||||
|
|
||||||
&.CharacterDiagram {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit * 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow {
|
|
||||||
align-items: center;
|
|
||||||
color: $grey-55;
|
|
||||||
display: flex;
|
|
||||||
font-size: 4rem;
|
|
||||||
text-align: center;
|
|
||||||
height: $weapon-diameter;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: $unit;
|
|
||||||
|
|
||||||
.Button {
|
|
||||||
font-size: $font-regular;
|
|
||||||
padding: ($unit * 1.5) ($unit * 2);
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&.btn-disabled {
|
|
||||||
background: $grey-90;
|
|
||||||
color: $grey-70;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.btn-disabled) {
|
|
||||||
background: $grey-90;
|
|
||||||
color: $grey-50;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $grey-80;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +1,40 @@
|
||||||
import React from 'react'
|
import React, { PropsWithChildren, useEffect, useState } from 'react'
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
import classNames from 'classnames'
|
import { useLockedBody } from 'usehooks-ts'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import Overlay from '~components/Overlay'
|
|
||||||
|
|
||||||
interface Props
|
interface Props extends DialogPrimitive.DialogProps {}
|
||||||
extends React.DetailedHTMLProps<
|
|
||||||
React.DialogHTMLAttributes<HTMLDivElement>,
|
|
||||||
HTMLDivElement
|
|
||||||
> {
|
|
||||||
onEscapeKeyDown: (event: KeyboardEvent) => void
|
|
||||||
onOpenAutoFocus: (event: Event) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DialogContent = React.forwardRef<HTMLDivElement, Props>(
|
export const Dialog = ({ children, ...props }: PropsWithChildren<Props>) => {
|
||||||
function dialog({ children, ...props }, forwardedRef) {
|
const [locked, setLocked] = useLockedBody(false, 'root')
|
||||||
const classes = classNames(
|
const [open, setOpen] = useState(false)
|
||||||
{
|
|
||||||
Dialog: true,
|
useEffect(() => {
|
||||||
},
|
if (props.open != undefined) {
|
||||||
props.className
|
toggleLocked(props.open)
|
||||||
)
|
setOpen(props.open)
|
||||||
|
}
|
||||||
|
}, [props.open])
|
||||||
|
|
||||||
|
function toggleLocked(open: boolean) {
|
||||||
|
setLocked(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
if (props.onOpenChange) props.onOpenChange(open)
|
||||||
|
if (props.open === undefined) {
|
||||||
|
toggleLocked(open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Portal>
|
<DialogPrimitive.Root open={props.open} onOpenChange={handleOpenChange}>
|
||||||
<DialogPrimitive.Content
|
|
||||||
className={classes}
|
|
||||||
{...props}
|
|
||||||
onOpenAutoFocus={props.onOpenAutoFocus}
|
|
||||||
onEscapeKeyDown={props.onEscapeKeyDown}
|
|
||||||
ref={forwardedRef}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Root>
|
||||||
<Overlay visible={true} open={true} />
|
|
||||||
</DialogPrimitive.Portal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
export const Dialog = DialogPrimitive.Root
|
|
||||||
export const DialogTitle = DialogPrimitive.Title
|
export const DialogTitle = DialogPrimitive.Title
|
||||||
export const DialogTrigger = DialogPrimitive.Trigger
|
export const DialogTrigger = DialogPrimitive.Trigger
|
||||||
export const DialogClose = DialogPrimitive.Close
|
export const DialogClose = DialogPrimitive.Close
|
||||||
|
|
|
||||||
287
components/DialogContent/index.scss
Normal file
287
components/DialogContent/index.scss
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
.Dialog {
|
||||||
|
position: fixed;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
padding: 0;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-width: 100vw;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: inherit;
|
||||||
|
z-index: 40;
|
||||||
|
|
||||||
|
.DialogContent {
|
||||||
|
$multiplier: 4;
|
||||||
|
|
||||||
|
// animation: $duration-modal-open cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal
|
||||||
|
// none running openModalDesktop;
|
||||||
|
background: var(--dialog-bg);
|
||||||
|
border-radius: $card-corner;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit * $multiplier;
|
||||||
|
height: auto;
|
||||||
|
min-width: $unit * 48;
|
||||||
|
// min-height: $unit-12x;
|
||||||
|
overflow-y: scroll;
|
||||||
|
// height: 80vh;
|
||||||
|
max-height: 80vh;
|
||||||
|
min-width: 580px;
|
||||||
|
max-width: 42vw;
|
||||||
|
// padding: $unit * $multiplier;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
// animation: slideUp;
|
||||||
|
// animation-duration: 3s;
|
||||||
|
// animation-fill-mode: forwards;
|
||||||
|
// animation-play-state: running;
|
||||||
|
// animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
min-width: inherit;
|
||||||
|
min-height: 90vh;
|
||||||
|
transform: initial;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 5vh;
|
||||||
|
height: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Scrollable {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.DialogHeader {
|
||||||
|
background: var(--dialog-bg);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2x;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: $unit-4x ($unit * $multiplier);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&.Short {
|
||||||
|
padding-top: $unit-3x;
|
||||||
|
padding-bottom: $unit-3x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: $font-small;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.DialogImage {
|
||||||
|
border-radius: $input-corner;
|
||||||
|
width: $unit-10x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.DialogClose {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: $error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: $grey-50;
|
||||||
|
float: right;
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.DialogTitle {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: $font-xlarge;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: $font-xlarge;
|
||||||
|
font-weight: $medium;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.DialogTop {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
gap: calc($unit / 2);
|
||||||
|
|
||||||
|
.SubTitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.DialogDescription {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.DialogFooter {
|
||||||
|
align-items: flex-end;
|
||||||
|
background: var(--dialog-bg);
|
||||||
|
bottom: 0;
|
||||||
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.16);
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.24);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: ($unit * 1.5) ($unit * $multiplier) $unit-3x;
|
||||||
|
position: sticky;
|
||||||
|
|
||||||
|
.Buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
&.Span {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.Button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Conflict {
|
||||||
|
$weapon-diameter: 14rem;
|
||||||
|
|
||||||
|
.Content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-4x;
|
||||||
|
padding: $unit-4x $unit-4x $unit-2x $unit-4x;
|
||||||
|
|
||||||
|
& > p {
|
||||||
|
font-size: $font-regular;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: $bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:lang(ja) {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.weapon,
|
||||||
|
.character {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
text-align: center;
|
||||||
|
width: $weapon-diameter;
|
||||||
|
font-weight: $medium;
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-radius: 1rem;
|
||||||
|
width: $weapon-diameter;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Diagram {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
&.CharacterDiagram {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
align-items: center;
|
||||||
|
color: $grey-55;
|
||||||
|
display: flex;
|
||||||
|
font-size: 4rem;
|
||||||
|
text-align: center;
|
||||||
|
height: $weapon-diameter;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
.Button {
|
||||||
|
font-size: $font-regular;
|
||||||
|
padding: ($unit * 1.5) ($unit * 2);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.btn-disabled {
|
||||||
|
background: $grey-90;
|
||||||
|
color: $grey-70;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.btn-disabled) {
|
||||||
|
background: $grey-90;
|
||||||
|
color: $grey-50;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $grey-80;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
144
components/DialogContent/index.tsx
Normal file
144
components/DialogContent/index.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import debounce from 'lodash.debounce'
|
||||||
|
|
||||||
|
import Overlay from '~components/Overlay'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props
|
||||||
|
extends React.DetailedHTMLProps<
|
||||||
|
React.DialogHTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> {
|
||||||
|
headerref?: React.RefObject<HTMLDivElement>
|
||||||
|
footerref?: React.RefObject<HTMLDivElement>
|
||||||
|
onEscapeKeyDown: (event: KeyboardEvent) => void
|
||||||
|
onOpenAutoFocus: (event: Event) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<HTMLDivElement, Props>(function Dialog(
|
||||||
|
{ children, ...props },
|
||||||
|
forwardedRef
|
||||||
|
) {
|
||||||
|
// Classes
|
||||||
|
const classes = classNames(props.className, {
|
||||||
|
DialogContent: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
function handleScroll(event: React.UIEvent<HTMLDivElement, UIEvent>) {
|
||||||
|
const scrollTop = event.currentTarget.scrollTop
|
||||||
|
const scrollHeight = event.currentTarget.scrollHeight
|
||||||
|
const clientHeight = event.currentTarget.clientHeight
|
||||||
|
|
||||||
|
if (props.headerref && props.headerref.current)
|
||||||
|
manipulateHeaderShadow(props.headerref.current, scrollTop)
|
||||||
|
|
||||||
|
if (props.footerref && props.footerref.current)
|
||||||
|
manipulateFooterShadow(
|
||||||
|
props.footerref.current,
|
||||||
|
scrollTop,
|
||||||
|
scrollHeight,
|
||||||
|
clientHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function manipulateHeaderShadow(header: HTMLDivElement, scrollTop: number) {
|
||||||
|
const boxShadowBase = '0 2px 8px'
|
||||||
|
const maxValue = 50
|
||||||
|
|
||||||
|
if (scrollTop >= 0) {
|
||||||
|
const input = scrollTop > maxValue ? maxValue : scrollTop
|
||||||
|
|
||||||
|
const boxShadowOpacity = mapRange(input, 0, maxValue, 0.0, 0.16)
|
||||||
|
const borderOpacity = mapRange(input, 0, maxValue, 0.0, 0.24)
|
||||||
|
|
||||||
|
header.style.boxShadow = `${boxShadowBase} rgba(0, 0, 0, ${boxShadowOpacity})`
|
||||||
|
header.style.borderBottomColor = `rgba(0, 0, 0, ${borderOpacity})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function manipulateFooterShadow(
|
||||||
|
footer: HTMLDivElement,
|
||||||
|
scrollTop: number,
|
||||||
|
scrollHeight: number,
|
||||||
|
clientHeight: number
|
||||||
|
) {
|
||||||
|
const boxShadowBase = '0 -2px 8px'
|
||||||
|
const minValue = scrollHeight - 200
|
||||||
|
const currentScroll = scrollTop + clientHeight
|
||||||
|
|
||||||
|
if (currentScroll >= minValue) {
|
||||||
|
const input = currentScroll < minValue ? minValue : currentScroll
|
||||||
|
|
||||||
|
const boxShadowOpacity = mapRange(
|
||||||
|
input,
|
||||||
|
minValue,
|
||||||
|
scrollHeight,
|
||||||
|
0.16,
|
||||||
|
0.0
|
||||||
|
)
|
||||||
|
const borderOpacity = mapRange(input, minValue, scrollHeight, 0.24, 0.0)
|
||||||
|
|
||||||
|
footer.style.boxShadow = `${boxShadowBase} rgba(0, 0, 0, ${boxShadowOpacity})`
|
||||||
|
footer.style.borderTopColor = `rgba(0, 0, 0, ${borderOpacity})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateFooterShadow = debounce(() => {
|
||||||
|
const boxShadowBase = '0 -2px 8px'
|
||||||
|
const scrollable = document.querySelector('.Scrollable')
|
||||||
|
const footer = props.footerref
|
||||||
|
|
||||||
|
if (footer && footer.current) {
|
||||||
|
if (scrollable && scrollable.clientHeight >= scrollable.scrollHeight) {
|
||||||
|
footer.current.style.boxShadow = `${boxShadowBase} rgba(0, 0, 0, 0)`
|
||||||
|
footer.current.style.borderTopColor = `rgba(0, 0, 0, 0)`
|
||||||
|
} else {
|
||||||
|
footer.current.style.boxShadow = `${boxShadowBase} rgba(0, 0, 0, 0.16)`
|
||||||
|
footer.current.style.borderTopColor = `rgba(0, 0, 0, 0.24)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('resize', calculateFooterShadow)
|
||||||
|
calculateFooterShadow()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', calculateFooterShadow)
|
||||||
|
}
|
||||||
|
}, [calculateFooterShadow])
|
||||||
|
|
||||||
|
function mapRange(
|
||||||
|
value: number,
|
||||||
|
low1: number,
|
||||||
|
high1: number,
|
||||||
|
low2: number,
|
||||||
|
high2: number
|
||||||
|
) {
|
||||||
|
return low2 + ((high2 - low2) * (value - low1)) / (high1 - low1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<dialog className="Dialog">
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
{...props}
|
||||||
|
className={classes}
|
||||||
|
onOpenAutoFocus={props.onOpenAutoFocus}
|
||||||
|
onEscapeKeyDown={props.onEscapeKeyDown}
|
||||||
|
ref={forwardedRef}
|
||||||
|
>
|
||||||
|
<div className="Scrollable" onScroll={handleScroll}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</dialog>
|
||||||
|
<Overlay visible={true} open={true} />
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default DialogContent
|
||||||
|
|
@ -1,20 +1,26 @@
|
||||||
.Menu {
|
.Menu {
|
||||||
|
transform-origin: --radix-dropdown-menu-content-transform-origin;
|
||||||
background: var(--menu-bg);
|
background: var(--menu-bg);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: none;
|
width: 30vw;
|
||||||
min-width: 220px;
|
max-width: 180px;
|
||||||
position: absolute;
|
margin: 0 $unit-2x;
|
||||||
top: $unit-8x; // This shouldn't be hardcoded. How to calculate it?
|
z-index: 15;
|
||||||
// Also, add space that doesn't make the menu disappear if you move your mouse slowly
|
|
||||||
z-index: 10;
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
@include breakpoint(phone) {
|
||||||
left: $unit-2x;
|
min-width: 50vw;
|
||||||
right: $unit-2x;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.MenuLabel {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding: $unit * 1.5 $unit * 1.5;
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $medium;
|
||||||
|
}
|
||||||
|
|
||||||
.MenuItem {
|
.MenuItem {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-weight: $normal;
|
font-weight: $normal;
|
||||||
40
components/DropdownMenuContent/index.tsx
Normal file
40
components/DropdownMenuContent/index.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import React, { PropsWithChildren } from 'react'
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props extends DropdownMenuPrimitive.DropdownMenuContentProps {}
|
||||||
|
|
||||||
|
export const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
export const DropdownMenuLabel = DropdownMenuPrimitive.Label
|
||||||
|
export const DropdownMenuItem = DropdownMenuPrimitive.Item
|
||||||
|
export const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
export const DropdownMenuSeparator = DropdownMenuPrimitive.Separator
|
||||||
|
|
||||||
|
export const DropdownMenuContent = React.forwardRef<HTMLDivElement, Props>(
|
||||||
|
function dropdownMenuContent(
|
||||||
|
{ children, ...props }: PropsWithChildren<Props>,
|
||||||
|
forwardedRef
|
||||||
|
) {
|
||||||
|
const classes = classNames(props.className, {
|
||||||
|
Menu: true,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
{...props}
|
||||||
|
className={classes}
|
||||||
|
ref={forwardedRef}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.Content>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DropdownMenuContent.defaultProps = {
|
||||||
|
sideOffset: 4,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
.Duration {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--input-bound-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: $input-corner;
|
||||||
|
display: flex;
|
||||||
|
padding: 0 calc($unit-2x - 2px);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--input-bound-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border: 2px solid $blue;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
width: initial;
|
||||||
|
height: 100%;
|
||||||
|
padding: calc($unit-2x - 2px) 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:focus-visible {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import React, { useState, ChangeEvent, KeyboardEvent, useEffect } from 'react'
|
import React, { useState, ChangeEvent, KeyboardEvent } from 'react'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
||||||
import Input from '~components/Input'
|
import Input from '~components/Input'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface Props
|
interface Props
|
||||||
|
|
@ -15,50 +14,57 @@ interface Props
|
||||||
}
|
}
|
||||||
|
|
||||||
const DurationInput = React.forwardRef<HTMLInputElement, Props>(
|
const DurationInput = React.forwardRef<HTMLInputElement, Props>(
|
||||||
function DurationInput(
|
function DurationInput({ className, value, onValueChange }, forwardedRef) {
|
||||||
{ className, placeholder, value, onValueChange },
|
// State
|
||||||
forwardedRef
|
|
||||||
) {
|
|
||||||
const [duration, setDuration] = useState('')
|
const [duration, setDuration] = useState('')
|
||||||
|
const [minutesSelected, setMinutesSelected] = useState(false)
|
||||||
|
const [secondsSelected, setSecondsSelected] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
// Refs
|
||||||
if (value > 0) setDuration(convertSecondsToString(value))
|
const minutesRef = React.createRef<HTMLInputElement>()
|
||||||
}, [value])
|
const secondsRef = React.createRef<HTMLInputElement>()
|
||||||
|
|
||||||
function convertStringToSeconds(string: string) {
|
// Event handlers: On value change
|
||||||
const parts = string.split(':')
|
function handleMinutesChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const minutes = parseInt(parts[0])
|
const minutes = parseInt(event.currentTarget.value)
|
||||||
const seconds = parseInt(parts[1])
|
const seconds = secondsRef.current
|
||||||
|
? parseInt(secondsRef.current.value)
|
||||||
|
: 0
|
||||||
|
|
||||||
return minutes * 60 + seconds
|
handleChange(minutes, seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertSecondsToString(value: number) {
|
function handleSecondsChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const minutes = Math.floor(value / 60)
|
const seconds = parseInt(event.currentTarget.value)
|
||||||
const seconds = value - minutes * 60
|
const minutes = minutesRef.current
|
||||||
|
? parseInt(minutesRef.current.value)
|
||||||
|
: 0
|
||||||
|
|
||||||
const paddedMinutes = padNumber(`${minutes}`, '0', 2)
|
handleChange(minutes, seconds)
|
||||||
|
|
||||||
return `${paddedMinutes}:${seconds}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function padNumber(string: string, pad: string, length: number) {
|
function handleChange(minutes: number, seconds: number) {
|
||||||
return (new Array(length + 1).join(pad) + string).slice(-length)
|
onValueChange(minutes * 60 + seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
// Event handler: Key presses
|
||||||
const value = event.currentTarget.value
|
function handleKeyUp(event: KeyboardEvent<HTMLInputElement>) {
|
||||||
const durationInSeconds = convertStringToSeconds(value)
|
const input = event.currentTarget
|
||||||
onValueChange(durationInSeconds)
|
|
||||||
|
if (input.selectionStart === 0 && input.selectionEnd === 2) {
|
||||||
|
if (input === minutesRef.current) {
|
||||||
|
setMinutesSelected(true)
|
||||||
|
} else if (input === secondsRef.current) {
|
||||||
|
setSecondsSelected(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||||
// Allow the key to be processed normally
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the current value
|
|
||||||
const input = event.currentTarget
|
const input = event.currentTarget
|
||||||
let value = event.currentTarget.value
|
let value = event.currentTarget.value
|
||||||
|
|
||||||
|
|
@ -95,16 +101,28 @@ const DurationInput = React.forwardRef<HTMLInputElement, Props>(
|
||||||
const isNumber = !isNaN(char)
|
const isNumber = !isNaN(char)
|
||||||
|
|
||||||
// Check if the character should be accepted or rejected
|
// Check if the character should be accepted or rejected
|
||||||
if (!isNumber || value.length >= 5) {
|
if (!isNumber || value.length >= 2) {
|
||||||
// Reject the character
|
// Reject the character if the user doesn't have the entire string selected
|
||||||
|
if (!minutesSelected && input === minutesRef.current)
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
} else if (value.length === 2) {
|
else if (
|
||||||
// Insert a colon after the second digit
|
!secondsSelected &&
|
||||||
input.value = value + ':'
|
input === secondsRef.current &&
|
||||||
|
getSeconds() > 9
|
||||||
|
)
|
||||||
|
event.preventDefault()
|
||||||
|
else {
|
||||||
|
setDuration(value)
|
||||||
|
setMinutesSelected(false)
|
||||||
|
setSecondsSelected(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDuration(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Methods: Time manipulation
|
||||||
function incrementTime(time: string): string {
|
function incrementTime(time: string): string {
|
||||||
// Split the time into minutes and seconds
|
// Split the time into minutes and seconds
|
||||||
let [minutes, seconds] = time.split(':').map(Number)
|
let [minutes, seconds] = time.split(':').map(Number)
|
||||||
|
|
@ -144,21 +162,54 @@ const DurationInput = React.forwardRef<HTMLInputElement, Props>(
|
||||||
return `${minutes}:${seconds}`
|
return `${minutes}:${seconds}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Methods: Miscellaneous
|
||||||
|
|
||||||
|
function getMinutes() {
|
||||||
|
const minutes = Math.floor(value / 60)
|
||||||
|
return minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeconds() {
|
||||||
|
const seconds = value % 60
|
||||||
|
return seconds
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={classNames(className, { Duration: true })}>
|
||||||
<Input
|
<Input
|
||||||
|
ref={minutesRef}
|
||||||
type="text"
|
type="text"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
{
|
{
|
||||||
Duration: true,
|
|
||||||
AlignRight: true,
|
AlignRight: true,
|
||||||
},
|
},
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
value={duration}
|
value={getMinutes()}
|
||||||
onChange={handleChange}
|
onChange={handleMinutesChange}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder}
|
placeholder="mm"
|
||||||
|
size={3}
|
||||||
/>
|
/>
|
||||||
|
<span>:</span>
|
||||||
|
<Input
|
||||||
|
ref={secondsRef}
|
||||||
|
type="text"
|
||||||
|
className={classNames(
|
||||||
|
{
|
||||||
|
AlignRight: true,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
value={`${getSeconds()}`.padStart(2, '0')}
|
||||||
|
onChange={handleSecondsChange}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="ss"
|
||||||
|
size={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
22
components/ErrorSection/index.scss
Normal file
22
components/ErrorSection/index.scss
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
section.Error {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 30vw;
|
||||||
|
justify-content: center;
|
||||||
|
height: 60vh;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.Code {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: $font-tiny;
|
||||||
|
font-weight: $bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Button {
|
||||||
|
margin-top: $unit-2x;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
components/ErrorSection/index.tsx
Normal file
48
components/ErrorSection/index.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
|
import Button from '~components/Button'
|
||||||
|
import { ResponseStatus } from '~types'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: ResponseStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrorSection = ({ status }: Props) => {
|
||||||
|
// Import translations
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
|
const [statusText, setStatusText] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStatusText(status.text.replaceAll(' ', '_').toLowerCase())
|
||||||
|
}, [status.text])
|
||||||
|
|
||||||
|
const errorBody = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="Code">{status.code}</div>
|
||||||
|
<h1>{t(`errors.${statusText}.title`)}</h1>
|
||||||
|
<p>{t(`errors.${statusText}.description`)}</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="Error">
|
||||||
|
{errorBody()}
|
||||||
|
{[401, 404].includes(status.code) ? (
|
||||||
|
<Link href="/new">
|
||||||
|
<Button text={t('errors.not_found.button')} />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorSection
|
||||||
17
components/ExtendedMasterySelect/index.scss
Normal file
17
components/ExtendedMasterySelect/index.scss
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
.SelectSet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: $unit;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.SelectTrigger.Left {
|
||||||
|
flex-grow: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.SelectTrigger.Right {
|
||||||
|
flex-grow: 0;
|
||||||
|
text-align: right;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
165
components/ExtendedMasterySelect/index.tsx
Normal file
165
components/ExtendedMasterySelect/index.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
// Core dependencies
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
// UI Dependencies
|
||||||
|
import Select from '~components/Select'
|
||||||
|
import SelectItem from '~components/SelectItem'
|
||||||
|
|
||||||
|
// Styles and icons
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface Props {
|
||||||
|
name: string
|
||||||
|
object: 'ring'
|
||||||
|
dataSet: ItemSkill[]
|
||||||
|
leftSelectValue: number
|
||||||
|
leftSelectDisabled: boolean
|
||||||
|
rightSelectValue: number
|
||||||
|
sendValues: (left: number, right: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
selectDisabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExtendedMasterySelect = ({
|
||||||
|
name,
|
||||||
|
object,
|
||||||
|
dataSet,
|
||||||
|
leftSelectDisabled,
|
||||||
|
leftSelectValue,
|
||||||
|
rightSelectValue,
|
||||||
|
sendValues,
|
||||||
|
}: Props) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const locale =
|
||||||
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [leftSelectOpen, setLeftSelectOpen] = useState(false)
|
||||||
|
const [rightSelectOpen, setRightSelectOpen] = useState(false)
|
||||||
|
|
||||||
|
// Field properties
|
||||||
|
// prettier-ignore
|
||||||
|
const [currentItemSkill, setCurrentItemSkill] = useState<ItemSkill | undefined>(undefined)
|
||||||
|
const [currentItemValue, setCurrentItemValue] = useState(rightSelectValue)
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
// if (currentItemSkill) sendValues(currentItemSkill.id, currentItemValue)
|
||||||
|
|
||||||
|
// Set default values from props
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentItemSkill(dataSet.find((sk) => sk.id === leftSelectValue))
|
||||||
|
setCurrentItemValue(rightSelectValue)
|
||||||
|
}, [leftSelectValue, rightSelectValue])
|
||||||
|
|
||||||
|
// Methods: UI state management
|
||||||
|
function changeOpen(side: 'left' | 'right') {
|
||||||
|
if (side === 'left' && !leftSelectDisabled) {
|
||||||
|
setLeftSelectOpen(!leftSelectOpen)
|
||||||
|
} else if (side === 'right') {
|
||||||
|
setRightSelectOpen(!rightSelectOpen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
setLeftSelectOpen(false)
|
||||||
|
setRightSelectOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Rendering
|
||||||
|
function generateLeftOptions() {
|
||||||
|
let options: React.ReactNode[] = dataSet.map((skill, i) => {
|
||||||
|
return (
|
||||||
|
<SelectItem key={`${name}-key-${i}`} value={skill.id}>
|
||||||
|
{skill.name[locale]}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRightOptions() {
|
||||||
|
if (currentItemSkill && currentItemSkill.values) {
|
||||||
|
let options = currentItemSkill.values.map((value, i) => {
|
||||||
|
return (
|
||||||
|
<SelectItem key={`${name}-values-${i + 1}`} value={value}>
|
||||||
|
{value}
|
||||||
|
{currentItemSkill.suffix ? currentItemSkill.suffix : ''}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
options.unshift(
|
||||||
|
<SelectItem key={`${name}-values-0`} value="no-value">
|
||||||
|
{t('no_value')}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: User input detection
|
||||||
|
function handleLeftSelectChange(rawValue: string) {
|
||||||
|
const value = parseInt(rawValue)
|
||||||
|
const skill = dataSet.find((sk) => sk.id === value)
|
||||||
|
|
||||||
|
setCurrentItemSkill(skill)
|
||||||
|
setCurrentItemValue(0)
|
||||||
|
|
||||||
|
if (skill) sendValues(skill.id, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRightSelectChange(rawValue: string) {
|
||||||
|
const value = parseFloat(rawValue)
|
||||||
|
setCurrentItemValue(value)
|
||||||
|
|
||||||
|
if (currentItemSkill) sendValues(currentItemSkill.id, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="SelectSet">
|
||||||
|
<Select
|
||||||
|
key={`${name}_type`}
|
||||||
|
value={`${currentItemSkill ? currentItemSkill.id : 0}`}
|
||||||
|
open={leftSelectOpen}
|
||||||
|
disabled={leftSelectDisabled}
|
||||||
|
onValueChange={handleLeftSelectChange}
|
||||||
|
onOpenChange={() => changeOpen('left')}
|
||||||
|
onClose={onClose}
|
||||||
|
triggerClass="Left modal"
|
||||||
|
overlayVisible={false}
|
||||||
|
>
|
||||||
|
{generateLeftOptions()}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
key={`${name}_value`}
|
||||||
|
value={`${currentItemValue > 0 ? currentItemValue : 'no-value'}`}
|
||||||
|
open={rightSelectOpen}
|
||||||
|
onValueChange={handleRightSelectChange}
|
||||||
|
onOpenChange={() => changeOpen('right')}
|
||||||
|
onClose={onClose}
|
||||||
|
overlayVisible={false}
|
||||||
|
triggerClass={classNames({
|
||||||
|
Right: true,
|
||||||
|
modal: true,
|
||||||
|
hidden: currentItemSkill?.id === 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{generateRightOptions()}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ExtendedMasterySelect.defaultProps = defaultProps
|
||||||
|
|
||||||
|
export default ExtendedMasterySelect
|
||||||
|
|
@ -11,8 +11,10 @@ interface Props {
|
||||||
exists: boolean
|
exists: boolean
|
||||||
found?: boolean
|
found?: boolean
|
||||||
offset: number
|
offset: number
|
||||||
|
removeSummon: (id: string) => void
|
||||||
updateObject: (object: SearchableObject, position: number) => void
|
updateObject: (object: SearchableObject, position: number) => void
|
||||||
updateUncap: (id: string, position: number, uncap: number) => void
|
updateUncap: (id: string, position: number, uncap: number) => void
|
||||||
|
updateTranscendence: (id: string, position: number, stage: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExtraSummons = (props: Props) => {
|
const ExtraSummons = (props: Props) => {
|
||||||
|
|
@ -31,9 +33,11 @@ const ExtraSummons = (props: Props) => {
|
||||||
editable={props.editable}
|
editable={props.editable}
|
||||||
position={props.offset + i}
|
position={props.offset + i}
|
||||||
unitType={1}
|
unitType={1}
|
||||||
|
removeSummon={props.removeSummon}
|
||||||
gridSummon={props.grid[props.offset + i]}
|
gridSummon={props.grid[props.offset + i]}
|
||||||
updateObject={props.updateObject}
|
updateObject={props.updateObject}
|
||||||
updateUncap={props.updateUncap}
|
updateUncap={props.updateUncap}
|
||||||
|
updateTranscendence={props.updateTranscendence}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ interface Props {
|
||||||
editable: boolean
|
editable: boolean
|
||||||
found?: boolean
|
found?: boolean
|
||||||
offset: number
|
offset: number
|
||||||
|
removeWeapon: (id: string) => void
|
||||||
updateObject: (object: SearchableObject, position: number) => void
|
updateObject: (object: SearchableObject, position: number) => void
|
||||||
updateUncap: (id: string, position: number, uncap: number) => void
|
updateUncap: (id: string, position: number, uncap: number) => void
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +33,7 @@ const ExtraWeapons = (props: Props) => {
|
||||||
position={props.offset + i}
|
position={props.offset + i}
|
||||||
unitType={1}
|
unitType={1}
|
||||||
gridWeapon={props.grid[props.offset + i]}
|
gridWeapon={props.grid[props.offset + i]}
|
||||||
|
removeWeapon={props.removeWeapon}
|
||||||
updateObject={props.updateObject}
|
updateObject={props.updateObject}
|
||||||
updateUncap={props.updateUncap}
|
updateUncap={props.updateUncap}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@
|
||||||
|
|
||||||
.Properties {
|
.Properties {
|
||||||
.full_auto {
|
.full_auto {
|
||||||
color: var(--full-auto-text);
|
color: var(--full-auto-label-text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,18 +136,24 @@ const GridRep = (props: Props) => {
|
||||||
src={`/profile/${props.user.avatar.picture}.png`}
|
src={`/profile/${props.user.avatar.picture}.png`}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else return <div className="no-user" />
|
} else
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
alt={t('no_user')}
|
||||||
|
className={`profile anonymous`}
|
||||||
|
srcSet={`/profile/npc.png,
|
||||||
|
/profile/npc@2x.png 2x`}
|
||||||
|
src={`/profile/npc.png`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const linkedAttribution = () => (
|
const linkedAttribution = () => (
|
||||||
<Link href={`/${props.user ? props.user.username : '#'}`}>
|
<Link href={`/${props.user ? props.user.username : '#'}`}>
|
||||||
<a
|
<span className={userClass}>
|
||||||
className={userClass}
|
|
||||||
href={`/${props.user ? props.user.username : '#'}`}
|
|
||||||
>
|
|
||||||
{userImage()}
|
{userImage()}
|
||||||
{props.user ? props.user.username : t('no_user')}
|
{props.user ? props.user.username : t('no_user')}
|
||||||
</a>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -205,16 +211,14 @@ const GridRep = (props: Props) => {
|
||||||
((props.user && account.user && account.user.id !== props.user.id) ||
|
((props.user && account.user && account.user.id !== props.user.id) ||
|
||||||
!props.user) ? (
|
!props.user) ? (
|
||||||
<Link href="#">
|
<Link href="#">
|
||||||
<a href="#">
|
|
||||||
<Button
|
<Button
|
||||||
className="Save"
|
className="Save"
|
||||||
accessoryIcon={<SaveIcon className="stroke" />}
|
leftAccessoryIcon={<SaveIcon className="stroke" />}
|
||||||
active={props.favorited}
|
active={props.favorited}
|
||||||
contained={true}
|
contained={true}
|
||||||
buttonSize="small"
|
buttonSize="small"
|
||||||
onClick={sendSaveData}
|
onClick={sendSaveData}
|
||||||
/>
|
/>
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,23 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
#Right > div {
|
section {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
.placeholder {
|
||||||
|
$diameter: 32px;
|
||||||
|
border-radius: calc($diameter / 2);
|
||||||
|
height: $diameter;
|
||||||
|
width: $diameter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
background: var(--placeholder-bg);
|
||||||
|
}
|
||||||
|
|
||||||
#DropdownWrapper {
|
#DropdownWrapper {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding-bottom: $unit;
|
padding-bottom: $unit;
|
||||||
|
|
@ -20,7 +32,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
padding-right: $unit-4x;
|
// padding-right: $unit-4x;
|
||||||
|
|
||||||
.Button {
|
.Button {
|
||||||
background: var(--button-bg-hover);
|
background: var(--button-bg-hover);
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,41 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useSnapshot } from 'valtio'
|
import { subscribe, useSnapshot } from 'valtio'
|
||||||
import { deleteCookie } from 'cookies-next'
|
import { setCookie, deleteCookie } from 'cookies-next'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useTranslation } from 'next-i18next'
|
import { Trans, useTranslation } from 'next-i18next'
|
||||||
|
import classNames from 'classnames'
|
||||||
import clonedeep from 'lodash.clonedeep'
|
import clonedeep from 'lodash.clonedeep'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
import api from '~utils/api'
|
import api from '~utils/api'
|
||||||
import { accountState, initialAccountState } from '~utils/accountState'
|
import { accountState, initialAccountState } from '~utils/accountState'
|
||||||
import { appState, initialAppState } from '~utils/appState'
|
import { appState, initialAppState } from '~utils/appState'
|
||||||
|
import { getLocalId } from '~utils/localId'
|
||||||
|
import { retrieveLocaleCookies } from '~utils/retrieveCookies'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
} from '~components/DropdownMenuContent'
|
||||||
|
import LoginModal from '~components/LoginModal'
|
||||||
|
import SignupModal from '~components/SignupModal'
|
||||||
|
import AccountModal from '~components/AccountModal'
|
||||||
|
import Toast from '~components/Toast'
|
||||||
import Button from '~components/Button'
|
import Button from '~components/Button'
|
||||||
import HeaderMenu from '~components/HeaderMenu'
|
import Tooltip from '~components/Tooltip'
|
||||||
|
import * as Switch from '@radix-ui/react-switch'
|
||||||
|
|
||||||
import AddIcon from '~public/icons/Add.svg'
|
import ArrowIcon from '~public/icons/Arrow.svg'
|
||||||
import LinkIcon from '~public/icons/Link.svg'
|
import LinkIcon from '~public/icons/Link.svg'
|
||||||
import MenuIcon from '~public/icons/Menu.svg'
|
import MenuIcon from '~public/icons/Menu.svg'
|
||||||
|
import RemixIcon from '~public/icons/Remix.svg'
|
||||||
|
import PlusIcon from '~public/icons/Add.svg'
|
||||||
import SaveIcon from '~public/icons/Save.svg'
|
import SaveIcon from '~public/icons/Save.svg'
|
||||||
import classNames from 'classnames'
|
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
|
|
@ -27,23 +45,106 @@ const Header = () => {
|
||||||
|
|
||||||
// Router
|
// Router
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const locale =
|
||||||
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
const localeData = retrieveLocaleCookies()
|
||||||
|
|
||||||
// State management
|
// State management
|
||||||
const [open, setOpen] = useState(false)
|
const [copyToastOpen, setCopyToastOpen] = useState(false)
|
||||||
|
const [remixToastOpen, setRemixToastOpen] = useState(false)
|
||||||
|
const [loginModalOpen, setLoginModalOpen] = useState(false)
|
||||||
|
const [signupModalOpen, setSignupModalOpen] = useState(false)
|
||||||
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false)
|
||||||
|
const [leftMenuOpen, setLeftMenuOpen] = useState(false)
|
||||||
|
const [rightMenuOpen, setRightMenuOpen] = useState(false)
|
||||||
|
const [languageChecked, setLanguageChecked] = useState(false)
|
||||||
|
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [originalName, setOriginalName] = useState('')
|
||||||
|
|
||||||
// Snapshots
|
// Snapshots
|
||||||
const { account } = useSnapshot(accountState)
|
const { account } = useSnapshot(accountState)
|
||||||
const { party } = useSnapshot(appState)
|
const { party: partySnapshot } = useSnapshot(appState)
|
||||||
|
|
||||||
function menuButtonClicked() {
|
// Subscribe to app state to listen for party name and
|
||||||
setOpen(!open)
|
// unsubscribe when component is unmounted
|
||||||
|
const unsubscribe = subscribe(appState, () => {
|
||||||
|
const newName =
|
||||||
|
appState.party && appState.party.name ? appState.party.name : ''
|
||||||
|
setName(newName)
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => () => unsubscribe(), [])
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
useEffect(() => {
|
||||||
|
setLanguageChecked(localeData === 'ja' ? true : false)
|
||||||
|
}, [localeData])
|
||||||
|
|
||||||
|
// Methods: Event handlers (Buttons)
|
||||||
|
function handleLeftMenuButtonClicked() {
|
||||||
|
setLeftMenuOpen(!leftMenuOpen)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClickOutsideMenu() {
|
function handleRightMenuButtonClicked() {
|
||||||
setOpen(false)
|
setRightMenuOpen(!rightMenuOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Event handlers (Menus)
|
||||||
|
function handleLeftMenuOpenChange(open: boolean) {
|
||||||
|
setLeftMenuOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRightMenuOpenChange(open: boolean) {
|
||||||
|
setRightMenuOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLeftMenu() {
|
||||||
|
setLeftMenuOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRightMenu() {
|
||||||
|
setRightMenuOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Event handlers (Copy toast)
|
||||||
|
function handleCopyToastOpenChanged(open: boolean) {
|
||||||
|
setCopyToastOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopyToastCloseClicked() {
|
||||||
|
setCopyToastOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Event handlers (Remix toasts)
|
||||||
|
function handleRemixToastOpenChanged(open: boolean) {
|
||||||
|
setRemixToastOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemixToastCloseClicked() {
|
||||||
|
setRemixToastOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Actions
|
||||||
|
function handleNewTeam(event: React.MouseEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
newTeam()
|
||||||
|
closeRightMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeLanguage(value: boolean) {
|
||||||
|
const language = value ? 'ja' : 'en'
|
||||||
|
const expiresAt = new Date()
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 120)
|
||||||
|
|
||||||
|
setCookie('NEXT_LOCALE', language, { path: '/', expires: expiresAt })
|
||||||
|
router.push(router.asPath, undefined, { locale: language })
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard() {
|
function copyToClipboard() {
|
||||||
|
const path = router.asPath.split('/')[1]
|
||||||
|
|
||||||
|
if (path === 'p') {
|
||||||
const el = document.createElement('input')
|
const el = document.createElement('input')
|
||||||
el.value = window.location.href
|
el.value = window.location.href
|
||||||
el.id = 'url-input'
|
el.id = 'url-input'
|
||||||
|
|
@ -52,23 +153,16 @@ const Header = () => {
|
||||||
el.select()
|
el.select()
|
||||||
document.execCommand('copy')
|
document.execCommand('copy')
|
||||||
el.remove()
|
el.remove()
|
||||||
|
|
||||||
|
setCopyToastOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function newParty() {
|
|
||||||
// Push the root URL
|
|
||||||
router.push('/')
|
|
||||||
|
|
||||||
// Clean state
|
|
||||||
const resetState = clonedeep(initialAppState)
|
|
||||||
Object.keys(resetState).forEach((key) => {
|
|
||||||
appState[key] = resetState[key]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set party to be editable
|
|
||||||
appState.party.editable = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
|
// Close menu
|
||||||
|
closeRightMenu()
|
||||||
|
|
||||||
|
// Delete cookies
|
||||||
deleteCookie('account')
|
deleteCookie('account')
|
||||||
deleteCookie('user')
|
deleteCookie('user')
|
||||||
|
|
||||||
|
|
@ -82,106 +176,433 @@ const Header = () => {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function newTeam() {
|
||||||
|
// Clean state
|
||||||
|
const resetState = clonedeep(initialAppState)
|
||||||
|
Object.keys(resetState).forEach((key) => {
|
||||||
|
appState[key] = resetState[key]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Push the root URL
|
||||||
|
router.push('/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
function remixTeam() {
|
||||||
|
setOriginalName(partySnapshot.name ? partySnapshot.name : t('no_title'))
|
||||||
|
|
||||||
|
if (partySnapshot.shortcode) {
|
||||||
|
const body = getLocalId()
|
||||||
|
api
|
||||||
|
.remix({ shortcode: partySnapshot.shortcode, body: body })
|
||||||
|
.then((response) => {
|
||||||
|
const remix = response.data.party
|
||||||
|
router.push(`/p/${remix.shortcode}`)
|
||||||
|
setRemixToastOpen(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleFavorite() {
|
function toggleFavorite() {
|
||||||
if (party.favorited) unsaveFavorite()
|
if (partySnapshot.favorited) unsaveFavorite()
|
||||||
else saveFavorite()
|
else saveFavorite()
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveFavorite() {
|
function saveFavorite() {
|
||||||
if (party.id)
|
if (partySnapshot.id)
|
||||||
api.saveTeam({ id: party.id }).then((response) => {
|
api.saveTeam({ id: partySnapshot.id }).then((response) => {
|
||||||
if (response.status == 201) appState.party.favorited = true
|
if (response.status == 201) appState.party.favorited = true
|
||||||
})
|
})
|
||||||
else console.error('Failed to save team: No party ID')
|
else console.error('Failed to save team: No party ID')
|
||||||
}
|
}
|
||||||
|
|
||||||
function unsaveFavorite() {
|
function unsaveFavorite() {
|
||||||
if (party.id)
|
if (partySnapshot.id)
|
||||||
api.unsaveTeam({ id: party.id }).then((response) => {
|
api.unsaveTeam({ id: partySnapshot.id }).then((response) => {
|
||||||
if (response.status == 200) appState.party.favorited = false
|
if (response.status == 200) appState.party.favorited = false
|
||||||
})
|
})
|
||||||
else console.error('Failed to unsave team: No party ID')
|
else console.error('Failed to unsave team: No party ID')
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyButton = () => {
|
// Rendering: Elements
|
||||||
if (router.route === '/p/[party]')
|
const pageTitle = () => {
|
||||||
return (
|
let title = ''
|
||||||
|
let hasAccessory = false
|
||||||
|
|
||||||
|
const path = router.asPath.split('/')[1]
|
||||||
|
if (path === 'p') {
|
||||||
|
hasAccessory = true
|
||||||
|
if (appState.party && appState.party.name) {
|
||||||
|
title = appState.party.name
|
||||||
|
} else {
|
||||||
|
title = t('no_title')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
title = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return title !== '' ? (
|
||||||
|
<Tooltip content={t('tooltips.copy_url')}>
|
||||||
<Button
|
<Button
|
||||||
accessoryIcon={<LinkIcon className="stroke" />}
|
|
||||||
blended={true}
|
blended={true}
|
||||||
text={t('buttons.copy')}
|
rightAccessoryIcon={
|
||||||
|
path === 'p' && hasAccessory ? (
|
||||||
|
<LinkIcon className="stroke" />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
text={title}
|
||||||
onClick={copyToClipboard}
|
onClick={copyToClipboard}
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftNav = () => {
|
const profileImage = () => {
|
||||||
return (
|
let image
|
||||||
<div id="DropdownWrapper">
|
|
||||||
<Button
|
const user = accountState.account.user
|
||||||
accessoryIcon={<MenuIcon />}
|
if (accountState.account.authorized && user) {
|
||||||
className={classNames({ Active: open })}
|
image = (
|
||||||
blended={true}
|
<img
|
||||||
text={t('buttons.menu')}
|
alt={user.username}
|
||||||
onClick={menuButtonClicked}
|
className={`profile ${user.avatar.element}`}
|
||||||
|
srcSet={`/profile/${user.avatar.picture}.png,
|
||||||
|
/profile/${user.avatar.picture}@2x.png 2x`}
|
||||||
|
src={`/profile/${user.avatar.picture}.png`}
|
||||||
/>
|
/>
|
||||||
<HeaderMenu
|
)
|
||||||
authenticated={account.authorized}
|
} else {
|
||||||
open={open}
|
image = (
|
||||||
username={account.user?.username}
|
<img
|
||||||
onClickOutside={onClickOutsideMenu}
|
alt={t('no_user')}
|
||||||
logout={logout}
|
className={`profile anonymous`}
|
||||||
|
srcSet={`/profile/npc.png,
|
||||||
|
/profile/npc@2x.png 2x`}
|
||||||
|
src={`/profile/npc.png`}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendering: Buttons
|
||||||
const saveButton = () => {
|
const saveButton = () => {
|
||||||
if (party.favorited)
|
|
||||||
return (
|
return (
|
||||||
|
<Tooltip content={t('tooltips.save')}>
|
||||||
<Button
|
<Button
|
||||||
accessoryIcon={<SaveIcon />}
|
leftAccessoryIcon={<SaveIcon />}
|
||||||
|
className={classNames({
|
||||||
|
Save: true,
|
||||||
|
Saved: partySnapshot.favorited,
|
||||||
|
})}
|
||||||
blended={true}
|
blended={true}
|
||||||
text="Saved"
|
text={
|
||||||
onClick={toggleFavorite}
|
partySnapshot.favorited ? t('buttons.saved') : t('buttons.save')
|
||||||
/>
|
}
|
||||||
)
|
|
||||||
else
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
accessoryIcon={<SaveIcon />}
|
|
||||||
blended={true}
|
|
||||||
text="Save"
|
|
||||||
onClick={toggleFavorite}
|
onClick={toggleFavorite}
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rightNav = () => {
|
const newButton = () => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<Tooltip content={t('tooltips.new')}>
|
||||||
{router.route === '/p/[party]' &&
|
|
||||||
account.user &&
|
|
||||||
(!party.user || party.user.id !== account.user.id)
|
|
||||||
? saveButton()
|
|
||||||
: ''}
|
|
||||||
|
|
||||||
{copyButton()}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
accessoryIcon={<AddIcon className="Add" />}
|
leftAccessoryIcon={<PlusIcon />}
|
||||||
|
className="New"
|
||||||
blended={true}
|
blended={true}
|
||||||
text={t('buttons.new')}
|
text={t('buttons.new')}
|
||||||
onClick={newParty}
|
onClick={newTeam}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const remixButton = () => {
|
||||||
|
return (
|
||||||
|
<Tooltip content={t('tooltips.remix')}>
|
||||||
|
<Button
|
||||||
|
leftAccessoryIcon={<RemixIcon />}
|
||||||
|
className="Remix"
|
||||||
|
blended={true}
|
||||||
|
text={t('buttons.remix')}
|
||||||
|
onClick={remixTeam}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendering: Toasts
|
||||||
|
const urlCopyToast = () => {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
altText={t('toasts.copied')}
|
||||||
|
open={copyToastOpen}
|
||||||
|
duration={2400}
|
||||||
|
type="foreground"
|
||||||
|
content={t('toasts.copied')}
|
||||||
|
onOpenChange={handleCopyToastOpenChanged}
|
||||||
|
onCloseClick={handleCopyToastCloseClicked}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remixToast = () => {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
altText={t('toasts.remixed', { title: originalName })}
|
||||||
|
open={remixToastOpen}
|
||||||
|
duration={2400}
|
||||||
|
type="foreground"
|
||||||
|
content={
|
||||||
|
<Trans i18nKey="toasts.remixed">
|
||||||
|
You remixed <strong>{{ title: originalName }}</strong>
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
onOpenChange={handleRemixToastOpenChanged}
|
||||||
|
onCloseClick={handleRemixToastCloseClicked}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendering: Modals
|
||||||
|
const settingsModal = () => {
|
||||||
|
const user = accountState.account.user
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return (
|
||||||
|
<AccountModal
|
||||||
|
open={settingsModalOpen}
|
||||||
|
username={user.username}
|
||||||
|
picture={user.avatar.picture}
|
||||||
|
gender={user.gender}
|
||||||
|
language={user.language}
|
||||||
|
theme={user.theme}
|
||||||
|
onOpenChange={setSettingsModalOpen}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginModal = () => {
|
||||||
|
return <LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const signupModal = () => {
|
||||||
|
return (
|
||||||
|
<SignupModal open={signupModalOpen} onOpenChange={setSignupModalOpen} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendering: Compositing
|
||||||
|
const left = () => {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div id="DropdownWrapper">
|
||||||
|
<DropdownMenu
|
||||||
|
open={leftMenuOpen}
|
||||||
|
onOpenChange={handleLeftMenuOpenChange}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
leftAccessoryIcon={<MenuIcon />}
|
||||||
|
className={classNames({ Active: leftMenuOpen })}
|
||||||
|
blended={true}
|
||||||
|
onClick={handleLeftMenuButtonClicked}
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="Left">
|
||||||
|
{leftMenuItems()}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
{!appState.errorCode ? pageTitle() : ''}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const right = () => {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
{router.route === '/p/[party]' &&
|
||||||
|
account.user &&
|
||||||
|
(!partySnapshot.user || partySnapshot.user.id !== account.user.id) &&
|
||||||
|
!appState.errorCode
|
||||||
|
? saveButton()
|
||||||
|
: ''}
|
||||||
|
{router.route === '/p/[party]' && !appState.errorCode
|
||||||
|
? remixButton()
|
||||||
|
: ''}
|
||||||
|
{newButton()}
|
||||||
|
<DropdownMenu
|
||||||
|
open={rightMenuOpen}
|
||||||
|
onOpenChange={handleRightMenuOpenChange}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className={classNames({ Active: rightMenuOpen })}
|
||||||
|
leftAccessoryIcon={profileImage()}
|
||||||
|
rightAccessoryIcon={<ArrowIcon />}
|
||||||
|
rightAccessoryClassName="Arrow"
|
||||||
|
onClick={handleRightMenuButtonClicked}
|
||||||
|
blended={true}
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="Right">
|
||||||
|
{rightMenuItems()}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftMenuItems = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{accountState.account.authorized && accountState.account.user ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuGroup className="MenuGroup">
|
||||||
|
<DropdownMenuItem className="MenuItem" onClick={closeRightMenu}>
|
||||||
|
<Link
|
||||||
|
href={`/${accountState.account.user.username}` || ''}
|
||||||
|
passHref
|
||||||
|
>
|
||||||
|
<span>{t('menu.profile')}</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
|
||||||
|
<Link href={`/saved` || ''}>{t('menu.saved')}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
<DropdownMenuGroup className="MenuGroup">
|
||||||
|
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
|
||||||
|
<Link href="/teams">{t('menu.teams')}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="MenuItem">
|
||||||
|
<div>
|
||||||
|
<span>{t('menu.guides')}</span>
|
||||||
|
<i className="tag">{t('coming_soon')}</i>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuGroup className="MenuGroup">
|
||||||
|
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
|
||||||
|
<a
|
||||||
|
href={locale == 'ja' ? '/ja/about' : '/about'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t('about.segmented_control.about')}
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
|
||||||
|
<a
|
||||||
|
href={locale == 'ja' ? '/ja/updates' : '/updates'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t('about.segmented_control.updates')}
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
|
||||||
|
<a
|
||||||
|
href={locale == 'ja' ? '/ja/roadmap' : '/roadmap'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t('about.segmented_control.roadmap')}
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightMenuItems = () => {
|
||||||
|
let items
|
||||||
|
|
||||||
|
const account = accountState.account
|
||||||
|
if (account.authorized && account.user) {
|
||||||
|
items = (
|
||||||
|
<>
|
||||||
|
<DropdownMenuGroup className="MenuGroup">
|
||||||
|
<DropdownMenuLabel className="MenuLabel">
|
||||||
|
{account.user ? `@${account.user.username}` : t('no_user')}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem className="MenuItem">
|
||||||
|
<Link href={`/${account.user.username}` || ''} passHref>
|
||||||
|
<span>{t('menu.profile')}</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup className="MenuGroup">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="MenuItem"
|
||||||
|
onClick={() => setSettingsModalOpen(true)}
|
||||||
|
>
|
||||||
|
<span>{t('menu.settings')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="MenuItem" onClick={logout}>
|
||||||
|
<span>{t('menu.logout')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
items = (
|
||||||
|
<>
|
||||||
|
<DropdownMenuGroup className="MenuGroup">
|
||||||
|
<DropdownMenuItem className="MenuItem language">
|
||||||
|
<span>{t('menu.language')}</span>
|
||||||
|
<Switch.Root
|
||||||
|
className="Switch"
|
||||||
|
onCheckedChange={changeLanguage}
|
||||||
|
checked={languageChecked}
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="Thumb" />
|
||||||
|
<span className="left">JP</span>
|
||||||
|
<span className="right">EN</span>
|
||||||
|
</Switch.Root>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuGroup className="MenuGroup">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="MenuItem"
|
||||||
|
onClick={() => setLoginModalOpen(true)}
|
||||||
|
>
|
||||||
|
<span>{t('menu.login')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="MenuItem"
|
||||||
|
onClick={() => setSignupModalOpen(true)}
|
||||||
|
>
|
||||||
|
<span>{t('menu.signup')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav id="Header">
|
<nav id="Header">
|
||||||
<div id="Left">{leftNav()}</div>
|
{left()}
|
||||||
<div id="Right">{rightNav()}</div>
|
{right()}
|
||||||
|
{urlCopyToast()}
|
||||||
|
{remixToast()}
|
||||||
|
{settingsModal()}
|
||||||
|
{loginModal()}
|
||||||
|
{signupModal()}
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { setCookie } from 'cookies-next'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
import { retrieveCookies, retrieveLocaleCookies } from '~utils/retrieveCookies'
|
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
import * as Switch from '@radix-ui/react-switch'
|
|
||||||
|
|
||||||
import AboutModal from '~components/AboutModal'
|
|
||||||
import AccountModal from '~components/AccountModal'
|
|
||||||
import ChangelogModal from '~components/ChangelogModal'
|
|
||||||
import RoadmapModal from '~components/RoadmapModal'
|
|
||||||
import LoginModal from '~components/LoginModal'
|
|
||||||
import SignupModal from '~components/SignupModal'
|
|
||||||
|
|
||||||
import './index.scss'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
authenticated: boolean
|
|
||||||
open: boolean
|
|
||||||
username?: string
|
|
||||||
onClickOutside: () => void
|
|
||||||
logout?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const HeaderMenu = (props: Props) => {
|
|
||||||
// Setup
|
|
||||||
const router = useRouter()
|
|
||||||
const data: GranblueCookie | undefined = retrieveCookies()
|
|
||||||
const localeData = retrieveLocaleCookies()
|
|
||||||
const { t } = useTranslation('common')
|
|
||||||
|
|
||||||
// Refs
|
|
||||||
const ref: React.RefObject<HTMLDivElement> = React.createRef()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: Event) => {
|
|
||||||
const target = event.target instanceof Element ? event.target : null
|
|
||||||
const isButton = target && target.closest('.Button.Active')
|
|
||||||
|
|
||||||
if (
|
|
||||||
ref.current &&
|
|
||||||
target &&
|
|
||||||
!ref.current.contains(target) &&
|
|
||||||
!isButton &&
|
|
||||||
props.open
|
|
||||||
) {
|
|
||||||
props.onClickOutside()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('click', handleClickOutside, true)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('click', handleClickOutside, true)
|
|
||||||
}
|
|
||||||
}, [props.onClickOutside])
|
|
||||||
|
|
||||||
const [checked, setChecked] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setChecked(localeData === 'ja' ? true : false)
|
|
||||||
}, [localeData])
|
|
||||||
|
|
||||||
function handleCheckedChange(value: boolean) {
|
|
||||||
const language = value ? 'ja' : 'en'
|
|
||||||
setCookie('NEXT_LOCALE', language, { path: '/' })
|
|
||||||
router.push(router.asPath, undefined, { locale: language })
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuClasses = classNames({
|
|
||||||
Menu: true,
|
|
||||||
auth: props.authenticated,
|
|
||||||
open: props.open,
|
|
||||||
})
|
|
||||||
|
|
||||||
function authItems() {
|
|
||||||
return (
|
|
||||||
<ul className={menuClasses}>
|
|
||||||
<div className="MenuGroup">
|
|
||||||
<li className="MenuItem profile">
|
|
||||||
<Link href={`/${data?.account.username}` || ''} passHref>
|
|
||||||
<div>
|
|
||||||
<span>{data?.account.username}</span>
|
|
||||||
<img
|
|
||||||
alt={data?.user.picture}
|
|
||||||
className={`profile ${data?.user.element}`}
|
|
||||||
srcSet={`/profile/${data?.user.picture}.png,
|
|
||||||
/profile/${data?.user.picture}@2x.png 2x`}
|
|
||||||
src={`/profile/${data?.user.picture}.png`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li className="MenuItem">
|
|
||||||
<Link href={`/saved` || ''}>{t('menu.saved')}</Link>
|
|
||||||
</li>
|
|
||||||
</div>
|
|
||||||
<div className="MenuGroup">
|
|
||||||
<li className="MenuItem">
|
|
||||||
<Link href="/teams">{t('menu.teams')}</Link>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li className="MenuItem disabled">
|
|
||||||
<div>
|
|
||||||
<span>{t('menu.guides')}</span>
|
|
||||||
<i className="tag">{t('coming_soon')}</i>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</div>
|
|
||||||
<div className="MenuGroup">
|
|
||||||
<AboutModal />
|
|
||||||
<ChangelogModal />
|
|
||||||
<RoadmapModal />
|
|
||||||
</div>
|
|
||||||
<div className="MenuGroup">
|
|
||||||
<AccountModal
|
|
||||||
username={data?.account.username}
|
|
||||||
picture={data?.user.picture}
|
|
||||||
gender={data?.user.gender}
|
|
||||||
language={data?.user.language}
|
|
||||||
theme={data?.user.theme}
|
|
||||||
/>
|
|
||||||
<li className="MenuItem" onClick={props.logout}>
|
|
||||||
<span>{t('menu.logout')}</span>
|
|
||||||
</li>
|
|
||||||
</div>
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function unauthItems() {
|
|
||||||
return (
|
|
||||||
<ul className={menuClasses}>
|
|
||||||
<div className="MenuGroup">
|
|
||||||
<li className="MenuItem language">
|
|
||||||
<span>{t('menu.language')}</span>
|
|
||||||
<Switch.Root
|
|
||||||
className="Switch"
|
|
||||||
onCheckedChange={handleCheckedChange}
|
|
||||||
checked={checked}
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="Thumb" />
|
|
||||||
<span className="left">JP</span>
|
|
||||||
<span className="right">EN</span>
|
|
||||||
</Switch.Root>
|
|
||||||
</li>
|
|
||||||
</div>
|
|
||||||
<div className="MenuGroup">
|
|
||||||
<li className="MenuItem">
|
|
||||||
<Link href="/teams">{t('menu.teams')}</Link>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li className="MenuItem disabled">
|
|
||||||
<div>
|
|
||||||
<span>{t('menu.guides')}</span>
|
|
||||||
<i className="tag">{t('coming_soon')}</i>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</div>
|
|
||||||
<div className="MenuGroup">
|
|
||||||
<AboutModal />
|
|
||||||
<ChangelogModal />
|
|
||||||
<RoadmapModal />
|
|
||||||
</div>
|
|
||||||
<div className="MenuGroup">
|
|
||||||
<LoginModal />
|
|
||||||
<SignupModal />
|
|
||||||
</div>
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav ref={ref}>{props.authenticated ? authItems() : unauthItems()}</nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default HeaderMenu
|
|
||||||
95
components/Hovercard/index.scss
Normal file
95
components/Hovercard/index.scss
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
.HovercardContent {
|
||||||
|
animation: scaleIn $duration-zoom ease-out;
|
||||||
|
transform-origin: var(--radix-hover-card-content-transform-origin);
|
||||||
|
background: var(--dialog-bg);
|
||||||
|
border-radius: $card-corner;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
max-height: 30vh;
|
||||||
|
overflow-y: scroll;
|
||||||
|
padding: $unit-2x;
|
||||||
|
width: 300px;
|
||||||
|
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc($unit / 2);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: $unit * 2;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
flex-grow: 1;
|
||||||
|
font-size: $font-medium;
|
||||||
|
line-height: 1.2;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: auto;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subInfo {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: $unit * 2;
|
||||||
|
|
||||||
|
.icons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-grow: 1;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.UncapIndicator {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
h5 {
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $medium;
|
||||||
|
opacity: 0.7;
|
||||||
|
|
||||||
|
&.wind {
|
||||||
|
color: $wind-bg-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fire {
|
||||||
|
color: $fire-bg-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.water {
|
||||||
|
color: $water-bg-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.earth {
|
||||||
|
color: $earth-bg-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
color: $dark-bg-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.light {
|
||||||
|
color: $light-bg-20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.Button {
|
||||||
|
display: block;
|
||||||
|
padding: $unit * 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
components/Hovercard/index.tsx
Normal file
31
components/Hovercard/index.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React, { PropsWithChildren } from 'react'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props extends HoverCardPrimitive.HoverCardContentProps {}
|
||||||
|
|
||||||
|
export const Hovercard = HoverCardPrimitive.Root
|
||||||
|
export const HovercardTrigger = HoverCardPrimitive.Trigger
|
||||||
|
|
||||||
|
export const HovercardContent = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<Props>) => {
|
||||||
|
const classes = classNames(props.className, {
|
||||||
|
HovercardContent: true,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Portal>
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
{...props}
|
||||||
|
className={classes}
|
||||||
|
sideOffset={4}
|
||||||
|
collisionPadding={{ top: 16, left: 16, right: 16, bottom: 16 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</HoverCardPrimitive.Content>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
background-color: var(--input-bg);
|
background-color: var(--input-bg);
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: $input-corner;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: block;
|
display: block;
|
||||||
padding: $unit-2x;
|
padding: calc($unit-2x - 2px);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&[type='number']::-webkit-inner-spin-button {
|
&[type='number']::-webkit-inner-spin-button {
|
||||||
|
|
|
||||||
52
components/JobAccessoryItem/index.scss
Normal file
52
components/JobAccessoryItem/index.scss
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
.JobAccessoryItem {
|
||||||
|
background: none;
|
||||||
|
border-radius: $input-corner;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&[data-state='checked'] {
|
||||||
|
background: var(--selected-item-bg);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--selected-item-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
color: var(--button-text-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--input-bg-hover);
|
||||||
|
|
||||||
|
img {
|
||||||
|
transform: scale(1.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
color: var(--button-text-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
color: var(--button-text);
|
||||||
|
font-size: $font-small;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-radius: $item-corner;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
position: relative;
|
||||||
|
transition: $duration-zoom all ease-in-out;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
components/JobAccessoryItem/index.tsx
Normal file
34
components/JobAccessoryItem/index.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
|
import * as RadioGroup from '@radix-ui/react-radio-group'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accessory: JobAccessory
|
||||||
|
selected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const JobAccessoryItem = ({ accessory, selected }: Props) => {
|
||||||
|
// Localization
|
||||||
|
const router = useRouter()
|
||||||
|
const locale =
|
||||||
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RadioGroup.Item
|
||||||
|
className="JobAccessoryItem"
|
||||||
|
data-state={selected ? 'checked' : 'unchecked'}
|
||||||
|
value={accessory.id}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt={accessory.name[locale]}
|
||||||
|
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/accessory-grid/${accessory.granblue_id}.jpg`}
|
||||||
|
/>
|
||||||
|
<h4>{accessory.name[locale]}</h4>
|
||||||
|
</RadioGroup.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JobAccessoryItem
|
||||||
67
components/JobAccessoryPopover/index.scss
Normal file
67
components/JobAccessoryPopover/index.scss
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
.JobAccessory.Popover {
|
||||||
|
padding: $unit-2x;
|
||||||
|
min-width: 40vw;
|
||||||
|
max-width: 40vw;
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow-y: scroll;
|
||||||
|
margin-left: $unit-2x;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: $font-regular;
|
||||||
|
font-weight: $medium;
|
||||||
|
margin: 0 0 $unit $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ReadOnly {
|
||||||
|
min-width: inherit;
|
||||||
|
max-width: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint(tablet) {
|
||||||
|
width: initial;
|
||||||
|
max-width: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
width: initial;
|
||||||
|
max-width: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Accessories {
|
||||||
|
display: grid;
|
||||||
|
gap: $unit;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
|
||||||
|
@include breakpoint(tablet) {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.EquippedAccessory {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Accessory {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $medium;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-radius: $item-corner;
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
components/JobAccessoryPopover/index.tsx
Normal file
152
components/JobAccessoryPopover/index.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import React, { PropsWithChildren, useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
import capitalizeFirstLetter from '~utils/capitalizeFirstLetter'
|
||||||
|
|
||||||
|
import * as RadioGroup from '@radix-ui/react-radio-group'
|
||||||
|
import Button from '~components/Button'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
} from '~components/PopoverContent'
|
||||||
|
import JobAccessoryItem from '~components/JobAccessoryItem'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
buttonref: React.RefObject<HTMLButtonElement>
|
||||||
|
currentAccessory?: JobAccessory
|
||||||
|
accessories: JobAccessory[]
|
||||||
|
editable: boolean
|
||||||
|
open: boolean
|
||||||
|
job: Job
|
||||||
|
onAccessorySelected: (value: string) => void
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const JobAccessoryPopover = ({
|
||||||
|
buttonref,
|
||||||
|
currentAccessory,
|
||||||
|
accessories,
|
||||||
|
editable,
|
||||||
|
open: modalOpen,
|
||||||
|
children,
|
||||||
|
job,
|
||||||
|
onAccessorySelected,
|
||||||
|
onOpenChange,
|
||||||
|
}: PropsWithChildren<Props>) => {
|
||||||
|
// Localization
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const locale =
|
||||||
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
|
||||||
|
// Component state
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const classes = classNames({
|
||||||
|
JobAccessory: true,
|
||||||
|
ReadOnly: !editable,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(modalOpen)
|
||||||
|
}, [modalOpen])
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
function handleAccessorySelected(value: string) {
|
||||||
|
onAccessorySelected(value)
|
||||||
|
closePopover()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDownOutside(
|
||||||
|
event: CustomEvent<{ originalEvent: PointerEvent }>
|
||||||
|
) {
|
||||||
|
const target = event.detail.originalEvent.target as Element
|
||||||
|
if (
|
||||||
|
target &&
|
||||||
|
buttonref.current &&
|
||||||
|
target.closest('.JobAccessory.Button') !== buttonref.current
|
||||||
|
) {
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePopover() {
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const radioGroup = (
|
||||||
|
<>
|
||||||
|
<h3>
|
||||||
|
{capitalizeFirstLetter(
|
||||||
|
job.accessory_type === 1
|
||||||
|
? `${t('accessories.paladin')}s`
|
||||||
|
: t('accessories.manadiver')
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<RadioGroup.Root
|
||||||
|
className="Accessories"
|
||||||
|
onValueChange={handleAccessorySelected}
|
||||||
|
>
|
||||||
|
{accessories.map((accessory) => (
|
||||||
|
<JobAccessoryItem
|
||||||
|
accessory={accessory}
|
||||||
|
key={accessory.id}
|
||||||
|
selected={
|
||||||
|
currentAccessory && currentAccessory.id === accessory.id
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</RadioGroup.Root>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const readOnly = currentAccessory ? (
|
||||||
|
<div className="EquippedAccessory">
|
||||||
|
<h3>
|
||||||
|
{t('equipped')}{' '}
|
||||||
|
{job.accessory_type === 1
|
||||||
|
? `${t('accessories.paladin')}s`
|
||||||
|
: t('accessories.manadiver')}
|
||||||
|
</h3>
|
||||||
|
<div className="Accessory">
|
||||||
|
<img
|
||||||
|
alt={currentAccessory.name[locale]}
|
||||||
|
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/accessory-grid/${currentAccessory.granblue_id}.jpg`}
|
||||||
|
/>
|
||||||
|
<h4>{currentAccessory.name[locale]}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h3>
|
||||||
|
{t('no_accessory', {
|
||||||
|
accessory: t(
|
||||||
|
`accessories.${job.accessory_type === 1 ? 'paladin' : 'manadiver'}`
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open}>
|
||||||
|
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className={classes}
|
||||||
|
onEscapeKeyDown={closePopover}
|
||||||
|
onPointerDownOutside={handlePointerDownOutside}
|
||||||
|
>
|
||||||
|
{editable ? radioGroup : readOnly}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JobAccessoryPopover
|
||||||
|
|
@ -8,7 +8,7 @@ import SelectItem from '~components/SelectItem'
|
||||||
import SelectGroup from '~components/SelectGroup'
|
import SelectGroup from '~components/SelectGroup'
|
||||||
|
|
||||||
import { appState } from '~utils/appState'
|
import { appState } from '~utils/appState'
|
||||||
import { jobGroups } from '~utils/jobGroups'
|
import { jobGroups } from '~data/jobGroups'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
|
|
@ -91,7 +91,12 @@ const JobDropdown = React.forwardRef<HTMLSelectElement, Props>(
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
.map((item, i) => {
|
.map((item, i) => {
|
||||||
return (
|
return (
|
||||||
<SelectItem key={i} value={item.id}>
|
<SelectItem
|
||||||
|
key={i}
|
||||||
|
value={item.id}
|
||||||
|
altText={item.name[locale]}
|
||||||
|
iconSrc={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-icons/${item.granblue_id}.png`}
|
||||||
|
>
|
||||||
{item.name[locale]}
|
{item.name[locale]}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)
|
)
|
||||||
|
|
@ -109,6 +114,12 @@ const JobDropdown = React.forwardRef<HTMLSelectElement, Props>(
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={currentJob ? currentJob.id : 'no-job'}
|
value={currentJob ? currentJob.id : 'no-job'}
|
||||||
|
altText={currentJob ? currentJob.name[locale] : ''}
|
||||||
|
iconSrc={
|
||||||
|
currentJob
|
||||||
|
? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-icons/${currentJob.granblue_id}.png`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
open={open}
|
open={open}
|
||||||
onClick={openJobSelect}
|
onClick={openJobSelect}
|
||||||
onOpenChange={() => setOpen(!open)}
|
onOpenChange={() => setOpen(!open)}
|
||||||
|
|
|
||||||
80
components/JobImage/index.scss
Normal file
80
components/JobImage/index.scss
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
.JobImage {
|
||||||
|
$height: 252px;
|
||||||
|
$width: 447px;
|
||||||
|
|
||||||
|
aspect-ratio: 7/9;
|
||||||
|
background: url('/images/background_a.jpg');
|
||||||
|
background-size: 500px 281px;
|
||||||
|
border-radius: $unit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||||
|
display: block;
|
||||||
|
isolation: isolate;
|
||||||
|
flex-grow: 2;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: $height;
|
||||||
|
margin-right: $unit * 3;
|
||||||
|
max-height: $height;
|
||||||
|
max-width: $width;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: box-shadow 0.15s ease-in-out;
|
||||||
|
width: $width;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
@media only screen
|
||||||
|
and (max-width: 800px)
|
||||||
|
and (max-height: 920px)
|
||||||
|
and (-webkit-min-device-pixel-ratio: 2) {
|
||||||
|
margin-right: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
-webkit-filter: drop-shadow(4px 4px 8px rgba(0, 0, 0, 0.48));
|
||||||
|
filter: drop-shadow(4px 4px 8px rgba(0, 0, 0, 0.48));
|
||||||
|
position: relative;
|
||||||
|
top: $unit * -4;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 100%;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.JobAccessory.Button {
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 99px;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
padding: $unit * 1.5;
|
||||||
|
top: $unit;
|
||||||
|
left: $unit;
|
||||||
|
height: auto;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&:hover .Accessory svg,
|
||||||
|
&.Selected .Accessory svg {
|
||||||
|
fill: var(--button-text-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Accessory svg {
|
||||||
|
fill: var(--button-text);
|
||||||
|
width: $unit-3x;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Overlay {
|
||||||
|
background: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
114
components/JobImage/index.tsx
Normal file
114
components/JobImage/index.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
|
import Button from '~components/Button'
|
||||||
|
import JobAccessoryPopover from '~components/JobAccessoryPopover'
|
||||||
|
|
||||||
|
import ShieldIcon from '~public/icons/Shield.svg'
|
||||||
|
import ManaturaIcon from '~public/icons/Manatura.svg'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
job?: Job
|
||||||
|
currentAccessory?: JobAccessory
|
||||||
|
accessories?: JobAccessory[]
|
||||||
|
editable: boolean
|
||||||
|
user?: User
|
||||||
|
onAccessorySelected: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const JobImage = ({
|
||||||
|
job,
|
||||||
|
currentAccessory,
|
||||||
|
editable,
|
||||||
|
accessories,
|
||||||
|
user,
|
||||||
|
onAccessorySelected,
|
||||||
|
}: Props) => {
|
||||||
|
// Localization
|
||||||
|
const router = useRouter()
|
||||||
|
const locale =
|
||||||
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
|
||||||
|
// Component state
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const buttonRef = React.createRef<HTMLButtonElement>()
|
||||||
|
|
||||||
|
// Static variables
|
||||||
|
const imageUrl = () => {
|
||||||
|
let source = ''
|
||||||
|
|
||||||
|
if (job) {
|
||||||
|
const slug = job.name.en.replaceAll(' ', '-').toLowerCase()
|
||||||
|
const gender = user && user.gender == 1 ? 'b' : 'a'
|
||||||
|
source = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/jobs/${slug}_${gender}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAccessory = job && job.accessory
|
||||||
|
const image = <img alt={job?.name[locale]} src={imageUrl()} />
|
||||||
|
|
||||||
|
const classes = classNames({
|
||||||
|
JobAccessory: true,
|
||||||
|
Selected: open,
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleAccessoryButtonClicked() {
|
||||||
|
setOpen(!open)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePopoverOpenChanged(open: boolean) {
|
||||||
|
setOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elements
|
||||||
|
const accessoryButton = () => {
|
||||||
|
let icon
|
||||||
|
|
||||||
|
if (job && job.accessory_type === 1) icon = <ShieldIcon />
|
||||||
|
else if (job && job.accessory_type === 2) icon = <ManaturaIcon />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
leftAccessoryIcon={icon}
|
||||||
|
className={classes}
|
||||||
|
onClick={handleAccessoryButtonClicked}
|
||||||
|
ref={buttonRef}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessoryPopover = () => {
|
||||||
|
return job && accessories ? (
|
||||||
|
<JobAccessoryPopover
|
||||||
|
buttonref={buttonRef}
|
||||||
|
currentAccessory={currentAccessory}
|
||||||
|
accessories={accessories}
|
||||||
|
editable={editable}
|
||||||
|
open={open}
|
||||||
|
job={job}
|
||||||
|
onAccessorySelected={onAccessorySelected}
|
||||||
|
onOpenChange={handlePopoverOpenChanged}
|
||||||
|
>
|
||||||
|
{accessoryButton()}
|
||||||
|
</JobAccessoryPopover>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="JobImage">
|
||||||
|
{hasAccessory ? accessoryPopover() : ''}
|
||||||
|
{job && job.id !== '-1' ? image : ''}
|
||||||
|
<div className="Job Overlay" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JobImage
|
||||||
|
|
@ -28,10 +28,20 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
.JobName {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-half;
|
||||||
|
padding: $unit 0 $unit * 2;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: $font-medium;
|
font-size: $font-medium;
|
||||||
font-weight: $medium;
|
font-weight: $medium;
|
||||||
padding: $unit 0 $unit * 2;
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: $unit-4x;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|
@ -43,63 +53,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.JobImage {
|
|
||||||
$height: 252px;
|
|
||||||
$width: 447px;
|
|
||||||
|
|
||||||
aspect-ratio: 7/9;
|
|
||||||
background: url('/images/background_a.jpg');
|
|
||||||
background-size: 500px 281px;
|
|
||||||
border-radius: $unit;
|
|
||||||
box-sizing: border-box;
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
|
|
||||||
display: block;
|
|
||||||
isolation: isolate;
|
|
||||||
flex-grow: 2;
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: $height;
|
|
||||||
margin-right: $unit * 3;
|
|
||||||
max-height: $height;
|
|
||||||
max-width: $width;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
width: $width;
|
|
||||||
transition: box-shadow 0.15s ease-in-out;
|
|
||||||
|
|
||||||
// prettier-ignore
|
|
||||||
@media only screen
|
|
||||||
and (max-width: 800px)
|
|
||||||
and (max-height: 920px)
|
|
||||||
and (-webkit-min-device-pixel-ratio: 2) {
|
|
||||||
margin-right: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
aspect-ratio: 16/9;
|
|
||||||
margin: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
-webkit-filter: drop-shadow(4px 4px 8px rgba(0, 0, 0, 0.48));
|
|
||||||
filter: drop-shadow(4px 4px 8px rgba(0, 0, 0, 0.48));
|
|
||||||
position: relative;
|
|
||||||
top: $unit * -4;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: 100%;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Overlay {
|
|
||||||
background: none;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.JobSkills {
|
.JobSkills {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import { useSnapshot } from 'valtio'
|
||||||
import { useTranslation } from 'next-i18next'
|
import { useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
import JobDropdown from '~components/JobDropdown'
|
import JobDropdown from '~components/JobDropdown'
|
||||||
|
import JobImage from '~components/JobImage'
|
||||||
import JobSkillItem from '~components/JobSkillItem'
|
import JobSkillItem from '~components/JobSkillItem'
|
||||||
import SearchModal from '~components/SearchModal'
|
import SearchModal from '~components/SearchModal'
|
||||||
|
|
||||||
|
import api from '~utils/api'
|
||||||
import { appState } from '~utils/appState'
|
import { appState } from '~utils/appState'
|
||||||
import type { JobSkillObject, SearchableObject } from '~types'
|
import type { JobSkillObject, SearchableObject } from '~types'
|
||||||
|
|
||||||
|
|
@ -16,9 +18,11 @@ import './index.scss'
|
||||||
interface Props {
|
interface Props {
|
||||||
job?: Job
|
job?: Job
|
||||||
jobSkills: JobSkillObject
|
jobSkills: JobSkillObject
|
||||||
|
jobAccessory?: JobAccessory
|
||||||
editable: boolean
|
editable: boolean
|
||||||
saveJob: (job?: Job) => void
|
saveJob: (job?: Job) => void
|
||||||
saveSkill: (skill: JobSkill, position: number) => void
|
saveSkill: (skill: JobSkill, position: number) => void
|
||||||
|
saveAccessory: (accessory: JobAccessory) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const JobSection = (props: Props) => {
|
const JobSection = (props: Props) => {
|
||||||
|
|
@ -29,13 +33,19 @@ const JobSection = (props: Props) => {
|
||||||
const locale =
|
const locale =
|
||||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
|
||||||
|
// Data state
|
||||||
const [job, setJob] = useState<Job>()
|
const [job, setJob] = useState<Job>()
|
||||||
const [imageUrl, setImageUrl] = useState('')
|
const [imageUrl, setImageUrl] = useState('')
|
||||||
const [numSkills, setNumSkills] = useState(4)
|
const [numSkills, setNumSkills] = useState(4)
|
||||||
const [skills, setSkills] = useState<{ [key: number]: JobSkill | undefined }>(
|
const [skills, setSkills] = useState<{ [key: number]: JobSkill | undefined }>(
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
const [accessories, setAccessories] = useState<JobAccessory[]>([])
|
||||||
|
const [currentAccessory, setCurrentAccessory] = useState<
|
||||||
|
JobAccessory | undefined
|
||||||
|
>()
|
||||||
|
|
||||||
|
// Refs
|
||||||
const selectRef = React.createRef<HTMLSelectElement>()
|
const selectRef = React.createRef<HTMLSelectElement>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -47,6 +57,7 @@ const JobSection = (props: Props) => {
|
||||||
2: props.jobSkills[2],
|
2: props.jobSkills[2],
|
||||||
3: props.jobSkills[3],
|
3: props.jobSkills[3],
|
||||||
})
|
})
|
||||||
|
setCurrentAccessory(props.jobAccessory)
|
||||||
|
|
||||||
if (selectRef.current && props.job) selectRef.current.value = props.job.id
|
if (selectRef.current && props.job) selectRef.current.value = props.job.id
|
||||||
}, [props])
|
}, [props])
|
||||||
|
|
@ -61,14 +72,33 @@ const JobSection = (props: Props) => {
|
||||||
appState.party.job = job
|
appState.party.job = job
|
||||||
if (job.row === '1') setNumSkills(3)
|
if (job.row === '1') setNumSkills(3)
|
||||||
else setNumSkills(4)
|
else setNumSkills(4)
|
||||||
|
fetchJobAccessories()
|
||||||
}
|
}
|
||||||
}, [job])
|
}, [job])
|
||||||
|
|
||||||
|
// Data fetching
|
||||||
|
async function fetchJobAccessories() {
|
||||||
|
if (job && job.accessory) {
|
||||||
|
const response = await api.jobAccessoriesForJob(job.id)
|
||||||
|
const jobAccessories: JobAccessory[] = response.data
|
||||||
|
setAccessories(jobAccessories)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function receiveJob(job?: Job) {
|
function receiveJob(job?: Job) {
|
||||||
setJob(job)
|
setJob(job)
|
||||||
props.saveJob(job)
|
props.saveJob(job)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAccessorySelected(value: string) {
|
||||||
|
const accessory = accessories.find((accessory) => accessory.id === value)
|
||||||
|
|
||||||
|
if (accessory) {
|
||||||
|
setCurrentAccessory(accessory)
|
||||||
|
props.saveAccessory(accessory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function generateImageUrl() {
|
function generateImageUrl() {
|
||||||
let imgSrc = ''
|
let imgSrc = ''
|
||||||
|
|
||||||
|
|
@ -84,7 +114,7 @@ const JobSection = (props: Props) => {
|
||||||
|
|
||||||
const canEditSkill = (skill?: JobSkill) => {
|
const canEditSkill = (skill?: JobSkill) => {
|
||||||
// If there is a job and a skill present in the slot
|
// If there is a job and a skill present in the slot
|
||||||
if (job) {
|
if (job && job.id !== '-1') {
|
||||||
// If the skill's job is one of the job's main skill
|
// If the skill's job is one of the job's main skill
|
||||||
if (skill && skill.job.id === job.id && skill.main) return false
|
if (skill && skill.job.id === job.id && skill.main) return false
|
||||||
|
|
||||||
|
|
@ -127,17 +157,37 @@ const JobSection = (props: Props) => {
|
||||||
props.saveSkill(skill, position)
|
props.saveSkill(skill, position)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emptyJobLabel = (
|
||||||
|
<div className="JobName">
|
||||||
|
<h3>{t('no_job')}</h3>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const filledJobLabel = (
|
||||||
|
<div className="JobName">
|
||||||
|
<img
|
||||||
|
alt={job?.name[locale]}
|
||||||
|
src={`/images/job-icons/${job?.granblue_id}.png`}
|
||||||
|
/>
|
||||||
|
<h3>{job?.name[locale]}</h3>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
function jobLabel() {
|
||||||
|
return job ? filledJobLabel : emptyJobLabel
|
||||||
|
}
|
||||||
|
|
||||||
// Render: JSX components
|
// Render: JSX components
|
||||||
return (
|
return (
|
||||||
<section id="Job">
|
<section id="Job">
|
||||||
<div className="JobImage">
|
<JobImage
|
||||||
{party.job && party.job.id !== '-1' ? (
|
job={party.job}
|
||||||
<img alt={party.job.name[locale]} src={imageUrl} />
|
currentAccessory={currentAccessory}
|
||||||
) : (
|
accessories={accessories}
|
||||||
''
|
editable={props.editable}
|
||||||
)}
|
user={party.user}
|
||||||
<div className="Overlay" />
|
onAccessorySelected={handleAccessorySelected}
|
||||||
</div>
|
/>
|
||||||
<div className="JobDetails">
|
<div className="JobDetails">
|
||||||
{props.editable ? (
|
{props.editable ? (
|
||||||
<JobDropdown
|
<JobDropdown
|
||||||
|
|
@ -146,7 +196,17 @@ const JobSection = (props: Props) => {
|
||||||
ref={selectRef}
|
ref={selectRef}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<h3>{party.job?.name[locale]}</h3>
|
<div className="JobName">
|
||||||
|
{party.job ? (
|
||||||
|
<img
|
||||||
|
alt={party.job.name[locale]}
|
||||||
|
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-icons/${party.job.granblue_id}.png`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
<h3>{party.job ? party.job.name[locale] : t('no_job')}</h3>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="JobSkills">
|
<ul className="JobSkills">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { SkillGroup, skillClassification } from '~utils/skillGroups'
|
import { SkillGroup, skillClassification } from '~data/skillGroups'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const JobSkillSearchFilterBar = (props: Props) => {
|
||||||
value={-1}
|
value={-1}
|
||||||
triggerClass="Bound"
|
triggerClass="Bound"
|
||||||
open={open}
|
open={open}
|
||||||
|
overlayVisible={false}
|
||||||
onValueChange={onChange}
|
onValueChange={onChange}
|
||||||
onOpenChange={openSelect}
|
onOpenChange={openSelect}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
15
components/Layout/index.scss
Normal file
15
components/Layout/index.scss
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
.ToastViewport {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0px;
|
||||||
|
right: 0px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 340px;
|
||||||
|
max-width: 100vw;
|
||||||
|
z-index: 2147483647;
|
||||||
|
padding: 25px;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0px;
|
||||||
|
list-style: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,72 @@
|
||||||
import type { ReactElement } from 'react'
|
import { PropsWithChildren, useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { add, format } from 'date-fns'
|
||||||
|
import { getCookie } from 'cookies-next'
|
||||||
|
|
||||||
|
import { appState } from '~utils/appState'
|
||||||
|
|
||||||
import TopHeader from '~components/Header'
|
import TopHeader from '~components/Header'
|
||||||
|
import UpdateToast from '~components/UpdateToast'
|
||||||
|
|
||||||
interface Props {
|
import './index.scss'
|
||||||
children: ReactElement
|
|
||||||
}
|
interface Props {}
|
||||||
|
|
||||||
|
const Layout = ({ children }: PropsWithChildren<Props>) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const [updateToastOpen, setUpdateToastOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cookie = getToastCookie()
|
||||||
|
const now = new Date()
|
||||||
|
const updatedAt = new Date(appState.version.updated_at)
|
||||||
|
const validUntil = add(updatedAt, { days: 7 })
|
||||||
|
|
||||||
|
if (now < validUntil && !cookie.seen) setUpdateToastOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function getToastCookie() {
|
||||||
|
if (appState.version.updated_at !== '') {
|
||||||
|
const updatedAt = new Date(appState.version.updated_at)
|
||||||
|
const cookieValues = getCookie(
|
||||||
|
`update-${format(updatedAt, 'yyyy-MM-dd')}`
|
||||||
|
)
|
||||||
|
return cookieValues
|
||||||
|
? (JSON.parse(cookieValues as string) as { seen: true })
|
||||||
|
: { seen: false }
|
||||||
|
} else {
|
||||||
|
return { seen: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToastActionClicked() {
|
||||||
|
setUpdateToastOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToastClosed() {
|
||||||
|
setUpdateToastOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateToast = () => {
|
||||||
|
const path = router.asPath.replaceAll('/', '')
|
||||||
|
|
||||||
|
return !['about', 'updates', 'roadmap'].includes(path) ? (
|
||||||
|
<UpdateToast
|
||||||
|
open={updateToastOpen}
|
||||||
|
updateType="feature"
|
||||||
|
onActionClicked={handleToastActionClicked}
|
||||||
|
onCloseClicked={handleToastClosed}
|
||||||
|
lastUpdated={appState.version.updated_at}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const Layout = ({ children }: Props) => {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopHeader />
|
<TopHeader />
|
||||||
|
{updateToast()}
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
.Login.Dialog form {
|
.Login.DialogContent {
|
||||||
|
gap: $unit;
|
||||||
|
// min-width: $unit * 52;
|
||||||
|
|
||||||
|
.Fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: calc($unit / 2);
|
gap: $unit;
|
||||||
margin-bottom: $unit;
|
padding: 0 $unit-4x;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,17 @@
|
||||||
import React, { useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { setCookie } from 'cookies-next'
|
import { setCookie } from 'cookies-next'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import axios, { AxiosError, AxiosResponse } from 'axios'
|
import axios, { AxiosError, AxiosResponse } from 'axios'
|
||||||
|
|
||||||
import api from '~utils/api'
|
import api from '~utils/api'
|
||||||
import setUserToken from '~utils/setUserToken'
|
import { setHeaders } from '~utils/userToken'
|
||||||
import { accountState } from '~utils/accountState'
|
import { accountState } from '~utils/accountState'
|
||||||
|
|
||||||
import Button from '~components/Button'
|
import Button from '~components/Button'
|
||||||
import Input from '~components/LabelledInput'
|
import Input from '~components/Input'
|
||||||
import {
|
import { Dialog, DialogTrigger, DialogClose } from '~components/Dialog'
|
||||||
Dialog,
|
import DialogContent from '~components/DialogContent'
|
||||||
DialogTrigger,
|
|
||||||
DialogContent,
|
|
||||||
DialogClose,
|
|
||||||
} from '~components/Dialog'
|
|
||||||
|
|
||||||
import changeLanguage from '~utils/changeLanguage'
|
import changeLanguage from '~utils/changeLanguage'
|
||||||
|
|
||||||
import CrossIcon from '~public/icons/Cross.svg'
|
import CrossIcon from '~public/icons/Cross.svg'
|
||||||
|
|
@ -31,7 +26,12 @@ interface ErrorMap {
|
||||||
const emailRegex =
|
const emailRegex =
|
||||||
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||||
|
|
||||||
const LoginModal = () => {
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginModal = (props: Props) => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
|
|
@ -48,8 +48,13 @@ const LoginModal = () => {
|
||||||
// Set up form refs
|
// Set up form refs
|
||||||
const emailInput: React.RefObject<HTMLInputElement> = React.createRef()
|
const emailInput: React.RefObject<HTMLInputElement> = React.createRef()
|
||||||
const passwordInput: React.RefObject<HTMLInputElement> = React.createRef()
|
const passwordInput: React.RefObject<HTMLInputElement> = React.createRef()
|
||||||
|
const footerRef: React.RefObject<HTMLDivElement> = React.createRef()
|
||||||
const form: React.RefObject<HTMLInputElement>[] = [emailInput, passwordInput]
|
const form: React.RefObject<HTMLInputElement>[] = [emailInput, passwordInput]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(props.open)
|
||||||
|
}, [props.open])
|
||||||
|
|
||||||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const { name, value } = event.target
|
const { name, value } = event.target
|
||||||
let newErrors = { ...errors }
|
let newErrors = { ...errors }
|
||||||
|
|
@ -137,10 +142,12 @@ const LoginModal = () => {
|
||||||
token: resp.access_token,
|
token: resp.access_token,
|
||||||
}
|
}
|
||||||
|
|
||||||
setCookie('account', cookieObj, { path: '/' })
|
const expiresAt = new Date()
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 60)
|
||||||
|
setCookie('account', cookieObj, { path: '/', expires: expiresAt })
|
||||||
|
|
||||||
// Set Axios default headers
|
// Set Axios default headers
|
||||||
setUserToken()
|
setHeaders()
|
||||||
}
|
}
|
||||||
|
|
||||||
function storeUserInfo(response: AxiosResponse) {
|
function storeUserInfo(response: AxiosResponse) {
|
||||||
|
|
@ -148,24 +155,32 @@ const LoginModal = () => {
|
||||||
const user = response.data
|
const user = response.data
|
||||||
|
|
||||||
// Set user data in the user cookie
|
// Set user data in the user cookie
|
||||||
|
const expiresAt = new Date()
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 60)
|
||||||
|
|
||||||
setCookie(
|
setCookie(
|
||||||
'user',
|
'user',
|
||||||
{
|
{
|
||||||
|
avatar: {
|
||||||
picture: user.avatar.picture,
|
picture: user.avatar.picture,
|
||||||
element: user.avatar.element,
|
element: user.avatar.element,
|
||||||
|
},
|
||||||
language: user.language,
|
language: user.language,
|
||||||
gender: user.gender,
|
gender: user.gender,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
},
|
},
|
||||||
{ path: '/' }
|
{ path: '/', expires: expiresAt }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set the user data in the account state
|
// Set the user data in the account state
|
||||||
accountState.account.user = {
|
accountState.account.user = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
granblueId: '',
|
||||||
|
avatar: {
|
||||||
picture: user.avatar.picture,
|
picture: user.avatar.picture,
|
||||||
element: user.avatar.element,
|
element: user.avatar.element,
|
||||||
|
},
|
||||||
gender: user.gender,
|
gender: user.gender,
|
||||||
language: user.language,
|
language: user.language,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
|
|
@ -184,6 +199,9 @@ const LoginModal = () => {
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
|
setFormValid(false)
|
||||||
|
|
||||||
|
if (props.onOpenChange) props.onOpenChange(open)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEscapeKeyDown(event: KeyboardEvent) {
|
function onEscapeKeyDown(event: KeyboardEvent) {
|
||||||
|
|
@ -197,13 +215,9 @@ const LoginModal = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={openChange}>
|
<Dialog open={open} onOpenChange={openChange}>
|
||||||
<DialogTrigger asChild>
|
|
||||||
<li className="MenuItem">
|
|
||||||
<span>{t('menu.login')}</span>
|
|
||||||
</li>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="Login Dialog"
|
className="Login"
|
||||||
|
footerref={footerRef}
|
||||||
onEscapeKeyDown={onEscapeKeyDown}
|
onEscapeKeyDown={onEscapeKeyDown}
|
||||||
onOpenAutoFocus={onOpenAutoFocus}
|
onOpenAutoFocus={onOpenAutoFocus}
|
||||||
>
|
>
|
||||||
|
|
@ -217,6 +231,7 @@ const LoginModal = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="form" onSubmit={login}>
|
<form className="form" onSubmit={login}>
|
||||||
|
<div className="Fields">
|
||||||
<Input
|
<Input
|
||||||
className="Bound"
|
className="Bound"
|
||||||
name="email"
|
name="email"
|
||||||
|
|
@ -235,11 +250,16 @@ const LoginModal = () => {
|
||||||
error={errors.password}
|
error={errors.password}
|
||||||
ref={passwordInput}
|
ref={passwordInput}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="DialogFooter" ref={footerRef}>
|
||||||
|
<div className="Buttons Span">
|
||||||
<Button
|
<Button
|
||||||
|
contained={true}
|
||||||
disabled={!formValid}
|
disabled={!formValid}
|
||||||
text={t('modals.login.buttons.confirm')}
|
text={t('modals.login.buttons.confirm')}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
32
components/NewHead/index.tsx
Normal file
32
components/NewHead/index.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import React from 'react'
|
||||||
|
import Head from 'next/head'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
|
const NewHead = () => {
|
||||||
|
// Import translations
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Head>
|
||||||
|
{/* HTML */}
|
||||||
|
<title>{t('page.titles.new')}</title>
|
||||||
|
<meta name="description" content={t('page.descriptions.new')} />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/images/favicon.png" />
|
||||||
|
|
||||||
|
{/* OpenGraph */}
|
||||||
|
<meta property="og:title" content={t('page.titles.new')} />
|
||||||
|
<meta property="og:description" content={t('page.descriptions.new')} />
|
||||||
|
<meta property="og:url" content={`https://app.granblue.team/`} />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
|
||||||
|
{/* Twitter */}
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:domain" content="app.granblue.team" />
|
||||||
|
<meta name="twitter:title" content={t('page.titles.new')} />
|
||||||
|
<meta name="twitter:description" content={t('page.descriptions.new')} />
|
||||||
|
</Head>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NewHead
|
||||||
|
|
@ -7,7 +7,15 @@
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
||||||
|
&.Job {
|
||||||
|
animation: none;
|
||||||
|
backdrop-filter: blur(5px) saturate(100%) brightness(80%) opacity(1);
|
||||||
|
}
|
||||||
|
|
||||||
&.Visible {
|
&.Visible {
|
||||||
|
animation: 0.24s ease-in fadeInFilter;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
backdrop-filter: blur(5px) saturate(100%) brightness(80%) opacity(0);
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { getCookie } from 'cookies-next'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useSnapshot } from 'valtio'
|
import { subscribe, useSnapshot } from 'valtio'
|
||||||
import clonedeep from 'lodash.clonedeep'
|
import clonedeep from 'lodash.clonedeep'
|
||||||
|
import ls from 'local-storage'
|
||||||
|
|
||||||
import PartySegmentedControl from '~components/PartySegmentedControl'
|
import PartySegmentedControl from '~components/PartySegmentedControl'
|
||||||
import PartyDetails from '~components/PartyDetails'
|
import PartyDetails from '~components/PartyDetails'
|
||||||
|
|
@ -10,8 +12,13 @@ import SummonGrid from '~components/SummonGrid'
|
||||||
import CharacterGrid from '~components/CharacterGrid'
|
import CharacterGrid from '~components/CharacterGrid'
|
||||||
|
|
||||||
import api from '~utils/api'
|
import api from '~utils/api'
|
||||||
|
import { accountState } from '~utils/accountState'
|
||||||
import { appState, initialAppState } from '~utils/appState'
|
import { appState, initialAppState } from '~utils/appState'
|
||||||
|
import { getLocalId } from '~utils/localId'
|
||||||
import { GridType } from '~utils/enums'
|
import { GridType } from '~utils/enums'
|
||||||
|
import { retrieveCookies } from '~utils/retrieveCookies'
|
||||||
|
import { setEditKey, unsetEditKey } from '~utils/userToken'
|
||||||
|
|
||||||
import type { DetailsObject } from '~types'
|
import type { DetailsObject } from '~types'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
@ -21,16 +28,26 @@ interface Props {
|
||||||
new?: boolean
|
new?: boolean
|
||||||
team?: Party
|
team?: Party
|
||||||
raids: Raid[][]
|
raids: Raid[][]
|
||||||
|
selectedTab: GridType
|
||||||
pushHistory?: (path: string) => void
|
pushHistory?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
selectedTab: GridType.Weapon,
|
||||||
|
}
|
||||||
|
|
||||||
const Party = (props: Props) => {
|
const Party = (props: Props) => {
|
||||||
// Set up router
|
// Set up router
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// Set up states
|
// Set up states
|
||||||
const { party } = useSnapshot(appState)
|
const { party } = useSnapshot(appState)
|
||||||
|
const [editable, setEditable] = useState(false)
|
||||||
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
|
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
|
||||||
|
const [refresh, setRefresh] = useState(false)
|
||||||
|
|
||||||
|
// Retrieve cookies
|
||||||
|
const cookies = retrieveCookies()
|
||||||
|
|
||||||
// Reset state on first load
|
// Reset state on first load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -39,19 +56,67 @@ const Party = (props: Props) => {
|
||||||
if (props.team) storeParty(props.team)
|
if (props.team) storeParty(props.team)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to app state to listen for account changes and
|
||||||
|
// unsubscribe when component is unmounted
|
||||||
|
const unsubscribe = subscribe(accountState, () => {
|
||||||
|
setRefresh(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => () => unsubscribe(), [])
|
||||||
|
|
||||||
|
// Set editable on first load
|
||||||
|
useEffect(() => {
|
||||||
|
// Get cookie
|
||||||
|
const cookie = getCookie('account')
|
||||||
|
const accountData: AccountCookie = cookie
|
||||||
|
? JSON.parse(cookie as string)
|
||||||
|
: null
|
||||||
|
|
||||||
|
let editable = false
|
||||||
|
unsetEditKey()
|
||||||
|
|
||||||
|
if (props.new) editable = true
|
||||||
|
|
||||||
|
if (accountData && props.team && !props.new) {
|
||||||
|
if (accountData.token) {
|
||||||
|
// Authenticated
|
||||||
|
if (props.team.user && accountData.userId === props.team.user.id) {
|
||||||
|
editable = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not authenticated
|
||||||
|
if (!props.team.user && accountData.userId === props.team.local_id) {
|
||||||
|
// Set editable
|
||||||
|
editable = true
|
||||||
|
|
||||||
|
// Also set edit key header
|
||||||
|
setEditKey(props.team.id, props.team.user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appState.party.editable = editable
|
||||||
|
setEditable(editable)
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
// Set selected tab from props
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentTab(props.selectedTab)
|
||||||
|
}, [props.selectedTab])
|
||||||
|
|
||||||
// Methods: Creating a new party
|
// Methods: Creating a new party
|
||||||
async function createParty(details?: DetailsObject) {
|
async function createParty(details?: DetailsObject) {
|
||||||
let payload = {}
|
let payload = {}
|
||||||
if (details) payload = formatDetailsObject(details)
|
if (details) payload = formatDetailsObject(details)
|
||||||
|
|
||||||
return await api.endpoints.parties
|
return await api.endpoints.parties
|
||||||
.create(payload)
|
.create({ ...payload, ...getLocalId() })
|
||||||
.then((response) => storeParty(response.data.party))
|
.then((response) => storeParty(response.data.party))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Methods: Updating the party's details
|
// Methods: Updating the party's details
|
||||||
async function updateDetails(details: DetailsObject) {
|
async function updateDetails(details: DetailsObject) {
|
||||||
if (!appState.party.id) return await createParty(details)
|
if (!props.team) return await createParty(details)
|
||||||
else updateParty(details)
|
else updateParty(details)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,9 +143,9 @@ const Party = (props: Props) => {
|
||||||
async function updateParty(details: DetailsObject) {
|
async function updateParty(details: DetailsObject) {
|
||||||
const payload = formatDetailsObject(details)
|
const payload = formatDetailsObject(details)
|
||||||
|
|
||||||
if (appState.party.id) {
|
if (props.team && props.team.id) {
|
||||||
return await api.endpoints.parties
|
return await api.endpoints.parties
|
||||||
.update(appState.party.id, payload)
|
.update(props.team.id, payload)
|
||||||
.then((response) => storeParty(response.data.party))
|
.then((response) => storeParty(response.data.party))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -89,21 +154,25 @@ const Party = (props: Props) => {
|
||||||
appState.party.extra = event.target.checked
|
appState.party.extra = event.target.checked
|
||||||
|
|
||||||
// Only save if this is a saved party
|
// Only save if this is a saved party
|
||||||
if (appState.party.id) {
|
if (props.team && props.team.id) {
|
||||||
api.endpoints.parties.update(appState.party.id, {
|
api.endpoints.parties.update(props.team.id, {
|
||||||
party: { extra: event.target.checked },
|
party: { extra: event.target.checked },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deleting the party
|
// Deleting the party
|
||||||
function deleteTeam(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
|
function deleteTeam() {
|
||||||
if (appState.party.editable && appState.party.id) {
|
if (props.team && editable) {
|
||||||
api.endpoints.parties
|
api.endpoints.parties
|
||||||
.destroy({ id: appState.party.id })
|
.destroy({ id: props.team.id })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Push to route
|
// Push to route
|
||||||
|
if (cookies && cookies.account.username) {
|
||||||
|
router.push(`/${cookies.account.username}`)
|
||||||
|
} else {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
// Clean state
|
// Clean state
|
||||||
const resetState = clonedeep(initialAppState)
|
const resetState = clonedeep(initialAppState)
|
||||||
|
|
@ -121,7 +190,7 @@ const Party = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Methods: Storing party data
|
// Methods: Storing party data
|
||||||
const storeParty = function (team: Party) {
|
const storeParty = function (team: any) {
|
||||||
// Store the important party and state-keeping values in global state
|
// Store the important party and state-keeping values in global state
|
||||||
appState.party.name = team.name
|
appState.party.name = team.name
|
||||||
appState.party.description = team.description
|
appState.party.description = team.description
|
||||||
|
|
@ -129,27 +198,52 @@ const Party = (props: Props) => {
|
||||||
appState.party.updated_at = team.updated_at
|
appState.party.updated_at = team.updated_at
|
||||||
appState.party.job = team.job
|
appState.party.job = team.job
|
||||||
appState.party.jobSkills = team.job_skills
|
appState.party.jobSkills = team.job_skills
|
||||||
|
appState.party.accessory = team.accessory
|
||||||
|
|
||||||
appState.party.id = team.id
|
appState.party.id = team.id
|
||||||
|
appState.party.shortcode = team.shortcode
|
||||||
appState.party.extra = team.extra
|
appState.party.extra = team.extra
|
||||||
appState.party.user = team.user
|
appState.party.user = team.user
|
||||||
appState.party.favorited = team.favorited
|
appState.party.favorited = team.favorited
|
||||||
|
appState.party.remix = team.remix
|
||||||
|
appState.party.remixes = team.remixes
|
||||||
|
appState.party.sourceParty = team.source_party
|
||||||
appState.party.created_at = team.created_at
|
appState.party.created_at = team.created_at
|
||||||
appState.party.updated_at = team.updated_at
|
appState.party.updated_at = team.updated_at
|
||||||
|
|
||||||
appState.party.detailsVisible = false
|
appState.party.detailsVisible = false
|
||||||
|
|
||||||
|
// Store the edit key in local storage
|
||||||
|
if (team.edit_key) {
|
||||||
|
storeEditKey(team.id, team.edit_key)
|
||||||
|
setEditKey(team.id, team.user)
|
||||||
|
}
|
||||||
|
|
||||||
// Populate state
|
// Populate state
|
||||||
storeCharacters(team.characters)
|
storeCharacters(team.characters)
|
||||||
storeWeapons(team.weapons)
|
storeWeapons(team.weapons)
|
||||||
storeSummons(team.summons)
|
storeSummons(team.summons)
|
||||||
|
|
||||||
|
// Create a string to send the user back to the tab they're currently on
|
||||||
|
let tab = ''
|
||||||
|
if (currentTab === GridType.Character) {
|
||||||
|
tab = 'characters'
|
||||||
|
} else if (currentTab === GridType.Summon) {
|
||||||
|
tab = 'summons'
|
||||||
|
}
|
||||||
|
|
||||||
// Then, push the browser history to the new party's URL
|
// Then, push the browser history to the new party's URL
|
||||||
if (props.pushHistory) props.pushHistory(`/p/${team.shortcode}`)
|
if (props.pushHistory) {
|
||||||
|
props.pushHistory(`/p/${team.shortcode}/${tab}`)
|
||||||
|
}
|
||||||
|
|
||||||
return team
|
return team
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const storeEditKey = (id: string, key: string) => {
|
||||||
|
ls(id, key)
|
||||||
|
}
|
||||||
|
|
||||||
const storeCharacters = (list: Array<GridCharacter>) => {
|
const storeCharacters = (list: Array<GridCharacter>) => {
|
||||||
list.forEach((object: GridCharacter) => {
|
list.forEach((object: GridCharacter) => {
|
||||||
if (object.position != null)
|
if (object.position != null)
|
||||||
|
|
@ -184,17 +278,22 @@ const Party = (props: Props) => {
|
||||||
|
|
||||||
// Methods: Navigating with segmented control
|
// Methods: Navigating with segmented control
|
||||||
function segmentClicked(event: React.ChangeEvent<HTMLInputElement>) {
|
function segmentClicked(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const path = [
|
||||||
|
router.asPath.split('/').filter((el) => el != '')[1],
|
||||||
|
event.target.value,
|
||||||
|
].join('/')
|
||||||
|
|
||||||
switch (event.target.value) {
|
switch (event.target.value) {
|
||||||
case 'class':
|
|
||||||
setCurrentTab(GridType.Class)
|
|
||||||
break
|
|
||||||
case 'characters':
|
case 'characters':
|
||||||
|
router.replace(path)
|
||||||
setCurrentTab(GridType.Character)
|
setCurrentTab(GridType.Character)
|
||||||
break
|
break
|
||||||
case 'weapons':
|
case 'weapons':
|
||||||
|
router.replace(path)
|
||||||
setCurrentTab(GridType.Weapon)
|
setCurrentTab(GridType.Weapon)
|
||||||
break
|
break
|
||||||
case 'summons':
|
case 'summons':
|
||||||
|
router.replace(path)
|
||||||
setCurrentTab(GridType.Summon)
|
setCurrentTab(GridType.Summon)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
|
|
@ -214,6 +313,7 @@ const Party = (props: Props) => {
|
||||||
const weaponGrid = (
|
const weaponGrid = (
|
||||||
<WeaponGrid
|
<WeaponGrid
|
||||||
new={props.new || false}
|
new={props.new || false}
|
||||||
|
editable={editable}
|
||||||
weapons={props.team?.weapons}
|
weapons={props.team?.weapons}
|
||||||
createParty={createParty}
|
createParty={createParty}
|
||||||
pushHistory={props.pushHistory}
|
pushHistory={props.pushHistory}
|
||||||
|
|
@ -223,6 +323,7 @@ const Party = (props: Props) => {
|
||||||
const summonGrid = (
|
const summonGrid = (
|
||||||
<SummonGrid
|
<SummonGrid
|
||||||
new={props.new || false}
|
new={props.new || false}
|
||||||
|
editable={editable}
|
||||||
summons={props.team?.summons}
|
summons={props.team?.summons}
|
||||||
createParty={createParty}
|
createParty={createParty}
|
||||||
pushHistory={props.pushHistory}
|
pushHistory={props.pushHistory}
|
||||||
|
|
@ -232,6 +333,7 @@ const Party = (props: Props) => {
|
||||||
const characterGrid = (
|
const characterGrid = (
|
||||||
<CharacterGrid
|
<CharacterGrid
|
||||||
new={props.new || false}
|
new={props.new || false}
|
||||||
|
editable={editable}
|
||||||
characters={props.team?.characters}
|
characters={props.team?.characters}
|
||||||
createParty={createParty}
|
createParty={createParty}
|
||||||
pushHistory={props.pushHistory}
|
pushHistory={props.pushHistory}
|
||||||
|
|
@ -264,4 +366,6 @@ const Party = (props: Props) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Party.defaultProps = defaultProps
|
||||||
|
|
||||||
export default Party
|
export default Party
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,35 @@
|
||||||
.DetailsWrapper {
|
.DetailsWrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
margin: $unit-4x auto 0 auto;
|
margin: $unit-4x auto 0 auto;
|
||||||
max-width: $grid-width;
|
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) {
|
@include breakpoint(phone) {
|
||||||
padding: 0 $unit;
|
padding: 0 $unit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.PartyDetails {
|
|
||||||
display: none;
|
|
||||||
margin: 0 auto;
|
|
||||||
max-width: $unit * 94;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&.Visible {
|
&.Visible {
|
||||||
margin-bottom: $unit-12x;
|
// margin-bottom: $unit-12x;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.Editable {
|
&.Editable {
|
||||||
|
|
@ -37,6 +50,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.SelectTrigger {
|
.SelectTrigger {
|
||||||
|
padding: $unit-2x;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,12 +59,16 @@
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.ToggleSection,
|
.ToggleSection,
|
||||||
.InputSection {
|
.InputSection {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border-radius: $card-corner;
|
border-radius: $input-corner;
|
||||||
|
|
||||||
& > label {
|
& > label {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -133,6 +151,11 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.left {
|
.left {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
@ -141,11 +164,18 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
.Button {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.ReadOnly {
|
&.ReadOnly {
|
||||||
|
box-sizing: border-box;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
||||||
|
|
@ -166,7 +196,8 @@
|
||||||
.Details {
|
.Details {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: $unit-half;
|
flex-wrap: wrap;
|
||||||
|
gap: $unit;
|
||||||
margin-bottom: $unit-2x;
|
margin-bottom: $unit-2x;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,29 +288,40 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.PartyInfo {
|
.PartyInfo {
|
||||||
align-items: center;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
margin-bottom: $unit * 2;
|
|
||||||
max-width: $unit * 94;
|
max-width: $unit * 94;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
.Left {
|
@include breakpoint(phone) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
padding: 0 $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .Left {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
||||||
|
.Header {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: $unit;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: $font-xlarge;
|
font-size: $font-xlarge;
|
||||||
font-weight: $normal;
|
font-weight: $normal;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
margin-bottom: $unit;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
||||||
&.empty {
|
&.empty {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.attribution {
|
.attribution {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -297,6 +339,18 @@
|
||||||
font-size: $font-small;
|
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 {
|
& > *:not(:last-child):after {
|
||||||
content: ' · ';
|
content: ' · ';
|
||||||
margin: 0 calc($unit / 2);
|
margin: 0 calc($unit / 2);
|
||||||
|
|
@ -333,3 +387,59 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Remixes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 720px;
|
||||||
|
|
||||||
|
@include breakpoint(tablet) {
|
||||||
|
gap: $unit;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
max-width: inherit;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: $font-medium;
|
||||||
|
font-weight: $medium;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
padding: 0 $unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.GridRepCollection {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
margin-left: $unit-2x * -1;
|
||||||
|
margin-right: $unit-2x * -1;
|
||||||
|
|
||||||
|
@include breakpoint(tablet) {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||||
|
max-width: inherit;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
margin-left: $unit * -1;
|
||||||
|
margin-right: $unit * -1;
|
||||||
|
max-width: inherit;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.GridRep {
|
||||||
|
min-width: 200px;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
min-width: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,38 @@
|
||||||
import React, { useEffect, useState, ChangeEvent, KeyboardEvent } from 'react'
|
import React, { useEffect, useState, ChangeEvent, KeyboardEvent } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useSnapshot } from 'valtio'
|
import { subscribe, useSnapshot } from 'valtio'
|
||||||
import { useTranslation } from 'next-i18next'
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import clonedeep from 'lodash.clonedeep'
|
||||||
|
|
||||||
import Linkify from 'react-linkify'
|
import Linkify from 'react-linkify'
|
||||||
import LiteYouTubeEmbed from 'react-lite-youtube-embed'
|
import LiteYouTubeEmbed from 'react-lite-youtube-embed'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import reactStringReplace from 'react-string-replace'
|
import reactStringReplace from 'react-string-replace'
|
||||||
|
|
||||||
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
import Alert from '~components/Alert'
|
||||||
|
|
||||||
import Button from '~components/Button'
|
import Button from '~components/Button'
|
||||||
import CharLimitedFieldset from '~components/CharLimitedFieldset'
|
import CharLimitedFieldset from '~components/CharLimitedFieldset'
|
||||||
import Input from '~components/Input'
|
|
||||||
import DurationInput from '~components/DurationInput'
|
import DurationInput from '~components/DurationInput'
|
||||||
|
import GridRepCollection from '~components/GridRepCollection'
|
||||||
|
import GridRep from '~components/GridRep'
|
||||||
|
import Input from '~components/Input'
|
||||||
|
import RaidDropdown from '~components/RaidDropdown'
|
||||||
|
import Switch from '~components/Switch'
|
||||||
|
import Tooltip from '~components/Tooltip'
|
||||||
|
import TextFieldset from '~components/TextFieldset'
|
||||||
import Token from '~components/Token'
|
import Token from '~components/Token'
|
||||||
|
|
||||||
import RaidDropdown from '~components/RaidDropdown'
|
import api from '~utils/api'
|
||||||
import TextFieldset from '~components/TextFieldset'
|
|
||||||
import Switch from '~components/Switch'
|
|
||||||
|
|
||||||
import { accountState } from '~utils/accountState'
|
import { accountState } from '~utils/accountState'
|
||||||
import { appState } from '~utils/appState'
|
import { appState, initialAppState } from '~utils/appState'
|
||||||
import { formatTimeAgo } from '~utils/timeAgo'
|
import { formatTimeAgo } from '~utils/timeAgo'
|
||||||
import { youtube } from '~utils/youtube'
|
import { youtube } from '~utils/youtube'
|
||||||
|
|
||||||
import CheckIcon from '~public/icons/Check.svg'
|
import CheckIcon from '~public/icons/Check.svg'
|
||||||
import CrossIcon from '~public/icons/Cross.svg'
|
import CrossIcon from '~public/icons/Cross.svg'
|
||||||
import EditIcon from '~public/icons/Edit.svg'
|
import EditIcon from '~public/icons/Edit.svg'
|
||||||
|
import RemixIcon from '~public/icons/Remix.svg'
|
||||||
|
|
||||||
import type { DetailsObject } from 'types'
|
import type { DetailsObject } from 'types'
|
||||||
|
|
||||||
|
|
@ -40,9 +44,7 @@ interface Props {
|
||||||
new: boolean
|
new: boolean
|
||||||
editable: boolean
|
editable: boolean
|
||||||
updateCallback: (details: DetailsObject) => void
|
updateCallback: (details: DetailsObject) => void
|
||||||
deleteCallback: (
|
deleteCallback: () => void
|
||||||
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
|
|
||||||
) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PartyDetails = (props: Props) => {
|
const PartyDetails = (props: Props) => {
|
||||||
|
|
@ -60,6 +62,7 @@ const PartyDetails = (props: Props) => {
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
|
const [alertOpen, setAlertOpen] = useState(false)
|
||||||
|
|
||||||
const [chargeAttack, setChargeAttack] = useState(true)
|
const [chargeAttack, setChargeAttack] = useState(true)
|
||||||
const [fullAuto, setFullAuto] = useState(false)
|
const [fullAuto, setFullAuto] = useState(false)
|
||||||
|
|
@ -70,6 +73,8 @@ const PartyDetails = (props: Props) => {
|
||||||
const [turnCount, setTurnCount] = useState<number | undefined>(undefined)
|
const [turnCount, setTurnCount] = useState<number | undefined>(undefined)
|
||||||
const [clearTime, setClearTime] = useState(0)
|
const [clearTime, setClearTime] = useState(0)
|
||||||
|
|
||||||
|
const [remixes, setRemixes] = useState<Party[]>([])
|
||||||
|
|
||||||
const [raidSlug, setRaidSlug] = useState('')
|
const [raidSlug, setRaidSlug] = useState('')
|
||||||
const [embeddedDescription, setEmbeddedDescription] =
|
const [embeddedDescription, setEmbeddedDescription] =
|
||||||
useState<React.ReactNode>()
|
useState<React.ReactNode>()
|
||||||
|
|
@ -112,12 +117,33 @@ const PartyDetails = (props: Props) => {
|
||||||
setFullAuto(props.party.full_auto)
|
setFullAuto(props.party.full_auto)
|
||||||
setChargeAttack(props.party.charge_attack)
|
setChargeAttack(props.party.charge_attack)
|
||||||
setClearTime(props.party.clear_time)
|
setClearTime(props.party.clear_time)
|
||||||
|
setRemixes(props.party.remixes)
|
||||||
if (props.party.turn_count) setTurnCount(props.party.turn_count)
|
if (props.party.turn_count) setTurnCount(props.party.turn_count)
|
||||||
if (props.party.button_count) setButtonCount(props.party.button_count)
|
if (props.party.button_count) setButtonCount(props.party.button_count)
|
||||||
if (props.party.chain_count) setChainCount(props.party.chain_count)
|
if (props.party.chain_count) setChainCount(props.party.chain_count)
|
||||||
}
|
}
|
||||||
}, [props.party])
|
}, [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)
|
||||||
|
setRemixes(party.remixes)
|
||||||
|
setTurnCount(party.turnCount)
|
||||||
|
setButtonCount(party.buttonCount)
|
||||||
|
setChainCount(party.chainCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Extract the video IDs from the description
|
// Extract the video IDs from the description
|
||||||
if (appState.party.description) {
|
if (appState.party.description) {
|
||||||
|
|
@ -293,6 +319,57 @@ const PartyDetails = (props: Props) => {
|
||||||
toggleDetails()
|
toggleDetails()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
setAlertOpen(!alertOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteParty() {
|
||||||
|
props.deleteCallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Navigation
|
||||||
|
function goTo(shortcode?: string) {
|
||||||
|
if (shortcode) router.push(`/p/${shortcode}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Favorites
|
||||||
|
function toggleFavorite(teamId: string, favorited: boolean) {
|
||||||
|
if (favorited) unsaveFavorite(teamId)
|
||||||
|
else saveFavorite(teamId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFavorite(teamId: string) {
|
||||||
|
api.saveTeam({ id: teamId }).then((response) => {
|
||||||
|
if (response.status == 201) {
|
||||||
|
const index = remixes.findIndex((p) => p.id === teamId)
|
||||||
|
const party = remixes[index]
|
||||||
|
|
||||||
|
party.favorited = true
|
||||||
|
|
||||||
|
let clonedParties = clonedeep(remixes)
|
||||||
|
clonedParties[index] = party
|
||||||
|
|
||||||
|
setRemixes(clonedParties)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsaveFavorite(teamId: string) {
|
||||||
|
api.unsaveTeam({ id: teamId }).then((response) => {
|
||||||
|
if (response.status == 200) {
|
||||||
|
const index = remixes.findIndex((p) => p.id === teamId)
|
||||||
|
const party = remixes[index]
|
||||||
|
|
||||||
|
party.favorited = false
|
||||||
|
|
||||||
|
let clonedParties = clonedeep(remixes)
|
||||||
|
clonedParties[index] = party
|
||||||
|
|
||||||
|
setRemixes(clonedParties)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function extractYoutubeVideoIds(text: string) {
|
function extractYoutubeVideoIds(text: string) {
|
||||||
// Initialize an array to store the video IDs
|
// Initialize an array to store the video IDs
|
||||||
const videoIds = []
|
const videoIds = []
|
||||||
|
|
@ -326,7 +403,16 @@ const PartyDetails = (props: Props) => {
|
||||||
src={`/profile/${picture}.png`}
|
src={`/profile/${picture}.png`}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
else return <div className="no-user" />
|
else
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
alt={t('no_user')}
|
||||||
|
className={`profile anonymous`}
|
||||||
|
srcSet={`/profile/npc.png,
|
||||||
|
/profile/npc@2x.png 2x`}
|
||||||
|
src={`/profile/npc.png`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const userBlock = (username?: string, picture?: string, element?: string) => {
|
const userBlock = (username?: string, picture?: string, element?: string) => {
|
||||||
|
|
@ -342,8 +428,8 @@ const PartyDetails = (props: Props) => {
|
||||||
let username, picture, element
|
let username, picture, element
|
||||||
if (accountState.account.authorized && props.new) {
|
if (accountState.account.authorized && props.new) {
|
||||||
username = accountState.account.user?.username
|
username = accountState.account.user?.username
|
||||||
picture = accountState.account.user?.picture
|
picture = accountState.account.user?.avatar.picture
|
||||||
element = accountState.account.user?.element
|
element = accountState.account.user?.avatar.element
|
||||||
} else if (party.user && !props.new) {
|
} else if (party.user && !props.new) {
|
||||||
username = party.user.username
|
username = party.user.username
|
||||||
picture = party.user.avatar.picture
|
picture = party.user.avatar.picture
|
||||||
|
|
@ -381,46 +467,45 @@ const PartyDetails = (props: Props) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteButton = () => {
|
function renderRemixes() {
|
||||||
|
return remixes.map((party, i) => {
|
||||||
|
return (
|
||||||
|
<GridRep
|
||||||
|
id={party.id}
|
||||||
|
shortcode={party.shortcode}
|
||||||
|
name={party.name}
|
||||||
|
createdAt={new Date(party.created_at)}
|
||||||
|
raid={party.raid}
|
||||||
|
grid={party.weapons}
|
||||||
|
user={party.user}
|
||||||
|
favorited={party.favorited}
|
||||||
|
fullAuto={party.full_auto}
|
||||||
|
key={`party-${i}`}
|
||||||
|
displayUser={true}
|
||||||
|
onClick={goTo}
|
||||||
|
onSave={toggleFavorite}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAlert = () => {
|
||||||
if (party.editable) {
|
if (party.editable) {
|
||||||
return (
|
return (
|
||||||
<AlertDialog.Root>
|
<Alert
|
||||||
<AlertDialog.Trigger className="Button Blended medium destructive">
|
open={alertOpen}
|
||||||
<span className="Accessory">
|
primaryAction={deleteParty}
|
||||||
<CrossIcon />
|
primaryActionText={t('modals.delete_team.buttons.confirm')}
|
||||||
</span>
|
cancelAction={() => setAlertOpen(false)}
|
||||||
<span className="Text">{t('buttons.delete')}</span>
|
cancelActionText={t('modals.delete_team.buttons.cancel')}
|
||||||
</AlertDialog.Trigger>
|
message={t('modals.delete_team.description')}
|
||||||
<AlertDialog.Portal>
|
/>
|
||||||
<AlertDialog.Overlay className="Overlay" />
|
|
||||||
<AlertDialog.Content className="Dialog">
|
|
||||||
<AlertDialog.Title className="DialogTitle">
|
|
||||||
{t('modals.delete_team.title')}
|
|
||||||
</AlertDialog.Title>
|
|
||||||
<AlertDialog.Description className="DialogDescription">
|
|
||||||
{t('modals.delete_team.description')}
|
|
||||||
</AlertDialog.Description>
|
|
||||||
<div className="actions">
|
|
||||||
<AlertDialog.Cancel className="Button modal">
|
|
||||||
{t('modals.delete_team.buttons.cancel')}
|
|
||||||
</AlertDialog.Cancel>
|
|
||||||
<AlertDialog.Action
|
|
||||||
className="Button modal destructive"
|
|
||||||
onClick={(e) => props.deleteCallback(e)}
|
|
||||||
>
|
|
||||||
{t('modals.delete_team.buttons.confirm')}
|
|
||||||
</AlertDialog.Action>
|
|
||||||
</div>
|
|
||||||
</AlertDialog.Content>
|
|
||||||
</AlertDialog.Portal>
|
|
||||||
</AlertDialog.Root>
|
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
return ''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const editable = (
|
const editable = () => {
|
||||||
|
return (
|
||||||
<section className={editableClasses}>
|
<section className={editableClasses}>
|
||||||
<CharLimitedFieldset
|
<CharLimitedFieldset
|
||||||
fieldName="name"
|
fieldName="name"
|
||||||
|
|
@ -553,12 +638,21 @@ const PartyDetails = (props: Props) => {
|
||||||
|
|
||||||
<div className="bottom">
|
<div className="bottom">
|
||||||
<div className="left">
|
<div className="left">
|
||||||
{router.pathname !== '/new' ? deleteButton() : ''}
|
{router.pathname !== '/new' ? (
|
||||||
|
<Button
|
||||||
|
leftAccessoryIcon={<CrossIcon />}
|
||||||
|
className="Blended medium destructive"
|
||||||
|
onClick={handleClick}
|
||||||
|
text={t('buttons.delete')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="right">
|
<div className="right">
|
||||||
<Button text={t('buttons.cancel')} onClick={toggleDetails} />
|
<Button text={t('buttons.cancel')} onClick={toggleDetails} />
|
||||||
<Button
|
<Button
|
||||||
accessoryIcon={<CheckIcon className="Check" />}
|
leftAccessoryIcon={<CheckIcon className="Check" />}
|
||||||
text={t('buttons.save_info')}
|
text={t('buttons.save_info')}
|
||||||
onClick={updateDetails}
|
onClick={updateDetails}
|
||||||
/>
|
/>
|
||||||
|
|
@ -566,6 +660,7 @@ const PartyDetails = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const clearTimeString = () => {
|
const clearTimeString = () => {
|
||||||
const minutes = Math.floor(clearTime / 60)
|
const minutes = Math.floor(clearTime / 60)
|
||||||
|
|
@ -600,18 +695,46 @@ const PartyDetails = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const readOnly = (
|
const readOnly = () => {
|
||||||
|
return (
|
||||||
<section className={readOnlyClasses}>
|
<section className={readOnlyClasses}>
|
||||||
<section className="Details">
|
<section className="Details">
|
||||||
{
|
<Token
|
||||||
<Token>
|
className={classNames({
|
||||||
|
ChargeAttack: true,
|
||||||
|
On: chargeAttack,
|
||||||
|
Off: !chargeAttack,
|
||||||
|
})}
|
||||||
|
>
|
||||||
{`${t('party.details.labels.charge_attack')} ${
|
{`${t('party.details.labels.charge_attack')} ${
|
||||||
chargeAttack ? 'On' : 'Off'
|
chargeAttack ? 'On' : 'Off'
|
||||||
}`}
|
}`}
|
||||||
</Token>
|
</Token>
|
||||||
}
|
|
||||||
{fullAuto ? <Token>{t('party.details.labels.full_auto')}</Token> : ''}
|
<Token
|
||||||
{autoGuard ? <Token>{t('party.details.labels.auto_guard')}</Token> : ''}
|
className={classNames({
|
||||||
|
FullAuto: true,
|
||||||
|
On: fullAuto,
|
||||||
|
Off: !fullAuto,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{`${t('party.details.labels.full_auto')} ${
|
||||||
|
fullAuto ? 'On' : 'Off'
|
||||||
|
}`}
|
||||||
|
</Token>
|
||||||
|
|
||||||
|
<Token
|
||||||
|
className={classNames({
|
||||||
|
AutoGuard: true,
|
||||||
|
On: autoGuard,
|
||||||
|
Off: !autoGuard,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{`${t('party.details.labels.auto_guard')} ${
|
||||||
|
fullAuto ? 'On' : 'Off'
|
||||||
|
}`}
|
||||||
|
</Token>
|
||||||
|
|
||||||
{turnCount ? (
|
{turnCount ? (
|
||||||
<Token>
|
<Token>
|
||||||
{t('party.details.turns.with_count', {
|
{t('party.details.turns.with_count', {
|
||||||
|
|
@ -627,14 +750,39 @@ const PartyDetails = (props: Props) => {
|
||||||
<Linkify>{embeddedDescription}</Linkify>
|
<Linkify>{embeddedDescription}</Linkify>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remixSection = () => {
|
||||||
|
return (
|
||||||
|
<section className="Remixes">
|
||||||
|
<h3>{t('remixes')}</h3>
|
||||||
|
{<GridRepCollection>{renderRemixes()}</GridRepCollection>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<section className="DetailsWrapper">
|
<section className="DetailsWrapper">
|
||||||
<div className="PartyInfo">
|
<div className="PartyInfo">
|
||||||
<div className="Left">
|
<div className="Left">
|
||||||
<h1 className={name === '' ? 'empty' : ''}>
|
<div className="Header">
|
||||||
{name !== '' ? name : 'Untitled'}
|
<h1 className={name ? '' : 'empty'}>
|
||||||
|
{name ? name : t('no_title')}
|
||||||
</h1>
|
</h1>
|
||||||
|
{party.remix && party.sourceParty ? (
|
||||||
|
<Tooltip content={t('tooltips.source')}>
|
||||||
|
<Button
|
||||||
|
className="IconButton Blended"
|
||||||
|
leftAccessoryIcon={<RemixIcon />}
|
||||||
|
text={t('tokens.remix')}
|
||||||
|
onClick={() => goTo(party.sourceParty?.shortcode)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="attribution">
|
<div className="attribution">
|
||||||
{renderUserBlock()}
|
{renderUserBlock()}
|
||||||
{party.raid ? linkedRaidBlock(party.raid) : ''}
|
{party.raid ? linkedRaidBlock(party.raid) : ''}
|
||||||
|
|
@ -650,21 +798,25 @@ const PartyDetails = (props: Props) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="Right">
|
|
||||||
{party.editable ? (
|
{party.editable ? (
|
||||||
|
<div className="Right">
|
||||||
<Button
|
<Button
|
||||||
accessoryIcon={<EditIcon />}
|
leftAccessoryIcon={<EditIcon />}
|
||||||
text={t('buttons.show_info')}
|
text={t('buttons.show_info')}
|
||||||
onClick={toggleDetails}
|
onClick={toggleDetails}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div />
|
''
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{readOnly()}
|
||||||
{readOnly}
|
{editable()}
|
||||||
{editable}
|
|
||||||
|
{deleteAlert()}
|
||||||
</section>
|
</section>
|
||||||
|
{remixes && remixes.length > 0 ? remixSection() : ''}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
74
components/PartyHead/index.tsx
Normal file
74
components/PartyHead/index.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import React from 'react'
|
||||||
|
import Head from 'next/head'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
|
import generateTitle from '~utils/generateTitle'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
party: Party
|
||||||
|
meta: { [key: string]: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PartyHead = ({ party, meta }: Props) => {
|
||||||
|
// Import translations
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
|
// Set up router
|
||||||
|
const router = useRouter()
|
||||||
|
const locale =
|
||||||
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Head>
|
||||||
|
{/* HTML */}
|
||||||
|
<title>
|
||||||
|
{generateTitle(meta.element, party.user?.username, party.name)}
|
||||||
|
</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content={t('page.descriptions.team', {
|
||||||
|
username: party.user?.username,
|
||||||
|
raidName: party.raid ? party.raid.name[locale] : '',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/images/favicon.png" />
|
||||||
|
|
||||||
|
{/* OpenGraph */}
|
||||||
|
<meta
|
||||||
|
property="og:title"
|
||||||
|
content={generateTitle(meta.element, party.user?.username, party.name)}
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content={t('page.descriptions.team', {
|
||||||
|
username: party.user?.username,
|
||||||
|
raidName: party.raid ? party.raid.name[locale] : '',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:url"
|
||||||
|
content={`https://app.granblue.team/p/${party.shortcode}`}
|
||||||
|
/>
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
|
||||||
|
{/* Twitter */}
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:domain" content="app.granblue.team" />
|
||||||
|
<meta
|
||||||
|
name="twitter:title"
|
||||||
|
content={generateTitle(meta.element, party.user?.username, party.name)}
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content={t('page.descriptions.team', {
|
||||||
|
username: party.user?.username,
|
||||||
|
raidName: party.raid ? party.raid.name[locale] : '',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PartyHead
|
||||||
|
|
@ -20,6 +20,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const PartySegmentedControl = (props: Props) => {
|
const PartySegmentedControl = (props: Props) => {
|
||||||
|
// Set up translations
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
const { party, grid } = useSnapshot(appState)
|
const { party, grid } = useSnapshot(appState)
|
||||||
|
|
@ -33,22 +34,16 @@ const PartySegmentedControl = (props: Props) => {
|
||||||
switch (element) {
|
switch (element) {
|
||||||
case 1:
|
case 1:
|
||||||
return 'wind'
|
return 'wind'
|
||||||
break
|
|
||||||
case 2:
|
case 2:
|
||||||
return 'fire'
|
return 'fire'
|
||||||
break
|
|
||||||
case 3:
|
case 3:
|
||||||
return 'water'
|
return 'water'
|
||||||
break
|
|
||||||
case 4:
|
case 4:
|
||||||
return 'earth'
|
return 'earth'
|
||||||
break
|
|
||||||
case 5:
|
case 5:
|
||||||
return 'dark'
|
return 'dark'
|
||||||
break
|
|
||||||
case 6:
|
case 6:
|
||||||
return 'light'
|
return 'light'
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,13 +67,6 @@ const PartySegmentedControl = (props: Props) => {
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<SegmentedControl elementClass={getElement()}>
|
<SegmentedControl elementClass={getElement()}>
|
||||||
{/* <Segment
|
|
||||||
groupName="grid"
|
|
||||||
name="class"
|
|
||||||
selected={props.selectedTab === GridType.Class}
|
|
||||||
onClick={props.onClick}
|
|
||||||
>Class</Segment> */}
|
|
||||||
|
|
||||||
<Segment
|
<Segment
|
||||||
groupName="grid"
|
groupName="grid"
|
||||||
name="characters"
|
name="characters"
|
||||||
|
|
|
||||||
10
components/PopoverContent/index.scss
Normal file
10
components/PopoverContent/index.scss
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
.Popover {
|
||||||
|
animation: scaleIn $duration-zoom ease-out;
|
||||||
|
background: var(--dialog-bg);
|
||||||
|
border-radius: $card-corner;
|
||||||
|
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.24);
|
||||||
|
outline: none;
|
||||||
|
padding: $unit;
|
||||||
|
transform-origin: var(--radix-popover-content-transform-origin);
|
||||||
|
}
|
||||||
44
components/PopoverContent/index.tsx
Normal file
44
components/PopoverContent/index.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import React, { PropsWithChildren } from 'react'
|
||||||
|
import classnames from 'classnames'
|
||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props
|
||||||
|
extends React.DetailedHTMLProps<
|
||||||
|
React.DialogHTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
>,
|
||||||
|
PopoverPrimitive.PopoverContentProps {}
|
||||||
|
|
||||||
|
export const Popover = PopoverPrimitive.Root
|
||||||
|
export const PopoverAnchor = PopoverPrimitive.Anchor
|
||||||
|
export const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
export const PopoverContent = React.forwardRef<HTMLDivElement, Props>(
|
||||||
|
function Popover(
|
||||||
|
{ children, ...props }: PropsWithChildren<Props>,
|
||||||
|
forwardedRef
|
||||||
|
) {
|
||||||
|
const classes = classnames(props.className, {
|
||||||
|
Popover: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
{...props}
|
||||||
|
className={classes}
|
||||||
|
ref={forwardedRef}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<PopoverPrimitive.Arrow className="Arrow" />
|
||||||
|
</PopoverPrimitive.Content>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
PopoverContent.defaultProps = {
|
||||||
|
sideOffset: 8,
|
||||||
|
}
|
||||||
60
components/ProfileHead/index.tsx
Normal file
60
components/ProfileHead/index.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import React from 'react'
|
||||||
|
import Head from 'next/head'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileHead = ({ user }: Props) => {
|
||||||
|
// Import translations
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Head>
|
||||||
|
{/* HTML */}
|
||||||
|
<title>{t('page.titles.profile', { username: user.username })}</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content={t('page.descriptions.profile', {
|
||||||
|
username: user.username,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/images/favicon.png" />
|
||||||
|
|
||||||
|
{/* OpenGraph */}
|
||||||
|
<meta
|
||||||
|
property="og:title"
|
||||||
|
content={t('page.titles.profile', { username: user.username })}
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content={t('page.descriptions.profile', {
|
||||||
|
username: user.username,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:url"
|
||||||
|
content={`https://app.granblue.team/${user.username}`}
|
||||||
|
/>
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
|
||||||
|
{/* Twitter */}
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:domain" content="app.granblue.team" />
|
||||||
|
<meta
|
||||||
|
name="twitter:title"
|
||||||
|
content={t('page.titles.profile', { username: user.username })}
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content={t('page.descriptions.profile', {
|
||||||
|
username: user.username,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfileHead
|
||||||
|
|
@ -8,7 +8,7 @@ import SelectGroup from '~components/SelectGroup'
|
||||||
import api from '~utils/api'
|
import api from '~utils/api'
|
||||||
import organizeRaids from '~utils/organizeRaids'
|
import organizeRaids from '~utils/organizeRaids'
|
||||||
import { appState } from '~utils/appState'
|
import { appState } from '~utils/appState'
|
||||||
import { raidGroups } from '~utils/raidGroups'
|
import { raidGroups } from '~data/raidGroups'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
|
|
|
||||||
5
components/RingSelect/index.scss
Normal file
5
components/RingSelect/index.scss
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.Rings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
150
components/RingSelect/index.tsx
Normal file
150
components/RingSelect/index.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
// Core dependencies
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
// UI dependencies
|
||||||
|
import ExtendedMasterySelect from '~components/ExtendedMasterySelect'
|
||||||
|
|
||||||
|
// Data
|
||||||
|
import { overMastery } from '~data/overMastery'
|
||||||
|
|
||||||
|
// Styles and icons
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import { CharacterOverMastery, ExtendedMastery } from '~types'
|
||||||
|
|
||||||
|
const emptyRing: ExtendedMastery = {
|
||||||
|
modifier: 0,
|
||||||
|
strength: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
gridCharacter: GridCharacter
|
||||||
|
sendValues: (overMastery: CharacterOverMastery) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RingSelect = ({ gridCharacter, sendValues }: Props) => {
|
||||||
|
// Ring value states
|
||||||
|
const [rings, setRings] = useState<CharacterOverMastery>({
|
||||||
|
1: { ...emptyRing, modifier: 1 },
|
||||||
|
2: { ...emptyRing, modifier: 2 },
|
||||||
|
3: emptyRing,
|
||||||
|
4: emptyRing,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gridCharacter.over_mastery) {
|
||||||
|
setRings({
|
||||||
|
1: gridCharacter.over_mastery[0],
|
||||||
|
2: gridCharacter.over_mastery[1],
|
||||||
|
3: gridCharacter.over_mastery[2],
|
||||||
|
4: gridCharacter.over_mastery[3],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [gridCharacter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sendValues(rings)
|
||||||
|
}, [rings])
|
||||||
|
|
||||||
|
function dataSet(index: number) {
|
||||||
|
const noValue = {
|
||||||
|
name: {
|
||||||
|
en: 'No over mastery bonus',
|
||||||
|
ja: 'EXリミットボーナスなし',
|
||||||
|
},
|
||||||
|
id: 0,
|
||||||
|
slug: 'no-bonus',
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 0,
|
||||||
|
suffix: '',
|
||||||
|
fractional: false,
|
||||||
|
secondary: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (index) {
|
||||||
|
case 1:
|
||||||
|
return overMastery.a ? [overMastery.a[0]] : []
|
||||||
|
case 2:
|
||||||
|
return overMastery.a ? [overMastery.a[1]] : []
|
||||||
|
case 3:
|
||||||
|
return overMastery.b ? [noValue, ...overMastery.b] : []
|
||||||
|
case 4:
|
||||||
|
return overMastery.c ? [noValue, ...overMastery.c] : []
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiveRingValues(index: number, left: number, right: number) {
|
||||||
|
console.log(`Receiving values from ${index}: ${left} ${right}`)
|
||||||
|
if (index == 1 || index == 2) {
|
||||||
|
setSyncedRingValues(index, right)
|
||||||
|
} else if (index == 3 && left == 0) {
|
||||||
|
setRings({
|
||||||
|
...rings,
|
||||||
|
3: {
|
||||||
|
modifier: 0,
|
||||||
|
strength: 0,
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
modifier: 0,
|
||||||
|
strength: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setRings({
|
||||||
|
...rings,
|
||||||
|
[index]: {
|
||||||
|
modifier: left,
|
||||||
|
strength: right,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSyncedRingValues(index: 1 | 2, value: number) {
|
||||||
|
console.log(`Setting synced value for ${index} with value ${value}`)
|
||||||
|
const atkValues = (dataSet(1)[0] as ItemSkill).values ?? []
|
||||||
|
const hpValues = (dataSet(2)[0] as ItemSkill).values ?? []
|
||||||
|
|
||||||
|
let found = index === 1 ? atkValues.indexOf(value) : hpValues.indexOf(value)
|
||||||
|
|
||||||
|
setRings({
|
||||||
|
...rings,
|
||||||
|
1: {
|
||||||
|
modifier: 1,
|
||||||
|
strength: atkValues[found],
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
modifier: 2,
|
||||||
|
strength: hpValues[found],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="Rings">
|
||||||
|
{[...Array(4)].map((e, i) => {
|
||||||
|
const ringIndex = i + 1
|
||||||
|
const ringStat = rings[ringIndex]
|
||||||
|
return (
|
||||||
|
<ExtendedMasterySelect
|
||||||
|
name={`ring-${ringIndex}`}
|
||||||
|
object="ring"
|
||||||
|
key={`ring-${ringIndex}`}
|
||||||
|
dataSet={dataSet(ringIndex)}
|
||||||
|
leftSelectDisabled={i === 0 || i === 1}
|
||||||
|
leftSelectValue={ringStat.modifier ? ringStat.modifier : 0}
|
||||||
|
rightSelectValue={ringStat.strength ? ringStat.strength : 0}
|
||||||
|
sendValues={(left: number, right: number) => {
|
||||||
|
receiveRingValues(ringIndex, left, right)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RingSelect
|
||||||
|
|
@ -1,92 +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 GithubIcon from '~public/icons/github.svg'
|
|
||||||
|
|
||||||
import './index.scss'
|
|
||||||
|
|
||||||
const RoadmapModal = () => {
|
|
||||||
const { t } = useTranslation('roadmap')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.Root>
|
|
||||||
<Dialog.Trigger asChild>
|
|
||||||
<li className="MenuItem">
|
|
||||||
<span>{t('modals.roadmap.title')}</span>
|
|
||||||
</li>
|
|
||||||
</Dialog.Trigger>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Content
|
|
||||||
className="Roadmap Dialog"
|
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
<div className="DialogHeader">
|
|
||||||
<Dialog.Title className="DialogTitle">{t('title')}</Dialog.Title>
|
|
||||||
<Dialog.Close className="DialogClose" asChild>
|
|
||||||
<span>
|
|
||||||
<CrossIcon />
|
|
||||||
</span>
|
|
||||||
</Dialog.Close>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div className="top">
|
|
||||||
<h3 className="priority in_progress">{t('subtitle')}</h3>
|
|
||||||
<p>{t('blurb')}</p>
|
|
||||||
<p>{t('link.intro')}</p>
|
|
||||||
<div className="LinkItem">
|
|
||||||
<Link href="https://github.com/users/jedmund/projects/1/views/3">
|
|
||||||
<a
|
|
||||||
href="https://github.com/users/jedmund/projects/1/views/3"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<div className="Left">
|
|
||||||
<GithubIcon />
|
|
||||||
<h3>{t('link.title')}</h3>
|
|
||||||
</div>
|
|
||||||
<ShareIcon className="ShareIcon" />
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="Separator" />
|
|
||||||
<ul className="notes">
|
|
||||||
<li>
|
|
||||||
<h4>{t('roadmap.item1.title')}</h4>
|
|
||||||
<p>{t('roadmap.item1.description')}</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h4>{t('roadmap.item2.title')}</h4>
|
|
||||||
<p>{t('roadmap.item2.description')}</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h4>{t('roadmap.item3.title')}</h4>
|
|
||||||
<p>{t('roadmap.item3.description')}</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h4>{t('roadmap.item4.title')}</h4>
|
|
||||||
<p>{t('roadmap.item4.description')}</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h4>{t('roadmap.item5.title')}</h4>
|
|
||||||
<p>{t('roadmap.item5.description')}</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h4>{t('roadmap.item6.title')}</h4>
|
|
||||||
<p>{t('roadmap.item6.description')}</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</Dialog.Content>
|
|
||||||
<Dialog.Overlay className="Overlay" />
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RoadmapModal
|
|
||||||
|
|
@ -1,15 +1,9 @@
|
||||||
.Roadmap.Dialog {
|
.Roadmap.PageContent {
|
||||||
max-height: 60vh;
|
padding-bottom: $unit-12x;
|
||||||
overflow-y: scroll;
|
|
||||||
|
|
||||||
.top {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit;
|
|
||||||
|
|
||||||
h3.priority {
|
h3.priority {
|
||||||
font-weight: $medium;
|
font-weight: $medium;
|
||||||
font-size: $font-large;
|
font-size: $font-large;
|
||||||
|
margin-bottom: $unit-4x;
|
||||||
|
|
||||||
&.in_progress {
|
&.in_progress {
|
||||||
color: $yellow;
|
color: $yellow;
|
||||||
|
|
@ -28,12 +22,20 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
margin-bottom: $unit-2x;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-bottom: $unit;
|
margin-bottom: $unit;
|
||||||
|
font-size: $font-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
.LinkItem {
|
.LinkItem {
|
||||||
$diameter: $unit-6x;
|
$diameter: $unit-6x;
|
||||||
|
background: var(--dialog-bg);
|
||||||
border: 1px solid var(--link-item-bg);
|
border: 1px solid var(--link-item-bg);
|
||||||
border-radius: $card-corner;
|
border-radius: $card-corner;
|
||||||
|
|
||||||
|
|
@ -79,22 +81,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.Separator {
|
ul {
|
||||||
background: var(--separator-bg);
|
|
||||||
border-radius: 2px;
|
|
||||||
margin: $unit-3x 0;
|
|
||||||
height: 2px;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
font-size: $font-regular;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notes {
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $unit-3x;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
display: flex;
|
display: flex;
|
||||||
56
components/RoadmapPage/index.tsx
Normal file
56
components/RoadmapPage/index.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import React from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
|
import ShareIcon from '~public/icons/Share.svg'
|
||||||
|
import GithubIcon from '~public/icons/github.svg'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
const ROADMAP_ITEMS = 6
|
||||||
|
|
||||||
|
const RoadmapPage = () => {
|
||||||
|
const { t: common } = useTranslation('common')
|
||||||
|
const { t: about } = useTranslation('about')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="Roadmap PageContent">
|
||||||
|
<h1>{common('about.segmented_control.roadmap')}</h1>
|
||||||
|
<section className="notes">
|
||||||
|
<p>{about('roadmap.blurb')}</p>
|
||||||
|
<p>{about('roadmap.link.intro')}</p>
|
||||||
|
<div className="Github LinkItem">
|
||||||
|
<Link href="https://github.com/users/jedmund/projects/1/views/3">
|
||||||
|
<a
|
||||||
|
href="https://github.com/users/jedmund/projects/1/views/3"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<div className="Left">
|
||||||
|
<GithubIcon />
|
||||||
|
<h3>{about('roadmap.link.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<ShareIcon className="ShareIcon" />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="features">
|
||||||
|
<h3 className="priority in_progress">{about('roadmap.subtitle')}</h3>
|
||||||
|
<ul>
|
||||||
|
{[...Array(ROADMAP_ITEMS)].map((e, i) => (
|
||||||
|
<li key={`roadmap-${i}`}>
|
||||||
|
<h4>{about(`roadmap.items.${i}.title`)}</h4>
|
||||||
|
<p>{about(`roadmap.items.${i}.description`)}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RoadmapPage
|
||||||
26
components/SavedHead/index.tsx
Normal file
26
components/SavedHead/index.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import React from 'react'
|
||||||
|
import Head from 'next/head'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
|
const SavedHead = () => {
|
||||||
|
// Import translations
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Head>
|
||||||
|
<title>{t('page.titles.saved')}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/images/favicon.png" />
|
||||||
|
|
||||||
|
<meta property="og:title" content={t('page.titles.saved')} />
|
||||||
|
<meta property="og:url" content="https://app.granblue.team/saved" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:domain" content="app.granblue.team" />
|
||||||
|
<meta name="twitter:title" content={t('page.titles.saved')} />
|
||||||
|
</Head>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SavedHead
|
||||||
|
|
@ -12,6 +12,7 @@ button.DropdownLabel {
|
||||||
padding: $unit ($unit * 1.5) $unit $unit-2x;
|
padding: $unit ($unit * 1.5) $unit $unit-2x;
|
||||||
|
|
||||||
div {
|
div {
|
||||||
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $unit-half;
|
gap: $unit-half;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
.Search.Dialog {
|
.Search.DialogContent {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 430px;
|
min-height: 430px;
|
||||||
height: 480px;
|
|
||||||
gap: 0;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
@include breakpoint(phone) {
|
||||||
|
|
@ -14,17 +12,16 @@
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
#Header {
|
.DialogHeader.Search {
|
||||||
border-bottom: 1px solid transparent;
|
align-items: inherit;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
padding-bottom: $unit * 2;
|
padding: 0;
|
||||||
|
padding-bottom: $unit-2x;
|
||||||
&.scrolled {
|
position: sticky;
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
top: 0;
|
||||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.12);
|
left: 0;
|
||||||
}
|
|
||||||
|
|
||||||
#Bar {
|
#Bar {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -63,7 +60,6 @@
|
||||||
|
|
||||||
#Results {
|
#Results {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-height: 356px;
|
|
||||||
padding: 0 ($unit * 1.5);
|
padding: 0 ($unit * 1.5);
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
|
||||||
|
|
@ -94,7 +90,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.Search.Dialog #NoResults {
|
.Search.DialogContent #NoResults {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -102,7 +98,7 @@
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Search.Dialog #NoResults h2 {
|
.Search.DialogContent #NoResults h2 {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: $font-large;
|
font-size: $font-large;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,12 @@ import { getCookie, setCookie } from 'cookies-next'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||||
|
import cloneDeep from 'lodash.clonedeep'
|
||||||
|
|
||||||
import api from '~utils/api'
|
import api from '~utils/api'
|
||||||
|
|
||||||
import {
|
import { Dialog, DialogTrigger, DialogClose } from '~components/Dialog'
|
||||||
Dialog,
|
import DialogContent from '~components/DialogContent'
|
||||||
DialogTrigger,
|
|
||||||
DialogContent,
|
|
||||||
DialogClose,
|
|
||||||
} from '~components/Dialog'
|
|
||||||
|
|
||||||
import Input from '~components/LabelledInput'
|
import Input from '~components/LabelledInput'
|
||||||
import CharacterSearchFilterBar from '~components/CharacterSearchFilterBar'
|
import CharacterSearchFilterBar from '~components/CharacterSearchFilterBar'
|
||||||
import WeaponSearchFilterBar from '~components/WeaponSearchFilterBar'
|
import WeaponSearchFilterBar from '~components/WeaponSearchFilterBar'
|
||||||
|
|
@ -24,19 +20,18 @@ import WeaponResult from '~components/WeaponResult'
|
||||||
import SummonResult from '~components/SummonResult'
|
import SummonResult from '~components/SummonResult'
|
||||||
import JobSkillResult from '~components/JobSkillResult'
|
import JobSkillResult from '~components/JobSkillResult'
|
||||||
|
|
||||||
|
import type { DialogProps } from '@radix-ui/react-dialog'
|
||||||
import type { SearchableObject, SearchableObjectArray } from '~types'
|
import type { SearchableObject, SearchableObjectArray } from '~types'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import CrossIcon from '~public/icons/Cross.svg'
|
import CrossIcon from '~public/icons/Cross.svg'
|
||||||
import cloneDeep from 'lodash.clonedeep'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props extends DialogProps {
|
||||||
send: (object: SearchableObject, position: number) => any
|
send: (object: SearchableObject, position: number) => any
|
||||||
placeholderText: string
|
placeholderText: string
|
||||||
fromPosition: number
|
fromPosition: number
|
||||||
job?: Job
|
job?: Job
|
||||||
object: 'weapons' | 'characters' | 'summons' | 'job_skills'
|
object: 'weapons' | 'characters' | 'summons' | 'job_skills'
|
||||||
children: React.ReactNode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchModal = (props: Props) => {
|
const SearchModal = (props: Props) => {
|
||||||
|
|
@ -47,8 +42,10 @@ const SearchModal = (props: Props) => {
|
||||||
// Set up translation
|
// Set up translation
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
let searchInput = React.createRef<HTMLInputElement>()
|
// Refs
|
||||||
let scrollContainer = React.createRef<HTMLDivElement>()
|
const headerRef = React.createRef<HTMLDivElement>()
|
||||||
|
const searchInput = React.createRef<HTMLInputElement>()
|
||||||
|
const scrollContainer = React.createRef<HTMLDivElement>()
|
||||||
|
|
||||||
const [firstLoad, setFirstLoad] = useState(true)
|
const [firstLoad, setFirstLoad] = useState(true)
|
||||||
const [filters, setFilters] = useState<{ [key: string]: any }>()
|
const [filters, setFilters] = useState<{ [key: string]: any }>()
|
||||||
|
|
@ -65,6 +62,10 @@ const SearchModal = (props: Props) => {
|
||||||
if (searchInput.current) searchInput.current.focus()
|
if (searchInput.current) searchInput.current.focus()
|
||||||
}, [searchInput])
|
}, [searchInput])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.open !== undefined) setOpen(props.open)
|
||||||
|
})
|
||||||
|
|
||||||
function inputChanged(event: React.ChangeEvent<HTMLInputElement>) {
|
function inputChanged(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const text = event.target.value
|
const text = event.target.value
|
||||||
if (text.length) {
|
if (text.length) {
|
||||||
|
|
@ -141,8 +142,14 @@ const SearchModal = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expiresAt = new Date()
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 60)
|
||||||
|
|
||||||
if (recents && recents.length > 5) recents.pop()
|
if (recents && recents.length > 5) recents.pop()
|
||||||
setCookie(`recent_${props.object}`, recents, { path: '/' })
|
setCookie(`recent_${props.object}`, recents, {
|
||||||
|
path: '/',
|
||||||
|
expires: expiresAt,
|
||||||
|
})
|
||||||
sendData(result)
|
sendData(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,8 +342,10 @@ const SearchModal = (props: Props) => {
|
||||||
setRecordCount(0)
|
setRecordCount(0)
|
||||||
setCurrentPage(1)
|
setCurrentPage(1)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
if (props.onOpenChange) props.onOpenChange(false)
|
||||||
} else {
|
} else {
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
|
if (props.onOpenChange) props.onOpenChange(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -354,11 +363,12 @@ const SearchModal = (props: Props) => {
|
||||||
<Dialog open={open} onOpenChange={openChange}>
|
<Dialog open={open} onOpenChange={openChange}>
|
||||||
<DialogTrigger asChild>{props.children}</DialogTrigger>
|
<DialogTrigger asChild>{props.children}</DialogTrigger>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="Search Dialog"
|
className="Search"
|
||||||
|
headerref={headerRef}
|
||||||
onEscapeKeyDown={onEscapeKeyDown}
|
onEscapeKeyDown={onEscapeKeyDown}
|
||||||
onOpenAutoFocus={onOpenAutoFocus}
|
onOpenAutoFocus={onOpenAutoFocus}
|
||||||
>
|
>
|
||||||
<div id="Header">
|
<div className="Search DialogHeader" ref={headerRef}>
|
||||||
<div id="Bar">
|
<div id="Bar">
|
||||||
<Input
|
<Input
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
border-radius: $input-corner;
|
border-radius: $input-corner;
|
||||||
border: none;
|
border: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: $unit-2x $unit-2x;
|
gap: $unit;
|
||||||
|
padding: ($unit * 1.5) $unit-2x;
|
||||||
|
|
||||||
&.modal {
|
&.modal {
|
||||||
background-color: var(--select-modal-bg);
|
background-color: var(--select-modal-bg);
|
||||||
|
|
@ -14,6 +15,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--input-bg-hover);
|
background-color: var(--input-bg-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
@ -24,6 +29,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.Disabled:hover {
|
||||||
|
background-color: var(--input-bg);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
&[data-placeholder] > span:not(.SelectIcon) {
|
&[data-placeholder] > span:not(.SelectIcon) {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +57,11 @@
|
||||||
min-width: $unit * 30;
|
min-width: $unit * 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: $unit-4x;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.SelectIcon {
|
.SelectIcon {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ interface Props
|
||||||
React.SelectHTMLAttributes<HTMLSelectElement>,
|
React.SelectHTMLAttributes<HTMLSelectElement>,
|
||||||
HTMLSelectElement
|
HTMLSelectElement
|
||||||
> {
|
> {
|
||||||
|
altText?: string
|
||||||
|
iconSrc?: string
|
||||||
open: boolean
|
open: boolean
|
||||||
trigger?: React.ReactNode
|
trigger?: React.ReactNode
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
|
@ -21,6 +23,7 @@ interface Props
|
||||||
onValueChange?: (value: string) => void
|
onValueChange?: (value: string) => void
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
triggerClass?: string
|
triggerClass?: string
|
||||||
|
overlayVisible?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
|
const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
|
||||||
|
|
@ -30,6 +33,14 @@ const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
|
|
||||||
|
const triggerClasses = classNames(
|
||||||
|
{
|
||||||
|
SelectTrigger: true,
|
||||||
|
Disabled: props.disabled,
|
||||||
|
},
|
||||||
|
props.triggerClass
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpen(props.open)
|
setOpen(props.open)
|
||||||
}, [props.open])
|
}, [props.open])
|
||||||
|
|
@ -67,19 +78,27 @@ const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
|
||||||
onOpenChange={props.onOpenChange}
|
onOpenChange={props.onOpenChange}
|
||||||
>
|
>
|
||||||
<RadixSelect.Trigger
|
<RadixSelect.Trigger
|
||||||
className={classNames('SelectTrigger', props.triggerClass)}
|
className={triggerClasses}
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
ref={forwardedRef}
|
ref={forwardedRef}
|
||||||
>
|
>
|
||||||
|
{props.iconSrc ? <img alt={props.altText} src={props.iconSrc} /> : ''}
|
||||||
<RadixSelect.Value placeholder={props.placeholder} />
|
<RadixSelect.Value placeholder={props.placeholder} />
|
||||||
|
{!props.disabled ? (
|
||||||
<RadixSelect.Icon className="SelectIcon">
|
<RadixSelect.Icon className="SelectIcon">
|
||||||
<ArrowIcon />
|
<ArrowIcon />
|
||||||
</RadixSelect.Icon>
|
</RadixSelect.Icon>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
</RadixSelect.Trigger>
|
</RadixSelect.Trigger>
|
||||||
|
|
||||||
<RadixSelect.Portal className="Select">
|
<RadixSelect.Portal className="Select">
|
||||||
<>
|
<>
|
||||||
<Overlay open={open} visible={false} />
|
<Overlay
|
||||||
|
open={open}
|
||||||
|
visible={props.overlayVisible != null ? props.overlayVisible : true}
|
||||||
|
/>
|
||||||
|
|
||||||
<RadixSelect.Content
|
<RadixSelect.Content
|
||||||
className="Select"
|
className="Select"
|
||||||
|
|
@ -101,4 +120,8 @@ const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Select.defaultProps = {
|
||||||
|
overlayVisible: true,
|
||||||
|
}
|
||||||
|
|
||||||
export default Select
|
export default Select
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
.SelectItem {
|
.SelectItem {
|
||||||
|
align-items: center;
|
||||||
border-radius: $item-corner;
|
border-radius: $item-corner;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
display: flex;
|
||||||
|
gap: $unit;
|
||||||
font-size: $font-regular;
|
font-size: $font-regular;
|
||||||
padding: ($unit * 1.5) $unit-2x;
|
padding: ($unit * 1.5) $unit-2x;
|
||||||
|
|
||||||
|
|
@ -24,4 +27,9 @@
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-bottom: $unit;
|
margin-bottom: $unit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: $unit-4x;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,23 @@ import classNames from 'classnames'
|
||||||
|
|
||||||
interface Props extends ComponentProps<'div'> {
|
interface Props extends ComponentProps<'div'> {
|
||||||
value: string | number
|
value: string | number
|
||||||
|
iconSrc?: string
|
||||||
|
altText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectItem = React.forwardRef<HTMLDivElement, Props>(function selectItem(
|
const SelectItem = React.forwardRef<HTMLDivElement, Props>(function selectItem(
|
||||||
{ children, ...props },
|
{ children, value, ...props },
|
||||||
forwardedRef
|
forwardedRef
|
||||||
) {
|
) {
|
||||||
|
const { altText, iconSrc, ...rest } = props
|
||||||
return (
|
return (
|
||||||
<Select.Item
|
<Select.Item
|
||||||
className={classNames('SelectItem', props.className)}
|
className={classNames('SelectItem', props.className)}
|
||||||
{...props}
|
{...rest}
|
||||||
ref={forwardedRef}
|
ref={forwardedRef}
|
||||||
value={`${props.value}`}
|
value={`${value}`}
|
||||||
>
|
>
|
||||||
|
{iconSrc ? <img alt={altText} src={iconSrc} /> : ''}
|
||||||
<Select.ItemText>{children}</Select.ItemText>
|
<Select.ItemText>{children}</Select.ItemText>
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,47 @@
|
||||||
.TableField {
|
.TableField {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: $unit * 2;
|
gap: $unit-2x;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
|
|
||||||
&.Image {
|
@include breakpoint(phone) {
|
||||||
grid-template-columns: 1fr auto 1fr;
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Left {
|
.Left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: $unit;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.Info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: calc($unit / 2);
|
flex-grow: 1;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Image {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
$diameter: $unit-5x;
|
||||||
|
width: $diameter;
|
||||||
|
height: $diameter;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: $diameter;
|
||||||
|
height: $diameter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
|
@ -30,6 +60,23 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: $unit-2x;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
.Image {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.SelectTrigger {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.preview {
|
.preview {
|
||||||
$diameter: $unit * 6;
|
$diameter: $unit * 6;
|
||||||
background-color: $grey-90;
|
background-color: $grey-90;
|
||||||
|
|
|
||||||
|
|
@ -44,13 +44,15 @@ const SelectTableField = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<div className={classNames({ TableField: true }, props.className)}>
|
<div className={classNames({ TableField: true }, props.className)}>
|
||||||
<div className="Left">
|
<div className="Left">
|
||||||
|
<div className="Info">
|
||||||
<h3>{props.label}</h3>
|
<h3>{props.label}</h3>
|
||||||
<p>{props.description}</p>
|
<p>{props.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="Image">{image()}</div>
|
||||||
{image()}
|
</div>
|
||||||
|
|
||||||
<div className="Right">
|
<div className="Right">
|
||||||
|
<div className="Image">{image()}</div>
|
||||||
<Select
|
<Select
|
||||||
name={props.name}
|
name={props.name}
|
||||||
open={props.open}
|
open={props.open}
|
||||||
|
|
@ -59,6 +61,7 @@ const SelectTableField = (props: Props) => {
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
triggerClass={classNames({ Bound: true, Table: true })}
|
triggerClass={classNames({ Bound: true, Table: true })}
|
||||||
value={value}
|
value={value}
|
||||||
|
overlayVisible={false}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
|
||||||
29
components/SelectWithInput/index.scss
Normal file
29
components/SelectWithInput/index.scss
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
.SelectWithItem {
|
||||||
|
.InputSet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: $unit;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.SelectTrigger {
|
||||||
|
flex-grow: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Input {
|
||||||
|
flex-grow: 0;
|
||||||
|
text-align: right;
|
||||||
|
width: 13rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.errors {
|
||||||
|
color: $error;
|
||||||
|
display: none;
|
||||||
|
padding: $unit 0;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
201
components/SelectWithInput/index.tsx
Normal file
201
components/SelectWithInput/index.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
// Core dependencies
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
// UI Dependencies
|
||||||
|
import Input from '~components/Input'
|
||||||
|
import Select from '~components/Select'
|
||||||
|
import SelectItem from '~components/SelectItem'
|
||||||
|
|
||||||
|
// Styles and icons
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface Props {
|
||||||
|
object: 'ax' | 'weapon_awakening' | 'character_awakening' | 'ring' | 'earring'
|
||||||
|
dataSet: ItemSkill[]
|
||||||
|
selectValue: number
|
||||||
|
selectDisabled: boolean
|
||||||
|
inputValue: number
|
||||||
|
awakeningLevel?: number
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
sendValidity: (isValid: boolean) => void
|
||||||
|
sendValues: (type: number, level: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
selectDisabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectWithInput = ({
|
||||||
|
object,
|
||||||
|
dataSet,
|
||||||
|
selectDisabled,
|
||||||
|
selectValue,
|
||||||
|
inputValue,
|
||||||
|
onOpenChange,
|
||||||
|
sendValidity,
|
||||||
|
sendValues,
|
||||||
|
}: Props) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const locale =
|
||||||
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
// Field properties
|
||||||
|
// prettier-ignore
|
||||||
|
const [currentItemSkill, setCurrentItemSkill] = useState<ItemSkill | undefined>(undefined)
|
||||||
|
const [fieldInputValue, setFieldInputValue] = useState(inputValue)
|
||||||
|
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const input = React.createRef<HTMLInputElement>()
|
||||||
|
|
||||||
|
// Classes
|
||||||
|
const inputClasses = classNames({
|
||||||
|
Bound: true,
|
||||||
|
Hidden: currentItemSkill?.id === 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorClasses = classNames({
|
||||||
|
errors: true,
|
||||||
|
visible: error !== '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
|
||||||
|
// Set default values from props
|
||||||
|
useEffect(() => {
|
||||||
|
const found = dataSet.find((sk) => sk.id === selectValue)
|
||||||
|
if (found) {
|
||||||
|
setCurrentItemSkill(found)
|
||||||
|
setFieldInputValue(inputValue)
|
||||||
|
}
|
||||||
|
}, [selectValue, inputValue])
|
||||||
|
|
||||||
|
// Methods: UI state management
|
||||||
|
function changeOpen() {
|
||||||
|
if (!selectDisabled) {
|
||||||
|
setOpen(!open)
|
||||||
|
if (onOpenChange) onOpenChange(!open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
if (onOpenChange) onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Rendering
|
||||||
|
function generateOptions() {
|
||||||
|
let options: React.ReactNode[] = dataSet.map((skill, i) => {
|
||||||
|
return (
|
||||||
|
<SelectItem key={i} value={skill.id}>
|
||||||
|
{skill.name[locale]}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: User input detection
|
||||||
|
function handleSelectChange(rawValue: string) {
|
||||||
|
const value = parseInt(rawValue)
|
||||||
|
const skill = dataSet.find((sk) => sk.id === value)
|
||||||
|
|
||||||
|
if (skill) {
|
||||||
|
setCurrentItemSkill(skill)
|
||||||
|
sendValues(skill.id, fieldInputValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const value = parseFloat(event.target.value)
|
||||||
|
if (handleInputError(value)) setFieldInputValue(value)
|
||||||
|
|
||||||
|
if (currentItemSkill) sendValues(currentItemSkill.id, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Handle error
|
||||||
|
function handleInputError(value: number) {
|
||||||
|
let error = ''
|
||||||
|
|
||||||
|
if (currentItemSkill) {
|
||||||
|
if (value < currentItemSkill.minValue) {
|
||||||
|
error = t(`${object}.errors.value_too_low`, {
|
||||||
|
minValue: currentItemSkill.minValue,
|
||||||
|
})
|
||||||
|
} else if (value > currentItemSkill.maxValue) {
|
||||||
|
error = t(`${object}.errors.value_too_high`, {
|
||||||
|
maxValue: currentItemSkill.maxValue,
|
||||||
|
})
|
||||||
|
} else if (!currentItemSkill.fractional && value % 1 != 0) {
|
||||||
|
error = t(`${object}.errors.value_not_whole`)
|
||||||
|
} else if (!value || value <= 0) {
|
||||||
|
error = t(`${object}.errors.value_empty`)
|
||||||
|
} else {
|
||||||
|
error = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(error)
|
||||||
|
|
||||||
|
return error.length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeString = () => {
|
||||||
|
let placeholder = ''
|
||||||
|
|
||||||
|
if (currentItemSkill) {
|
||||||
|
const minValue = currentItemSkill.minValue
|
||||||
|
const maxValue = currentItemSkill.maxValue
|
||||||
|
placeholder = `${minValue}~${maxValue}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="SelectWithItem">
|
||||||
|
<div className="InputSet">
|
||||||
|
<Select
|
||||||
|
key={`${currentItemSkill?.name.en}_type`}
|
||||||
|
value={`${currentItemSkill ? currentItemSkill.id : 0}`}
|
||||||
|
open={open}
|
||||||
|
disabled={selectDisabled}
|
||||||
|
onValueChange={handleSelectChange}
|
||||||
|
onOpenChange={changeOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
triggerClass="modal"
|
||||||
|
overlayVisible={false}
|
||||||
|
>
|
||||||
|
{generateOptions()}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
value={fieldInputValue}
|
||||||
|
className={inputClasses}
|
||||||
|
type="number"
|
||||||
|
placeholder={rangeString()}
|
||||||
|
min={currentItemSkill?.minValue}
|
||||||
|
max={currentItemSkill?.maxValue}
|
||||||
|
step="1"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
visible={currentItemSkill ? 'true' : 'false'}
|
||||||
|
ref={input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className={errorClasses}>{error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectWithInput.defaultProps = defaultProps
|
||||||
|
|
||||||
|
export default SelectWithInput
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
.Signup.Dialog form {
|
.Signup.DialogContent {
|
||||||
|
gap: $unit;
|
||||||
|
// min-width: $unit * 52;
|
||||||
|
|
||||||
|
.Fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: calc($unit / 2);
|
gap: calc($unit / 2);
|
||||||
margin-bottom: $unit;
|
padding: 0 $unit-4x;
|
||||||
|
|
||||||
.terms {
|
.terms {
|
||||||
color: $grey-50;
|
color: $grey-50;
|
||||||
|
|
@ -19,4 +23,5 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,24 @@
|
||||||
import React, { useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { setCookie } from 'cookies-next'
|
import { setCookie } from 'cookies-next'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useTranslation } from 'next-i18next'
|
import { useTranslation } from 'next-i18next'
|
||||||
import { AxiosResponse } from 'axios'
|
import { AxiosResponse } from 'axios'
|
||||||
|
|
||||||
import api from '~utils/api'
|
import api from '~utils/api'
|
||||||
import setUserToken from '~utils/setUserToken'
|
import { setHeaders } from '~utils/userToken'
|
||||||
import { accountState } from '~utils/accountState'
|
import { accountState } from '~utils/accountState'
|
||||||
|
|
||||||
import Button from '~components/Button'
|
import Button from '~components/Button'
|
||||||
import Input from '~components/LabelledInput'
|
import Input from '~components/Input'
|
||||||
import {
|
import { Dialog, DialogTrigger, DialogClose } from '~components/Dialog'
|
||||||
Dialog,
|
import DialogContent from '~components/DialogContent'
|
||||||
DialogTrigger,
|
|
||||||
DialogContent,
|
|
||||||
DialogClose,
|
|
||||||
} from '~components/Dialog'
|
|
||||||
|
|
||||||
import CrossIcon from '~public/icons/Cross.svg'
|
import CrossIcon from '~public/icons/Cross.svg'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface Props {}
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
interface ErrorMap {
|
interface ErrorMap {
|
||||||
[index: string]: string
|
[index: string]: string
|
||||||
|
|
@ -54,6 +52,8 @@ const SignupModal = (props: Props) => {
|
||||||
const emailInput = React.createRef<HTMLInputElement>()
|
const emailInput = React.createRef<HTMLInputElement>()
|
||||||
const passwordInput = React.createRef<HTMLInputElement>()
|
const passwordInput = React.createRef<HTMLInputElement>()
|
||||||
const passwordConfirmationInput = React.createRef<HTMLInputElement>()
|
const passwordConfirmationInput = React.createRef<HTMLInputElement>()
|
||||||
|
const footerRef = React.createRef<HTMLDivElement>()
|
||||||
|
|
||||||
const form = [
|
const form = [
|
||||||
usernameInput,
|
usernameInput,
|
||||||
emailInput,
|
emailInput,
|
||||||
|
|
@ -61,6 +61,10 @@ const SignupModal = (props: Props) => {
|
||||||
passwordConfirmationInput,
|
passwordConfirmationInput,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(props.open)
|
||||||
|
}, [props.open])
|
||||||
|
|
||||||
function register(event: React.FormEvent) {
|
function register(event: React.FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
|
|
@ -94,10 +98,12 @@ const SignupModal = (props: Props) => {
|
||||||
token: resp.token,
|
token: resp.token,
|
||||||
}
|
}
|
||||||
|
|
||||||
setCookie('account', cookieObj, { path: '/' })
|
const expiresAt = new Date()
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 60)
|
||||||
|
setCookie('account', cookieObj, { path: '/', expires: expiresAt })
|
||||||
|
|
||||||
// Set Axios default headers
|
// Set Axios default headers
|
||||||
setUserToken()
|
setHeaders()
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchUserInfo(id: string) {
|
function fetchUserInfo(id: string) {
|
||||||
|
|
@ -109,24 +115,32 @@ const SignupModal = (props: Props) => {
|
||||||
const user = response.data
|
const user = response.data
|
||||||
|
|
||||||
// Set user data in the user cookie
|
// Set user data in the user cookie
|
||||||
|
const expiresAt = new Date()
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 60)
|
||||||
|
|
||||||
setCookie(
|
setCookie(
|
||||||
'user',
|
'user',
|
||||||
{
|
{
|
||||||
|
avatar: {
|
||||||
picture: user.avatar.picture,
|
picture: user.avatar.picture,
|
||||||
element: user.avatar.element,
|
element: user.avatar.element,
|
||||||
|
},
|
||||||
language: user.language,
|
language: user.language,
|
||||||
gender: user.gender,
|
gender: user.gender,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
},
|
},
|
||||||
{ path: '/' }
|
{ path: '/', expires: expiresAt }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set the user data in the account state
|
// Set the user data in the account state
|
||||||
accountState.account.user = {
|
accountState.account.user = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
granblueId: '',
|
||||||
|
avatar: {
|
||||||
picture: user.avatar.picture,
|
picture: user.avatar.picture,
|
||||||
element: user.avatar.element,
|
element: user.avatar.element,
|
||||||
|
},
|
||||||
gender: user.gender,
|
gender: user.gender,
|
||||||
language: user.language,
|
language: user.language,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
|
|
@ -264,6 +278,9 @@ const SignupModal = (props: Props) => {
|
||||||
password: '',
|
password: '',
|
||||||
passwordConfirmation: '',
|
passwordConfirmation: '',
|
||||||
})
|
})
|
||||||
|
setFormValid(false)
|
||||||
|
|
||||||
|
if (props.onOpenChange) props.onOpenChange(open)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEscapeKeyDown(event: KeyboardEvent) {
|
function onEscapeKeyDown(event: KeyboardEvent) {
|
||||||
|
|
@ -277,13 +294,9 @@ const SignupModal = (props: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={openChange}>
|
<Dialog open={open} onOpenChange={openChange}>
|
||||||
<DialogTrigger asChild>
|
|
||||||
<li className="MenuItem">
|
|
||||||
<span>{t('menu.signup')}</span>
|
|
||||||
</li>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="Signup Dialog"
|
className="Signup"
|
||||||
|
footerref={footerRef}
|
||||||
onEscapeKeyDown={onEscapeKeyDown}
|
onEscapeKeyDown={onEscapeKeyDown}
|
||||||
onOpenAutoFocus={onOpenAutoFocus}
|
onOpenAutoFocus={onOpenAutoFocus}
|
||||||
>
|
>
|
||||||
|
|
@ -297,6 +310,7 @@ const SignupModal = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="form" onSubmit={register}>
|
<form className="form" onSubmit={register}>
|
||||||
|
<div className="Fields">
|
||||||
<Input
|
<Input
|
||||||
className="Bound"
|
className="Bound"
|
||||||
name="username"
|
name="username"
|
||||||
|
|
@ -334,11 +348,17 @@ const SignupModal = (props: Props) => {
|
||||||
error={errors.passwordConfirmation}
|
error={errors.passwordConfirmation}
|
||||||
ref={passwordConfirmationInput}
|
ref={passwordConfirmationInput}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="DialogFooter" ref={footerRef}>
|
||||||
|
<div className="Buttons Span">
|
||||||
<Button
|
<Button
|
||||||
|
contained={true}
|
||||||
disabled={!formValid}
|
disabled={!formValid}
|
||||||
text={t('modals.signup.buttons.confirm')}
|
text={t('modals.signup.buttons.confirm')}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="terms">
|
<p className="terms">
|
||||||
{/* <Trans i18nKey="modals.signup.agreement">
|
{/* <Trans i18nKey="modals.signup.agreement">
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue