February 2023 Update (#158)

This commit is contained in:
Justin Edmund 2023-02-04 23:46:24 -08:00 committed by GitHub
parent c0890e6e96
commit c7e0836202
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
249 changed files with 11948 additions and 3724 deletions

View file

@ -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
View file

@ -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/

View file

@ -1,8 +1,42 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
![Header image for hensei-web](README.png)
# hensei-web
**hensei-web** is the frontend for [granblue.team](https://app.granblue.team/), an app for saving and sharing teams for [Granblue Fantasy](https://game.granbluefantasy.jp).
## Getting Started
First, run the development server:
First, you have to set up your environment file. You should start with [.env.sample](https://github.com/jedmund/hensei-web/blob/staging/.env.sample), but here are some gotchas:
#### App URLs
Don't add a trailing slash to these URLs!
The API will run on port 3000 by default, but make sure to change these to match your instance of the API.
```
NEXT_PUBLIC_SIERO_API_URL='http://127.0.0.1:3000/api/v1'
NEXT_PUBLIC_SIERO_OAUTH_URL='http://127.0.0.1:3000/oauth'
```
#### Asset URLs
Next.js serves all assets out of the /public directory. In development we utilize this for all assets, but in production, you will want to host these images on a cloud storage provider like Amazon S3. Once you have that set up and you're running in a production environment, change this to the full bucket URL.
```
NEXT_PUBLIC_SIERO_IMG_URL='/images'
```
#### Dependencies
Once your `.env` is all set up, install all dependencies:
```bash
npm install
# or
yarn install
```
Then, run the development server with:
```bash
npm run dev
@ -10,25 +44,28 @@ npm run dev
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Assets
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
The [hensei-api](https://github.com/jedmund/hensei-api) repository has tasks that will help you get assets, although some were crafted or renamed by hand. The front-end expects this folder structure inside of the `images` folder:
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
```
root
├─ accessory-grid/
├─ accessory-square/
├─ awakening/
├─ ax/
├─ chara-main/
├─ chara-grid/
├─ chara-square/
├─ jobs/
├─ job-icons/
├─ job-skills/
├─ mastery/
├─ summon-main/
├─ summon-grid/
├─ summon-square/
├─ updates/
├─ weapon-main/
├─ weapon-grid/
├─ weapon-square/
```

BIN
README.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View 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

View file

@ -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%;
}

View file

@ -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&apos;re recruiting!) And yoey, but he won&apos;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

View 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%;
}
}
}

View 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&apos;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

View file

@ -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 {

View file

@ -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

View file

@ -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;
}
}

View file

@ -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>
)

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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;

View file

@ -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>
)
})

View file

@ -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;
}
}

View file

@ -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

View 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;
}
}

View 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

View file

@ -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>

View file

@ -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>
)
}

View file

@ -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;
// }
}

View file

@ -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>&nbsp;
{`+${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>&nbsp;
{`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>
)
}

View 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;
}
}
}
}

View 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

View file

@ -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');
}
}
}
}

View file

@ -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

View file

@ -0,0 +1,6 @@
.ContextMenu {
background: var(--menu-bg);
border-radius: $input-corner;
padding: $unit 0;
margin-top: $unit-fourth;
}

View 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

View 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;
}
}

View 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

View file

@ -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;
}
}
}
}
}
}

View file

@ -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 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)
}
export const DialogContent = React.forwardRef<HTMLDivElement, Props>(
function dialog({ children, ...props }, forwardedRef) {
const classes = classNames(
{
Dialog: true,
},
props.className
)
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

View 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;
}
}
}
}
}
}
}

View 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

View file

@ -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;

View 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,
}

View file

@ -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;
}
}
}

View file

@ -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>
)
}
)

View 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;
}
}

View 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

View 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;
}
}

View 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

View file

@ -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>
)

View file

@ -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}
/>

View file

@ -137,7 +137,7 @@
.Properties {
.full_auto {
color: var(--full-auto-text);
color: var(--full-auto-label-text);
}
}

View file

@ -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>
) : (
''

View file

@ -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);

View file

@ -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>
)
}

View file

@ -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

View 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;
}
}

View 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>
)
}

View file

@ -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 {

View 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;
}
}

View 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

View 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;
}
}
}
}

View 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

View file

@ -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)}

View 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;
}
}

View 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

View file

@ -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;

View file

@ -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">

View file

@ -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'

View file

@ -44,6 +44,7 @@ const JobSkillSearchFilterBar = (props: Props) => {
value={-1}
triggerClass="Bound"
open={open}
overlayVisible={false}
onValueChange={onChange}
onOpenChange={openSelect}
>

View 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;
}

View file

@ -1,14 +1,72 @@
import type { ReactElement } from 'react'
import TopHeader from '~components/Header'
import { PropsWithChildren, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { add, format } from 'date-fns'
import { getCookie } from 'cookies-next'
interface Props {
children: ReactElement
import { appState } from '~utils/appState'
import TopHeader from '~components/Header'
import UpdateToast from '~components/UpdateToast'
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>
</>
)

View file

@ -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;
}
}

View file

@ -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>

View 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

View file

@ -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);
}
}

View file

@ -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

View file

@ -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;
}
}
}
}

View file

@ -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() : ''}
</>
)
}

View 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

View file

@ -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"

View 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);
}

View 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,
}

View 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

View file

@ -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'

View file

@ -0,0 +1,5 @@
.Rings {
display: flex;
flex-direction: column;
gap: $unit;
}

View 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

View file

@ -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

View file

@ -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;

View 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

View 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

View file

@ -12,6 +12,7 @@ button.DropdownLabel {
padding: $unit ($unit * 1.5) $unit $unit-2x;
div {
align-items: center;
display: flex;
gap: $unit-half;
}

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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

View file

@ -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;
}
}

View file

@ -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>
)

View file

@ -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;

View file

@ -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>

View 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;
}
}
}

View 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

View file

@ -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;
@ -20,3 +24,4 @@
}
}
}
}

View file

@ -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