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_OAUTH_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/awakening*
|
||||
public/images/ax*
|
||||
public/images/accessory*
|
||||
public/images/mastery*
|
||||
public/images/updates*
|
||||
|
||||
# Typescript v1 declaration files
|
||||
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
|
||||
|
||||
First, run the development server:
|
||||
First, you have to set up your environment file. You should start with [.env.sample](https://github.com/jedmund/hensei-web/blob/staging/.env.sample), but here are some gotchas:
|
||||
|
||||
#### App URLs
|
||||
|
||||
Don't add a trailing slash to these URLs!
|
||||
The API will run on port 3000 by default, but make sure to change these to match your instance of the API.
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_SIERO_API_URL='http://127.0.0.1:3000/api/v1'
|
||||
NEXT_PUBLIC_SIERO_OAUTH_URL='http://127.0.0.1:3000/oauth'
|
||||
```
|
||||
|
||||
#### Asset URLs
|
||||
|
||||
Next.js serves all assets out of the /public directory. In development we utilize this for all assets, but in production, you will want to host these images on a cloud storage provider like Amazon S3. Once you have that set up and you're running in a production environment, change this to the full bucket URL.
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_SIERO_IMG_URL='/images'
|
||||
```
|
||||
|
||||
#### Dependencies
|
||||
|
||||
Once your `.env` is all set up, install all dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
yarn install
|
||||
```
|
||||
|
||||
Then, run the development server with:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
|
@ -10,25 +44,28 @@ npm run dev
|
|||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
## Assets
|
||||
|
||||
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
|
||||
The [hensei-api](https://github.com/jedmund/hensei-api) repository has tasks that will help you get assets, although some were crafted or renamed by hand. The front-end expects this folder structure inside of the `images` folder:
|
||||
|
||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
```
|
||||
root
|
||||
├─ accessory-grid/
|
||||
├─ accessory-square/
|
||||
├─ awakening/
|
||||
├─ ax/
|
||||
├─ chara-main/
|
||||
├─ chara-grid/
|
||||
├─ chara-square/
|
||||
├─ jobs/
|
||||
├─ job-icons/
|
||||
├─ job-skills/
|
||||
├─ mastery/
|
||||
├─ summon-main/
|
||||
├─ summon-grid/
|
||||
├─ summon-square/
|
||||
├─ updates/
|
||||
├─ weapon-main/
|
||||
├─ weapon-grid/
|
||||
├─ weapon-square/
|
||||
```
|
||||
|
|
|
|||
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;
|
||||
flex-direction: column;
|
||||
gap: $unit * 2;
|
||||
gap: $unit-2x;
|
||||
width: $unit * 64;
|
||||
overflow-y: hidden;
|
||||
|
||||
form {
|
||||
.Fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit * 2;
|
||||
gap: $unit-2x;
|
||||
padding: 0 $unit-4x;
|
||||
|
||||
@include breakpoint(phone) {
|
||||
gap: $unit-4x;
|
||||
}
|
||||
}
|
||||
|
||||
.DialogDescription {
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@ import React, { useEffect, useState } from 'react'
|
|||
import { getCookie, setCookie } from 'cookies-next'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '~components/Dialog'
|
||||
import DialogContent from '~components/DialogContent'
|
||||
import Button from '~components/Button'
|
||||
import SelectItem from '~components/SelectItem'
|
||||
import PictureSelectItem from '~components/PictureSelectItem'
|
||||
|
|
@ -23,7 +24,6 @@ import { pictureData } from '~utils/pictureData'
|
|||
|
||||
import CrossIcon from '~public/icons/Cross.svg'
|
||||
import './index.scss'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
type StateVariables = {
|
||||
[key: string]: boolean
|
||||
|
|
@ -34,20 +34,25 @@ type StateVariables = {
|
|||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
username?: string
|
||||
picture?: string
|
||||
gender?: number
|
||||
language?: string
|
||||
theme?: string
|
||||
private?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const AccountModal = (props: Props) => {
|
||||
const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
||||
function AccountModal(props: Props, forwardedRef) {
|
||||
// Localization
|
||||
const { t } = useTranslation('common')
|
||||
const router = useRouter()
|
||||
const locale =
|
||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||
router.locale && ['en', 'ja'].includes(router.locale)
|
||||
? router.locale
|
||||
: 'en'
|
||||
|
||||
// useEffect only runs on the client, so now we can safely show the UI
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
|
@ -85,8 +90,17 @@ const AccountModal = (props: Props) => {
|
|||
const [languageOpen, setLanguageOpen] = 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
|
||||
function openChange(open: boolean) {
|
||||
if (props.onOpenChange) props.onOpenChange(open)
|
||||
setOpen(open)
|
||||
}
|
||||
|
||||
|
|
@ -145,26 +159,34 @@ const AccountModal = (props: Props) => {
|
|||
const user = response.data
|
||||
|
||||
const cookieObj = {
|
||||
avatar: {
|
||||
picture: user.avatar.picture,
|
||||
element: user.avatar.element,
|
||||
},
|
||||
gender: user.gender,
|
||||
language: user.language,
|
||||
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 = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
granblueId: '',
|
||||
avatar: {
|
||||
picture: user.avatar.picture,
|
||||
element: user.avatar.element,
|
||||
},
|
||||
language: user.language,
|
||||
theme: user.theme,
|
||||
gender: user.gender,
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
if (props.onOpenChange) props.onOpenChange(false)
|
||||
changeLanguage(router, user.language)
|
||||
})
|
||||
}
|
||||
|
|
@ -279,17 +301,14 @@ const AccountModal = (props: Props) => {
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={openChange}>
|
||||
<DialogTrigger asChild>
|
||||
<li className="MenuItem">
|
||||
<span>{t('menu.settings')}</span>
|
||||
</li>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="Account Dialog"
|
||||
className="Account"
|
||||
headerref={headerRef}
|
||||
footerref={footerRef}
|
||||
onOpenAutoFocus={(event: Event) => {}}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
>
|
||||
<div className="DialogHeader">
|
||||
<div className="DialogHeader" ref={headerRef}>
|
||||
<div className="DialogTop">
|
||||
<DialogTitle className="SubTitle">
|
||||
{t('modals.settings.title')}
|
||||
|
|
@ -304,18 +323,23 @@ const AccountModal = (props: Props) => {
|
|||
</div>
|
||||
|
||||
<form onSubmit={update}>
|
||||
<div className="Fields">
|
||||
{pictureField()}
|
||||
{genderField()}
|
||||
{languageField()}
|
||||
{themeField()}
|
||||
</div>
|
||||
<div className="DialogFooter" ref={footerRef}>
|
||||
<Button
|
||||
contained={true}
|
||||
text={t('modals.settings.buttons.confirm')}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default AccountModal
|
||||
|
|
|
|||
|
|
@ -2,51 +2,43 @@
|
|||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 21;
|
||||
z-index: 31;
|
||||
}
|
||||
|
||||
.Alert {
|
||||
background: $grey-100;
|
||||
animation: $duration-modal-open cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal none
|
||||
running openModalDesktop;
|
||||
background: var(--dialog-bg);
|
||||
border-radius: $unit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
min-width: $unit * 20;
|
||||
max-width: $unit * 40;
|
||||
gap: $unit-2x;
|
||||
min-width: 20vw;
|
||||
max-width: 30vw;
|
||||
padding: $unit * 4;
|
||||
|
||||
@include breakpoint(phone) {
|
||||
max-width: inherit;
|
||||
width: 60vw;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: $font-regular;
|
||||
line-height: 1.26;
|
||||
line-height: 1.4;
|
||||
|
||||
strong {
|
||||
font-weight: $bold;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.Button {
|
||||
font-size: $font-regular;
|
||||
padding: ($unit * 1.5) ($unit * 2);
|
||||
margin-top: $unit * 2;
|
||||
|
||||
&.btn-disabled {
|
||||
background: $grey-90;
|
||||
color: $grey-70;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:not(.btn-disabled) {
|
||||
background: $grey-90;
|
||||
color: $grey-50;
|
||||
|
||||
&:hover {
|
||||
background: $grey-80;
|
||||
}
|
||||
}
|
||||
gap: $unit;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
|||
|
||||
import './index.scss'
|
||||
import Button from '~components/Button'
|
||||
import Overlay from '~components/Overlay'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
open: boolean
|
||||
title?: string
|
||||
message: string
|
||||
message: string | React.ReactNode
|
||||
primaryAction?: () => void
|
||||
primaryActionText?: string
|
||||
cancelAction: () => void
|
||||
|
|
@ -22,20 +23,29 @@ const Alert = (props: Props) => {
|
|||
<AlertDialog.Overlay className="Overlay" onClick={props.cancelAction} />
|
||||
<div className="AlertWrapper">
|
||||
<AlertDialog.Content className="Alert">
|
||||
{props.title ? <AlertDialog.Title>Error</AlertDialog.Title> : ''}
|
||||
{props.title ? (
|
||||
<AlertDialog.Title>{props.title}</AlertDialog.Title>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<AlertDialog.Description className="description">
|
||||
{props.message}
|
||||
</AlertDialog.Description>
|
||||
<div className="buttons">
|
||||
<AlertDialog.Cancel asChild>
|
||||
<Button
|
||||
contained={true}
|
||||
onClick={props.cancelAction}
|
||||
text={props.cancelActionText}
|
||||
/>
|
||||
</AlertDialog.Cancel>
|
||||
{props.primaryAction ? (
|
||||
<AlertDialog.Action onClick={props.primaryAction}>
|
||||
{props.primaryActionText}
|
||||
<AlertDialog.Action asChild>
|
||||
<Button
|
||||
contained={true}
|
||||
onClick={props.primaryAction}
|
||||
text={props.primaryActionText}
|
||||
/>
|
||||
</AlertDialog.Action>
|
||||
) : (
|
||||
''
|
||||
|
|
@ -43,6 +53,7 @@ const Alert = (props: Props) => {
|
|||
</div>
|
||||
</AlertDialog.Content>
|
||||
</div>
|
||||
<Overlay open={props.open} visible={true} />
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,199 +1,95 @@
|
|||
import React, { ForwardedRef, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import cloneDeep from 'lodash.clonedeep'
|
||||
|
||||
import Input from '~components/LabelledInput'
|
||||
import Select from '~components/Select'
|
||||
import SelectItem from '~components/SelectItem'
|
||||
|
||||
import classNames from 'classnames'
|
||||
|
||||
import { weaponAwakening, characterAwakening } from '~utils/awakening'
|
||||
import type { Awakening } from '~utils/awakening'
|
||||
import SelectWithInput from '~components/SelectWithInput'
|
||||
import { weaponAwakening, characterAwakening } from '~data/awakening'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
interface Props {
|
||||
object: 'character' | 'weapon'
|
||||
awakeningType?: number
|
||||
awakeningLevel?: number
|
||||
onOpenChange: (open: boolean) => void
|
||||
type?: number
|
||||
level?: number
|
||||
onOpenChange?: (open: boolean) => void
|
||||
sendValidity: (isValid: boolean) => void
|
||||
sendValues: (type: number, level: number) => void
|
||||
}
|
||||
|
||||
const AwakeningSelect = (props: Props) => {
|
||||
const router = useRouter()
|
||||
const locale =
|
||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Refs
|
||||
const awakeningLevelInput = React.createRef<HTMLInputElement>()
|
||||
|
||||
// States
|
||||
const [awakeningType, setAwakeningType] = useState(-1)
|
||||
// Data states
|
||||
const [awakeningType, setAwakeningType] = useState(
|
||||
props.object === 'weapon' ? 0 : 1
|
||||
)
|
||||
const [awakeningLevel, setAwakeningLevel] = useState(1)
|
||||
|
||||
const [maxValue, setMaxValue] = useState(1)
|
||||
// Data
|
||||
const chooseDataset = () => {
|
||||
let list: ItemSkill[] = []
|
||||
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Classes
|
||||
const inputClasses = classNames({
|
||||
Bound: true,
|
||||
Hidden: awakeningType === -1,
|
||||
switch (props.object) {
|
||||
case 'character':
|
||||
list = characterAwakening
|
||||
break
|
||||
case 'weapon':
|
||||
// WARNING: Clonedeep is masking a deeper error
|
||||
// which is running this method every time this component is rerendered
|
||||
// causing multiple "No awakening" items to be added
|
||||
const awakening = cloneDeep(weaponAwakening)
|
||||
awakening.unshift({
|
||||
id: 0,
|
||||
name: {
|
||||
en: 'No awakening',
|
||||
ja: '覚醒なし',
|
||||
},
|
||||
slug: 'no-awakening',
|
||||
minValue: 0,
|
||||
maxValue: 0,
|
||||
fractional: false,
|
||||
})
|
||||
list = awakening
|
||||
break
|
||||
}
|
||||
|
||||
const errorClasses = classNames({
|
||||
errors: true,
|
||||
visible: error !== '',
|
||||
})
|
||||
|
||||
// Set max value based on object type
|
||||
useEffect(() => {
|
||||
if (props.object === 'character') setMaxValue(9)
|
||||
else if (props.object === 'weapon') setMaxValue(15)
|
||||
}, [props.object])
|
||||
return list
|
||||
}
|
||||
|
||||
// Set default awakening and level based on object type
|
||||
useEffect(() => {
|
||||
let defaultAwakening = 0
|
||||
if (props.object === 'weapon') defaultAwakening = -1
|
||||
const defaultAwakening = props.object === 'weapon' ? 0 : 1
|
||||
const type = props.type != undefined ? props.type : defaultAwakening
|
||||
|
||||
setAwakeningType(
|
||||
props.awakeningType != undefined ? props.awakeningType : defaultAwakening
|
||||
)
|
||||
setAwakeningLevel(props.awakeningLevel ? props.awakeningLevel : 1)
|
||||
}, [props.object, props.awakeningType, props.awakeningLevel])
|
||||
|
||||
// Send awakening type and level when changed
|
||||
useEffect(() => {
|
||||
props.sendValues(awakeningType, awakeningLevel)
|
||||
}, [props.sendValues, awakeningType, awakeningLevel])
|
||||
setAwakeningType(type)
|
||||
setAwakeningLevel(props.level ? props.level : 1)
|
||||
}, [props.object, props.type, props.level])
|
||||
|
||||
// Send validity of form when awakening level changes
|
||||
useEffect(() => {
|
||||
props.sendValidity(awakeningLevel > 0 && error === '')
|
||||
}, [props.sendValidity, awakeningLevel, error])
|
||||
props.sendValidity(awakeningLevel > 0)
|
||||
}, [props.sendValidity, awakeningLevel])
|
||||
|
||||
// Classes
|
||||
function changeOpen() {
|
||||
setOpen(!open)
|
||||
props.onOpenChange(!open)
|
||||
function changeOpen(open: boolean) {
|
||||
if (props.onOpenChange) props.onOpenChange(open)
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
props.onOpenChange(false)
|
||||
}
|
||||
|
||||
function generateOptions(object: 'character' | 'weapon') {
|
||||
let options: Awakening[] = []
|
||||
if (object === 'character') options = characterAwakening
|
||||
else if (object === 'weapon') options = weaponAwakening
|
||||
else return
|
||||
|
||||
let optionElements: React.ReactNode[] = options.map((awakening, i) => {
|
||||
return (
|
||||
<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}`
|
||||
function handleValueChange(type: number, level: number) {
|
||||
setAwakeningType(type)
|
||||
setAwakeningLevel(level)
|
||||
props.sendValues(type, level)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="AwakeningSelect">
|
||||
<div className="AwakeningSet">
|
||||
<div className="fields">
|
||||
<Select
|
||||
key="awakening_type"
|
||||
value={`${awakeningType}`}
|
||||
open={open}
|
||||
onValueChange={handleSelectChange}
|
||||
onOpenChange={() => changeOpen()}
|
||||
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 className="Awakening">
|
||||
<SelectWithInput
|
||||
object={`${props.object}_awakening`}
|
||||
dataSet={chooseDataset()}
|
||||
selectValue={awakeningType}
|
||||
inputValue={awakeningLevel}
|
||||
onOpenChange={changeOpen}
|
||||
sendValidity={props.sendValidity}
|
||||
sendValues={handleValueChange}
|
||||
/>
|
||||
</div>
|
||||
<p className={errorClasses}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import SelectItem from '~components/SelectItem'
|
|||
|
||||
import classNames from 'classnames'
|
||||
|
||||
import { axData } from '~utils/axData'
|
||||
import ax from '~data/ax'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
|
|
@ -155,7 +155,7 @@ const AXSelect = (props: Props) => {
|
|||
|
||||
if (props.currentSkills[0].modifier > -1 && primaryAxValueInput.current) {
|
||||
const modifier = props.currentSkills[0].modifier
|
||||
const axSkill = axData[props.axType - 1][modifier]
|
||||
const axSkill = ax[props.axType - 1][modifier]
|
||||
setupInput(axSkill, primaryAxValueInput.current)
|
||||
}
|
||||
}
|
||||
|
|
@ -169,7 +169,7 @@ const AXSelect = (props: Props) => {
|
|||
props.currentSkills[1].modifier != null
|
||||
) {
|
||||
const firstSkill = props.currentSkills[0]
|
||||
const primaryAxSkill = axData[props.axType - 1][firstSkill.modifier]
|
||||
const primaryAxSkill = ax[props.axType - 1][firstSkill.modifier]
|
||||
const secondaryAxSkill = findSecondaryAxSkill(
|
||||
primaryAxSkill,
|
||||
props.currentSkills[1]
|
||||
|
|
@ -185,7 +185,7 @@ const AXSelect = (props: Props) => {
|
|||
}
|
||||
|
||||
function findSecondaryAxSkill(
|
||||
axSkill: AxSkill | undefined,
|
||||
axSkill: ItemSkill | undefined,
|
||||
skillAtIndex: SimpleAxSkill
|
||||
) {
|
||||
if (axSkill)
|
||||
|
|
@ -213,7 +213,7 @@ const AXSelect = (props: Props) => {
|
|||
}
|
||||
|
||||
function generateOptions(modifierSet: number) {
|
||||
const axOptions = axData[props.axType - 1]
|
||||
const axOptions = ax[props.axType - 1]
|
||||
|
||||
let axOptionElements: React.ReactNode[] = []
|
||||
if (modifierSet == 0) {
|
||||
|
|
@ -264,7 +264,7 @@ const AXSelect = (props: Props) => {
|
|||
secondaryAxModifierSelect.current &&
|
||||
secondaryAxValueInput.current
|
||||
) {
|
||||
setupInput(axData[props.axType - 1][value], primaryAxValueInput.current)
|
||||
setupInput(ax[props.axType - 1][value], primaryAxValueInput.current)
|
||||
setPrimaryAxValue(0)
|
||||
primaryAxValueInput.current.value = ''
|
||||
|
||||
|
|
@ -280,7 +280,7 @@ const AXSelect = (props: Props) => {
|
|||
const value = parseInt(rawValue)
|
||||
setSecondaryAxModifier(value)
|
||||
|
||||
const primaryAxSkill = axData[props.axType - 1][primaryAxModifier]
|
||||
const primaryAxSkill = ax[props.axType - 1][primaryAxModifier]
|
||||
const currentAxSkill = primaryAxSkill.secondary
|
||||
? primaryAxSkill.secondary.find((skill) => skill.id == value)
|
||||
: undefined
|
||||
|
|
@ -304,7 +304,7 @@ const AXSelect = (props: Props) => {
|
|||
}
|
||||
|
||||
function handlePrimaryErrors(value: number) {
|
||||
const primaryAxSkill = axData[props.axType - 1][primaryAxModifier]
|
||||
const primaryAxSkill = ax[props.axType - 1][primaryAxModifier]
|
||||
let newErrors = { ...errors }
|
||||
|
||||
if (value < primaryAxSkill.minValue) {
|
||||
|
|
@ -333,7 +333,7 @@ const AXSelect = (props: Props) => {
|
|||
}
|
||||
|
||||
function handleSecondaryErrors(value: number) {
|
||||
const primaryAxSkill = axData[props.axType - 1][primaryAxModifier]
|
||||
const primaryAxSkill = ax[props.axType - 1][primaryAxModifier]
|
||||
let newErrors = { ...errors }
|
||||
|
||||
if (primaryAxSkill.secondary) {
|
||||
|
|
@ -373,7 +373,7 @@ const AXSelect = (props: Props) => {
|
|||
return newErrors.axValue2.length === 0
|
||||
}
|
||||
|
||||
function setupInput(ax: AxSkill | undefined, element: HTMLInputElement) {
|
||||
function setupInput(ax: ItemSkill | undefined, element: HTMLInputElement) {
|
||||
if (ax) {
|
||||
const rangeString = `${ax.minValue}~${ax.maxValue}${ax.suffix || ''}`
|
||||
|
||||
|
|
@ -410,6 +410,7 @@ const AXSelect = (props: Props) => {
|
|||
onOpenChange={() => openSelect(1)}
|
||||
onValueChange={handleAX1SelectChange}
|
||||
triggerClass="modal"
|
||||
overlayVisible={false}
|
||||
>
|
||||
{generateOptions(0)}
|
||||
</Select>
|
||||
|
|
@ -439,6 +440,7 @@ const AXSelect = (props: Props) => {
|
|||
onValueChange={handleAX2SelectChange}
|
||||
triggerClass="modal"
|
||||
ref={secondaryAxModifierSelect}
|
||||
overlayVisible={false}
|
||||
>
|
||||
{generateOptions(1)}
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
font-size: $font-button;
|
||||
font-weight: $normal;
|
||||
gap: 6px;
|
||||
transition: 0.18s opacity ease-in-out;
|
||||
user-select: none;
|
||||
|
||||
&:hover,
|
||||
&.Blended:hover,
|
||||
|
|
@ -30,6 +32,24 @@
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
&.IconButton.medium {
|
||||
height: inherit;
|
||||
padding: $unit-half;
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.Text {
|
||||
font-size: $font-small;
|
||||
font-weight: $bold;
|
||||
|
||||
@include breakpoint(phone) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.Contained {
|
||||
background: var(--button-contained-bg);
|
||||
|
||||
|
|
@ -42,10 +62,10 @@
|
|||
stroke: #ff4d4d;
|
||||
}
|
||||
|
||||
&.Active.Save {
|
||||
&.Save {
|
||||
color: #ff4d4d;
|
||||
|
||||
.Accessory svg {
|
||||
&.Active .Accessory svg {
|
||||
fill: #ff4d4d;
|
||||
stroke: #ff4d4d;
|
||||
}
|
||||
|
|
@ -61,6 +81,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.Options {
|
||||
box-shadow: 0px 1px 3px rgb(0 0 0 / 14%);
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--button-bg-disabled);
|
||||
color: var(--button-text-disabled);
|
||||
|
|
@ -81,6 +109,17 @@
|
|||
padding: $unit * 1.5;
|
||||
}
|
||||
|
||||
@include breakpoint(phone) {
|
||||
&.destructive {
|
||||
background: $error;
|
||||
color: $grey-100;
|
||||
|
||||
.Accessory svg {
|
||||
fill: $grey-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.destructive:hover {
|
||||
background: $error;
|
||||
color: $grey-100;
|
||||
|
|
@ -90,24 +129,27 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.save:hover {
|
||||
&.Save {
|
||||
.Accessory svg {
|
||||
fill: none;
|
||||
stroke: var(--button-text);
|
||||
}
|
||||
|
||||
&.Saved {
|
||||
color: #ff4d4d;
|
||||
|
||||
.Accessory svg {
|
||||
fill: #ff4d4d;
|
||||
stroke: #ff4d4d;
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.save.Active {
|
||||
color: #ff4d4d;
|
||||
|
||||
&:hover {
|
||||
color: darken(#ff4d4d, 30);
|
||||
color: #ff4d4d;
|
||||
|
||||
.icon svg {
|
||||
fill: darken(#ff4d4d, 30);
|
||||
stroke: darken(#ff4d4d, 30);
|
||||
.Accessory svg {
|
||||
fill: none;
|
||||
stroke: #ff4d4d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -129,6 +171,10 @@
|
|||
|
||||
display: flex;
|
||||
|
||||
&.Arrow {
|
||||
margin-top: $unit-half;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: var(--button-text);
|
||||
height: $dimension;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ interface Props
|
|||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
> {
|
||||
accessoryIcon?: React.ReactNode
|
||||
leftAccessoryIcon?: React.ReactNode
|
||||
leftAccessoryClassName?: string
|
||||
rightAccessoryIcon?: React.ReactNode
|
||||
rightAccessoryClassName?: string
|
||||
active?: boolean
|
||||
blended?: boolean
|
||||
contained?: boolean
|
||||
|
|
@ -24,22 +27,45 @@ const defaultProps = {
|
|||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
|
||||
{ accessoryIcon, active, blended, contained, buttonSize, text, ...props },
|
||||
{
|
||||
leftAccessoryIcon,
|
||||
leftAccessoryClassName,
|
||||
rightAccessoryIcon,
|
||||
rightAccessoryClassName,
|
||||
active,
|
||||
blended,
|
||||
contained,
|
||||
buttonSize,
|
||||
text,
|
||||
...props
|
||||
},
|
||||
forwardedRef
|
||||
) {
|
||||
const classes = classNames(
|
||||
{
|
||||
const classes = classNames(buttonSize, props.className, {
|
||||
Button: true,
|
||||
Active: active,
|
||||
Blended: blended,
|
||||
Contained: contained,
|
||||
},
|
||||
buttonSize,
|
||||
props.className
|
||||
)
|
||||
})
|
||||
|
||||
const hasAccessory = () => {
|
||||
if (accessoryIcon) return <span className="Accessory">{accessoryIcon}</span>
|
||||
const leftAccessoryClasses = classNames(leftAccessoryClassName, {
|
||||
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 = () => {
|
||||
|
|
@ -48,8 +74,9 @@ const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
|
|||
|
||||
return (
|
||||
<button {...props} className={classes} ref={forwardedRef}>
|
||||
{hasAccessory()}
|
||||
{hasLeftAccessory()}
|
||||
{hasText()}
|
||||
{hasRightAccessory()}
|
||||
</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 { Trans, useTranslation } from 'next-i18next'
|
||||
|
||||
import { Dialog, DialogContent } from '~components/Dialog'
|
||||
import { Dialog } from '~components/Dialog'
|
||||
import DialogContent from '~components/DialogContent'
|
||||
import Button from '~components/Button'
|
||||
import Overlay from '~components/Overlay'
|
||||
|
||||
|
|
@ -29,6 +30,9 @@ const CharacterConflictModal = (props: Props) => {
|
|||
// States
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Refs
|
||||
const footerRef = React.createRef<HTMLDivElement>()
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(props.open)
|
||||
}, [setOpen, props.open])
|
||||
|
|
@ -71,10 +75,12 @@ const CharacterConflictModal = (props: Props) => {
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={openChange}>
|
||||
<DialogContent
|
||||
className="Conflict Dialog"
|
||||
className="Conflict"
|
||||
footerref={footerRef}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onEscapeKeyDown={close}
|
||||
>
|
||||
<div className="Content">
|
||||
<p>
|
||||
<Trans i18nKey="modals.conflict.character"></Trans>
|
||||
</p>
|
||||
|
|
@ -101,13 +107,21 @@ const CharacterConflictModal = (props: Props) => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<Button onClick={close} text={t('buttons.cancel')} />
|
||||
</div>
|
||||
<div className="DialogFooter" ref={footerRef}>
|
||||
<div className="Buttons Span">
|
||||
<Button
|
||||
contained={true}
|
||||
onClick={close}
|
||||
text={t('buttons.cancel')}
|
||||
/>
|
||||
<Button
|
||||
contained={true}
|
||||
onClick={props.resolveConflict}
|
||||
text={t('modals.conflict.buttons.confirm')}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<Overlay open={open} visible={true} />
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { getCookie } from 'cookies-next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
|
||||
import { AxiosResponse } from 'axios'
|
||||
import { AxiosError, AxiosResponse } from 'axios'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import Alert from '~components/Alert'
|
||||
|
|
@ -15,13 +16,13 @@ import type { DetailsObject, JobSkillObject, SearchableObject } from '~types'
|
|||
|
||||
import api from '~utils/api'
|
||||
import { appState } from '~utils/appState'
|
||||
import { accountState } from '~utils/accountState'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
new: boolean
|
||||
editable: boolean
|
||||
characters?: GridCharacter[]
|
||||
createParty: (details?: DetailsObject) => Promise<Party>
|
||||
pushHistory?: (path: string) => void
|
||||
|
|
@ -31,15 +32,21 @@ const CharacterGrid = (props: Props) => {
|
|||
// Constants
|
||||
const numCharacters: number = 5
|
||||
|
||||
// Localization
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
// Cookies
|
||||
const cookie = getCookie('account')
|
||||
const accountData: AccountCookie = cookie
|
||||
? JSON.parse(cookie as string)
|
||||
: null
|
||||
|
||||
// Set up state for error handling
|
||||
const [axiosError, setAxiosError] = useState<AxiosResponse>()
|
||||
const [errorAlertOpen, setErrorAlertOpen] = useState(false)
|
||||
|
||||
// Set up state for view management
|
||||
const { party, grid } = useSnapshot(appState)
|
||||
const [slug, setSlug] = useState()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
|
||||
// Set up state for conflict management
|
||||
|
|
@ -55,27 +62,23 @@ const CharacterGrid = (props: Props) => {
|
|||
2: undefined,
|
||||
3: undefined,
|
||||
})
|
||||
const [jobAccessory, setJobAccessory] = useState<JobAccessory>()
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
// Create a temporary state to store previous character uncap values
|
||||
// Create a temporary state to store previous weapon uncap values and transcendence stages
|
||||
const [previousUncapValues, setPreviousUncapValues] = useState<{
|
||||
[key: number]: number | undefined
|
||||
}>({})
|
||||
|
||||
// Set the editable flag only on first load
|
||||
useEffect(() => {
|
||||
// If user is logged in and matches
|
||||
if (
|
||||
(accountData && party.user && accountData.userId === party.user.id) ||
|
||||
props.new
|
||||
)
|
||||
appState.party.editable = true
|
||||
else appState.party.editable = false
|
||||
}, [props.new, accountData, party])
|
||||
const [previousTranscendenceStages, setPreviousTranscendenceStages] =
|
||||
useState<{
|
||||
[key: number]: number | undefined
|
||||
}>({})
|
||||
|
||||
useEffect(() => {
|
||||
setJob(appState.party.job)
|
||||
setJobSkills(appState.party.jobSkills)
|
||||
setJobAccessory(appState.party.accessory)
|
||||
}, [appState])
|
||||
|
||||
// Initialize an array of current uncap values for each characters
|
||||
|
|
@ -101,10 +104,18 @@ const CharacterGrid = (props: Props) => {
|
|||
.catch((error) => console.error(error))
|
||||
})
|
||||
} else {
|
||||
if (party.editable)
|
||||
if (props.editable)
|
||||
saveCharacter(party.id, character, position)
|
||||
.then((response) => handleCharacterResponse(response.data))
|
||||
.catch((error) => console.error(error))
|
||||
.catch((error) => {
|
||||
const axiosError = error as AxiosError
|
||||
const response = axiosError.response
|
||||
|
||||
if (response) {
|
||||
setErrorAlertOpen(true)
|
||||
setAxiosError(response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -171,8 +182,17 @@ const CharacterGrid = (props: Props) => {
|
|||
setIncoming(undefined)
|
||||
}
|
||||
|
||||
async function removeCharacter(id: string) {
|
||||
try {
|
||||
const response = await api.endpoints.grid_characters.destroy({ id: id })
|
||||
appState.grid.characters[response.data.position] = undefined
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Methods: Saving job and job skills
|
||||
const saveJob = async function (job?: Job) {
|
||||
async function saveJob(job?: Job) {
|
||||
const payload = {
|
||||
party: {
|
||||
job_id: job ? job.id : -1,
|
||||
|
|
@ -200,8 +220,8 @@ const CharacterGrid = (props: Props) => {
|
|||
}
|
||||
}
|
||||
|
||||
const saveJobSkill = function (skill: JobSkill, position: number) {
|
||||
if (party.id && appState.party.editable) {
|
||||
function saveJobSkill(skill: JobSkill, position: number) {
|
||||
if (party.id && props.editable) {
|
||||
const positionedKey = `skill${position}_id`
|
||||
|
||||
let skillObject: {
|
||||
|
|
@ -239,6 +259,24 @@ const CharacterGrid = (props: Props) => {
|
|||
}
|
||||
}
|
||||
|
||||
async function saveAccessory(accessory: JobAccessory) {
|
||||
const payload = {
|
||||
party: {
|
||||
accessory_id: accessory.id,
|
||||
},
|
||||
}
|
||||
|
||||
if (appState.party.id) {
|
||||
const response = await api.endpoints.parties.update(
|
||||
appState.party.id,
|
||||
payload
|
||||
)
|
||||
const team = response.data.party
|
||||
setJobAccessory(team.accessory)
|
||||
appState.party.accessory = team.accessory
|
||||
}
|
||||
}
|
||||
|
||||
// Methods: Helpers
|
||||
function characterUncapLevel(character: Character) {
|
||||
let uncapLevel
|
||||
|
|
@ -260,6 +298,7 @@ const CharacterGrid = (props: Props) => {
|
|||
// Note: Saves, but debouncing is not working properly
|
||||
async function saveUncap(id: string, position: number, uncapLevel: number) {
|
||||
storePreviousUncapValue(position)
|
||||
storePreviousTranscendenceStage(position)
|
||||
|
||||
try {
|
||||
if (uncapLevel != previousUncapValues[position])
|
||||
|
|
@ -271,11 +310,17 @@ const CharacterGrid = (props: Props) => {
|
|||
|
||||
// Revert optimistic UI
|
||||
updateUncapLevel(position, previousUncapValues[position])
|
||||
updateTranscendenceStage(position, previousTranscendenceStages[position])
|
||||
|
||||
// Remove optimistic key
|
||||
let newPreviousValues = { ...previousUncapValues }
|
||||
delete newPreviousValues[position]
|
||||
setPreviousUncapValues(newPreviousValues)
|
||||
let newPreviousTranscendenceStages = { ...previousTranscendenceStages }
|
||||
let newPreviousUncapValues = { ...previousUncapValues }
|
||||
|
||||
delete newPreviousTranscendenceStages[position]
|
||||
delete newPreviousUncapValues[position]
|
||||
|
||||
setPreviousTranscendenceStages(newPreviousTranscendenceStages)
|
||||
setPreviousUncapValues(newPreviousUncapValues)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -284,26 +329,26 @@ const CharacterGrid = (props: Props) => {
|
|||
position: number,
|
||||
uncapLevel: number
|
||||
) {
|
||||
if (
|
||||
party.user &&
|
||||
accountState.account.user &&
|
||||
party.user.id === accountState.account.user.id
|
||||
) {
|
||||
memoizeAction(id, position, uncapLevel)
|
||||
if (props.editable) {
|
||||
memoizeUncapAction(id, position, uncapLevel)
|
||||
|
||||
// Optimistically update UI
|
||||
updateUncapLevel(position, uncapLevel)
|
||||
|
||||
if (uncapLevel < 6) {
|
||||
updateTranscendenceStage(position, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const memoizeAction = useCallback(
|
||||
const memoizeUncapAction = useCallback(
|
||||
(id: string, position: number, uncapLevel: number) => {
|
||||
debouncedAction(id, position, uncapLevel)
|
||||
debouncedUncapAction(id, position, uncapLevel)
|
||||
},
|
||||
[props, previousUncapValues]
|
||||
)
|
||||
|
||||
const debouncedAction = useMemo(
|
||||
const debouncedUncapAction = useMemo(
|
||||
() =>
|
||||
debounce((id, position, number) => {
|
||||
saveUncap(id, position, number)
|
||||
|
|
@ -332,11 +377,119 @@ const CharacterGrid = (props: Props) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Methods: Updating transcendence stage
|
||||
// Note: Saves, but debouncing is not working properly
|
||||
async function saveTranscendence(
|
||||
id: string,
|
||||
position: number,
|
||||
stage: number
|
||||
) {
|
||||
storePreviousUncapValue(position)
|
||||
storePreviousTranscendenceStage(position)
|
||||
|
||||
const payload = {
|
||||
character: {
|
||||
uncap_level: stage > 0 ? 6 : 5,
|
||||
transcendence_step: stage,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
if (stage != previousTranscendenceStages[position])
|
||||
await api.endpoints.grid_characters
|
||||
.update(id, payload)
|
||||
.then((response) => {
|
||||
storeGridCharacter(response.data)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
// Revert optimistic UI
|
||||
updateUncapLevel(position, previousUncapValues[position])
|
||||
updateTranscendenceStage(position, previousTranscendenceStages[position])
|
||||
|
||||
// Remove optimistic key
|
||||
let newPreviousTranscendenceStages = { ...previousTranscendenceStages }
|
||||
let newPreviousUncapValues = { ...previousUncapValues }
|
||||
|
||||
delete newPreviousTranscendenceStages[position]
|
||||
delete newPreviousUncapValues[position]
|
||||
|
||||
setPreviousTranscendenceStages(newPreviousTranscendenceStages)
|
||||
setPreviousUncapValues(newPreviousUncapValues)
|
||||
}
|
||||
}
|
||||
|
||||
function initiateTranscendenceUpdate(
|
||||
id: string,
|
||||
position: number,
|
||||
stage: number
|
||||
) {
|
||||
if (props.editable) {
|
||||
memoizeTranscendenceAction(id, position, stage)
|
||||
|
||||
// Optimistically update UI
|
||||
updateTranscendenceStage(position, stage)
|
||||
|
||||
if (stage > 0) {
|
||||
updateUncapLevel(position, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const memoizeTranscendenceAction = useCallback(
|
||||
(id: string, position: number, stage: number) => {
|
||||
debouncedTranscendenceAction(id, position, stage)
|
||||
},
|
||||
[props, previousTranscendenceStages]
|
||||
)
|
||||
|
||||
const debouncedTranscendenceAction = useMemo(
|
||||
() =>
|
||||
debounce((id, position, number) => {
|
||||
saveTranscendence(id, position, number)
|
||||
}, 500),
|
||||
[props, saveTranscendence]
|
||||
)
|
||||
|
||||
const updateTranscendenceStage = (
|
||||
position: number,
|
||||
stage: number | undefined
|
||||
) => {
|
||||
const character = appState.grid.characters[position]
|
||||
if (character && stage !== undefined) {
|
||||
character.transcendence_step = stage
|
||||
appState.grid.characters[position] = character
|
||||
}
|
||||
}
|
||||
|
||||
function storePreviousTranscendenceStage(position: number) {
|
||||
// Save the current value in case of an unexpected result
|
||||
let newPreviousValues = { ...previousUncapValues }
|
||||
|
||||
if (grid.characters[position]) {
|
||||
newPreviousValues[position] = grid.characters[position]?.uncap_level
|
||||
setPreviousTranscendenceStages(newPreviousValues)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAlert() {
|
||||
setErrorMessage('')
|
||||
}
|
||||
|
||||
// Render: JSX components
|
||||
const errorAlert = () => {
|
||||
return (
|
||||
<Alert
|
||||
open={errorAlertOpen}
|
||||
title={axiosError ? `${axiosError.status}` : 'Error'}
|
||||
message={t(`errors.${axiosError?.statusText.toLowerCase()}`)}
|
||||
cancelAction={() => setErrorAlertOpen(false)}
|
||||
cancelActionText={t('buttons.confirm')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert
|
||||
|
|
@ -349,9 +502,11 @@ const CharacterGrid = (props: Props) => {
|
|||
<JobSection
|
||||
job={job}
|
||||
jobSkills={jobSkills}
|
||||
editable={party.editable}
|
||||
jobAccessory={jobAccessory}
|
||||
editable={props.editable}
|
||||
saveJob={saveJob}
|
||||
saveSkill={saveJobSkill}
|
||||
saveAccessory={saveAccessory}
|
||||
/>
|
||||
<CharacterConflictModal
|
||||
open={modalOpen}
|
||||
|
|
@ -367,16 +522,19 @@ const CharacterGrid = (props: Props) => {
|
|||
<li key={`grid_unit_${i}`}>
|
||||
<CharacterUnit
|
||||
gridCharacter={grid.characters[i]}
|
||||
editable={party.editable}
|
||||
editable={props.editable}
|
||||
position={i}
|
||||
updateObject={receiveCharacterFromSearch}
|
||||
updateUncap={initiateUncapUpdate}
|
||||
updateTranscendence={initiateTranscendenceUpdate}
|
||||
removeCharacter={removeCharacter}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{errorAlert()}
|
||||
</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 { useTranslation } from 'next-i18next'
|
||||
|
||||
import * as HoverCard from '@radix-ui/react-hover-card'
|
||||
|
||||
import {
|
||||
Hovercard,
|
||||
HovercardContent,
|
||||
HovercardTrigger,
|
||||
} from '~components/Hovercard'
|
||||
import Button from '~components/Button'
|
||||
import WeaponLabelIcon from '~components/WeaponLabelIcon'
|
||||
import UncapIndicator from '~components/UncapIndicator'
|
||||
|
||||
import {
|
||||
overMastery,
|
||||
aetherialMastery,
|
||||
permanentMastery,
|
||||
} from '~data/overMastery'
|
||||
import { characterAwakening } from '~data/awakening'
|
||||
import { ExtendedMastery } from '~types'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
interface Props {
|
||||
gridCharacter: GridCharacter
|
||||
children: React.ReactNode
|
||||
onTriggerClick: () => void
|
||||
}
|
||||
|
||||
interface KeyNames {
|
||||
|
|
@ -43,10 +56,19 @@ const CharacterHovercard = (props: Props) => {
|
|||
]
|
||||
|
||||
const tintElement = Element[props.gridCharacter.object.element]
|
||||
const wikiUrl = `https://gbf.wiki/${props.gridCharacter.object.name.en.replaceAll(
|
||||
' ',
|
||||
'_'
|
||||
)}`
|
||||
|
||||
function goTo() {
|
||||
const urlSafeName = props.gridCharacter.object.name.en.replaceAll(' ', '_')
|
||||
const url = `https://gbf.wiki/${urlSafeName}`
|
||||
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const perpetuity = () => {
|
||||
if (props.gridCharacter && props.gridCharacter.perpetuity) {
|
||||
return <i className="Perpetuity" />
|
||||
}
|
||||
}
|
||||
|
||||
function characterImage() {
|
||||
let imgSrc = ''
|
||||
|
|
@ -66,19 +88,154 @@ const CharacterHovercard = (props: Props) => {
|
|||
return imgSrc
|
||||
}
|
||||
|
||||
function masteryElement(dictionary: ItemSkill[], mastery: ExtendedMastery) {
|
||||
const canonicalMastery = dictionary.find(
|
||||
(item) => item.id === mastery.modifier
|
||||
)
|
||||
|
||||
if (canonicalMastery) {
|
||||
return (
|
||||
<HoverCard.Root>
|
||||
<HoverCard.Trigger>{props.children}</HoverCard.Trigger>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content className="Weapon Hovercard">
|
||||
<li className="ExtendedMastery" key={canonicalMastery.id}>
|
||||
<img
|
||||
alt={canonicalMastery.name[locale]}
|
||||
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="title">
|
||||
<h4>{props.gridCharacter.object.name[locale]}</h4>
|
||||
<div className="Image">
|
||||
{perpetuity()}
|
||||
<img
|
||||
alt={props.gridCharacter.object.name[locale]}
|
||||
src={characterImage()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="subInfo">
|
||||
<div className="icons">
|
||||
<WeaponLabelIcon
|
||||
|
|
@ -107,18 +264,18 @@ const CharacterHovercard = (props: Props) => {
|
|||
type="character"
|
||||
ulb={props.gridCharacter.object.uncap.ulb || false}
|
||||
flb={props.gridCharacter.object.uncap.flb || false}
|
||||
special={false}
|
||||
transcendenceStage={props.gridCharacter.transcendence_step}
|
||||
special={props.gridCharacter.object.special}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a className={`Button ${tintElement}`} href={wikiUrl} target="_new">
|
||||
{t('buttons.wiki')}
|
||||
</a>
|
||||
<HoverCard.Arrow />
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
{wikiButton}
|
||||
{awakeningSection()}
|
||||
{overMasterySection()}
|
||||
{aetherialMasterySection()}
|
||||
{permanentMasterySection()}
|
||||
</HovercardContent>
|
||||
</Hovercard>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
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);
|
||||
// min-height: 320px;
|
||||
// max-width: 200px;
|
||||
position: relative;
|
||||
margin-bottom: $unit * 4;
|
||||
|
||||
&.editable .CharacterImage:hover {
|
||||
|
|
@ -22,6 +23,17 @@
|
|||
display: flex;
|
||||
}
|
||||
|
||||
.Button {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .Button,
|
||||
.Button.Clicked {
|
||||
pointer-events: initial;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
h3,
|
||||
ul {
|
||||
display: none;
|
||||
|
|
@ -57,9 +69,11 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: all 0.18s ease-in-out;
|
||||
transition: $duration-zoom all ease-in-out;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
-webkit-user-select: none; /* Safari */
|
||||
user-select: none;
|
||||
|
||||
&:hover .icon svg {
|
||||
fill: var(--icon-secondary-hover);
|
||||
|
|
@ -72,6 +86,7 @@
|
|||
z-index: 1;
|
||||
|
||||
svg {
|
||||
transition: $duration-color-fade fill ease-in-out;
|
||||
fill: var(--icon-secondary);
|
||||
}
|
||||
}
|
||||
|
|
@ -82,4 +97,34 @@
|
|||
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 { useSnapshot } from 'valtio'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
import classnames from 'classnames'
|
||||
|
||||
import { appState } from '~utils/appState'
|
||||
import { Trans, useTranslation } from 'next-i18next'
|
||||
import { AxiosResponse } from 'axios'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import Alert from '~components/Alert'
|
||||
import Button from '~components/Button'
|
||||
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 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'
|
||||
|
||||
|
|
@ -19,48 +37,151 @@ interface Props {
|
|||
gridCharacter?: GridCharacter
|
||||
position: number
|
||||
editable: boolean
|
||||
removeCharacter: (id: string) => void
|
||||
updateObject: (object: SearchableObject, position: 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 { party, grid } = useSnapshot(appState)
|
||||
|
||||
const router = useRouter()
|
||||
const locale =
|
||||
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 classes = classnames({
|
||||
// Classes
|
||||
const classes = classNames({
|
||||
CharacterUnit: true,
|
||||
editable: props.editable,
|
||||
filled: props.gridCharacter !== undefined,
|
||||
editable: editable,
|
||||
filled: gridCharacter !== undefined,
|
||||
})
|
||||
|
||||
const gridCharacter = props.gridCharacter
|
||||
const buttonClasses = classNames({
|
||||
Options: true,
|
||||
Clicked: contextMenuOpen,
|
||||
})
|
||||
|
||||
// Other
|
||||
const character = gridCharacter?.object
|
||||
|
||||
// Hooks
|
||||
useEffect(() => {
|
||||
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() {
|
||||
let imgSrc = ''
|
||||
|
||||
if (props.gridCharacter) {
|
||||
const character = props.gridCharacter.object!
|
||||
if (gridCharacter) {
|
||||
const character = gridCharacter.object!
|
||||
|
||||
// Change the image based on the uncap level
|
||||
let suffix = '01'
|
||||
if (props.gridCharacter.uncap_level == 6) suffix = '04'
|
||||
else if (props.gridCharacter.uncap_level == 5) suffix = '03'
|
||||
else if (props.gridCharacter.uncap_level > 2) suffix = '02'
|
||||
if (gridCharacter.transcendence_step > 0) suffix = '04'
|
||||
else if (gridCharacter.uncap_level >= 5) suffix = '03'
|
||||
else if (gridCharacter.uncap_level > 2) suffix = '02'
|
||||
|
||||
// Special casing for Lyria (and Young Cat eventually)
|
||||
if (props.gridCharacter.object.granblue_id === '3030182000') {
|
||||
if (gridCharacter.object.granblue_id === '3030182000') {
|
||||
let element = 1
|
||||
if (grid.weapons.mainWeapon && grid.weapons.mainWeapon.element) {
|
||||
element = grid.weapons.mainWeapon.element
|
||||
|
|
@ -77,15 +198,111 @@ const CharacterUnit = (props: Props) => {
|
|||
setImageUrl(imgSrc)
|
||||
}
|
||||
|
||||
function passUncapData(uncap: number) {
|
||||
if (props.gridCharacter)
|
||||
props.updateUncap(props.gridCharacter.id, props.position, uncap)
|
||||
// Methods: Layer element rendering
|
||||
const characterModal = () => {
|
||||
if (gridCharacter) {
|
||||
return (
|
||||
<CharacterModal
|
||||
gridCharacter={gridCharacter}
|
||||
open={detailsModalOpen}
|
||||
onOpenChange={handleCharacterModalOpenChange}
|
||||
updateCharacter={updateCharacter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const image = (
|
||||
<div className="CharacterImage">
|
||||
<img alt={character?.name.en} className="grid_image" src={imageUrl} />
|
||||
{props.editable ? (
|
||||
const contextMenu = () => {
|
||||
if (editable && gridCharacter && gridCharacter.id) {
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<PlusIcon />
|
||||
</span>
|
||||
|
|
@ -95,27 +312,35 @@ const CharacterUnit = (props: Props) => {
|
|||
</div>
|
||||
)
|
||||
|
||||
const editableImage = (
|
||||
<SearchModal
|
||||
placeholderText={t('search.placeholders.character')}
|
||||
fromPosition={props.position}
|
||||
object="characters"
|
||||
send={props.updateObject}
|
||||
return gridCharacter ? (
|
||||
<CharacterHovercard
|
||||
gridCharacter={gridCharacter}
|
||||
onTriggerClick={openSearchModal}
|
||||
>
|
||||
{image}
|
||||
</SearchModal>
|
||||
{content}
|
||||
</CharacterHovercard>
|
||||
) : (
|
||||
content
|
||||
)
|
||||
}
|
||||
|
||||
const unitContent = (
|
||||
<>
|
||||
<div className={classes}>
|
||||
{props.editable ? editableImage : image}
|
||||
{contextMenu()}
|
||||
{perpetuity()}
|
||||
{image()}
|
||||
{gridCharacter && character ? (
|
||||
<UncapIndicator
|
||||
type="character"
|
||||
flb={character.uncap.flb || false}
|
||||
ulb={character.uncap.ulb || false}
|
||||
uncapLevel={gridCharacter.uncap_level}
|
||||
transcendenceStage={gridCharacter.transcendence_step}
|
||||
position={gridCharacter.position}
|
||||
editable={editable}
|
||||
updateUncap={passUncapData}
|
||||
updateTranscendence={passTranscendenceData}
|
||||
special={character.special}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -123,15 +348,11 @@ const CharacterUnit = (props: Props) => {
|
|||
)}
|
||||
<h3 className="CharacterName">{character?.name[locale]}</h3>
|
||||
</div>
|
||||
{searchModal()}
|
||||
</>
|
||||
)
|
||||
|
||||
const withHovercard = (
|
||||
<CharacterHovercard gridCharacter={gridCharacter!}>
|
||||
{unitContent}
|
||||
</CharacterHovercard>
|
||||
)
|
||||
|
||||
return gridCharacter && !props.editable ? withHovercard : unitContent
|
||||
return unitContent
|
||||
}
|
||||
|
||||
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 classNames from 'classnames'
|
||||
import { useLockedBody } from 'usehooks-ts'
|
||||
|
||||
import './index.scss'
|
||||
import Overlay from '~components/Overlay'
|
||||
|
||||
interface Props
|
||||
extends React.DetailedHTMLProps<
|
||||
React.DialogHTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> {
|
||||
onEscapeKeyDown: (event: KeyboardEvent) => void
|
||||
onOpenAutoFocus: (event: Event) => void
|
||||
}
|
||||
interface Props extends DialogPrimitive.DialogProps {}
|
||||
|
||||
export const DialogContent = React.forwardRef<HTMLDivElement, Props>(
|
||||
function dialog({ children, ...props }, forwardedRef) {
|
||||
const classes = classNames(
|
||||
{
|
||||
Dialog: true,
|
||||
},
|
||||
props.className
|
||||
)
|
||||
export const Dialog = ({ children, ...props }: PropsWithChildren<Props>) => {
|
||||
const [locked, setLocked] = useLockedBody(false, 'root')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open != undefined) {
|
||||
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 (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Content
|
||||
className={classes}
|
||||
{...props}
|
||||
onOpenAutoFocus={props.onOpenAutoFocus}
|
||||
onEscapeKeyDown={props.onEscapeKeyDown}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<DialogPrimitive.Root open={props.open} onOpenChange={handleOpenChange}>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
<Overlay visible={true} open={true} />
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const Dialog = DialogPrimitive.Root
|
||||
export const DialogTitle = DialogPrimitive.Title
|
||||
export const DialogTrigger = DialogPrimitive.Trigger
|
||||
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 {
|
||||
transform-origin: --radix-dropdown-menu-content-transform-origin;
|
||||
background: var(--menu-bg);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
min-width: 220px;
|
||||
position: absolute;
|
||||
top: $unit-8x; // This shouldn't be hardcoded. How to calculate it?
|
||||
// Also, add space that doesn't make the menu disappear if you move your mouse slowly
|
||||
z-index: 10;
|
||||
width: 30vw;
|
||||
max-width: 180px;
|
||||
margin: 0 $unit-2x;
|
||||
z-index: 15;
|
||||
|
||||
@include breakpoint(phone) {
|
||||
left: $unit-2x;
|
||||
right: $unit-2x;
|
||||
min-width: 50vw;
|
||||
}
|
||||
}
|
||||
|
||||
.MenuLabel {
|
||||
color: var(--text-tertiary);
|
||||
padding: $unit * 1.5 $unit * 1.5;
|
||||
font-size: $font-small;
|
||||
font-weight: $medium;
|
||||
}
|
||||
|
||||
.MenuItem {
|
||||
color: var(--text-tertiary);
|
||||
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 Input from '~components/Input'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
interface Props
|
||||
|
|
@ -15,50 +14,57 @@ interface Props
|
|||
}
|
||||
|
||||
const DurationInput = React.forwardRef<HTMLInputElement, Props>(
|
||||
function DurationInput(
|
||||
{ className, placeholder, value, onValueChange },
|
||||
forwardedRef
|
||||
) {
|
||||
function DurationInput({ className, value, onValueChange }, forwardedRef) {
|
||||
// State
|
||||
const [duration, setDuration] = useState('')
|
||||
const [minutesSelected, setMinutesSelected] = useState(false)
|
||||
const [secondsSelected, setSecondsSelected] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (value > 0) setDuration(convertSecondsToString(value))
|
||||
}, [value])
|
||||
// Refs
|
||||
const minutesRef = React.createRef<HTMLInputElement>()
|
||||
const secondsRef = React.createRef<HTMLInputElement>()
|
||||
|
||||
function convertStringToSeconds(string: string) {
|
||||
const parts = string.split(':')
|
||||
const minutes = parseInt(parts[0])
|
||||
const seconds = parseInt(parts[1])
|
||||
// Event handlers: On value change
|
||||
function handleMinutesChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const minutes = parseInt(event.currentTarget.value)
|
||||
const seconds = secondsRef.current
|
||||
? parseInt(secondsRef.current.value)
|
||||
: 0
|
||||
|
||||
return minutes * 60 + seconds
|
||||
handleChange(minutes, seconds)
|
||||
}
|
||||
|
||||
function convertSecondsToString(value: number) {
|
||||
const minutes = Math.floor(value / 60)
|
||||
const seconds = value - minutes * 60
|
||||
function handleSecondsChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const seconds = parseInt(event.currentTarget.value)
|
||||
const minutes = minutesRef.current
|
||||
? parseInt(minutesRef.current.value)
|
||||
: 0
|
||||
|
||||
const paddedMinutes = padNumber(`${minutes}`, '0', 2)
|
||||
|
||||
return `${paddedMinutes}:${seconds}`
|
||||
handleChange(minutes, seconds)
|
||||
}
|
||||
|
||||
function padNumber(string: string, pad: string, length: number) {
|
||||
return (new Array(length + 1).join(pad) + string).slice(-length)
|
||||
function handleChange(minutes: number, seconds: number) {
|
||||
onValueChange(minutes * 60 + seconds)
|
||||
}
|
||||
|
||||
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const value = event.currentTarget.value
|
||||
const durationInSeconds = convertStringToSeconds(value)
|
||||
onValueChange(durationInSeconds)
|
||||
// Event handler: Key presses
|
||||
function handleKeyUp(event: KeyboardEvent<HTMLInputElement>) {
|
||||
const input = event.currentTarget
|
||||
|
||||
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>) {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
// Allow the key to be processed normally
|
||||
return
|
||||
}
|
||||
|
||||
// Get the current value
|
||||
const input = event.currentTarget
|
||||
let value = event.currentTarget.value
|
||||
|
||||
|
|
@ -95,16 +101,28 @@ const DurationInput = React.forwardRef<HTMLInputElement, Props>(
|
|||
const isNumber = !isNaN(char)
|
||||
|
||||
// Check if the character should be accepted or rejected
|
||||
if (!isNumber || value.length >= 5) {
|
||||
// Reject the character
|
||||
if (!isNumber || value.length >= 2) {
|
||||
// Reject the character if the user doesn't have the entire string selected
|
||||
if (!minutesSelected && input === minutesRef.current)
|
||||
event.preventDefault()
|
||||
} else if (value.length === 2) {
|
||||
// Insert a colon after the second digit
|
||||
input.value = value + ':'
|
||||
else if (
|
||||
!secondsSelected &&
|
||||
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 {
|
||||
// Split the time into minutes and seconds
|
||||
let [minutes, seconds] = time.split(':').map(Number)
|
||||
|
|
@ -144,21 +162,54 @@ const DurationInput = React.forwardRef<HTMLInputElement, Props>(
|
|||
return `${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
// Methods: Miscellaneous
|
||||
|
||||
function getMinutes() {
|
||||
const minutes = Math.floor(value / 60)
|
||||
return minutes
|
||||
}
|
||||
|
||||
function getSeconds() {
|
||||
const seconds = value % 60
|
||||
return seconds
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(className, { Duration: true })}>
|
||||
<Input
|
||||
ref={minutesRef}
|
||||
type="text"
|
||||
className={classNames(
|
||||
{
|
||||
Duration: true,
|
||||
AlignRight: true,
|
||||
},
|
||||
className
|
||||
)}
|
||||
value={duration}
|
||||
onChange={handleChange}
|
||||
value={getMinutes()}
|
||||
onChange={handleMinutesChange}
|
||||
onKeyUp={handleKeyUp}
|
||||
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
|
||||
found?: boolean
|
||||
offset: number
|
||||
removeSummon: (id: string) => void
|
||||
updateObject: (object: SearchableObject, position: number) => void
|
||||
updateUncap: (id: string, position: number, uncap: number) => void
|
||||
updateTranscendence: (id: string, position: number, stage: number) => void
|
||||
}
|
||||
|
||||
const ExtraSummons = (props: Props) => {
|
||||
|
|
@ -31,9 +33,11 @@ const ExtraSummons = (props: Props) => {
|
|||
editable={props.editable}
|
||||
position={props.offset + i}
|
||||
unitType={1}
|
||||
removeSummon={props.removeSummon}
|
||||
gridSummon={props.grid[props.offset + i]}
|
||||
updateObject={props.updateObject}
|
||||
updateUncap={props.updateUncap}
|
||||
updateTranscendence={props.updateTranscendence}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ interface Props {
|
|||
editable: boolean
|
||||
found?: boolean
|
||||
offset: number
|
||||
removeWeapon: (id: string) => void
|
||||
updateObject: (object: SearchableObject, position: number) => void
|
||||
updateUncap: (id: string, position: number, uncap: number) => void
|
||||
}
|
||||
|
|
@ -32,6 +33,7 @@ const ExtraWeapons = (props: Props) => {
|
|||
position={props.offset + i}
|
||||
unitType={1}
|
||||
gridWeapon={props.grid[props.offset + i]}
|
||||
removeWeapon={props.removeWeapon}
|
||||
updateObject={props.updateObject}
|
||||
updateUncap={props.updateUncap}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@
|
|||
|
||||
.Properties {
|
||||
.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`}
|
||||
/>
|
||||
)
|
||||
} 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 = () => (
|
||||
<Link href={`/${props.user ? props.user.username : '#'}`}>
|
||||
<a
|
||||
className={userClass}
|
||||
href={`/${props.user ? props.user.username : '#'}`}
|
||||
>
|
||||
<span className={userClass}>
|
||||
{userImage()}
|
||||
{props.user ? props.user.username : t('no_user')}
|
||||
</a>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
|
||||
|
|
@ -205,16 +211,14 @@ const GridRep = (props: Props) => {
|
|||
((props.user && account.user && account.user.id !== props.user.id) ||
|
||||
!props.user) ? (
|
||||
<Link href="#">
|
||||
<a href="#">
|
||||
<Button
|
||||
className="Save"
|
||||
accessoryIcon={<SaveIcon className="stroke" />}
|
||||
leftAccessoryIcon={<SaveIcon className="stroke" />}
|
||||
active={props.favorited}
|
||||
contained={true}
|
||||
buttonSize="small"
|
||||
onClick={sendSaveData}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
''
|
||||
|
|
|
|||
|
|
@ -5,11 +5,23 @@
|
|||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
#Right > div {
|
||||
section {
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
img,
|
||||
.placeholder {
|
||||
$diameter: 32px;
|
||||
border-radius: calc($diameter / 2);
|
||||
height: $diameter;
|
||||
width: $diameter;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
background: var(--placeholder-bg);
|
||||
}
|
||||
|
||||
#DropdownWrapper {
|
||||
display: inline-block;
|
||||
padding-bottom: $unit;
|
||||
|
|
@ -20,7 +32,7 @@
|
|||
}
|
||||
|
||||
&:hover {
|
||||
padding-right: $unit-4x;
|
||||
// padding-right: $unit-4x;
|
||||
|
||||
.Button {
|
||||
background: var(--button-bg-hover);
|
||||
|
|
|
|||
|
|
@ -1,23 +1,41 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { deleteCookie } from 'cookies-next'
|
||||
import { subscribe, useSnapshot } from 'valtio'
|
||||
import { setCookie, deleteCookie } from 'cookies-next'
|
||||
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 Link from 'next/link'
|
||||
|
||||
import api from '~utils/api'
|
||||
import { accountState, initialAccountState } from '~utils/accountState'
|
||||
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 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 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 classNames from 'classnames'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
|
|
@ -27,23 +45,106 @@ const Header = () => {
|
|||
|
||||
// Router
|
||||
const router = useRouter()
|
||||
const locale =
|
||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||
const localeData = retrieveLocaleCookies()
|
||||
|
||||
// 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
|
||||
const { account } = useSnapshot(accountState)
|
||||
const { party } = useSnapshot(appState)
|
||||
const { party: partySnapshot } = useSnapshot(appState)
|
||||
|
||||
function menuButtonClicked() {
|
||||
setOpen(!open)
|
||||
// Subscribe to app state to listen for party name and
|
||||
// unsubscribe when component is unmounted
|
||||
const unsubscribe = subscribe(appState, () => {
|
||||
const newName =
|
||||
appState.party && appState.party.name ? appState.party.name : ''
|
||||
setName(newName)
|
||||
})
|
||||
|
||||
useEffect(() => () => unsubscribe(), [])
|
||||
|
||||
// Hooks
|
||||
useEffect(() => {
|
||||
setLanguageChecked(localeData === 'ja' ? true : false)
|
||||
}, [localeData])
|
||||
|
||||
// Methods: Event handlers (Buttons)
|
||||
function handleLeftMenuButtonClicked() {
|
||||
setLeftMenuOpen(!leftMenuOpen)
|
||||
}
|
||||
|
||||
function onClickOutsideMenu() {
|
||||
setOpen(false)
|
||||
function handleRightMenuButtonClicked() {
|
||||
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() {
|
||||
const path = router.asPath.split('/')[1]
|
||||
|
||||
if (path === 'p') {
|
||||
const el = document.createElement('input')
|
||||
el.value = window.location.href
|
||||
el.id = 'url-input'
|
||||
|
|
@ -52,23 +153,16 @@ const Header = () => {
|
|||
el.select()
|
||||
document.execCommand('copy')
|
||||
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() {
|
||||
// Close menu
|
||||
closeRightMenu()
|
||||
|
||||
// Delete cookies
|
||||
deleteCookie('account')
|
||||
deleteCookie('user')
|
||||
|
||||
|
|
@ -82,106 +176,433 @@ const Header = () => {
|
|||
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() {
|
||||
if (party.favorited) unsaveFavorite()
|
||||
if (partySnapshot.favorited) unsaveFavorite()
|
||||
else saveFavorite()
|
||||
}
|
||||
|
||||
function saveFavorite() {
|
||||
if (party.id)
|
||||
api.saveTeam({ id: party.id }).then((response) => {
|
||||
if (partySnapshot.id)
|
||||
api.saveTeam({ id: partySnapshot.id }).then((response) => {
|
||||
if (response.status == 201) appState.party.favorited = true
|
||||
})
|
||||
else console.error('Failed to save team: No party ID')
|
||||
}
|
||||
|
||||
function unsaveFavorite() {
|
||||
if (party.id)
|
||||
api.unsaveTeam({ id: party.id }).then((response) => {
|
||||
if (partySnapshot.id)
|
||||
api.unsaveTeam({ id: partySnapshot.id }).then((response) => {
|
||||
if (response.status == 200) appState.party.favorited = false
|
||||
})
|
||||
else console.error('Failed to unsave team: No party ID')
|
||||
}
|
||||
|
||||
const copyButton = () => {
|
||||
if (router.route === '/p/[party]')
|
||||
return (
|
||||
// Rendering: Elements
|
||||
const pageTitle = () => {
|
||||
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
|
||||
accessoryIcon={<LinkIcon className="stroke" />}
|
||||
blended={true}
|
||||
text={t('buttons.copy')}
|
||||
rightAccessoryIcon={
|
||||
path === 'p' && hasAccessory ? (
|
||||
<LinkIcon className="stroke" />
|
||||
) : undefined
|
||||
}
|
||||
text={title}
|
||||
onClick={copyToClipboard}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
const leftNav = () => {
|
||||
return (
|
||||
<div id="DropdownWrapper">
|
||||
<Button
|
||||
accessoryIcon={<MenuIcon />}
|
||||
className={classNames({ Active: open })}
|
||||
blended={true}
|
||||
text={t('buttons.menu')}
|
||||
onClick={menuButtonClicked}
|
||||
const profileImage = () => {
|
||||
let image
|
||||
|
||||
const user = accountState.account.user
|
||||
if (accountState.account.authorized && user) {
|
||||
image = (
|
||||
<img
|
||||
alt={user.username}
|
||||
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}
|
||||
open={open}
|
||||
username={account.user?.username}
|
||||
onClickOutside={onClickOutsideMenu}
|
||||
logout={logout}
|
||||
)
|
||||
} else {
|
||||
image = (
|
||||
<img
|
||||
alt={t('no_user')}
|
||||
className={`profile anonymous`}
|
||||
srcSet={`/profile/npc.png,
|
||||
/profile/npc@2x.png 2x`}
|
||||
src={`/profile/npc.png`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
// Rendering: Buttons
|
||||
const saveButton = () => {
|
||||
if (party.favorited)
|
||||
return (
|
||||
<Tooltip content={t('tooltips.save')}>
|
||||
<Button
|
||||
accessoryIcon={<SaveIcon />}
|
||||
leftAccessoryIcon={<SaveIcon />}
|
||||
className={classNames({
|
||||
Save: true,
|
||||
Saved: partySnapshot.favorited,
|
||||
})}
|
||||
blended={true}
|
||||
text="Saved"
|
||||
onClick={toggleFavorite}
|
||||
/>
|
||||
)
|
||||
else
|
||||
return (
|
||||
<Button
|
||||
accessoryIcon={<SaveIcon />}
|
||||
blended={true}
|
||||
text="Save"
|
||||
text={
|
||||
partySnapshot.favorited ? t('buttons.saved') : t('buttons.save')
|
||||
}
|
||||
onClick={toggleFavorite}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const rightNav = () => {
|
||||
const newButton = () => {
|
||||
return (
|
||||
<div>
|
||||
{router.route === '/p/[party]' &&
|
||||
account.user &&
|
||||
(!party.user || party.user.id !== account.user.id)
|
||||
? saveButton()
|
||||
: ''}
|
||||
|
||||
{copyButton()}
|
||||
|
||||
<Tooltip content={t('tooltips.new')}>
|
||||
<Button
|
||||
accessoryIcon={<AddIcon className="Add" />}
|
||||
leftAccessoryIcon={<PlusIcon />}
|
||||
className="New"
|
||||
blended={true}
|
||||
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 (
|
||||
<nav id="Header">
|
||||
<div id="Left">{leftNav()}</div>
|
||||
<div id="Right">{rightNav()}</div>
|
||||
{left()}
|
||||
{right()}
|
||||
{urlCopyToast()}
|
||||
{remixToast()}
|
||||
{settingsModal()}
|
||||
{loginModal()}
|
||||
{signupModal()}
|
||||
</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;
|
||||
background-color: var(--input-bg);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
border-radius: $input-corner;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
padding: $unit-2x;
|
||||
padding: calc($unit-2x - 2px);
|
||||
width: 100%;
|
||||
|
||||
&[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 { appState } from '~utils/appState'
|
||||
import { jobGroups } from '~utils/jobGroups'
|
||||
import { jobGroups } from '~data/jobGroups'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
|
|
@ -91,7 +91,12 @@ const JobDropdown = React.forwardRef<HTMLSelectElement, Props>(
|
|||
.sort((a, b) => a.order - b.order)
|
||||
.map((item, i) => {
|
||||
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]}
|
||||
</SelectItem>
|
||||
)
|
||||
|
|
@ -109,6 +114,12 @@ const JobDropdown = React.forwardRef<HTMLSelectElement, Props>(
|
|||
return (
|
||||
<Select
|
||||
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}
|
||||
onClick={openJobSelect}
|
||||
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;
|
||||
width: 100%;
|
||||
|
||||
.JobName {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
padding: $unit 0 $unit * 2;
|
||||
|
||||
h3 {
|
||||
font-size: $font-medium;
|
||||
font-weight: $medium;
|
||||
padding: $unit 0 $unit * 2;
|
||||
}
|
||||
|
||||
img {
|
||||
width: $unit-4x;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import { useSnapshot } from 'valtio'
|
|||
import { useTranslation } from 'next-i18next'
|
||||
|
||||
import JobDropdown from '~components/JobDropdown'
|
||||
import JobImage from '~components/JobImage'
|
||||
import JobSkillItem from '~components/JobSkillItem'
|
||||
import SearchModal from '~components/SearchModal'
|
||||
|
||||
import api from '~utils/api'
|
||||
import { appState } from '~utils/appState'
|
||||
import type { JobSkillObject, SearchableObject } from '~types'
|
||||
|
||||
|
|
@ -16,9 +18,11 @@ import './index.scss'
|
|||
interface Props {
|
||||
job?: Job
|
||||
jobSkills: JobSkillObject
|
||||
jobAccessory?: JobAccessory
|
||||
editable: boolean
|
||||
saveJob: (job?: Job) => void
|
||||
saveSkill: (skill: JobSkill, position: number) => void
|
||||
saveAccessory: (accessory: JobAccessory) => void
|
||||
}
|
||||
|
||||
const JobSection = (props: Props) => {
|
||||
|
|
@ -29,13 +33,19 @@ const JobSection = (props: Props) => {
|
|||
const locale =
|
||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||
|
||||
// Data state
|
||||
const [job, setJob] = useState<Job>()
|
||||
const [imageUrl, setImageUrl] = useState('')
|
||||
const [numSkills, setNumSkills] = useState(4)
|
||||
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>()
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -47,6 +57,7 @@ const JobSection = (props: Props) => {
|
|||
2: props.jobSkills[2],
|
||||
3: props.jobSkills[3],
|
||||
})
|
||||
setCurrentAccessory(props.jobAccessory)
|
||||
|
||||
if (selectRef.current && props.job) selectRef.current.value = props.job.id
|
||||
}, [props])
|
||||
|
|
@ -61,14 +72,33 @@ const JobSection = (props: Props) => {
|
|||
appState.party.job = job
|
||||
if (job.row === '1') setNumSkills(3)
|
||||
else setNumSkills(4)
|
||||
fetchJobAccessories()
|
||||
}
|
||||
}, [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) {
|
||||
setJob(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() {
|
||||
let imgSrc = ''
|
||||
|
||||
|
|
@ -84,7 +114,7 @@ const JobSection = (props: Props) => {
|
|||
|
||||
const canEditSkill = (skill?: JobSkill) => {
|
||||
// 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 (skill && skill.job.id === job.id && skill.main) return false
|
||||
|
||||
|
|
@ -127,17 +157,37 @@ const JobSection = (props: Props) => {
|
|||
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
|
||||
return (
|
||||
<section id="Job">
|
||||
<div className="JobImage">
|
||||
{party.job && party.job.id !== '-1' ? (
|
||||
<img alt={party.job.name[locale]} src={imageUrl} />
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<div className="Overlay" />
|
||||
</div>
|
||||
<JobImage
|
||||
job={party.job}
|
||||
currentAccessory={currentAccessory}
|
||||
accessories={accessories}
|
||||
editable={props.editable}
|
||||
user={party.user}
|
||||
onAccessorySelected={handleAccessorySelected}
|
||||
/>
|
||||
<div className="JobDetails">
|
||||
{props.editable ? (
|
||||
<JobDropdown
|
||||
|
|
@ -146,7 +196,17 @@ const JobSection = (props: Props) => {
|
|||
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">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { SkillGroup, skillClassification } from '~utils/skillGroups'
|
||||
import { SkillGroup, skillClassification } from '~data/skillGroups'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ const JobSkillSearchFilterBar = (props: Props) => {
|
|||
value={-1}
|
||||
triggerClass="Bound"
|
||||
open={open}
|
||||
overlayVisible={false}
|
||||
onValueChange={onChange}
|
||||
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 UpdateToast from '~components/UpdateToast'
|
||||
|
||||
interface Props {
|
||||
children: ReactElement
|
||||
}
|
||||
import './index.scss'
|
||||
|
||||
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 (
|
||||
<>
|
||||
<TopHeader />
|
||||
{updateToast()}
|
||||
<main>{children}</main>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
.Login.Dialog form {
|
||||
.Login.DialogContent {
|
||||
gap: $unit;
|
||||
// min-width: $unit * 52;
|
||||
|
||||
.Fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc($unit / 2);
|
||||
margin-bottom: $unit;
|
||||
gap: $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 { useRouter } from 'next/router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import axios, { AxiosError, AxiosResponse } from 'axios'
|
||||
|
||||
import api from '~utils/api'
|
||||
import setUserToken from '~utils/setUserToken'
|
||||
import { setHeaders } from '~utils/userToken'
|
||||
import { accountState } from '~utils/accountState'
|
||||
|
||||
import Button from '~components/Button'
|
||||
import Input from '~components/LabelledInput'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogClose,
|
||||
} from '~components/Dialog'
|
||||
|
||||
import Input from '~components/Input'
|
||||
import { Dialog, DialogTrigger, DialogClose } from '~components/Dialog'
|
||||
import DialogContent from '~components/DialogContent'
|
||||
import changeLanguage from '~utils/changeLanguage'
|
||||
|
||||
import CrossIcon from '~public/icons/Cross.svg'
|
||||
|
|
@ -31,7 +26,12 @@ interface ErrorMap {
|
|||
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,}))$/
|
||||
|
||||
const LoginModal = () => {
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const LoginModal = (props: Props) => {
|
||||
const router = useRouter()
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
|
|
@ -48,8 +48,13 @@ const LoginModal = () => {
|
|||
// Set up form refs
|
||||
const emailInput: 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]
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(props.open)
|
||||
}, [props.open])
|
||||
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const { name, value } = event.target
|
||||
let newErrors = { ...errors }
|
||||
|
|
@ -137,10 +142,12 @@ const LoginModal = () => {
|
|||
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
|
||||
setUserToken()
|
||||
setHeaders()
|
||||
}
|
||||
|
||||
function storeUserInfo(response: AxiosResponse) {
|
||||
|
|
@ -148,24 +155,32 @@ const LoginModal = () => {
|
|||
const user = response.data
|
||||
|
||||
// Set user data in the user cookie
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 60)
|
||||
|
||||
setCookie(
|
||||
'user',
|
||||
{
|
||||
avatar: {
|
||||
picture: user.avatar.picture,
|
||||
element: user.avatar.element,
|
||||
},
|
||||
language: user.language,
|
||||
gender: user.gender,
|
||||
theme: user.theme,
|
||||
},
|
||||
{ path: '/' }
|
||||
{ path: '/', expires: expiresAt }
|
||||
)
|
||||
|
||||
// Set the user data in the account state
|
||||
accountState.account.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
granblueId: '',
|
||||
avatar: {
|
||||
picture: user.avatar.picture,
|
||||
element: user.avatar.element,
|
||||
},
|
||||
gender: user.gender,
|
||||
language: user.language,
|
||||
theme: user.theme,
|
||||
|
|
@ -184,6 +199,9 @@ const LoginModal = () => {
|
|||
email: '',
|
||||
password: '',
|
||||
})
|
||||
setFormValid(false)
|
||||
|
||||
if (props.onOpenChange) props.onOpenChange(open)
|
||||
}
|
||||
|
||||
function onEscapeKeyDown(event: KeyboardEvent) {
|
||||
|
|
@ -197,13 +215,9 @@ const LoginModal = () => {
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={openChange}>
|
||||
<DialogTrigger asChild>
|
||||
<li className="MenuItem">
|
||||
<span>{t('menu.login')}</span>
|
||||
</li>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="Login Dialog"
|
||||
className="Login"
|
||||
footerref={footerRef}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
>
|
||||
|
|
@ -217,6 +231,7 @@ const LoginModal = () => {
|
|||
</div>
|
||||
|
||||
<form className="form" onSubmit={login}>
|
||||
<div className="Fields">
|
||||
<Input
|
||||
className="Bound"
|
||||
name="email"
|
||||
|
|
@ -235,11 +250,16 @@ const LoginModal = () => {
|
|||
error={errors.password}
|
||||
ref={passwordInput}
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div className="DialogFooter" ref={footerRef}>
|
||||
<div className="Buttons Span">
|
||||
<Button
|
||||
contained={true}
|
||||
disabled={!formValid}
|
||||
text={t('modals.login.buttons.confirm')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</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;
|
||||
left: 0;
|
||||
|
||||
&.Job {
|
||||
animation: none;
|
||||
backdrop-filter: blur(5px) saturate(100%) brightness(80%) opacity(1);
|
||||
}
|
||||
|
||||
&.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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { getCookie } from 'cookies-next'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { subscribe, useSnapshot } from 'valtio'
|
||||
import clonedeep from 'lodash.clonedeep'
|
||||
import ls from 'local-storage'
|
||||
|
||||
import PartySegmentedControl from '~components/PartySegmentedControl'
|
||||
import PartyDetails from '~components/PartyDetails'
|
||||
|
|
@ -10,8 +12,13 @@ import SummonGrid from '~components/SummonGrid'
|
|||
import CharacterGrid from '~components/CharacterGrid'
|
||||
|
||||
import api from '~utils/api'
|
||||
import { accountState } from '~utils/accountState'
|
||||
import { appState, initialAppState } from '~utils/appState'
|
||||
import { getLocalId } from '~utils/localId'
|
||||
import { GridType } from '~utils/enums'
|
||||
import { retrieveCookies } from '~utils/retrieveCookies'
|
||||
import { setEditKey, unsetEditKey } from '~utils/userToken'
|
||||
|
||||
import type { DetailsObject } from '~types'
|
||||
|
||||
import './index.scss'
|
||||
|
|
@ -21,16 +28,26 @@ interface Props {
|
|||
new?: boolean
|
||||
team?: Party
|
||||
raids: Raid[][]
|
||||
selectedTab: GridType
|
||||
pushHistory?: (path: string) => void
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
selectedTab: GridType.Weapon,
|
||||
}
|
||||
|
||||
const Party = (props: Props) => {
|
||||
// Set up router
|
||||
const router = useRouter()
|
||||
|
||||
// Set up states
|
||||
const { party } = useSnapshot(appState)
|
||||
const [editable, setEditable] = useState(false)
|
||||
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
|
||||
const [refresh, setRefresh] = useState(false)
|
||||
|
||||
// Retrieve cookies
|
||||
const cookies = retrieveCookies()
|
||||
|
||||
// Reset state on first load
|
||||
useEffect(() => {
|
||||
|
|
@ -39,19 +56,67 @@ const Party = (props: Props) => {
|
|||
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
|
||||
async function createParty(details?: DetailsObject) {
|
||||
let payload = {}
|
||||
if (details) payload = formatDetailsObject(details)
|
||||
|
||||
return await api.endpoints.parties
|
||||
.create(payload)
|
||||
.create({ ...payload, ...getLocalId() })
|
||||
.then((response) => storeParty(response.data.party))
|
||||
}
|
||||
|
||||
// Methods: Updating the party's details
|
||||
async function updateDetails(details: DetailsObject) {
|
||||
if (!appState.party.id) return await createParty(details)
|
||||
if (!props.team) return await createParty(details)
|
||||
else updateParty(details)
|
||||
}
|
||||
|
||||
|
|
@ -78,9 +143,9 @@ const Party = (props: Props) => {
|
|||
async function updateParty(details: DetailsObject) {
|
||||
const payload = formatDetailsObject(details)
|
||||
|
||||
if (appState.party.id) {
|
||||
if (props.team && props.team.id) {
|
||||
return await api.endpoints.parties
|
||||
.update(appState.party.id, payload)
|
||||
.update(props.team.id, payload)
|
||||
.then((response) => storeParty(response.data.party))
|
||||
}
|
||||
}
|
||||
|
|
@ -89,21 +154,25 @@ const Party = (props: Props) => {
|
|||
appState.party.extra = event.target.checked
|
||||
|
||||
// Only save if this is a saved party
|
||||
if (appState.party.id) {
|
||||
api.endpoints.parties.update(appState.party.id, {
|
||||
if (props.team && props.team.id) {
|
||||
api.endpoints.parties.update(props.team.id, {
|
||||
party: { extra: event.target.checked },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting the party
|
||||
function deleteTeam(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
|
||||
if (appState.party.editable && appState.party.id) {
|
||||
function deleteTeam() {
|
||||
if (props.team && editable) {
|
||||
api.endpoints.parties
|
||||
.destroy({ id: appState.party.id })
|
||||
.destroy({ id: props.team.id })
|
||||
.then(() => {
|
||||
// Push to route
|
||||
if (cookies && cookies.account.username) {
|
||||
router.push(`/${cookies.account.username}`)
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// Clean state
|
||||
const resetState = clonedeep(initialAppState)
|
||||
|
|
@ -121,7 +190,7 @@ const Party = (props: Props) => {
|
|||
}
|
||||
|
||||
// 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
|
||||
appState.party.name = team.name
|
||||
appState.party.description = team.description
|
||||
|
|
@ -129,27 +198,52 @@ const Party = (props: Props) => {
|
|||
appState.party.updated_at = team.updated_at
|
||||
appState.party.job = team.job
|
||||
appState.party.jobSkills = team.job_skills
|
||||
appState.party.accessory = team.accessory
|
||||
|
||||
appState.party.id = team.id
|
||||
appState.party.shortcode = team.shortcode
|
||||
appState.party.extra = team.extra
|
||||
appState.party.user = team.user
|
||||
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.updated_at = team.updated_at
|
||||
|
||||
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
|
||||
storeCharacters(team.characters)
|
||||
storeWeapons(team.weapons)
|
||||
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
|
||||
if (props.pushHistory) props.pushHistory(`/p/${team.shortcode}`)
|
||||
if (props.pushHistory) {
|
||||
props.pushHistory(`/p/${team.shortcode}/${tab}`)
|
||||
}
|
||||
|
||||
return team
|
||||
}
|
||||
|
||||
const storeEditKey = (id: string, key: string) => {
|
||||
ls(id, key)
|
||||
}
|
||||
|
||||
const storeCharacters = (list: Array<GridCharacter>) => {
|
||||
list.forEach((object: GridCharacter) => {
|
||||
if (object.position != null)
|
||||
|
|
@ -184,17 +278,22 @@ const Party = (props: Props) => {
|
|||
|
||||
// Methods: Navigating with segmented control
|
||||
function segmentClicked(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const path = [
|
||||
router.asPath.split('/').filter((el) => el != '')[1],
|
||||
event.target.value,
|
||||
].join('/')
|
||||
|
||||
switch (event.target.value) {
|
||||
case 'class':
|
||||
setCurrentTab(GridType.Class)
|
||||
break
|
||||
case 'characters':
|
||||
router.replace(path)
|
||||
setCurrentTab(GridType.Character)
|
||||
break
|
||||
case 'weapons':
|
||||
router.replace(path)
|
||||
setCurrentTab(GridType.Weapon)
|
||||
break
|
||||
case 'summons':
|
||||
router.replace(path)
|
||||
setCurrentTab(GridType.Summon)
|
||||
break
|
||||
default:
|
||||
|
|
@ -214,6 +313,7 @@ const Party = (props: Props) => {
|
|||
const weaponGrid = (
|
||||
<WeaponGrid
|
||||
new={props.new || false}
|
||||
editable={editable}
|
||||
weapons={props.team?.weapons}
|
||||
createParty={createParty}
|
||||
pushHistory={props.pushHistory}
|
||||
|
|
@ -223,6 +323,7 @@ const Party = (props: Props) => {
|
|||
const summonGrid = (
|
||||
<SummonGrid
|
||||
new={props.new || false}
|
||||
editable={editable}
|
||||
summons={props.team?.summons}
|
||||
createParty={createParty}
|
||||
pushHistory={props.pushHistory}
|
||||
|
|
@ -232,6 +333,7 @@ const Party = (props: Props) => {
|
|||
const characterGrid = (
|
||||
<CharacterGrid
|
||||
new={props.new || false}
|
||||
editable={editable}
|
||||
characters={props.team?.characters}
|
||||
createParty={createParty}
|
||||
pushHistory={props.pushHistory}
|
||||
|
|
@ -264,4 +366,6 @@ const Party = (props: Props) => {
|
|||
)
|
||||
}
|
||||
|
||||
Party.defaultProps = defaultProps
|
||||
|
||||
export default Party
|
||||
|
|
|
|||
|
|
@ -1,22 +1,35 @@
|
|||
.DetailsWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
margin: $unit-4x auto 0 auto;
|
||||
max-width: $grid-width;
|
||||
|
||||
@include breakpoint(phone) {
|
||||
.Button:not(.IconButton) {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
.Text {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.PartyDetails {
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
margin: 0 auto $unit-2x;
|
||||
max-width: $unit * 94;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
@include breakpoint(phone) {
|
||||
padding: 0 $unit;
|
||||
}
|
||||
|
||||
.PartyDetails {
|
||||
display: none;
|
||||
margin: 0 auto;
|
||||
max-width: $unit * 94;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
&.Visible {
|
||||
margin-bottom: $unit-12x;
|
||||
// margin-bottom: $unit-12x;
|
||||
}
|
||||
|
||||
&.Editable {
|
||||
|
|
@ -37,6 +50,7 @@
|
|||
}
|
||||
|
||||
.SelectTrigger {
|
||||
padding: $unit-2x;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
|
@ -45,12 +59,16 @@
|
|||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: $unit;
|
||||
|
||||
@include breakpoint(phone) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ToggleSection,
|
||||
.InputSection {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
background: var(--card-bg);
|
||||
border-radius: $card-corner;
|
||||
border-radius: $input-corner;
|
||||
|
||||
& > label {
|
||||
align-items: center;
|
||||
|
|
@ -133,6 +151,11 @@
|
|||
flex-direction: row;
|
||||
gap: $unit;
|
||||
|
||||
@include breakpoint(phone) {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.left {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
|
@ -141,11 +164,18 @@
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $unit;
|
||||
|
||||
@include breakpoint(phone) {
|
||||
.Button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.ReadOnly {
|
||||
box-sizing: border-box;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
|
||||
|
|
@ -166,7 +196,8 @@
|
|||
.Details {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $unit-half;
|
||||
flex-wrap: wrap;
|
||||
gap: $unit;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
|
|
@ -257,29 +288,40 @@
|
|||
}
|
||||
|
||||
.PartyInfo {
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $unit;
|
||||
margin: 0 auto;
|
||||
margin-bottom: $unit * 2;
|
||||
max-width: $unit * 94;
|
||||
width: 100%;
|
||||
|
||||
.Left {
|
||||
@include breakpoint(phone) {
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
padding: 0 $unit;
|
||||
}
|
||||
|
||||
& > .Left {
|
||||
flex-grow: 1;
|
||||
|
||||
.Header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
margin-bottom: $unit;
|
||||
|
||||
h1 {
|
||||
font-size: $font-xlarge;
|
||||
font-weight: $normal;
|
||||
text-align: left;
|
||||
margin-bottom: $unit;
|
||||
color: var(--text-primary);
|
||||
|
||||
&.empty {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attribution {
|
||||
align-items: center;
|
||||
|
|
@ -297,6 +339,18 @@
|
|||
font-size: $font-small;
|
||||
}
|
||||
|
||||
a:visited:not(.fire):not(.water):not(.wind):not(.earth):not(.dark):not(
|
||||
.light
|
||||
) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
a:hover:not(.fire):not(.water):not(.wind):not(.earth):not(.dark):not(
|
||||
.light
|
||||
) {
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
& > *:not(:last-child):after {
|
||||
content: ' · ';
|
||||
margin: 0 calc($unit / 2);
|
||||
|
|
@ -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 Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { subscribe, useSnapshot } from 'valtio'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
import clonedeep from 'lodash.clonedeep'
|
||||
|
||||
import Linkify from 'react-linkify'
|
||||
import LiteYouTubeEmbed from 'react-lite-youtube-embed'
|
||||
import classNames from 'classnames'
|
||||
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 CharLimitedFieldset from '~components/CharLimitedFieldset'
|
||||
import Input from '~components/Input'
|
||||
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 RaidDropdown from '~components/RaidDropdown'
|
||||
import TextFieldset from '~components/TextFieldset'
|
||||
import Switch from '~components/Switch'
|
||||
|
||||
import api from '~utils/api'
|
||||
import { accountState } from '~utils/accountState'
|
||||
import { appState } from '~utils/appState'
|
||||
import { appState, initialAppState } from '~utils/appState'
|
||||
import { formatTimeAgo } from '~utils/timeAgo'
|
||||
import { youtube } from '~utils/youtube'
|
||||
|
||||
import CheckIcon from '~public/icons/Check.svg'
|
||||
import CrossIcon from '~public/icons/Cross.svg'
|
||||
import EditIcon from '~public/icons/Edit.svg'
|
||||
import RemixIcon from '~public/icons/Remix.svg'
|
||||
|
||||
import type { DetailsObject } from 'types'
|
||||
|
||||
|
|
@ -40,9 +44,7 @@ interface Props {
|
|||
new: boolean
|
||||
editable: boolean
|
||||
updateCallback: (details: DetailsObject) => void
|
||||
deleteCallback: (
|
||||
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||
) => void
|
||||
deleteCallback: () => void
|
||||
}
|
||||
|
||||
const PartyDetails = (props: Props) => {
|
||||
|
|
@ -60,6 +62,7 @@ const PartyDetails = (props: Props) => {
|
|||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [alertOpen, setAlertOpen] = useState(false)
|
||||
|
||||
const [chargeAttack, setChargeAttack] = useState(true)
|
||||
const [fullAuto, setFullAuto] = useState(false)
|
||||
|
|
@ -70,6 +73,8 @@ const PartyDetails = (props: Props) => {
|
|||
const [turnCount, setTurnCount] = useState<number | undefined>(undefined)
|
||||
const [clearTime, setClearTime] = useState(0)
|
||||
|
||||
const [remixes, setRemixes] = useState<Party[]>([])
|
||||
|
||||
const [raidSlug, setRaidSlug] = useState('')
|
||||
const [embeddedDescription, setEmbeddedDescription] =
|
||||
useState<React.ReactNode>()
|
||||
|
|
@ -112,12 +117,33 @@ const PartyDetails = (props: Props) => {
|
|||
setFullAuto(props.party.full_auto)
|
||||
setChargeAttack(props.party.charge_attack)
|
||||
setClearTime(props.party.clear_time)
|
||||
setRemixes(props.party.remixes)
|
||||
if (props.party.turn_count) setTurnCount(props.party.turn_count)
|
||||
if (props.party.button_count) setButtonCount(props.party.button_count)
|
||||
if (props.party.chain_count) setChainCount(props.party.chain_count)
|
||||
}
|
||||
}, [props.party])
|
||||
|
||||
// Subscribe to router changes and reset state
|
||||
// if the new route is a new team
|
||||
useEffect(() => {
|
||||
router.events.on('routeChangeStart', (url, { shallow }) => {
|
||||
if (url === '/new' || url === '/') {
|
||||
const party = initialAppState.party
|
||||
|
||||
setName(party.name ? party.name : '')
|
||||
setAutoGuard(party.autoGuard)
|
||||
setFullAuto(party.fullAuto)
|
||||
setChargeAttack(party.chargeAttack)
|
||||
setClearTime(party.clearTime)
|
||||
setRemixes(party.remixes)
|
||||
setTurnCount(party.turnCount)
|
||||
setButtonCount(party.buttonCount)
|
||||
setChainCount(party.chainCount)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Extract the video IDs from the description
|
||||
if (appState.party.description) {
|
||||
|
|
@ -293,6 +319,57 @@ const PartyDetails = (props: Props) => {
|
|||
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) {
|
||||
// Initialize an array to store the video IDs
|
||||
const videoIds = []
|
||||
|
|
@ -326,7 +403,16 @@ const PartyDetails = (props: Props) => {
|
|||
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) => {
|
||||
|
|
@ -342,8 +428,8 @@ const PartyDetails = (props: Props) => {
|
|||
let username, picture, element
|
||||
if (accountState.account.authorized && props.new) {
|
||||
username = accountState.account.user?.username
|
||||
picture = accountState.account.user?.picture
|
||||
element = accountState.account.user?.element
|
||||
picture = accountState.account.user?.avatar.picture
|
||||
element = accountState.account.user?.avatar.element
|
||||
} else if (party.user && !props.new) {
|
||||
username = party.user.username
|
||||
picture = party.user.avatar.picture
|
||||
|
|
@ -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) {
|
||||
return (
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger className="Button Blended medium destructive">
|
||||
<span className="Accessory">
|
||||
<CrossIcon />
|
||||
</span>
|
||||
<span className="Text">{t('buttons.delete')}</span>
|
||||
</AlertDialog.Trigger>
|
||||
<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>
|
||||
<Alert
|
||||
open={alertOpen}
|
||||
primaryAction={deleteParty}
|
||||
primaryActionText={t('modals.delete_team.buttons.confirm')}
|
||||
cancelAction={() => setAlertOpen(false)}
|
||||
cancelActionText={t('modals.delete_team.buttons.cancel')}
|
||||
message={t('modals.delete_team.description')}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const editable = (
|
||||
const editable = () => {
|
||||
return (
|
||||
<section className={editableClasses}>
|
||||
<CharLimitedFieldset
|
||||
fieldName="name"
|
||||
|
|
@ -553,12 +638,21 @@ const PartyDetails = (props: Props) => {
|
|||
|
||||
<div className="bottom">
|
||||
<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 className="right">
|
||||
<Button text={t('buttons.cancel')} onClick={toggleDetails} />
|
||||
<Button
|
||||
accessoryIcon={<CheckIcon className="Check" />}
|
||||
leftAccessoryIcon={<CheckIcon className="Check" />}
|
||||
text={t('buttons.save_info')}
|
||||
onClick={updateDetails}
|
||||
/>
|
||||
|
|
@ -566,6 +660,7 @@ const PartyDetails = (props: Props) => {
|
|||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const clearTimeString = () => {
|
||||
const minutes = Math.floor(clearTime / 60)
|
||||
|
|
@ -600,18 +695,46 @@ const PartyDetails = (props: Props) => {
|
|||
}
|
||||
}
|
||||
|
||||
const readOnly = (
|
||||
const readOnly = () => {
|
||||
return (
|
||||
<section className={readOnlyClasses}>
|
||||
<section className="Details">
|
||||
{
|
||||
<Token>
|
||||
<Token
|
||||
className={classNames({
|
||||
ChargeAttack: true,
|
||||
On: chargeAttack,
|
||||
Off: !chargeAttack,
|
||||
})}
|
||||
>
|
||||
{`${t('party.details.labels.charge_attack')} ${
|
||||
chargeAttack ? 'On' : 'Off'
|
||||
}`}
|
||||
</Token>
|
||||
}
|
||||
{fullAuto ? <Token>{t('party.details.labels.full_auto')}</Token> : ''}
|
||||
{autoGuard ? <Token>{t('party.details.labels.auto_guard')}</Token> : ''}
|
||||
|
||||
<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 ? (
|
||||
<Token>
|
||||
{t('party.details.turns.with_count', {
|
||||
|
|
@ -627,14 +750,39 @@ const PartyDetails = (props: Props) => {
|
|||
<Linkify>{embeddedDescription}</Linkify>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const remixSection = () => {
|
||||
return (
|
||||
<section className="Remixes">
|
||||
<h3>{t('remixes')}</h3>
|
||||
{<GridRepCollection>{renderRemixes()}</GridRepCollection>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="DetailsWrapper">
|
||||
<div className="PartyInfo">
|
||||
<div className="Left">
|
||||
<h1 className={name === '' ? 'empty' : ''}>
|
||||
{name !== '' ? name : 'Untitled'}
|
||||
<div className="Header">
|
||||
<h1 className={name ? '' : 'empty'}>
|
||||
{name ? name : t('no_title')}
|
||||
</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">
|
||||
{renderUserBlock()}
|
||||
{party.raid ? linkedRaidBlock(party.raid) : ''}
|
||||
|
|
@ -650,21 +798,25 @@ const PartyDetails = (props: Props) => {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="Right">
|
||||
{party.editable ? (
|
||||
<div className="Right">
|
||||
<Button
|
||||
accessoryIcon={<EditIcon />}
|
||||
leftAccessoryIcon={<EditIcon />}
|
||||
text={t('buttons.show_info')}
|
||||
onClick={toggleDetails}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
''
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{readOnly}
|
||||
{editable}
|
||||
{readOnly()}
|
||||
{editable()}
|
||||
|
||||
{deleteAlert()}
|
||||
</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) => {
|
||||
// Set up translations
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
const { party, grid } = useSnapshot(appState)
|
||||
|
|
@ -33,22 +34,16 @@ const PartySegmentedControl = (props: Props) => {
|
|||
switch (element) {
|
||||
case 1:
|
||||
return 'wind'
|
||||
break
|
||||
case 2:
|
||||
return 'fire'
|
||||
break
|
||||
case 3:
|
||||
return 'water'
|
||||
break
|
||||
case 4:
|
||||
return 'earth'
|
||||
break
|
||||
case 5:
|
||||
return 'dark'
|
||||
break
|
||||
case 6:
|
||||
return 'light'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -72,13 +67,6 @@ const PartySegmentedControl = (props: Props) => {
|
|||
})}
|
||||
>
|
||||
<SegmentedControl elementClass={getElement()}>
|
||||
{/* <Segment
|
||||
groupName="grid"
|
||||
name="class"
|
||||
selected={props.selectedTab === GridType.Class}
|
||||
onClick={props.onClick}
|
||||
>Class</Segment> */}
|
||||
|
||||
<Segment
|
||||
groupName="grid"
|
||||
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 organizeRaids from '~utils/organizeRaids'
|
||||
import { appState } from '~utils/appState'
|
||||
import { raidGroups } from '~utils/raidGroups'
|
||||
import { raidGroups } from '~data/raidGroups'
|
||||
|
||||
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 {
|
||||
max-height: 60vh;
|
||||
overflow-y: scroll;
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
|
||||
.Roadmap.PageContent {
|
||||
padding-bottom: $unit-12x;
|
||||
h3.priority {
|
||||
font-weight: $medium;
|
||||
font-size: $font-large;
|
||||
margin-bottom: $unit-4x;
|
||||
|
||||
&.in_progress {
|
||||
color: $yellow;
|
||||
|
|
@ -28,12 +22,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
.notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
margin-bottom: $unit-2x;
|
||||
|
||||
p {
|
||||
margin-bottom: $unit;
|
||||
font-size: $font-medium;
|
||||
}
|
||||
|
||||
.LinkItem {
|
||||
$diameter: $unit-6x;
|
||||
background: var(--dialog-bg);
|
||||
border: 1px solid var(--link-item-bg);
|
||||
border-radius: $card-corner;
|
||||
|
||||
|
|
@ -79,22 +81,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
.Separator {
|
||||
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 {
|
||||
ul {
|
||||
color: var(--text-primary);
|
||||
list-style-type: none;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $unit-3x;
|
||||
|
||||
li {
|
||||
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;
|
||||
|
||||
div {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
.Search.Dialog {
|
||||
.Search.DialogContent {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 430px;
|
||||
height: 480px;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
|
||||
@include breakpoint(phone) {
|
||||
|
|
@ -14,17 +12,16 @@
|
|||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#Header {
|
||||
border-bottom: 1px solid transparent;
|
||||
.DialogHeader.Search {
|
||||
align-items: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
padding-bottom: $unit * 2;
|
||||
|
||||
&.scrolled {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
padding: 0;
|
||||
padding-bottom: $unit-2x;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
#Bar {
|
||||
align-items: center;
|
||||
|
|
@ -63,7 +60,6 @@
|
|||
|
||||
#Results {
|
||||
margin: 0;
|
||||
max-height: 356px;
|
||||
padding: 0 ($unit * 1.5);
|
||||
overflow-y: scroll;
|
||||
|
||||
|
|
@ -94,7 +90,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.Search.Dialog #NoResults {
|
||||
.Search.DialogContent #NoResults {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
|
@ -102,7 +98,7 @@
|
|||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.Search.Dialog #NoResults h2 {
|
||||
.Search.DialogContent #NoResults h2 {
|
||||
color: var(--text-secondary);
|
||||
font-size: $font-large;
|
||||
font-weight: 500;
|
||||
|
|
|
|||
|
|
@ -3,16 +3,12 @@ import { getCookie, setCookie } from 'cookies-next'
|
|||
import { useRouter } from 'next/router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||
import cloneDeep from 'lodash.clonedeep'
|
||||
|
||||
import api from '~utils/api'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogClose,
|
||||
} from '~components/Dialog'
|
||||
|
||||
import { Dialog, DialogTrigger, DialogClose } from '~components/Dialog'
|
||||
import DialogContent from '~components/DialogContent'
|
||||
import Input from '~components/LabelledInput'
|
||||
import CharacterSearchFilterBar from '~components/CharacterSearchFilterBar'
|
||||
import WeaponSearchFilterBar from '~components/WeaponSearchFilterBar'
|
||||
|
|
@ -24,19 +20,18 @@ import WeaponResult from '~components/WeaponResult'
|
|||
import SummonResult from '~components/SummonResult'
|
||||
import JobSkillResult from '~components/JobSkillResult'
|
||||
|
||||
import type { DialogProps } from '@radix-ui/react-dialog'
|
||||
import type { SearchableObject, SearchableObjectArray } from '~types'
|
||||
|
||||
import './index.scss'
|
||||
import CrossIcon from '~public/icons/Cross.svg'
|
||||
import cloneDeep from 'lodash.clonedeep'
|
||||
|
||||
interface Props {
|
||||
interface Props extends DialogProps {
|
||||
send: (object: SearchableObject, position: number) => any
|
||||
placeholderText: string
|
||||
fromPosition: number
|
||||
job?: Job
|
||||
object: 'weapons' | 'characters' | 'summons' | 'job_skills'
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const SearchModal = (props: Props) => {
|
||||
|
|
@ -47,8 +42,10 @@ const SearchModal = (props: Props) => {
|
|||
// Set up translation
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
let searchInput = React.createRef<HTMLInputElement>()
|
||||
let scrollContainer = React.createRef<HTMLDivElement>()
|
||||
// Refs
|
||||
const headerRef = React.createRef<HTMLDivElement>()
|
||||
const searchInput = React.createRef<HTMLInputElement>()
|
||||
const scrollContainer = React.createRef<HTMLDivElement>()
|
||||
|
||||
const [firstLoad, setFirstLoad] = useState(true)
|
||||
const [filters, setFilters] = useState<{ [key: string]: any }>()
|
||||
|
|
@ -65,6 +62,10 @@ const SearchModal = (props: Props) => {
|
|||
if (searchInput.current) searchInput.current.focus()
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open !== undefined) setOpen(props.open)
|
||||
})
|
||||
|
||||
function inputChanged(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const text = event.target.value
|
||||
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()
|
||||
setCookie(`recent_${props.object}`, recents, { path: '/' })
|
||||
setCookie(`recent_${props.object}`, recents, {
|
||||
path: '/',
|
||||
expires: expiresAt,
|
||||
})
|
||||
sendData(result)
|
||||
}
|
||||
|
||||
|
|
@ -335,8 +342,10 @@ const SearchModal = (props: Props) => {
|
|||
setRecordCount(0)
|
||||
setCurrentPage(1)
|
||||
setOpen(false)
|
||||
if (props.onOpenChange) props.onOpenChange(false)
|
||||
} else {
|
||||
setOpen(true)
|
||||
if (props.onOpenChange) props.onOpenChange(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -354,11 +363,12 @@ const SearchModal = (props: Props) => {
|
|||
<Dialog open={open} onOpenChange={openChange}>
|
||||
<DialogTrigger asChild>{props.children}</DialogTrigger>
|
||||
<DialogContent
|
||||
className="Search Dialog"
|
||||
className="Search"
|
||||
headerref={headerRef}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
>
|
||||
<div id="Header">
|
||||
<div className="Search DialogHeader" ref={headerRef}>
|
||||
<div id="Bar">
|
||||
<Input
|
||||
autoComplete="off"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
border-radius: $input-corner;
|
||||
border: none;
|
||||
display: flex;
|
||||
padding: $unit-2x $unit-2x;
|
||||
gap: $unit;
|
||||
padding: ($unit * 1.5) $unit-2x;
|
||||
|
||||
&.modal {
|
||||
background-color: var(--select-modal-bg);
|
||||
|
|
@ -14,6 +15,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--input-bg-hover);
|
||||
color: var(--text-primary);
|
||||
|
|
@ -24,6 +29,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.Disabled:hover {
|
||||
background-color: var(--input-bg);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&[data-placeholder] > span:not(.SelectIcon) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
|
@ -47,6 +57,11 @@
|
|||
min-width: $unit * 30;
|
||||
}
|
||||
|
||||
img {
|
||||
width: $unit-4x;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.SelectIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ interface Props
|
|||
React.SelectHTMLAttributes<HTMLSelectElement>,
|
||||
HTMLSelectElement
|
||||
> {
|
||||
altText?: string
|
||||
iconSrc?: string
|
||||
open: boolean
|
||||
trigger?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
|
|
@ -21,6 +23,7 @@ interface Props
|
|||
onValueChange?: (value: string) => void
|
||||
onClose?: () => void
|
||||
triggerClass?: string
|
||||
overlayVisible?: boolean
|
||||
}
|
||||
|
||||
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 [value, setValue] = useState('')
|
||||
|
||||
const triggerClasses = classNames(
|
||||
{
|
||||
SelectTrigger: true,
|
||||
Disabled: props.disabled,
|
||||
},
|
||||
props.triggerClass
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(props.open)
|
||||
}, [props.open])
|
||||
|
|
@ -67,19 +78,27 @@ const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
|
|||
onOpenChange={props.onOpenChange}
|
||||
>
|
||||
<RadixSelect.Trigger
|
||||
className={classNames('SelectTrigger', props.triggerClass)}
|
||||
className={triggerClasses}
|
||||
placeholder={props.placeholder}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
{props.iconSrc ? <img alt={props.altText} src={props.iconSrc} /> : ''}
|
||||
<RadixSelect.Value placeholder={props.placeholder} />
|
||||
{!props.disabled ? (
|
||||
<RadixSelect.Icon className="SelectIcon">
|
||||
<ArrowIcon />
|
||||
</RadixSelect.Icon>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</RadixSelect.Trigger>
|
||||
|
||||
<RadixSelect.Portal className="Select">
|
||||
<>
|
||||
<Overlay open={open} visible={false} />
|
||||
<Overlay
|
||||
open={open}
|
||||
visible={props.overlayVisible != null ? props.overlayVisible : true}
|
||||
/>
|
||||
|
||||
<RadixSelect.Content
|
||||
className="Select"
|
||||
|
|
@ -101,4 +120,8 @@ const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
|
|||
)
|
||||
})
|
||||
|
||||
Select.defaultProps = {
|
||||
overlayVisible: true,
|
||||
}
|
||||
|
||||
export default Select
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
.SelectItem {
|
||||
align-items: center;
|
||||
border-radius: $item-corner;
|
||||
border: 2px solid transparent;
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
font-size: $font-regular;
|
||||
padding: ($unit * 1.5) $unit-2x;
|
||||
|
||||
|
|
@ -24,4 +27,9 @@
|
|||
&:last-child {
|
||||
margin-bottom: $unit;
|
||||
}
|
||||
|
||||
img {
|
||||
width: $unit-4x;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,19 +6,23 @@ import classNames from 'classnames'
|
|||
|
||||
interface Props extends ComponentProps<'div'> {
|
||||
value: string | number
|
||||
iconSrc?: string
|
||||
altText?: string
|
||||
}
|
||||
|
||||
const SelectItem = React.forwardRef<HTMLDivElement, Props>(function selectItem(
|
||||
{ children, ...props },
|
||||
{ children, value, ...props },
|
||||
forwardedRef
|
||||
) {
|
||||
const { altText, iconSrc, ...rest } = props
|
||||
return (
|
||||
<Select.Item
|
||||
className={classNames('SelectItem', props.className)}
|
||||
{...props}
|
||||
{...rest}
|
||||
ref={forwardedRef}
|
||||
value={`${props.value}`}
|
||||
value={`${value}`}
|
||||
>
|
||||
{iconSrc ? <img alt={altText} src={iconSrc} /> : ''}
|
||||
<Select.ItemText>{children}</Select.ItemText>
|
||||
</Select.Item>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,47 @@
|
|||
.TableField {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: $unit * 2;
|
||||
gap: $unit-2x;
|
||||
grid-template-columns: 1fr auto;
|
||||
|
||||
&.Image {
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
@include breakpoint(phone) {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.Left {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $unit;
|
||||
width: 100%;
|
||||
|
||||
.Info {
|
||||
display: flex;
|
||||
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 {
|
||||
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 {
|
||||
$diameter: $unit * 6;
|
||||
background-color: $grey-90;
|
||||
|
|
|
|||
|
|
@ -44,13 +44,15 @@ const SelectTableField = (props: Props) => {
|
|||
return (
|
||||
<div className={classNames({ TableField: true }, props.className)}>
|
||||
<div className="Left">
|
||||
<div className="Info">
|
||||
<h3>{props.label}</h3>
|
||||
<p>{props.description}</p>
|
||||
</div>
|
||||
|
||||
{image()}
|
||||
<div className="Image">{image()}</div>
|
||||
</div>
|
||||
|
||||
<div className="Right">
|
||||
<div className="Image">{image()}</div>
|
||||
<Select
|
||||
name={props.name}
|
||||
open={props.open}
|
||||
|
|
@ -59,6 +61,7 @@ const SelectTableField = (props: Props) => {
|
|||
onClose={props.onClose}
|
||||
triggerClass={classNames({ Bound: true, Table: true })}
|
||||
value={value}
|
||||
overlayVisible={false}
|
||||
>
|
||||
{props.children}
|
||||
</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;
|
||||
flex-direction: column;
|
||||
gap: calc($unit / 2);
|
||||
margin-bottom: $unit;
|
||||
padding: 0 $unit-4x;
|
||||
|
||||
.terms {
|
||||
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 { useRouter } from 'next/router'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
import { AxiosResponse } from 'axios'
|
||||
|
||||
import api from '~utils/api'
|
||||
import setUserToken from '~utils/setUserToken'
|
||||
import { setHeaders } from '~utils/userToken'
|
||||
import { accountState } from '~utils/accountState'
|
||||
|
||||
import Button from '~components/Button'
|
||||
import Input from '~components/LabelledInput'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogClose,
|
||||
} from '~components/Dialog'
|
||||
|
||||
import Input from '~components/Input'
|
||||
import { Dialog, DialogTrigger, DialogClose } from '~components/Dialog'
|
||||
import DialogContent from '~components/DialogContent'
|
||||
import CrossIcon from '~public/icons/Cross.svg'
|
||||
import './index.scss'
|
||||
|
||||
interface Props {}
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface ErrorMap {
|
||||
[index: string]: string
|
||||
|
|
@ -54,6 +52,8 @@ const SignupModal = (props: Props) => {
|
|||
const emailInput = React.createRef<HTMLInputElement>()
|
||||
const passwordInput = React.createRef<HTMLInputElement>()
|
||||
const passwordConfirmationInput = React.createRef<HTMLInputElement>()
|
||||
const footerRef = React.createRef<HTMLDivElement>()
|
||||
|
||||
const form = [
|
||||
usernameInput,
|
||||
emailInput,
|
||||
|
|
@ -61,6 +61,10 @@ const SignupModal = (props: Props) => {
|
|||
passwordConfirmationInput,
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(props.open)
|
||||
}, [props.open])
|
||||
|
||||
function register(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
|
|
@ -94,10 +98,12 @@ const SignupModal = (props: Props) => {
|
|||
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
|
||||
setUserToken()
|
||||
setHeaders()
|
||||
}
|
||||
|
||||
function fetchUserInfo(id: string) {
|
||||
|
|
@ -109,24 +115,32 @@ const SignupModal = (props: Props) => {
|
|||
const user = response.data
|
||||
|
||||
// Set user data in the user cookie
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 60)
|
||||
|
||||
setCookie(
|
||||
'user',
|
||||
{
|
||||
avatar: {
|
||||
picture: user.avatar.picture,
|
||||
element: user.avatar.element,
|
||||
},
|
||||
language: user.language,
|
||||
gender: user.gender,
|
||||
theme: user.theme,
|
||||
},
|
||||
{ path: '/' }
|
||||
{ path: '/', expires: expiresAt }
|
||||
)
|
||||
|
||||
// Set the user data in the account state
|
||||
accountState.account.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
granblueId: '',
|
||||
avatar: {
|
||||
picture: user.avatar.picture,
|
||||
element: user.avatar.element,
|
||||
},
|
||||
gender: user.gender,
|
||||
language: user.language,
|
||||
theme: user.theme,
|
||||
|
|
@ -264,6 +278,9 @@ const SignupModal = (props: Props) => {
|
|||
password: '',
|
||||
passwordConfirmation: '',
|
||||
})
|
||||
setFormValid(false)
|
||||
|
||||
if (props.onOpenChange) props.onOpenChange(open)
|
||||
}
|
||||
|
||||
function onEscapeKeyDown(event: KeyboardEvent) {
|
||||
|
|
@ -277,13 +294,9 @@ const SignupModal = (props: Props) => {
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={openChange}>
|
||||
<DialogTrigger asChild>
|
||||
<li className="MenuItem">
|
||||
<span>{t('menu.signup')}</span>
|
||||
</li>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="Signup Dialog"
|
||||
className="Signup"
|
||||
footerref={footerRef}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
>
|
||||
|
|
@ -297,6 +310,7 @@ const SignupModal = (props: Props) => {
|
|||
</div>
|
||||
|
||||
<form className="form" onSubmit={register}>
|
||||
<div className="Fields">
|
||||
<Input
|
||||
className="Bound"
|
||||
name="username"
|
||||
|
|
@ -334,11 +348,17 @@ const SignupModal = (props: Props) => {
|
|||
error={errors.passwordConfirmation}
|
||||
ref={passwordConfirmationInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="DialogFooter" ref={footerRef}>
|
||||
<div className="Buttons Span">
|
||||
<Button
|
||||
contained={true}
|
||||
disabled={!formValid}
|
||||
text={t('modals.signup.buttons.confirm')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="terms">
|
||||
{/* <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