Merge branch 'staging' into fix-job-errors

This commit is contained in:
Justin Edmund 2023-01-28 03:52:58 -08:00 committed by GitHub
commit a7e3718a7e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
91 changed files with 3661 additions and 1596 deletions

1
.gitignore vendored
View file

@ -53,6 +53,7 @@ public/images/chara*
public/images/job*
public/images/awakening*
public/images/ax*
public/images/accessory*
# Typescript v1 declaration files
typings/

View file

@ -1,98 +0,0 @@
.About.DialogContent {
gap: 0;
padding-bottom: $unit;
.content {
display: flex;
flex-direction: column;
gap: $unit-2x;
padding: 0 $unit-4x;
}
.sections {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
section {
margin-bottom: $unit;
& > h2 {
font-size: $font-medium;
font-weight: $medium;
margin-bottom: $unit * 3;
}
}
p {
color: var(--text-secondary);
font-size: $font-regular;
line-height: 1.3;
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;
h3 {
font-weight: 600;
max-width: 70%;
line-height: 1.3;
}
}
svg {
fill: var(--link-item-image-color);
width: $diameter;
height: auto;
&.ShareIcon {
width: $unit-4x;
}
}
}
h3 {
font-weight: $bold;
}
}
}

View file

@ -1,196 +0,0 @@
import React from 'react'
import Link from 'next/link'
import { useTranslation } from 'next-i18next'
import {
Dialog,
DialogClose,
DialogTitle,
DialogTrigger,
} from '~components/Dialog'
import DialogContent from '~components/DialogContent'
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')
const headerRef = React.createRef<HTMLDivElement>()
return (
<Dialog>
<DialogTrigger asChild>
<li className="MenuItem">
<span>{t('modals.about.title')}</span>
</li>
</DialogTrigger>
<DialogContent
className="About"
headerref={headerRef}
onOpenAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={() => {}}
>
<div className="DialogHeader" ref={headerRef}>
<DialogTitle className="DialogTitle">{t('menu.about')}</DialogTitle>
<DialogClose className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</DialogClose>
</div>
<div className="content">
<section>
<p>
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>
.
</p>
<p>
Start adding to a team and a URL will be created for you to share
wherever you like, no account needed.
</p>
<p>
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.
</p>
</section>
<section>
<h2>Feedback</h2>
<p>
This is an evolving project so feedback and suggestions are
greatly appreciated!
</p>
<p>
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!
</p>
<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>
<h2>Credits</h2>
<p>
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>
.
</p>
<p>
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.
</p>
</section>
<section>
<h2>Contributing</h2>
<p>
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.
</p>
<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>
</div>
</DialogContent>
</Dialog>
)
}
export default AboutModal

View file

@ -0,0 +1,11 @@
.About.PageContent {
.Links {
display: grid;
gap: $unit;
margin: $unit-2x 0;
}
div.LinkItem {
margin-top: $unit-2x;
}
}

View file

@ -0,0 +1,165 @@
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 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')
return (
<div className="About PageContent">
<h1>{common('about.segmented_control.about')}</h1>
<section>
<p>
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>
.
</p>
<p>
Start adding to a team and a URL will be created for you to share
wherever you like, no account needed.
</p>
<p>
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.
</p>
</section>
<section>
<h2>Feedback</h2>
<p>
This is an evolving project so feedback and suggestions are greatly
appreciated!
</p>
<p>
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!
</p>
<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>
<h2>Credits</h2>
<p>
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>
.
</p>
<p>
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.
</p>
</section>
<section>
<h2>Contributing</h2>
<p>
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.
</p>
<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>
</div>
)
}
export default AboutPage

View file

@ -34,298 +34,312 @@ type StateVariables = {
}
interface Props {
open: boolean
username?: string
picture?: string
gender?: number
language?: string
theme?: string
private?: boolean
onOpenChange?: (open: boolean) => void
}
const AccountModal = (props: Props) => {
// Localization
const { t } = useTranslation('common')
const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const AccountModal = React.forwardRef<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'
// useEffect only runs on the client, so now we can safely show the UI
const [mounted, setMounted] = useState(false)
const { theme: appTheme, setTheme: setAppTheme } = useTheme()
// useEffect only runs on the client, so now we can safely show the UI
const [mounted, setMounted] = useState(false)
const { theme: appTheme, setTheme: setAppTheme } = useTheme()
// Cookies
const accountCookie = getCookie('account')
const userCookie = getCookie('user')
// Cookies
const accountCookie = getCookie('account')
const userCookie = getCookie('user')
const cookieData = {
account: accountCookie ? JSON.parse(accountCookie as string) : undefined,
user: userCookie ? JSON.parse(userCookie as string) : undefined,
}
// UI State
const [open, setOpen] = useState(false)
const [selectOpenState, setSelectOpenState] = useState<StateVariables>({
picture: false,
gender: false,
language: false,
theme: false,
})
// Values
const [username, setUsername] = useState(props.username || '')
const [picture, setPicture] = useState(props.picture || '')
const [language, setLanguage] = useState(props.language || '')
const [gender, setGender] = useState(props.gender || 0)
const [theme, setTheme] = useState(props.theme || 'system')
// const [privateProfile, setPrivateProfile] = useState(false)
// Setup
const [pictureOpen, setPictureOpen] = useState(false)
const [genderOpen, setGenderOpen] = useState(false)
const [languageOpen, setLanguageOpen] = useState(false)
const [themeOpen, setThemeOpen] = useState(false)
// Refs
const headerRef = React.createRef<HTMLDivElement>()
const footerRef = React.createRef<HTMLDivElement>()
// UI management
function openChange(open: boolean) {
setOpen(open)
}
function openSelect(name: 'picture' | 'gender' | 'language' | 'theme') {
setPictureOpen(name === 'picture' ? !pictureOpen : false)
setGenderOpen(name === 'gender' ? !genderOpen : false)
setLanguageOpen(name === 'language' ? !languageOpen : false)
setThemeOpen(name === 'theme' ? !themeOpen : false)
}
// Event handlers
function handlePictureChange(value: string) {
setPicture(value)
}
function handleLanguageChange(value: string) {
setLanguage(value)
}
function handleGenderChange(value: string) {
setGender(parseInt(value))
}
function handleThemeChange(value: string) {
setTheme(value)
setAppTheme(value)
}
function onEscapeKeyDown(event: KeyboardEvent) {
if (pictureOpen || genderOpen || languageOpen || themeOpen) {
return event.preventDefault()
} else {
setOpen(false)
}
}
// API calls
function update(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const object = {
user: {
picture: picture,
element: pictureData.find((i) => i.filename === picture)?.element,
language: language,
gender: gender,
theme: theme,
// private: privateProfile,
},
const cookieData = {
account: accountCookie ? JSON.parse(accountCookie as string) : undefined,
user: userCookie ? JSON.parse(userCookie as string) : undefined,
}
if (accountState.account.user) {
api.endpoints.users
.update(accountState.account.user?.id, object)
.then((response) => {
const user = response.data
const cookieObj = {
picture: user.avatar.picture,
element: user.avatar.element,
gender: user.gender,
language: user.language,
theme: user.theme,
}
setCookie('user', cookieObj, { path: '/' })
accountState.account.user = {
id: user.id,
username: user.username,
picture: user.avatar.picture,
element: user.avatar.element,
language: user.language,
theme: user.theme,
gender: user.gender,
}
setOpen(false)
changeLanguage(router, user.language)
})
}
}
// Views
const pictureOptions = pictureData
.sort((a, b) => (a.name.en > b.name.en ? 1 : -1))
.map((item, i) => {
return (
<PictureSelectItem
key={`picture-${i}`}
element={item.element}
src={[
`/profile/${item.filename}.png`,
`/profile/${item.filename}@2x.png 2x`,
]}
value={item.filename}
>
{item.name[locale]}
</PictureSelectItem>
)
// UI State
const [open, setOpen] = useState(false)
const [selectOpenState, setSelectOpenState] = useState<StateVariables>({
picture: false,
gender: false,
language: false,
theme: false,
})
const pictureField = () => (
<SelectTableField
name="picture"
description={t('modals.settings.descriptions.picture')}
className="Image"
label={t('modals.settings.labels.picture')}
open={pictureOpen}
onOpenChange={() => openSelect('picture')}
onChange={handlePictureChange}
onClose={() => setPictureOpen(false)}
imageAlt={t('modals.settings.labels.image_alt')}
imageClass={pictureData.find((i) => i.filename === picture)?.element}
imageSrc={[`/profile/${picture}.png`, `/profile/${picture}@2x.png 2x`]}
value={picture}
>
{pictureOptions}
</SelectTableField>
)
// Values
const [username, setUsername] = useState(props.username || '')
const [picture, setPicture] = useState(props.picture || '')
const [language, setLanguage] = useState(props.language || '')
const [gender, setGender] = useState(props.gender || 0)
const [theme, setTheme] = useState(props.theme || 'system')
// const [privateProfile, setPrivateProfile] = useState(false)
const genderField = () => (
<SelectTableField
name="gender"
description={t('modals.settings.descriptions.gender')}
label={t('modals.settings.labels.gender')}
open={genderOpen}
onOpenChange={() => openSelect('gender')}
onChange={handleGenderChange}
onClose={() => setGenderOpen(false)}
value={`${gender}`}
>
<SelectItem key="gran" value="0">
{t('modals.settings.gender.gran')}
</SelectItem>
<SelectItem key="djeeta" value="1">
{t('modals.settings.gender.djeeta')}
</SelectItem>
</SelectTableField>
)
// Setup
const [pictureOpen, setPictureOpen] = useState(false)
const [genderOpen, setGenderOpen] = useState(false)
const [languageOpen, setLanguageOpen] = useState(false)
const [themeOpen, setThemeOpen] = useState(false)
const languageField = () => (
<SelectTableField
name="language"
label={t('modals.settings.labels.language')}
open={languageOpen}
onOpenChange={() => openSelect('language')}
onChange={handleLanguageChange}
onClose={() => setLanguageOpen(false)}
value={language}
>
<SelectItem key="en" value="en">
{t('modals.settings.language.english')}
</SelectItem>
<SelectItem key="ja" value="ja">
{t('modals.settings.language.japanese')}
</SelectItem>
</SelectTableField>
)
// Refs
const headerRef = React.createRef<HTMLDivElement>()
const footerRef = React.createRef<HTMLDivElement>()
const themeField = () => (
<SelectTableField
name="theme"
label={t('modals.settings.labels.theme')}
open={themeOpen}
onOpenChange={() => openSelect('theme')}
onChange={handleThemeChange}
onClose={() => setThemeOpen(false)}
value={theme}
>
<SelectItem key="system" value="system">
{t('modals.settings.theme.system')}
</SelectItem>
<SelectItem key="light" value="light">
{t('modals.settings.theme.light')}
</SelectItem>
<SelectItem key="dark" value="dark">
{t('modals.settings.theme.dark')}
</SelectItem>
</SelectTableField>
)
useEffect(() => {
setOpen(props.open)
}, [props.open])
useEffect(() => {
setMounted(true)
}, [])
// UI management
function openChange(open: boolean) {
if (props.onOpenChange) props.onOpenChange(open)
setOpen(open)
}
if (!mounted) {
return null
}
function openSelect(name: 'picture' | 'gender' | 'language' | 'theme') {
setPictureOpen(name === 'picture' ? !pictureOpen : false)
setGenderOpen(name === 'gender' ? !genderOpen : false)
setLanguageOpen(name === 'language' ? !languageOpen : false)
setThemeOpen(name === 'theme' ? !themeOpen : false)
}
return (
<Dialog open={open} onOpenChange={openChange}>
<DialogTrigger asChild>
<li className="MenuItem">
<span>{t('menu.settings')}</span>
</li>
</DialogTrigger>
<DialogContent
className="Account"
headerref={headerRef}
footerref={footerRef}
onOpenAutoFocus={(event: Event) => {}}
onEscapeKeyDown={onEscapeKeyDown}
// Event handlers
function handlePictureChange(value: string) {
setPicture(value)
}
function handleLanguageChange(value: string) {
setLanguage(value)
}
function handleGenderChange(value: string) {
setGender(parseInt(value))
}
function handleThemeChange(value: string) {
setTheme(value)
setAppTheme(value)
}
function onEscapeKeyDown(event: KeyboardEvent) {
if (pictureOpen || genderOpen || languageOpen || themeOpen) {
return event.preventDefault()
} else {
setOpen(false)
}
}
// API calls
function update(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const object = {
user: {
picture: picture,
element: pictureData.find((i) => i.filename === picture)?.element,
language: language,
gender: gender,
theme: theme,
// private: privateProfile,
},
}
if (accountState.account.user) {
api.endpoints.users
.update(accountState.account.user?.id, object)
.then((response) => {
const user = response.data
const cookieObj = {
avatar: {
picture: user.avatar.picture,
element: user.avatar.element,
},
gender: user.gender,
language: user.language,
theme: user.theme,
}
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 60)
setCookie('user', cookieObj, { path: '/', expires: expiresAt })
accountState.account.user = {
id: user.id,
username: user.username,
granblueId: '',
avatar: {
picture: user.avatar.picture,
element: user.avatar.element,
},
language: user.language,
theme: user.theme,
gender: user.gender,
}
setOpen(false)
if (props.onOpenChange) props.onOpenChange(false)
changeLanguage(router, user.language)
})
}
}
// Views
const pictureOptions = pictureData
.sort((a, b) => (a.name.en > b.name.en ? 1 : -1))
.map((item, i) => {
return (
<PictureSelectItem
key={`picture-${i}`}
element={item.element}
src={[
`/profile/${item.filename}.png`,
`/profile/${item.filename}@2x.png 2x`,
]}
value={item.filename}
>
{item.name[locale]}
</PictureSelectItem>
)
})
const pictureField = () => (
<SelectTableField
name="picture"
description={t('modals.settings.descriptions.picture')}
className="Image"
label={t('modals.settings.labels.picture')}
open={pictureOpen}
onOpenChange={() => openSelect('picture')}
onChange={handlePictureChange}
onClose={() => setPictureOpen(false)}
imageAlt={t('modals.settings.labels.image_alt')}
imageClass={pictureData.find((i) => i.filename === picture)?.element}
imageSrc={[`/profile/${picture}.png`, `/profile/${picture}@2x.png 2x`]}
value={picture}
>
<div className="DialogHeader" ref={headerRef}>
<div className="DialogTop">
<DialogTitle className="SubTitle">
{t('modals.settings.title')}
</DialogTitle>
<DialogTitle className="DialogTitle">@{username}</DialogTitle>
</div>
<DialogClose className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</DialogClose>
</div>
{pictureOptions}
</SelectTableField>
)
<form onSubmit={update}>
<div className="Fields">
{pictureField()}
{genderField()}
{languageField()}
{themeField()}
const genderField = () => (
<SelectTableField
name="gender"
description={t('modals.settings.descriptions.gender')}
label={t('modals.settings.labels.gender')}
open={genderOpen}
onOpenChange={() => openSelect('gender')}
onChange={handleGenderChange}
onClose={() => setGenderOpen(false)}
value={`${gender}`}
>
<SelectItem key="gran" value="0">
{t('modals.settings.gender.gran')}
</SelectItem>
<SelectItem key="djeeta" value="1">
{t('modals.settings.gender.djeeta')}
</SelectItem>
</SelectTableField>
)
const languageField = () => (
<SelectTableField
name="language"
label={t('modals.settings.labels.language')}
open={languageOpen}
onOpenChange={() => openSelect('language')}
onChange={handleLanguageChange}
onClose={() => setLanguageOpen(false)}
value={language}
>
<SelectItem key="en" value="en">
{t('modals.settings.language.english')}
</SelectItem>
<SelectItem key="ja" value="ja">
{t('modals.settings.language.japanese')}
</SelectItem>
</SelectTableField>
)
const themeField = () => (
<SelectTableField
name="theme"
label={t('modals.settings.labels.theme')}
open={themeOpen}
onOpenChange={() => openSelect('theme')}
onChange={handleThemeChange}
onClose={() => setThemeOpen(false)}
value={theme}
>
<SelectItem key="system" value="system">
{t('modals.settings.theme.system')}
</SelectItem>
<SelectItem key="light" value="light">
{t('modals.settings.theme.light')}
</SelectItem>
<SelectItem key="dark" value="dark">
{t('modals.settings.theme.dark')}
</SelectItem>
</SelectTableField>
)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return null
}
return (
<Dialog open={open} onOpenChange={openChange}>
<DialogContent
className="Account"
headerref={headerRef}
footerref={footerRef}
onOpenAutoFocus={(event: Event) => {}}
onEscapeKeyDown={onEscapeKeyDown}
>
<div className="DialogHeader" ref={headerRef}>
<div className="DialogTop">
<DialogTitle className="SubTitle">
{t('modals.settings.title')}
</DialogTitle>
<DialogTitle className="DialogTitle">@{username}</DialogTitle>
</div>
<DialogClose className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</DialogClose>
</div>
<div className="DialogFooter" ref={footerRef}>
<Button
contained={true}
text={t('modals.settings.buttons.confirm')}
/>
</div>
</form>
</DialogContent>
</Dialog>
)
}
<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

@ -23,7 +23,11 @@ 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>

View file

@ -31,6 +31,15 @@
background: transparent;
}
&.IconButton.medium {
height: inherit;
padding: $unit-half;
&:hover {
background: none;
}
}
&.Contained {
background: var(--button-contained-bg);
@ -43,7 +52,7 @@
stroke: #ff4d4d;
}
&.Active.Save {
&.Save {
color: #ff4d4d;
.Accessory svg {
@ -99,24 +108,27 @@
}
}
&.save:hover {
color: #ff4d4d;
&.Save {
.Accessory svg {
fill: #ff4d4d;
stroke: #ff4d4d;
fill: none;
stroke: var(--button-text);
}
}
&.save.Active {
color: #ff4d4d;
&.Saved {
color: #ff4d4d;
.Accessory svg {
fill: #ff4d4d;
stroke: none;
}
}
&:hover {
color: darken(#ff4d4d, 30);
color: #ff4d4d;
.icon svg {
fill: darken(#ff4d4d, 30);
stroke: darken(#ff4d4d, 30);
.Accessory svg {
fill: none;
stroke: #ff4d4d;
}
}
}
@ -138,6 +150,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(
{
Button: true,
Active: active,
Blended: blended,
Contained: contained,
},
buttonSize,
props.className
)
const classes = classNames(buttonSize, props.className, {
Button: true,
Active: active,
Blended: blended,
Contained: contained,
})
const hasAccessory = () => {
if (accessoryIcon) return <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,166 +0,0 @@
import React from 'react'
import { useTranslation } from 'next-i18next'
import ChangelogUnit from '~components/ChangelogUnit'
import {
Dialog,
DialogClose,
DialogTitle,
DialogTrigger,
} from '~components/Dialog'
import DialogContent from '~components/DialogContent'
import CrossIcon from '~public/icons/Cross.svg'
import './index.scss'
const ChangelogModal = () => {
const { t } = useTranslation('common')
const headerRef = React.createRef<HTMLDivElement>()
return (
<Dialog>
<DialogTrigger asChild>
<li className="MenuItem">
<span>{t('modals.changelog.title')}</span>
</li>
</DialogTrigger>
<DialogContent
className="Changelog"
title={t('menu.changelog')}
headerref={headerRef}
onOpenAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={() => {}}
>
<div className="DialogHeader" ref={headerRef}>
<DialogTitle className="DialogTitle">
{t('menu.changelog')}
</DialogTitle>
<DialogClose className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</DialogClose>
</div>
<div className="updates">
<section className="version" data-version="1.0">
<div className="top">
<h3>1.0.1</h3>
<time>2023/01/08</time>
</div>
<ul className="notes">
<li>Extra party fields: Full Auto, Clear Time, and more</li>
<li>Support for Youtube short URLs</li>
<li>Responsive grids and lots of other mobile fixes</li>
<li>Many other bug fixes</li>
</ul>
</section>
<section className="content version" data-version="2022-12L">
<div className="top">
<h3>2022-12 Legend Festival</h3>
<time>2022/12/26</time>
</div>
<div className="update">
<section className="characters">
<h4>New characters</h4>
<div className="items">
<ChangelogUnit
name="Michael (Grand)"
id="3040440000"
type="character"
/>
<ChangelogUnit
name="Makura"
id="3040441000"
type="character"
/>
<ChangelogUnit
name="Ultimate Friday"
id="3040442000"
type="character"
/>
</div>
</section>
<section className="weapons">
<h4>New weapons</h4>
<div className="items">
<ChangelogUnit
name="Crimson Scale"
id="1040315900"
type="weapon"
/>
<ChangelogUnit
name="Leporidius"
id="1040914500"
type="weapon"
/>
<ChangelogUnit
name="FRIED Spear"
id="1040218200"
type="weapon"
/>
</div>
</section>
<section className="summons">
<h4>New summons</h4>
<div className="items">
<ChangelogUnit name="Yatima" id="2040417000" type="summon" />
</div>
</section>
</div>
</section>
<section className="content version" data-version="2022-12F2">
<div className="top">
<h3>2022-12 Flash Gala</h3>
<time>2022/12/26</time>
</div>
<div className="update">
<section className="characters">
<h4>New characters</h4>
<div className="items">
<ChangelogUnit
name="Charlotta (Grand)"
id="3040438000"
type="character"
/>
<ChangelogUnit name="Erin" id="3040439000" type="character" />
</div>
</section>
<section className="weapons">
<h4>New weapons</h4>
<div className="items">
<ChangelogUnit
name="Claíomh Solais Díon"
id="1040024200"
type="weapon"
/>
<ChangelogUnit
name="Crystal Edge"
id="1040116500"
type="weapon"
/>
</div>
</section>
</div>
</section>
<section className="version" data-version="1.0">
<div className="top">
<h3>1.0.0</h3>
<time>2022/12/26</time>
</div>
<ul className="notes">
<li>First release!</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>
</section>
</div>
</DialogContent>
</Dialog>
)
}
export default ChangelogModal

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'
@ -31,12 +32,19 @@ 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()
@ -55,6 +63,7 @@ const CharacterGrid = (props: Props) => {
2: undefined,
3: undefined,
})
const [jobAccessory, setJobAccessory] = useState<JobAccessory>()
const [errorMessage, setErrorMessage] = useState('')
// Create a temporary state to store previous weapon uncap values and transcendence stages
@ -81,6 +90,7 @@ const CharacterGrid = (props: Props) => {
useEffect(() => {
setJob(appState.party.job)
setJobSkills(appState.party.jobSkills)
setJobAccessory(appState.party.accessory)
}, [appState])
// Initialize an array of current uncap values for each characters
@ -109,7 +119,15 @@ const CharacterGrid = (props: Props) => {
if (party.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)
}
})
}
}
@ -186,7 +204,7 @@ const CharacterGrid = (props: Props) => {
}
// 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,
@ -214,7 +232,7 @@ const CharacterGrid = (props: Props) => {
}
}
const saveJobSkill = function (skill: JobSkill, position: number) {
function saveJobSkill(skill: JobSkill, position: number) {
if (party.id && appState.party.editable) {
const positionedKey = `skill${position}_id`
@ -253,6 +271,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
@ -462,6 +498,18 @@ const CharacterGrid = (props: Props) => {
}
// 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
@ -474,9 +522,11 @@ const CharacterGrid = (props: Props) => {
<JobSection
job={job}
jobSkills={jobSkills}
jobAccessory={jobAccessory}
editable={party.editable}
saveJob={saveJob}
saveSkill={saveJobSkill}
saveAccessory={saveAccessory}
/>
<CharacterConflictModal
open={modalOpen}
@ -504,6 +554,7 @@ const CharacterGrid = (props: Props) => {
})}
</ul>
</div>
{errorAlert()}
</div>
)
}

View file

@ -164,6 +164,7 @@ const CharacterUnit = ({
function removeCharacter() {
if (gridCharacter) sendCharacterToRemove(gridCharacter.id)
setAlertOpen(false)
}
// Methods: Image string generation
@ -218,7 +219,7 @@ const CharacterUnit = ({
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
<ContextMenuTrigger asChild>
<Button
accessoryIcon={<SettingsIcon />}
leftAccessoryIcon={<SettingsIcon />}
className={buttonClasses}
onClick={handleButtonClicked}
/>

View file

@ -0,0 +1,188 @@
.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;
min-width: 15vw;
margin: 0 $unit-2x;
// 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: 15;
@include breakpoint(phone) {
left: $unit-2x;
right: $unit-2x;
}
}
.MenuItem {
color: var(--text-tertiary);
font-weight: $normal;
@include breakpoint(phone) {
cursor: pointer;
}
&:hover:not(.disabled) {
background: var(--menu-bg-item-hover);
color: var(--text-primary);
cursor: pointer;
a {
color: var(--text-primary);
&:hover {
text-decoration: none;
}
&:visited {
color: var(--text-primary);
}
}
@include breakpoint(phone) {
background: inherit;
color: inherit;
cursor: default;
a {
color: inherit;
}
}
}
&.profile > div {
padding: 6px 12px;
}
&.language {
align-items: center;
display: flex;
flex-direction: row;
gap: $unit;
padding-right: $unit;
span {
flex-grow: 1;
}
.Switch {
$height: 24px;
background: $grey-60;
border-radius: calc($height / 2);
border: none;
position: relative;
width: 44px;
height: $height;
&:hover {
cursor: pointer;
}
.Thumb {
$diameter: 18px;
background: $grey-100;
border-radius: calc($diameter / 2);
display: block;
height: $diameter;
width: $diameter;
position: absolute;
top: 3px;
left: 3px;
z-index: 3;
&:hover {
cursor: pointer;
}
&[data-state='checked'] {
background: $grey-100;
left: 23px;
}
}
.left,
.right {
color: $grey-100;
font-size: 10px;
font-weight: $bold;
position: absolute;
z-index: 2;
}
.left {
top: 6px;
left: 6px;
}
.right {
top: 6px;
right: 5px;
}
}
}
a {
color: $grey-50;
&:hover {
text-decoration: none;
}
&:visited {
color: $grey-50;
}
}
& > a,
& > span {
display: block;
padding: 12px 12px;
}
& > div {
align-items: center;
display: flex;
flex-direction: row;
padding: 10px 12px;
&:hover {
i.tag {
background: var(--tag-bg);
color: var(--tag-text);
}
}
span {
flex-grow: 1;
}
img {
$diameter: 32px;
border-radius: calc($diameter / 2);
height: $diameter;
width: $diameter;
}
}
}
.MenuGroup {
border-bottom: 1px solid var(--menu-separator);
&:first-child .MenuItem:first-child:hover {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
&:last-child .MenuItem:last-child:hover {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
&:last-child {
border-bottom: none;
}
}

View file

@ -0,0 +1,37 @@
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>(
({ 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

@ -208,7 +208,7 @@ const GridRep = (props: Props) => {
<a href="#">
<Button
className="Save"
accessoryIcon={<SaveIcon className="stroke" />}
leftAccessoryIcon={<SaveIcon className="stroke" />}
active={props.favorited}
contained={true}
buttonSize="small"

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

@ -3,23 +3,37 @@ import { useSnapshot } from 'valtio'
import { deleteCookie } from 'cookies-next'
import { useRouter } from 'next/router'
import { 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 { appState } from '~utils/appState'
import capitalizeFirstLetter from '~utils/capitalizeFirstLetter'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
} 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 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 SaveIcon from '~public/icons/Save.svg'
import classNames from 'classnames'
import './index.scss'
import Tooltip from '~components/Tooltip'
const Header = () => {
// Localization
@ -29,18 +43,46 @@ const Header = () => {
const router = useRouter()
// State management
const [open, setOpen] = useState(false)
const [copyToastOpen, setCopyToastOpen] = 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)
// Snapshots
const { account } = useSnapshot(accountState)
const { party } = useSnapshot(appState)
function menuButtonClicked() {
setOpen(!open)
function handleCopyToastOpenChanged(open: boolean) {
setCopyToastOpen(open)
}
function onClickOutsideMenu() {
setOpen(false)
function handleCopyToastCloseClicked() {
setCopyToastOpen(false)
}
function handleLeftMenuButtonClicked() {
setLeftMenuOpen(!leftMenuOpen)
}
function handleRightMenuButtonClicked() {
setRightMenuOpen(!rightMenuOpen)
}
function handleLeftMenuOpenChange(open: boolean) {
setLeftMenuOpen(open)
}
function handleRightMenuOpenChange(open: boolean) {
setRightMenuOpen(open)
}
function closeLeftMenu() {
setLeftMenuOpen(false)
}
function closeRightMenu() {
setRightMenuOpen(false)
}
function copyToClipboard() {
@ -52,23 +94,25 @@ const Header = () => {
el.select()
document.execCommand('copy')
el.remove()
setCopyToastOpen(true)
}
function newParty() {
function handleNewParty(event: React.MouseEvent, path: string) {
event.preventDefault()
// Push the root URL
router.push('/')
router.push(path)
// Clean state
const resetState = clonedeep(initialAppState)
Object.keys(resetState).forEach((key) => {
appState[key] = resetState[key]
})
// Set party to be editable
appState.party.editable = true
// Close right menu
closeRightMenu()
}
function logout() {
// Close menu
closeRightMenu()
// Delete cookies
deleteCookie('account')
deleteCookie('user')
@ -103,85 +147,329 @@ const Header = () => {
else console.error('Failed to unsave team: No party ID')
}
const copyButton = () => {
if (router.route === '/p/[party]')
return (
<Button
accessoryIcon={<LinkIcon className="stroke" />}
blended={true}
text={t('buttons.copy')}
onClick={copyToClipboard}
/>
)
function remixTeam() {
if (party.shortcode)
api.remix(party.shortcode).then((response) => {
const remix = response.data.party
router.push(`/p/${remix.shortcode}`)
})
}
const leftNav = () => {
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 if (['weapons', 'summons', 'characters', 'new', ''].includes(path)) {
title = t('new_party')
} else {
title = ''
}
return title !== '' ? (
<Button
blended={true}
rightAccessoryIcon={
path === 'p' && hasAccessory ? (
<LinkIcon className="stroke" />
) : undefined
}
text={title}
onClick={copyToClipboard}
/>
) : (
''
)
}
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`}
/>
)
} else {
image = <div className="profile placeholder" />
}
return image
}
const urlCopyToast = () => {
return (
<div id="DropdownWrapper">
<Button
accessoryIcon={<MenuIcon />}
className={classNames({ Active: open })}
blended={true}
text={t('buttons.menu')}
onClick={menuButtonClicked}
/>
<HeaderMenu
authenticated={account.authorized}
open={open}
username={account.user?.username}
onClickOutside={onClickOutsideMenu}
logout={logout}
/>
</div>
<Toast
open={copyToastOpen}
duration={2400}
type="foreground"
content={t('toasts.copied')}
onOpenChange={handleCopyToastOpenChanged}
onCloseClick={handleCopyToastCloseClicked}
/>
)
}
const saveButton = () => {
if (party.favorited)
return (
return (
<Tooltip content={t('tooltips.save')}>
<Button
accessoryIcon={<SaveIcon />}
leftAccessoryIcon={<SaveIcon />}
className={classNames({
Save: true,
Saved: party.favorited,
})}
blended={true}
text="Saved"
text={party.favorited ? t('buttons.saved') : t('buttons.save')}
onClick={toggleFavorite}
/>
)
else
return (
<Button
accessoryIcon={<SaveIcon />}
blended={true}
text="Save"
onClick={toggleFavorite}
/>
)
</Tooltip>
)
}
const rightNav = () => {
const remixButton = () => {
return (
<div>
<Tooltip content={t('tooltips.remix')}>
<Button
leftAccessoryIcon={<RemixIcon />}
className="Remix"
blended={true}
text={t('buttons.remix')}
onClick={remixTeam}
/>
</Tooltip>
)
}
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} />
)
}
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>
{pageTitle()}
</section>
)
}
const right = () => {
return (
<section>
{router.route === '/p/[party]' &&
account.user &&
(!party.user || party.user.id !== account.user.id)
? saveButton()
: ''}
{copyButton()}
<Button
accessoryIcon={<AddIcon className="Add" />}
blended={true}
text={t('buttons.new')}
onClick={newParty}
/>
</div>
{router.route === '/p/[party]' ? remixButton() : ''}
<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
>
Your profile
</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="/about" target="_blank">
{t('about.segmented_control.about')}
</a>
</DropdownMenuItem>
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
<a href="/updates" target="_blank">
{t('about.segmented_control.updates')}
</a>
</DropdownMenuItem>
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
<a href="/roadmap" target="_blank">
{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">
<DropdownMenuItem className="MenuItem">
<Link href="/new">
<a onClick={(e: React.MouseEvent) => handleNewParty(e, '/new')}>
New party
</a>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="MenuItem">
<Link href={`/${account.user.username}` || ''} passHref>
Your profile
</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">
<Link href="/new">
<a onClick={(e: React.MouseEvent) => handleNewParty(e, '/new')}>
New party
</a>
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup className="MenuGroup">
<DropdownMenuItem
className="MenuItem"
onClick={() => setLoginModalOpen(true)}
>
<span>Log in</span>
</DropdownMenuItem>
<DropdownMenuItem
className="MenuItem"
onClick={() => setSignupModalOpen(true)}
>
<span>Sign up</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)
}
return items
}
return (
<nav id="Header">
<div id="Left">{leftNav()}</div>
<div id="Right">{rightNav()}</div>
{left()}
{right()}
{urlCopyToast()}
{settingsModal()}
{loginModal()}
{signupModal()}
</nav>
)
}

View file

@ -1,187 +0,0 @@
.Menu {
background: var(--menu-bg);
border-radius: 6px;
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;
@include breakpoint(phone) {
left: $unit-2x;
right: $unit-2x;
}
}
.MenuItem {
color: var(--text-tertiary);
font-weight: $normal;
@include breakpoint(phone) {
cursor: pointer;
}
&:hover:not(.disabled) {
background: var(--menu-bg-item-hover);
color: var(--text-primary);
cursor: pointer;
a {
color: var(--text-primary);
&:hover {
text-decoration: none;
}
&:visited {
color: var(--text-primary);
}
}
@include breakpoint(phone) {
background: inherit;
color: inherit;
cursor: default;
a {
color: inherit;
}
}
}
&.profile > div {
padding: 6px 12px;
}
&.language {
align-items: center;
display: flex;
flex-direction: row;
gap: $unit;
padding-right: $unit;
span {
flex-grow: 1;
}
.Switch {
$height: 24px;
background: $grey-60;
border-radius: calc($height / 2);
border: none;
position: relative;
width: 44px;
height: $height;
&:hover {
cursor: pointer;
}
.Thumb {
$diameter: 18px;
background: $grey-100;
border-radius: calc($diameter / 2);
display: block;
height: $diameter;
width: $diameter;
position: absolute;
top: 3px;
left: 3px;
z-index: 3;
&:hover {
cursor: pointer;
}
&[data-state='checked'] {
background: $grey-100;
left: 23px;
}
}
.left,
.right {
color: $grey-100;
font-size: 10px;
font-weight: $bold;
position: absolute;
z-index: 2;
}
.left {
top: 6px;
left: 6px;
}
.right {
top: 6px;
right: 5px;
}
}
}
a {
color: $grey-50;
&:hover {
text-decoration: none;
}
&:visited {
color: $grey-50;
}
}
& > a,
& > span {
display: block;
padding: 12px 12px;
}
& > div {
align-items: center;
display: flex;
flex-direction: row;
padding: 10px 12px;
&:hover {
i.tag {
background: var(--tag-bg);
color: var(--tag-text);
}
}
span {
flex-grow: 1;
}
img {
$diameter: 32px;
border-radius: calc($diameter / 2);
height: $diameter;
width: $diameter;
}
}
}
.MenuGroup {
border-bottom: 1px solid var(--menu-separator);
&:first-child .MenuItem:first-child:hover {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
&:last-child .MenuItem:last-child:hover {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
&:last-child {
border-bottom: none;
}
}

View file

@ -8,10 +8,7 @@ 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'
@ -65,7 +62,11 @@ const HeaderMenu = (props: Props) => {
function handleCheckedChange(value: boolean) {
const language = value ? 'ja' : 'en'
setCookie('NEXT_LOCALE', language, { path: '/' })
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 60)
setCookie('NEXT_LOCALE', language, { path: '/', expires: expiresAt })
router.push(router.asPath, undefined, { locale: language })
}
@ -110,9 +111,21 @@ const HeaderMenu = (props: Props) => {
</li>
</div>
<div className="MenuGroup">
<AboutModal />
<ChangelogModal />
<RoadmapModal />
<li className="MenuItem">
<a href="/about" target="_blank">
{t('about.segmented_control.about')}
</a>
</li>
<li className="MenuItem">
<a href="/updates" target="_blank">
{t('about.segmented_control.updates')}
</a>
</li>
<li className="MenuItem">
<a href="/roadmap" target="_blank">
{t('about.segmented_control.roadmap')}
</a>
</li>
</div>
<div className="MenuGroup">
<AccountModal
@ -160,9 +173,21 @@ const HeaderMenu = (props: Props) => {
</li>
</div>
<div className="MenuGroup">
<AboutModal />
<ChangelogModal />
<RoadmapModal />
<li className="MenuItem">
<a href="/about" target="_blank">
{t('about.segmented_control.about')}
</a>
</li>
<li className="MenuItem">
<a href="/updates" target="_blank">
{t('about.segmented_control.updates')}
</a>
</li>
<li className="MenuItem">
<a href="/roadmap" target="_blank">
{t('about.segmented_control.roadmap')}
</a>
</li>
</div>
<div className="MenuGroup">
<LoginModal />

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

@ -95,7 +95,7 @@ const JobDropdown = React.forwardRef<HTMLSelectElement, Props>(
key={i}
value={item.id}
altText={item.name[locale]}
iconSrc={`/images/job-icons/${item.granblue_id}.png`}
iconSrc={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-icons/${item.granblue_id}.png`}
>
{item.name[locale]}
</SelectItem>
@ -116,7 +116,9 @@ const JobDropdown = React.forwardRef<HTMLSelectElement, Props>(
value={currentJob ? currentJob.id : 'no-job'}
altText={currentJob ? currentJob.name[locale] : ''}
iconSrc={
currentJob ? `/images/job-icons/${currentJob.granblue_id}.png` : ''
currentJob
? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-icons/${currentJob.granblue_id}.png`
: ''
}
open={open}
onClick={openJobSelect}

View file

@ -0,0 +1,79 @@
.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;
}
.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: 1;
}
}

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

@ -53,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,10 +4,13 @@ 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 { ACCESSORY_JOB_IDS } from '~utils/jobsWithAccessories'
import type { JobSkillObject, SearchableObject } from '~types'
import './index.scss'
@ -16,9 +19,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 +34,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 +58,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 +73,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 = ''
@ -150,14 +181,14 @@ const JobSection = (props: Props) => {
// 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="Job Overlay" />
</div>
<JobImage
job={party.job}
currentAccessory={currentAccessory}
accessories={accessories}
editable={props.editable}
user={party.user}
onAccessorySelected={handleAccessorySelected}
/>
<div className="JobDetails">
{props.editable ? (
<JobDropdown
@ -166,7 +197,13 @@ const JobSection = (props: Props) => {
ref={selectRef}
/>
) : (
jobLabel()
<div className="JobName">
<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

@ -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 { PropsWithChildren, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { add, format } from 'date-fns'
import { getCookie } from 'cookies-next'
import { appState } from '~utils/appState'
import TopHeader from '~components/Header'
import UpdateToast from '~components/UpdateToast'
interface Props {
children: ReactElement
}
import './index.scss'
interface Props {}
const Layout = ({ children }: PropsWithChildren<Props>) => {
const router = useRouter()
const [updateToastOpen, setUpdateToastOpen] = useState(false)
useEffect(() => {
const cookie = getToastCookie()
const now = new Date()
const updatedAt = new Date(appState.version.updated_at)
const validUntil = add(updatedAt, { days: 7 })
if (now < validUntil && !cookie.seen) setUpdateToastOpen(true)
}, [])
function getToastCookie() {
if (appState.version.updated_at !== '') {
const updatedAt = new Date(appState.version.updated_at)
const cookieValues = getCookie(
`update-${format(updatedAt, 'yyyy-MM-dd')}`
)
return cookieValues
? (JSON.parse(cookieValues as string) as { seen: true })
: { seen: false }
} else {
return { seen: false }
}
}
function handleToastActionClicked() {
setUpdateToastOpen(false)
}
function handleToastClosed() {
setUpdateToastOpen(false)
}
const updateToast = () => {
const path = router.asPath.replaceAll('/', '')
return !['about', 'updates', 'roadmap'].includes(path) ? (
<UpdateToast
open={updateToastOpen}
updateType="feature"
onActionClicked={handleToastActionClicked}
onCloseClicked={handleToastClosed}
lastUpdated={appState.version.updated_at}
/>
) : (
''
)
}
const Layout = ({ children }: Props) => {
return (
<>
<TopHeader />
{updateToast()}
<main>{children}</main>
</>
)

View file

@ -1,4 +1,4 @@
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'
@ -26,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')
@ -46,6 +51,10 @@ const LoginModal = () => {
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 }
@ -133,7 +142,9 @@ 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()
@ -144,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',
{
picture: user.avatar.picture,
element: user.avatar.element,
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,
picture: user.avatar.picture,
element: user.avatar.element,
granblueId: '',
avatar: {
picture: user.avatar.picture,
element: user.avatar.element,
},
gender: user.gender,
language: user.language,
theme: user.theme,
@ -180,6 +199,8 @@ const LoginModal = () => {
email: '',
password: '',
})
if (props.onOpenChange) props.onOpenChange(open)
}
function onEscapeKeyDown(event: KeyboardEvent) {
@ -193,11 +214,6 @@ const LoginModal = () => {
return (
<Dialog open={open} onOpenChange={openChange}>
<DialogTrigger asChild>
<li className="MenuItem">
<span>{t('menu.login')}</span>
</li>
</DialogTrigger>
<DialogContent
className="Login"
footerref={footerRef}

View file

@ -147,11 +147,16 @@ 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

View file

@ -3,6 +3,7 @@
flex-direction: column;
margin: $unit-4x auto 0 auto;
max-width: $grid-width;
padding-bottom: $unit-12x;
@include breakpoint(phone) {
padding: 0 $unit;
@ -10,13 +11,13 @@
.PartyDetails {
display: none;
margin: 0 auto;
margin: 0 auto $unit-2x;
max-width: $unit * 94;
overflow: hidden;
width: 100%;
&.Visible {
margin-bottom: $unit-12x;
// margin-bottom: $unit-12x;
}
&.Editable {
@ -269,15 +270,21 @@
.Left {
flex-grow: 1;
h1 {
font-size: $font-xlarge;
font-weight: $normal;
text-align: left;
.Header {
align-items: center;
display: flex;
gap: $unit;
margin-bottom: $unit;
color: var(--text-primary);
&.empty {
color: var(--text-secondary);
h1 {
font-size: $font-xlarge;
font-weight: $normal;
text-align: left;
color: var(--text-primary);
&.empty {
color: var(--text-secondary);
}
}
}
@ -332,4 +339,26 @@
}
}
}
.Remixes {
display: flex;
flex-direction: column;
gap: $unit-2x;
width: 752px;
h3 {
font-size: $font-medium;
font-weight: $medium;
}
.GridRepCollection {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
margin-left: $unit-2x * -1;
margin-right: $unit-2x * -1;
.GridRep {
min-width: 200px;
}
}
}
}

View file

@ -3,6 +3,7 @@ import Link from 'next/link'
import { useRouter } from 'next/router'
import { 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'
@ -10,17 +11,19 @@ import classNames from 'classnames'
import reactStringReplace from 'react-string-replace'
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 { formatTimeAgo } from '~utils/timeAgo'
@ -29,6 +32,7 @@ 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'
@ -69,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>()
@ -111,6 +117,7 @@ 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)
@ -300,6 +307,49 @@ const PartyDetails = (props: Props) => {
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 = []
@ -388,6 +438,28 @@ const PartyDetails = (props: Props) => {
)
}
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 (
@ -538,7 +610,7 @@ const PartyDetails = (props: Props) => {
<div className="left">
{router.pathname !== '/new' ? (
<Button
accessoryIcon={<CrossIcon />}
leftAccessoryIcon={<CrossIcon />}
className="Blended medium destructive"
onClick={handleClick}
text={t('buttons.delete')}
@ -550,7 +622,7 @@ const PartyDetails = (props: Props) => {
<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}
/>
@ -620,11 +692,35 @@ const PartyDetails = (props: Props) => {
</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 : t('no_title')}</h1>
<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 />}
onClick={() => goTo(party.sourceParty?.shortcode)}
/>
</Tooltip>
) : (
''
)}
</div>
<div className="attribution">
{renderUserBlock()}
{party.raid ? linkedRaidBlock(party.raid) : ''}
@ -643,7 +739,7 @@ const PartyDetails = (props: Props) => {
<div className="Right">
{party.editable ? (
<Button
accessoryIcon={<EditIcon />}
leftAccessoryIcon={<EditIcon />}
text={t('buttons.show_info')}
onClick={toggleDetails}
/>
@ -654,6 +750,7 @@ const PartyDetails = (props: Props) => {
</div>
{readOnly}
{editable}
{remixes && remixes.length > 0 ? remixSection() : ''}
{deleteAlert()}
</section>
)

View file

@ -4,6 +4,7 @@
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);
transform-origin: var(--radix-popover-content-transform-origin);
outline: none;
padding: $unit;
transform-origin: var(--radix-popover-content-transform-origin);
}

View file

@ -6,9 +6,10 @@ import './index.scss'
interface Props
extends React.DetailedHTMLProps<
React.DialogHTMLAttributes<HTMLDivElement>,
HTMLDivElement
> {}
React.DialogHTMLAttributes<HTMLDivElement>,
HTMLDivElement
>,
PopoverPrimitive.PopoverContentProps {}
export const Popover = PopoverPrimitive.Root
export const PopoverAnchor = PopoverPrimitive.Anchor
@ -26,15 +27,18 @@ export const PopoverContent = React.forwardRef<HTMLDivElement, Props>(
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
sideOffset={5}
{...props}
className={classes}
ref={forwardedRef}
>
{children}
<PopoverPrimitive.Arrow />
<PopoverPrimitive.Arrow className="Arrow" />
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
)
}
)
PopoverContent.defaultProps = {
sideOffset: 8,
}

View file

@ -1,118 +0,0 @@
.Roadmap.DialogContent {
gap: 0;
padding-bottom: $unit-2x;
h3.priority {
font-weight: $medium;
font-size: $font-large;
margin-bottom: $unit-4x;
&.in_progress {
color: $yellow;
}
&.high {
color: $red;
}
&.mid {
color: $orange-10;
}
&.low {
color: $blue;
}
}
.content {
display: flex;
flex-direction: column;
gap: $unit-2x;
padding: 0 $unit-4x;
section.notes {
display: flex;
flex-direction: column;
gap: $unit;
margin-bottom: $unit-2x;
p {
margin-bottom: $unit;
}
.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;
max-width: 70%;
line-height: 1.3;
}
}
}
p {
color: var(--text-secondary);
font-size: $font-regular;
line-height: 1.3;
}
ul {
color: var(--text-primary);
list-style-type: none;
li {
display: flex;
flex-direction: column;
gap: $unit;
margin-bottom: $unit-2x;
h4 {
font-size: $font-medium;
font-weight: $bold;
}
p {
font-size: $font-regular;
}
}
}
}
}

View file

@ -1,101 +0,0 @@
import React from 'react'
import Link from 'next/link'
import { useTranslation } from 'next-i18next'
import {
Dialog,
DialogClose,
DialogTitle,
DialogTrigger,
} from '~components/Dialog'
import DialogContent from '~components/DialogContent'
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')
const headerRef = React.createRef<HTMLDivElement>()
return (
<Dialog>
<DialogTrigger asChild>
<li className="MenuItem">
<span>{t('modals.roadmap.title')}</span>
</li>
</DialogTrigger>
<DialogContent
className="Roadmap"
title={t('title')}
headerref={headerRef}
onOpenAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={() => {}}
>
<div className="DialogHeader" ref={headerRef}>
<DialogTitle className="DialogTitle">{t('title')}</DialogTitle>
<DialogClose className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</DialogClose>
</div>
<div className="content">
<section className="notes">
<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>
</section>
<section className="features">
<h3 className="priority in_progress">{t('subtitle')}</h3>
<ul>
<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>
</div>
</DialogContent>
</Dialog>
)
}
export default RoadmapModal

View file

@ -0,0 +1,108 @@
.Roadmap.PageContent {
h3.priority {
font-weight: $medium;
font-size: $font-large;
margin-bottom: $unit-4x;
&.in_progress {
color: $yellow;
}
&.high {
color: $red;
}
&.mid {
color: $orange-10;
}
&.low {
color: $blue;
}
}
.notes {
display: flex;
flex-direction: column;
gap: $unit;
margin-bottom: $unit-2x;
p {
margin-bottom: $unit;
}
.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;
max-width: 70%;
line-height: 1.3;
}
}
}
p {
color: var(--text-secondary);
font-size: $font-regular;
line-height: 1.3;
}
ul {
color: var(--text-primary);
list-style-type: none;
li {
display: flex;
flex-direction: column;
gap: $unit;
margin-bottom: $unit-2x;
h4 {
font-size: $font-medium;
font-weight: $bold;
}
p {
font-size: $font-regular;
}
}
}
}

View file

@ -0,0 +1,73 @@
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'
interface Props {}
const RoadmapPage: React.FC<Props> = (props: Props) => {
const { t: common } = useTranslation('common')
const { t: roadmap } = useTranslation('roadmap')
return (
<div className="Roadmap PageContent">
<h1>{common('about.segmented_control.roadmap')}</h1>
<section className="notes">
<p>{roadmap('blurb')}</p>
<p>{roadmap('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>{roadmap('link.title')}</h3>
</div>
<ShareIcon className="ShareIcon" />
</a>
</Link>
</div>
</section>
<section className="features">
<h3 className="priority in_progress">{roadmap('subtitle')}</h3>
<ul>
<li>
<h4>{roadmap('roadmap.item1.title')}</h4>
<p>{roadmap('roadmap.item1.description')}</p>
</li>
<li>
<h4>{roadmap('roadmap.item2.title')}</h4>
<p>{roadmap('roadmap.item2.description')}</p>
</li>
<li>
<h4>{roadmap('roadmap.item3.title')}</h4>
<p>{roadmap('roadmap.item3.description')}</p>
</li>
<li>
<h4>{roadmap('roadmap.item4.title')}</h4>
<p>{roadmap('roadmap.item4.description')}</p>
</li>
<li>
<h4>{roadmap('roadmap.item5.title')}</h4>
<p>{roadmap('roadmap.item5.description')}</p>
</li>
<li>
<h4>{roadmap('roadmap.item6.title')}</h4>
<p>{roadmap('roadmap.item6.description')}</p>
</li>
</ul>
</section>
</div>
)
}
export default RoadmapPage

View file

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

View file

@ -14,10 +14,11 @@ const SelectItem = React.forwardRef<HTMLDivElement, Props>(function selectItem(
{ children, value, iconSrc, altText, ...props },
forwardedRef
) {
const { altText, iconSrc, ...rest } = props
return (
<Select.Item
className={classNames('SelectItem', props.className)}
{...props}
{...rest}
ref={forwardedRef}
value={`${value}`}
>

View file

@ -1,4 +1,4 @@
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'
@ -15,7 +15,10 @@ 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
@ -58,6 +61,10 @@ const SignupModal = (props: Props) => {
passwordConfirmationInput,
]
useEffect(() => {
setOpen(props.open)
}, [props.open])
function register(event: React.FormEvent) {
event.preventDefault()
@ -91,7 +98,9 @@ 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()
@ -106,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',
{
picture: user.avatar.picture,
element: user.avatar.element,
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,
picture: user.avatar.picture,
element: user.avatar.element,
granblueId: '',
avatar: {
picture: user.avatar.picture,
element: user.avatar.element,
},
gender: user.gender,
language: user.language,
theme: user.theme,
@ -261,6 +278,8 @@ const SignupModal = (props: Props) => {
password: '',
passwordConfirmation: '',
})
if (props.onOpenChange) props.onOpenChange(open)
}
function onEscapeKeyDown(event: KeyboardEvent) {
@ -274,11 +293,6 @@ 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"
footerref={footerRef}

View file

@ -4,9 +4,10 @@ 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'
import SummonUnit from '~components/SummonUnit'
import ExtraSummons from '~components/ExtraSummons'
@ -38,6 +39,10 @@ const SummonGrid = (props: Props) => {
// Localization
const { t } = useTranslation('common')
// 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()
@ -100,12 +105,12 @@ const SummonGrid = (props: Props) => {
saveSummon(party.id, summon, position)
.then((response) => handleSummonResponse(response.data))
.catch((error) => {
const code = error.response.status
const data = error.response.data
if (code === 422) {
if (data.code === 'incompatible_summon_for_position') {
// setShowIncompatibleAlert(true)
}
const axiosError = error as AxiosError
const response = axiosError.response
if (response) {
setErrorAlertOpen(true)
setAxiosError(response)
}
})
}
@ -380,6 +385,18 @@ const SummonGrid = (props: Props) => {
}
// 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')}
/>
)
}
const mainSummonElement = (
<div className="LabeledUnit">
<div className="Label">{t('summons.main')}</div>
@ -460,6 +477,7 @@ const SummonGrid = (props: Props) => {
</div>
{subAuraSummonElement}
{errorAlert()}
</div>
)
}

View file

@ -113,6 +113,7 @@ const SummonUnit = ({
function removeSummon() {
if (gridSummon) sendSummonToRemove(gridSummon.id)
setAlertOpen(false)
}
// Methods: Image string generation
@ -172,7 +173,7 @@ const SummonUnit = ({
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
<ContextMenuTrigger asChild>
<Button
accessoryIcon={<SettingsIcon />}
leftAccessoryIcon={<SettingsIcon />}
className={buttonClasses}
onClick={handleButtonClicked}
/>

View file

@ -0,0 +1,56 @@
.Toast {
background: var(--dialog-bg);
border-radius: $card-corner;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12), 0 0 1px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
gap: $unit-2x;
padding: $unit-3x;
&[data-state='open'] {
animation: slideLeft 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
&[data-state='closed'] {
animation: fadeOut 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
&[data-swipe='move'] {
transform: translateX(var(--radix-toast-swipe-move-x));
}
&[data-swipe='cancel'] {
transform: translateX(0);
transition: transform 200ms ease-out;
}
&[data-swipe='end'] {
animation: slideRight 100ms ease-out;
}
.Header {
align-items: center;
display: flex;
justify-content: space-between;
h3 {
font-size: $font-regular;
font-weight: $medium;
}
button {
background: none;
border: none;
font-size: $font-regular;
font-weight: $bold;
&:hover {
cursor: pointer;
}
}
}
p {
line-height: 1.3;
}
}

View file

@ -0,0 +1,50 @@
import React, { PropsWithChildren } from 'react'
import classNames from 'classnames'
import * as ToastPrimitive from '@radix-ui/react-toast'
import './index.scss'
interface Props extends ToastPrimitive.ToastProps {
className?: string
title?: string
content: string
onCloseClick: () => void
}
const Toast = ({
children,
title,
content,
...props
}: PropsWithChildren<Props>) => {
const { onCloseClick, ...toastProps } = props
const classes = classNames(props.className, {
Toast: true,
})
return (
<ToastPrimitive.Root {...toastProps} className={classes}>
{title && (
<div className="Header">
<ToastPrimitive.Title asChild>
<h3>{title}</h3>
</ToastPrimitive.Title>
<ToastPrimitive.Close aria-label="Close" onClick={onCloseClick}>
<span aria-hidden>×</span>
</ToastPrimitive.Close>
</div>
)}
<ToastPrimitive.Description asChild>
<p>{content}</p>
</ToastPrimitive.Description>
{children && (
<ToastPrimitive.Action asChild altText={content}>
{children}
</ToastPrimitive.Action>
)}
</ToastPrimitive.Root>
)
}
export default Toast

View file

@ -0,0 +1,8 @@
.Tooltip {
background: var(--dialog-bg);
border-radius: $card-corner;
line-height: 1.3;
padding: $unit * 1.5;
z-index: 35;
max-width: 200px;
}

View file

@ -0,0 +1,39 @@
import React, { PropsWithChildren } from 'react'
import classNames from 'classnames'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import './index.scss'
interface Props extends TooltipPrimitive.TooltipContentProps {
content: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
export default function Tooltip({
children,
content,
open,
onOpenChange,
...props
}: PropsWithChildren<Props>) {
const classes = classNames(props.className, {
Tooltip: true,
})
return (
<TooltipPrimitive.Root open={open} onOpenChange={onOpenChange}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Content
side="top"
align="center"
className={classes}
sideOffset={4}
{...props}
>
{content}
{/* <TooltipPrimitive.Arrow width={11} height={5} /> */}
</TooltipPrimitive.Content>
</TooltipPrimitive.Root>
)
}

View file

@ -5,7 +5,6 @@
display: flex;
width: $unit-10x;
height: $unit-10x;
padding: $unit;
justify-content: center;
z-index: 32;

View file

@ -0,0 +1,11 @@
.Notice {
align-items: center;
border-radius: $card-corner;
background: blue;
display: flex;
padding: $unit;
p {
font-size: $font-small;
}
}

View file

@ -0,0 +1,74 @@
import React from 'react'
import { useRouter } from 'next/router'
import { setCookie } from 'cookies-next'
import { add, format } from 'date-fns'
import classNames from 'classnames'
import Button from '~components/Button'
import Toast from '~components/Toast'
import './index.scss'
import { useTranslation } from 'next-i18next'
interface Props {
open: boolean
updateType: 'feature' | 'content'
lastUpdated: string
onActionClicked: () => void
onCloseClicked: () => void
}
const UpdateToast = ({
open,
updateType,
lastUpdated,
onActionClicked,
onCloseClicked,
}: Props) => {
const { t } = useTranslation('roadmap')
const classes = classNames({
Update: true,
})
function setToastCookie() {
const updatedAt = new Date(lastUpdated)
const expiresAt = add(updatedAt, { days: 7 })
setCookie(
`update-${format(updatedAt, 'yyyy-MM-dd')}`,
{ seen: true },
{ path: '/', expires: expiresAt }
)
}
function handleButtonClicked() {
window.open('/updates', '_blank')
setToastCookie()
onActionClicked()
}
function handleCloseClicked() {
setToastCookie()
onCloseClicked()
}
return (
<Toast
className={classes}
title={t(`toasts.title`)}
content={t(`toasts.description.${updateType}`)}
open={open}
type="background"
onCloseClick={handleCloseClicked}
>
<Button
buttonSize="small"
contained={true}
onClick={handleButtonClicked}
text={t('toasts.button')}
/>
</Toast>
)
}
export default UpdateToast

View file

@ -1,17 +1,4 @@
.Changelog.DialogContent {
gap: 0;
.updates {
padding: 0 $unit-4x;
}
.updates {
display: flex;
flex-direction: column;
gap: $unit-4x;
margin-bottom: $unit-4x;
}
.Updates.PageContent {
.version {
&.content {
.top h3 {

View file

@ -0,0 +1,122 @@
import React from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import ChangelogUnit from '~components/ChangelogUnit'
import './index.scss'
interface Props {}
const UpdatesPage: React.FC<Props> = (props: Props) => {
const { t: common } = useTranslation('common')
return (
<div className="Updates PageContent">
<h1>{common('about.segmented_control.updates')}</h1>
<section className="version" data-version="1.0">
<div className="top">
<h3>1.0.1</h3>
<time>2023/01/08</time>
</div>
<ul className="notes">
<li>Extra party fields: Full Auto, Clear Time, and more</li>
<li>Support for Youtube short URLs</li>
<li>Responsive grids and lots of other mobile fixes</li>
<li>Many other bug fixes</li>
</ul>
</section>
<section className="content version" data-version="2022-12L">
<div className="top">
<h3>2022-12 Legend Festival</h3>
<time>2022/12/26</time>
</div>
<div className="update">
<section className="characters">
<h4>New characters</h4>
<div className="items">
<ChangelogUnit
name="Michael (Grand)"
id="3040440000"
type="character"
/>
<ChangelogUnit name="Makura" id="3040441000" type="character" />
<ChangelogUnit
name="Ultimate Friday"
id="3040442000"
type="character"
/>
</div>
</section>
<section className="weapons">
<h4>New weapons</h4>
<div className="items">
<ChangelogUnit
name="Crimson Scale"
id="1040315900"
type="weapon"
/>
<ChangelogUnit name="Leporidius" id="1040914500" type="weapon" />
<ChangelogUnit name="FRIED Spear" id="1040218200" type="weapon" />
</div>
</section>
<section className="summons">
<h4>New summons</h4>
<div className="items">
<ChangelogUnit name="Yatima" id="2040417000" type="summon" />
</div>
</section>
</div>
</section>
<section className="content version" data-version="2022-12F2">
<div className="top">
<h3>2022-12 Flash Gala</h3>
<time>2022/12/26</time>
</div>
<div className="update">
<section className="characters">
<h4>New characters</h4>
<div className="items">
<ChangelogUnit
name="Charlotta (Grand)"
id="3040438000"
type="character"
/>
<ChangelogUnit name="Erin" id="3040439000" type="character" />
</div>
</section>
<section className="weapons">
<h4>New weapons</h4>
<div className="items">
<ChangelogUnit
name="Claíomh Solais Díon"
id="1040024200"
type="weapon"
/>
<ChangelogUnit
name="Crystal Edge"
id="1040116500"
type="weapon"
/>
</div>
</section>
</div>
</section>
<section className="version" data-version="1.0">
<div className="top">
<h3>1.0.0</h3>
<time>2022/12/26</time>
</div>
<ul className="notes">
<li>First release!</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>
</section>
</div>
)
}
export default UpdatesPage

View file

@ -4,7 +4,7 @@ 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'
@ -41,11 +41,15 @@ const WeaponGrid = (props: Props) => {
? JSON.parse(cookie as string)
: null
// Set up state for error handling
const [axiosError, setAxiosError] = useState<AxiosResponse>()
const [errorAlertOpen, setErrorAlertOpen] = useState(false)
const [showIncompatibleAlert, setShowIncompatibleAlert] = useState(false)
// Set up state for view management
const { party, grid } = useSnapshot(appState)
const [slug, setSlug] = useState()
const [modalOpen, setModalOpen] = useState(false)
const [showIncompatibleAlert, setShowIncompatibleAlert] = useState(false)
// Set up state for conflict management
const [incoming, setIncoming] = useState<Weapon>()
@ -100,11 +104,21 @@ const WeaponGrid = (props: Props) => {
saveWeapon(party.id, weapon, position)
.then((response) => handleWeaponResponse(response.data))
.catch((error) => {
const code = error.response.status
const data = error.response.data
if (code === 422) {
if (data.code === 'incompatible_weapon_for_position') {
const axiosError = error as AxiosError
const response = axiosError.response
if (response) {
const code = response.status
const data = response.data
if (
code === 422 &&
data.code === 'incompatible_weapon_for_position'
) {
setShowIncompatibleAlert(true)
} else {
setErrorAlertOpen(true)
setAxiosError(response)
}
}
})
@ -362,16 +376,29 @@ const WeaponGrid = (props: Props) => {
cancelAction={() => setShowIncompatibleAlert(!showIncompatibleAlert)}
cancelActionText={t('buttons.confirm')}
message={t('alert.incompatible_weapon')}
></Alert>
/>
) : (
''
)
}
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 id="WeaponGrid">
{conflicts ? conflictModal() : ''}
{incompatibleAlert()}
{errorAlert()}
<div id="MainGrid">
{mainhandElement}
<ul id="Weapons">{weaponGridElement}</ul>

View file

@ -131,6 +131,7 @@ const WeaponUnit = ({
function removeWeapon() {
if (gridWeapon) sendWeaponToRemove(gridWeapon.id)
setAlertOpen(false)
}
// Methods: Data fetching and manipulation
@ -450,7 +451,7 @@ const WeaponUnit = ({
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
<ContextMenuTrigger asChild>
<Button
accessoryIcon={<SettingsIcon />}
leftAccessoryIcon={<SettingsIcon />}
className={buttonClasses}
onClick={handleButtonClicked}
/>

View file

@ -15,6 +15,26 @@ module.exports = {
source: '/',
destination: '/new',
},
{
source: '/characters',
destination: '/new',
},
{
source: '/summons',
destination: '/new',
},
{
source: '/weapons',
destination: '/new',
},
{
source: '/updates',
destination: '/about',
},
{
source: '/roadmap',
destination: '/about',
},
{
source: '/p/:shortcode/characters',
destination: '/p/:shortcode',

186
package-lock.json generated
View file

@ -11,14 +11,17 @@
"@radix-ui/react-dropdown-menu": "^2.0.1",
"@radix-ui/react-hover-card": "^1.0.2",
"@radix-ui/react-popover": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.1",
"@radix-ui/react-select": "^1.1.2",
"@radix-ui/react-switch": "^1.0.1",
"@radix-ui/react-toast": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.0.1",
"@radix-ui/react-tooltip": "^1.0.3",
"@svgr/webpack": "^6.2.0",
"axios": "^0.25.0",
"classnames": "^2.3.1",
"cookies-next": "^2.1.1",
"date-fns": "^2.29.3",
"fix-date": "^1.1.6",
"i18next": "^21.6.13",
"i18next-browser-languagedetector": "^6.1.3",
@ -2436,6 +2439,49 @@
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/@radix-ui/react-radio-group": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.1.1.tgz",
"integrity": "sha512-fmg1CuDKt3GAkL3YnHekmdOicyrXlbp/s/D0MrHa+YB2Un+umpJGheiRowlQtxSpb1eeehKNTINgNESi8WK5rA==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.0",
"@radix-ui/react-compose-refs": "1.0.0",
"@radix-ui/react-context": "1.0.0",
"@radix-ui/react-direction": "1.0.0",
"@radix-ui/react-presence": "1.0.0",
"@radix-ui/react-primitive": "1.0.1",
"@radix-ui/react-roving-focus": "1.0.2",
"@radix-ui/react-use-controllable-state": "1.0.0",
"@radix-ui/react-use-previous": "1.0.0",
"@radix-ui/react-use-size": "1.0.0"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-roving-focus": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.2.tgz",
"integrity": "sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.0",
"@radix-ui/react-collection": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.0",
"@radix-ui/react-context": "1.0.0",
"@radix-ui/react-direction": "1.0.0",
"@radix-ui/react-id": "1.0.0",
"@radix-ui/react-primitive": "1.0.1",
"@radix-ui/react-use-callback-ref": "1.0.0",
"@radix-ui/react-use-controllable-state": "1.0.0"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.1.tgz",
@ -2578,6 +2624,52 @@
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.3.tgz",
"integrity": "sha512-cmc9qV4KpgqdXVTn1K8KN8MnuSXvw+E719pKwyvpCGrQ+0AA2qTjcIL3uxCj4jc4k3sDR36RF7R3H7N5hPybBQ==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.0",
"@radix-ui/react-compose-refs": "1.0.0",
"@radix-ui/react-context": "1.0.0",
"@radix-ui/react-dismissable-layer": "1.0.2",
"@radix-ui/react-id": "1.0.0",
"@radix-ui/react-popper": "1.1.0",
"@radix-ui/react-portal": "1.0.1",
"@radix-ui/react-presence": "1.0.0",
"@radix-ui/react-primitive": "1.0.1",
"@radix-ui/react-slot": "1.0.1",
"@radix-ui/react-use-controllable-state": "1.0.0",
"@radix-ui/react-visually-hidden": "1.0.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@floating-ui/react-dom": "0.7.2",
"@radix-ui/react-arrow": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.0",
"@radix-ui/react-context": "1.0.0",
"@radix-ui/react-primitive": "1.0.1",
"@radix-ui/react-use-callback-ref": "1.0.0",
"@radix-ui/react-use-layout-effect": "1.0.0",
"@radix-ui/react-use-rect": "1.0.0",
"@radix-ui/react-use-size": "1.0.0",
"@radix-ui/rect": "1.0.0"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz",
@ -3861,6 +3953,18 @@
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
"dev": true
},
"node_modules/date-fns": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==",
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@ -8947,6 +9051,43 @@
"@radix-ui/react-slot": "1.0.1"
}
},
"@radix-ui/react-radio-group": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.1.1.tgz",
"integrity": "sha512-fmg1CuDKt3GAkL3YnHekmdOicyrXlbp/s/D0MrHa+YB2Un+umpJGheiRowlQtxSpb1eeehKNTINgNESi8WK5rA==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.0",
"@radix-ui/react-compose-refs": "1.0.0",
"@radix-ui/react-context": "1.0.0",
"@radix-ui/react-direction": "1.0.0",
"@radix-ui/react-presence": "1.0.0",
"@radix-ui/react-primitive": "1.0.1",
"@radix-ui/react-roving-focus": "1.0.2",
"@radix-ui/react-use-controllable-state": "1.0.0",
"@radix-ui/react-use-previous": "1.0.0",
"@radix-ui/react-use-size": "1.0.0"
},
"dependencies": {
"@radix-ui/react-roving-focus": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.2.tgz",
"integrity": "sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.0",
"@radix-ui/react-collection": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.0",
"@radix-ui/react-context": "1.0.0",
"@radix-ui/react-direction": "1.0.0",
"@radix-ui/react-id": "1.0.0",
"@radix-ui/react-primitive": "1.0.1",
"@radix-ui/react-use-callback-ref": "1.0.0",
"@radix-ui/react-use-controllable-state": "1.0.0"
}
}
}
},
"@radix-ui/react-roving-focus": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.1.tgz",
@ -9062,6 +9203,46 @@
"@radix-ui/react-use-controllable-state": "1.0.0"
}
},
"@radix-ui/react-tooltip": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.3.tgz",
"integrity": "sha512-cmc9qV4KpgqdXVTn1K8KN8MnuSXvw+E719pKwyvpCGrQ+0AA2qTjcIL3uxCj4jc4k3sDR36RF7R3H7N5hPybBQ==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.0",
"@radix-ui/react-compose-refs": "1.0.0",
"@radix-ui/react-context": "1.0.0",
"@radix-ui/react-dismissable-layer": "1.0.2",
"@radix-ui/react-id": "1.0.0",
"@radix-ui/react-popper": "1.1.0",
"@radix-ui/react-portal": "1.0.1",
"@radix-ui/react-presence": "1.0.0",
"@radix-ui/react-primitive": "1.0.1",
"@radix-ui/react-slot": "1.0.1",
"@radix-ui/react-use-controllable-state": "1.0.0",
"@radix-ui/react-visually-hidden": "1.0.1"
},
"dependencies": {
"@radix-ui/react-popper": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
"requires": {
"@babel/runtime": "^7.13.10",
"@floating-ui/react-dom": "0.7.2",
"@radix-ui/react-arrow": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.0",
"@radix-ui/react-context": "1.0.0",
"@radix-ui/react-primitive": "1.0.1",
"@radix-ui/react-use-callback-ref": "1.0.0",
"@radix-ui/react-use-layout-effect": "1.0.0",
"@radix-ui/react-use-rect": "1.0.0",
"@radix-ui/react-use-size": "1.0.0",
"@radix-ui/rect": "1.0.0"
}
}
}
},
"@radix-ui/react-use-callback-ref": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz",
@ -9956,6 +10137,11 @@
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
"dev": true
},
"date-fns": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA=="
},
"debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",

View file

@ -16,14 +16,17 @@
"@radix-ui/react-dropdown-menu": "^2.0.1",
"@radix-ui/react-hover-card": "^1.0.2",
"@radix-ui/react-popover": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.1",
"@radix-ui/react-select": "^1.1.2",
"@radix-ui/react-switch": "^1.0.1",
"@radix-ui/react-toast": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.0.1",
"@radix-ui/react-tooltip": "^1.0.3",
"@svgr/webpack": "^6.2.0",
"axios": "^0.25.0",
"classnames": "^2.3.1",
"cookies-next": "^2.1.1",
"date-fns": "^2.29.3",
"fix-date": "^1.1.6",
"i18next": "^21.6.13",
"i18next-browser-languagedetector": "^6.1.3",

View file

@ -9,12 +9,16 @@ import InfiniteScroll from 'react-infinite-scroll-component'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import api from '~utils/api'
import setUserToken from '~utils/setUserToken'
import extractFilters from '~utils/extractFilters'
import fetchLatestVersion from '~utils/fetchLatestVersion'
import organizeRaids from '~utils/organizeRaids'
import useDidMountEffect from '~utils/useDidMountEffect'
import { appState } from '~utils/appState'
import { elements, allElement } from '~data/elements'
import { emptyPaginationObject } from '~utils/emptyStates'
import { printError } from '~utils/reportError'
import GridRep from '~components/GridRep'
import GridRepCollection from '~components/GridRepCollection'
@ -29,6 +33,7 @@ interface Props {
meta: PaginationObject
raids: Raid[]
sortedRaids: Raid[][]
version: AppUpdate
}
const ProfileRoute: React.FC<Props> = (props: Props) => {
@ -98,6 +103,7 @@ const ProfileRoute: React.FC<Props> = (props: Props) => {
setTotalPages(props.meta.totalPages)
setRecordCount(props.meta.count)
replaceResults(props.meta.count, props.teams)
appState.version = props.version
}
setCurrentPage(1)
}, [])
@ -350,50 +356,58 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
// Set headers for server-side requests
setUserToken(req, res)
// Fetch and organize raids
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
try {
// Fetch latest version
const version = await fetchLatestVersion()
// Create filter object
const filters: FilterObject = extractFilters(query, raids)
const params = {
params: { ...filters },
}
// Fetch and organize raids
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
// Set up empty variables
let user: User | null = null
let teams: Party[] | null = null
let meta: PaginationObject = emptyPaginationObject
// Create filter object
const filters: FilterObject = extractFilters(query, raids)
const params = {
params: { ...filters },
}
// Perform a request only if we received a username
if (query.username) {
const response = await api.endpoints.users.getOne({
id: query.username,
params,
})
// Set up empty variables
let user: User | null = null
let teams: Party[] | null = null
let meta: PaginationObject = emptyPaginationObject
// Assign values to pass to props
user = response.data.profile
// Perform a request only if we received a username
if (query.username) {
const response = await api.endpoints.users.getOne({
id: query.username,
params,
})
if (response.data.profile.parties) teams = response.data.profile.parties
else teams = []
// Assign values to pass to props
user = response.data.profile
meta.count = response.data.meta.count
meta.totalPages = response.data.meta.total_pages
meta.perPage = response.data.meta.per_page
}
if (response.data.profile.parties) teams = response.data.profile.parties
else teams = []
return {
props: {
user: user,
teams: teams,
meta: meta,
raids: raids,
sortedRaids: sortedRaids,
...(await serverSideTranslations(locale, ['common', 'roadmap'])),
// Will be passed to the page component as props
},
meta.count = response.data.meta.count
meta.totalPages = response.data.meta.total_pages
meta.perPage = response.data.meta.per_page
}
return {
props: {
user: user,
teams: teams,
meta: meta,
raids: raids,
sortedRaids: sortedRaids,
version: version,
...(await serverSideTranslations(locale, ['common', 'roadmap'])),
// Will be passed to the page component as props
},
}
} catch (error) {
printError(error, 'axios')
}
}

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { getCookie, getCookies } from 'cookies-next'
import { useEffect } from 'react'
import { getCookie } from 'cookies-next'
import { appWithTranslation } from 'next-i18next'
import { ThemeProvider, useTheme } from 'next-themes'
import { ThemeProvider } from 'next-themes'
import type { AppProps } from 'next/app'
import Layout from '~components/Layout'
@ -10,6 +10,8 @@ import { accountState } from '~utils/accountState'
import setUserToken from '~utils/setUserToken'
import '../styles/globals.scss'
import { ToastProvider, Viewport } from '@radix-ui/react-toast'
import { TooltipProvider } from '@radix-ui/react-tooltip'
function MyApp({ Component, pageProps }: AppProps) {
const accountCookie = getCookie('account')
@ -30,8 +32,11 @@ function MyApp({ Component, pageProps }: AppProps) {
accountState.account.user = {
id: cookieData.account.userId,
username: cookieData.account.username,
picture: cookieData.user.picture,
element: cookieData.user.element,
granblueId: '',
avatar: {
picture: cookieData.user.avatar.picture,
element: cookieData.user.avatar.element,
},
gender: cookieData.user.gender,
language: cookieData.user.language,
theme: cookieData.user.theme,
@ -43,9 +48,14 @@ function MyApp({ Component, pageProps }: AppProps) {
return (
<ThemeProvider>
<Layout>
<Component {...pageProps} />
</Layout>
<ToastProvider swipeDirection="right">
<TooltipProvider>
<Layout>
<Component {...pageProps} />
</Layout>
<Viewport className="ToastViewport" />
</TooltipProvider>
</ToastProvider>
</ThemeProvider>
)
}

174
pages/about.tsx Normal file
View file

@ -0,0 +1,174 @@
import React, { useEffect, useState } from 'react'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import { AboutTabs } from '~utils/enums'
import setUserToken from '~utils/setUserToken'
import AboutPage from '~components/AboutPage'
import UpdatesPage from '~components/UpdatesPage'
import RoadmapPage from '~components/RoadmapPage'
import SegmentedControl from '~components/SegmentedControl'
import Segment from '~components/Segment'
import type { NextApiRequest, NextApiResponse } from 'next'
interface Props {}
const AboutRoute: React.FC<Props> = (props: Props) => {
// Set up router
const router = useRouter()
// Import translations
const { t } = useTranslation('common')
const [currentTab, setCurrentTab] = useState<AboutTabs>(AboutTabs.About)
const [currentPage, setCurrentPage] = useState('')
useEffect(() => {
const parts = router.asPath.split('/')
const tab = parts[parts.length - 1]
switch (tab) {
case 'about':
setCurrentTab(AboutTabs.About)
setCurrentPage(parts[1])
break
case 'updates':
setCurrentTab(AboutTabs.Updates)
setCurrentPage(parts[1])
break
case 'roadmap':
setCurrentTab(AboutTabs.Roadmap)
setCurrentPage(parts[1])
break
}
}, [router.asPath])
function handleTabClicked(event: React.ChangeEvent<HTMLInputElement>) {
const parts = router.asPath.split('/')
const path = `/${event.target.value}`
switch (event.target.value) {
case 'about':
router.replace(path)
setCurrentTab(AboutTabs.About)
setCurrentPage(parts[1])
break
case 'updates':
router.replace(path)
setCurrentTab(AboutTabs.Updates)
setCurrentPage(parts[1])
break
case 'roadmap':
router.replace(path)
setCurrentTab(AboutTabs.Roadmap)
setCurrentPage(parts[1])
break
default:
break
}
}
const currentSection = () => {
switch (currentTab) {
case AboutTabs.About:
return <AboutPage />
case AboutTabs.Updates:
return <UpdatesPage />
case AboutTabs.Roadmap:
return <RoadmapPage />
}
}
return (
<div id="About">
<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" />
{/* 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>
<section>
<SegmentedControl>
<Segment
groupName="about"
name="about"
selected={currentTab == AboutTabs.About}
onClick={handleTabClicked}
>
{t('about.segmented_control.about')}
</Segment>
<Segment
groupName="about"
name="updates"
selected={currentTab == AboutTabs.Updates}
onClick={handleTabClicked}
>
{t('about.segmented_control.updates')}
</Segment>
<Segment
groupName="about"
name="roadmap"
selected={currentTab == AboutTabs.Roadmap}
onClick={handleTabClicked}
>
{t('about.segmented_control.roadmap')}
</Segment>
</SegmentedControl>
{currentSection()}
</section>
</div>
)
}
export const getServerSidePaths = async () => {
return {
paths: [],
fallback: true,
}
}
// prettier-ignore
export const getServerSideProps = async ({ req, res, locale, query }: { req: NextApiRequest, res: NextApiResponse, locale: string, query: { [index: string]: string } }) => {
// Set headers for server-side requests
setUserToken(req, res)
// Fetch and organize raids
return {
props: {
...(await serverSideTranslations(locale, ['common', 'roadmap'])),
// Will be passed to the page component as props
},
}
}
export default AboutRoute

View file

@ -1,15 +1,19 @@
import React, { useEffect } from 'react'
import { useRouter } from 'next/router'
import Head from 'next/head'
import { useTranslation } from 'next-i18next'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import clonedeep from 'lodash.clonedeep'
import Party from '~components/Party'
import { appState } from '~utils/appState'
import { groupWeaponKeys } from '~utils/groupWeaponKeys'
import api from '~utils/api'
import fetchLatestVersion from '~utils/fetchLatestVersion'
import organizeRaids from '~utils/organizeRaids'
import setUserToken from '~utils/setUserToken'
import api from '~utils/api'
import { appState, initialAppState } from '~utils/appState'
import { groupWeaponKeys } from '~utils/groupWeaponKeys'
import { printError } from '~utils/reportError'
import type { NextApiRequest, NextApiResponse } from 'next'
import type { GroupedWeaponKeys } from '~utils/groupWeaponKeys'
@ -20,12 +24,16 @@ interface Props {
raids: Raid[]
sortedRaids: Raid[][]
weaponKeys: GroupedWeaponKeys
version: AppUpdate
}
const NewRoute: React.FC<Props> = (props: Props) => {
// Import translations
const { t } = useTranslation('common')
// Set up router
const router = useRouter()
function callback(path: string) {
// This is scuffed, how do we do this natively?
window.history.replaceState(null, `Grid Tool`, `${path}`)
@ -35,15 +43,26 @@ const NewRoute: React.FC<Props> = (props: Props) => {
persistStaticData()
}, [persistStaticData])
useEffect(() => {
// 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 persistStaticData() {
appState.raids = props.raids
appState.jobs = props.jobs
appState.jobSkills = props.jobSkills
appState.weaponKeys = props.weaponKeys
appState.version = props.version
}
return (
<React.Fragment>
<React.Fragment key={router.asPath}>
<Head>
{/* HTML */}
<title>{t('page.titles.new')}</title>
@ -82,32 +101,39 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
// Set headers for server-side requests
setUserToken(req, res)
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
try {
// Fetch latest version
const version = await fetchLatestVersion()
let jobs = await api.endpoints.jobs
.getAll()
.then((response) => {
// Fetch and organize raids
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
let jobs = await api.endpoints.jobs.getAll().then((response) => {
return response.data
})
let jobSkills = await api.allJobSkills().then((response) => response.data)
let jobSkills = await api.allJobSkills().then((response) => response.data)
let weaponKeys = await api.endpoints.weapon_keys
.getAll()
.then((response) => groupWeaponKeys(response.data))
let weaponKeys = await api.endpoints.weapon_keys
.getAll()
.then((response) => groupWeaponKeys(response.data))
return {
props: {
jobs: jobs,
jobSkills: jobSkills,
raids: raids,
sortedRaids: sortedRaids,
weaponKeys: weaponKeys,
...(await serverSideTranslations(locale, ['common', 'roadmap'])),
// Will be passed to the page component as props
},
return {
props: {
jobs: jobs,
jobSkills: jobSkills,
raids: raids,
sortedRaids: sortedRaids,
weaponKeys: weaponKeys,
version: version,
...(await serverSideTranslations(locale, ['common', 'roadmap'])),
// Will be passed to the page component as props
},
}
} catch (error) {
printError(error, 'axios')
}
}

View file

@ -6,17 +6,17 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import Party from '~components/Party'
import { appState } from '~utils/appState'
import { groupWeaponKeys } from '~utils/groupWeaponKeys'
import api from '~utils/api'
import generateTitle from '~utils/generateTitle'
import organizeRaids from '~utils/organizeRaids'
import setUserToken from '~utils/setUserToken'
import api from '~utils/api'
import { appState } from '~utils/appState'
import { groupWeaponKeys } from '~utils/groupWeaponKeys'
import { GridType } from '~utils/enums'
import { printError } from '~utils/reportError'
import type { NextApiRequest, NextApiResponse } from 'next'
import type { GroupedWeaponKeys } from '~utils/groupWeaponKeys'
import { useQueryState } from 'next-usequerystate'
interface Props {
party: Party
@ -70,7 +70,7 @@ const PartyRoute: React.FC<Props> = (props: Props) => {
}
return (
<React.Fragment>
<React.Fragment key={router.asPath}>
<Party
team={props.party}
raids={props.sortedRaids}
@ -154,35 +154,7 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
// Set headers for server-side requests
setUserToken(req, res)
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
let jobs = await api.endpoints.jobs
.getAll()
.then((response) => {
return response.data
})
let jobSkills = await api
.allJobSkills()
.then((response) => response.data)
let weaponKeys = await api.endpoints.weapon_keys
.getAll()
.then((response) => groupWeaponKeys(response.data))
let party: Party | null = null
if (query.party) {
let response = await api.endpoints.parties.getOne({
id: query.party
})
party = response.data.party
} else {
console.log('No party code')
}
function getElement() {
function getElement(party?: Party) {
if (party) {
const mainhand = party.weapons.find((weapon) => weapon.mainhand)
if (mainhand && mainhand.object.element === 0) {
@ -195,8 +167,8 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
}
}
function elementEmoji() {
const element = getElement()
function elementEmoji(party?: Party) {
const element = getElement(party)
if (element === 0) return '⚪'
else if (element === 1) return '🟢'
@ -208,20 +180,48 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
else return '⚪'
}
return {
props: {
party: party,
jobs: jobs,
jobSkills: jobSkills,
raids: raids,
sortedRaids: sortedRaids,
weaponKeys: weaponKeys,
meta: {
element: elementEmoji(),
try {
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
let jobs = await api.endpoints.jobs.getAll().then((response) => {
return response.data
})
let jobSkills = await api.allJobSkills().then((response) => response.data)
let weaponKeys = await api.endpoints.weapon_keys
.getAll()
.then((response) => groupWeaponKeys(response.data))
let party: Party | undefined = undefined
if (query.party) {
let response = await api.endpoints.parties.getOne({
id: query.party,
})
party = response.data.party
} else {
console.log('No party code')
}
return {
props: {
party: party,
jobs: jobs,
jobSkills: jobSkills,
raids: raids,
sortedRaids: sortedRaids,
weaponKeys: weaponKeys,
meta: {
element: elementEmoji(party),
},
...(await serverSideTranslations(locale, ['common', 'roadmap'])),
// Will be passed to the page component as props
},
...(await serverSideTranslations(locale, ['common', 'roadmap'])),
// Will be passed to the page component as props
},
}
} catch (error) {
printError(error, 'axios')
}
}

View file

@ -12,10 +12,13 @@ import clonedeep from 'lodash.clonedeep'
import api from '~utils/api'
import setUserToken from '~utils/setUserToken'
import extractFilters from '~utils/extractFilters'
import fetchLatestVersion from '~utils/fetchLatestVersion'
import organizeRaids from '~utils/organizeRaids'
import useDidMountEffect from '~utils/useDidMountEffect'
import { appState } from '~utils/appState'
import { elements, allElement } from '~data/elements'
import { emptyPaginationObject } from '~utils/emptyStates'
import { printError } from '~utils/reportError'
import GridRep from '~components/GridRep'
import GridRepCollection from '~components/GridRepCollection'
@ -29,6 +32,7 @@ interface Props {
meta: PaginationObject
raids: Raid[]
sortedRaids: Raid[][]
version: AppUpdate
}
const SavedRoute: React.FC<Props> = (props: Props) => {
@ -97,6 +101,7 @@ const SavedRoute: React.FC<Props> = (props: Props) => {
setTotalPages(props.meta.totalPages)
setRecordCount(props.meta.count)
replaceResults(props.meta.count, props.teams)
appState.version = props.version
}
setCurrentPage(1)
}, [])
@ -352,39 +357,47 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
// Set headers for server-side requests
setUserToken(req, res)
// Fetch and organize raids
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
try {
// Fetch latest version
const version = await fetchLatestVersion()
// Create filter object
const filters: FilterObject = extractFilters(query, raids)
const params = {
params: { ...filters },
}
// Fetch and organize raids
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
// Set up empty variables
let teams: Party[] | null = null
let meta: PaginationObject = emptyPaginationObject
// Create filter object
const filters: FilterObject = extractFilters(query, raids)
const params = {
params: { ...filters },
}
// Fetch initial set of saved parties
const response = await api.savedTeams(params)
// Set up empty variables
let teams: Party[] | null = null
let meta: PaginationObject = emptyPaginationObject
// Assign values to pass to props
teams = response.data.results
meta.count = response.data.meta.count
meta.totalPages = response.data.meta.total_pages
meta.perPage = response.data.meta.per_page
// Fetch initial set of saved parties
const response = await api.savedTeams(params)
return {
props: {
teams: teams,
meta: meta,
raids: raids,
sortedRaids: sortedRaids,
...(await serverSideTranslations(locale, ['common', 'roadmap'])),
// Will be passed to the page component as props
},
// Assign values to pass to props
teams = response.data.results
meta.count = response.data.meta.count
meta.totalPages = response.data.meta.total_pages
meta.perPage = response.data.meta.per_page
return {
props: {
teams: teams,
meta: meta,
raids: raids,
sortedRaids: sortedRaids,
version: version,
...(await serverSideTranslations(locale, ['common', 'roadmap'])),
// Will be passed to the page component as props
},
}
} catch (error) {
printError(error, 'axios')
}
}

View file

@ -12,10 +12,13 @@ import clonedeep from 'lodash.clonedeep'
import api from '~utils/api'
import setUserToken from '~utils/setUserToken'
import extractFilters from '~utils/extractFilters'
import fetchLatestVersion from '~utils/fetchLatestVersion'
import organizeRaids from '~utils/organizeRaids'
import useDidMountEffect from '~utils/useDidMountEffect'
import { appState } from '~utils/appState'
import { elements, allElement } from '~data/elements'
import { emptyPaginationObject } from '~utils/emptyStates'
import { printError } from '~utils/reportError'
import GridRep from '~components/GridRep'
import GridRepCollection from '~components/GridRepCollection'
@ -28,6 +31,7 @@ interface Props {
teams?: Party[]
meta: PaginationObject
sortedRaids: Raid[][]
version: AppUpdate
}
const TeamsRoute: React.FC<Props> = (props: Props) => {
@ -96,6 +100,7 @@ const TeamsRoute: React.FC<Props> = (props: Props) => {
setTotalPages(props.meta.totalPages)
setRecordCount(props.meta.count)
replaceResults(props.meta.count, props.teams)
appState.version = props.version
}
setCurrentPage(1)
}, [])
@ -363,39 +368,47 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
// Set headers for server-side requests
setUserToken(req, res)
// Fetch and organize raids
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
try {
// Fetch latest version
const version = await fetchLatestVersion()
// Create filter object
const filters: FilterObject = extractFilters(query, raids)
const params = {
params: { ...filters },
}
// Fetch and organize raids
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
// Set up empty variables
let teams: Party[] | null = null
let meta: PaginationObject = emptyPaginationObject
// Create filter object
const filters: FilterObject = extractFilters(query, raids)
const params = {
params: { ...filters },
}
// Fetch initial set of parties
const response = await api.endpoints.parties.getAll(params)
// Set up empty variables
let teams: Party[] | null = null
let meta: PaginationObject = emptyPaginationObject
// Assign values to pass to props
teams = response.data.results
meta.count = response.data.meta.count
meta.totalPages = response.data.meta.total_pages
meta.perPage = response.data.meta.per_page
// Fetch initial set of parties
const response = await api.endpoints.parties.getAll(params)
return {
props: {
teams: teams,
meta: meta,
raids: raids,
sortedRaids: sortedRaids,
...(await serverSideTranslations(locale, ['common', 'roadmap'])),
// Will be passed to the page component as props
},
// Assign values to pass to props
teams = response.data.results
meta.count = response.data.meta.count
meta.totalPages = response.data.meta.total_pages
meta.perPage = response.data.meta.per_page
return {
props: {
teams: teams,
meta: meta,
raids: raids,
sortedRaids: sortedRaids,
version: version,
...(await serverSideTranslations(locale, ['common', 'roadmap'])),
// Will be passed to the page component as props
},
}
} catch (error) {
printError(error, 'axios')
}
}

View file

@ -0,0 +1,3 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.49997 27.5C-0.500051 27.5 1.14279 17.8454 1.78667 15.9435C2.37392 14.2088 3.10735 12.4293 3.96802 10.8994C4.77642 9.46249 5.91051 7.85264 7.45945 6.92577C9.76438 5.54652 12.3986 5.18186 14.3985 4.905C14.8148 4.84736 15.2037 4.79353 15.5563 4.73515C17.7785 4.36726 19.8103 3.78856 21.9629 1.79762C22.9062 0.925157 24.3004 0.750323 25.43 1.36285C26.5595 1.97538 27.1736 3.23925 26.957 4.5058C26.1138 9.43587 23.0247 17.362 14.8822 19.8674C2.17635 23.7769 3.5 27.5 1.49997 27.5Z" />
</svg>

After

Width:  |  Height:  |  Size: 612 B

4
public/icons/Remix.svg Normal file
View file

@ -0,0 +1,4 @@
<svg viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.50019 2.00047C5.18025 2.00132 4.83617 2.00371 4.51209 2.01042C3.93815 2.0223 3.42693 2.04775 3.22357 2.10224C2.1883 2.37964 1.37965 3.18828 1.10225 4.22356C1.04775 4.42694 1.02231 4.93829 1.01043 5.51233C1.00003 6.01504 1.00003 6.56583 1.00003 7.00001C1.00003 7.43419 1.00003 7.98498 1.01043 8.48769C1.02231 9.06174 1.04775 9.57308 1.10225 9.77647C1.36232 10.747 2.08929 11.5184 3.03219 11.8396C3.09505 11.861 3.15887 11.8805 3.22357 11.8978C3.42692 11.9523 3.9381 11.9777 4.51201 11.9896C4.7811 11.9952 5.06399 11.9978 5.33539 11.999L4.73226 12.6021C4.537 12.7974 4.537 13.1139 4.73226 13.3092C4.92752 13.5045 5.24411 13.5045 5.43937 13.3092L6.85358 11.895C6.95385 11.7947 7.00263 11.6625 6.99992 11.5311C7.00263 11.3997 6.95385 11.2674 6.85358 11.1672L5.43937 9.75295C5.24411 9.55769 4.92752 9.55769 4.73226 9.75295C4.537 9.94822 4.537 10.2648 4.73226 10.4601L5.27027 10.9981C5.01654 10.9966 4.7638 10.994 4.53092 10.9898C4.01686 10.9805 3.59961 10.9633 3.48239 10.9319C2.83534 10.7585 2.32109 10.2738 2.10695 9.64524C2.09268 9.60333 2.07974 9.56079 2.06818 9.51765C2.03675 9.40038 2.01952 8.98285 2.01025 8.4685C2.00195 8.00794 2.00003 7.46976 2.00003 7.00001C2.00003 6.53027 2.00195 5.99208 2.01025 5.53152C2.01952 5.01718 2.03676 4.59965 2.06818 4.48238C2.25311 3.79219 2.79221 3.2531 3.48239 3.06816C3.59963 3.03675 4.01694 3.01951 4.53106 3.01024C4.83429 3.00477 5.17118 3.00206 5.50006 3.00086C5.7762 2.99984 6.00003 2.77615 6.00003 2.50001C6.00003 2.22386 5.77633 1.99974 5.50019 2.00047Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.7765 11.8999C10.462 11.9842 9.40436 11.9981 8.52156 12.0003L8.4928 12.0004L8.48903 12.0004C8.21325 12.001 7.98986 11.7772 7.98986 11.5015C7.98986 11.2265 8.21208 11.0036 8.48675 11.0013L8.48916 11.0013L8.51572 11.0012C9.39725 10.9977 10.333 10.9834 10.5176 10.934C11.2078 10.749 11.7469 10.2099 11.9319 9.51976C11.9633 9.40248 11.9805 8.98495 11.9898 8.47061C11.9981 8.01005 12 7.47186 12 7.00212C12 6.53237 11.9981 5.99419 11.9898 5.53363C11.9805 5.01928 11.9633 4.60175 11.9319 4.48448C11.9203 4.44134 11.9074 4.3988 11.8931 4.35689C11.6789 3.72829 11.1647 3.24364 10.5176 3.07026C10.4004 3.03886 9.98317 3.02162 9.46911 3.01235C9.23664 3.00815 8.98438 3.00559 8.7311 3.00407L9.26777 3.54074C9.46303 3.736 9.46303 4.05258 9.26777 4.24784C9.0725 4.44311 8.75592 4.44311 8.56066 4.24784L7.14645 2.83363C7.04618 2.73336 6.9974 2.60111 7.00011 2.46972C6.9974 2.33833 7.04618 2.20607 7.14645 2.1058L8.56066 0.69159C8.75592 0.496328 9.0725 0.496328 9.26777 0.69159C9.46303 0.886852 9.46303 1.20343 9.26777 1.3987L8.66331 2.00316C8.93512 2.00436 9.21849 2.00695 9.48802 2.01253C10.0619 2.02441 10.5731 2.04985 10.7765 2.10434C10.8412 2.12168 10.905 2.14109 10.9678 2.1625C11.9107 2.48371 12.6377 3.25509 12.8978 4.22566C12.9523 4.42905 12.9777 4.94039 12.9896 5.51444C13 6.01715 13 6.56794 13 7.00212C13 7.43629 13 7.98709 12.9896 8.48979C12.9777 9.06384 12.9523 9.57519 12.8978 9.77857C12.6204 10.8139 11.8117 11.6225 10.7765 11.8999Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3 KiB

3
public/icons/Shield.svg Normal file
View file

@ -0,0 +1,3 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.2485 18.9009L23.2738 18.8461L23.2969 18.7903C25.0719 14.5024 25.7609 9.66488 25.9448 6.46207C26.0895 3.94417 24.3044 1.84216 21.9579 1.40085C15.9228 0.265829 11.8484 0.285157 6.02633 1.39844C3.68545 1.84606 1.90969 3.9468 2.05687 6.45935C2.23934 9.57444 2.91185 14.4595 4.70282 18.7895C5.52785 20.7841 6.67748 22.8599 8.06609 24.4951C9.34592 26.0023 11.3727 27.7692 14 27.7692C16.7045 27.7692 18.7211 25.8684 19.9427 24.385C21.2895 22.7497 22.4117 20.7123 23.2485 18.9009Z" />
</svg>

After

Width:  |  Height:  |  Size: 610 B

View file

@ -1,4 +1,15 @@
{
"about": {
"segmented_control": {
"about": "About",
"updates": "Updates",
"roadmap": "Roadmap"
}
},
"accessories": {
"paladin": "shield",
"manadiver": "manatura"
},
"alert": {
"incompatible_weapon": "You've selected a weapon that can't be added to the Additional Weapon slots."
},
@ -28,6 +39,9 @@
"show_info": "Edit info",
"hide_info": "Hide info",
"save_info": "Save info",
"remix": "Remix",
"save": "Save",
"saved": "Saved",
"menu": "Menu",
"new": "New",
"wiki": "View more on gbf.wiki"
@ -40,6 +54,9 @@
},
"remove": "Remove from grid"
},
"errors": {
"unauthorized": "You don't have permission to perform that action"
},
"filters": {
"labels": {
"element": "Element",
@ -338,6 +355,9 @@
},
"page": {
"titles": {
"about": "About granblue.team",
"updates": "Updates / granblue.team",
"roadmap": "Roadmap / granblue.team",
"discover": "Discover teams / granblue.team",
"new": "Create a new team / granblue.team",
"profile": "@{{username}}'s Teams / granblue.team",
@ -345,6 +365,9 @@
"saved": "Your saved teams / granblue.team"
},
"descriptions": {
"about": "More about granblue.team / Save and discover teams to use in Granblue Fantasy",
"updates": "Latest updates to granblue.team",
"roadmap": "Upcoming planned features for granblue.team",
"discover": "Save and discover teams to use in Granblue Fantasy and search by raid, element or recency",
"new": "Create and theorycraft teams to use in Granblue Fantasy and share with the community",
"profile": "Browse @{{username}}'s Teams and filter by raid, element or recency",
@ -364,12 +387,24 @@
"no_skill": "No skill"
}
},
"toasts": {
"copied": "This party's URL was copied to your clipboard"
},
"tooltips": {
"remix": "Make a copy of this team",
"save": "Save this team to your account",
"source": "Go to original team"
},
"extra_weapons": "Additional Weapons",
"equipped": "Equipped",
"coming_soon": "Coming Soon",
"new_party": "New party",
"no_accessory": "No {{accessory}} equipped",
"no_title": "Untitled",
"no_raid": "No raid",
"no_user": "Anonymous",
"no_job": "No class",
"no_value": "No value",
"remixes": "Remixes",
"level": "Level"
}

View file

@ -4,6 +4,14 @@
"title": "Roadmap"
}
},
"toasts": {
"title": "New update",
"description": {
"content": "New items have been added from the latest Granblue Fantasy update.",
"feature": "Now you can remix other people's teams, add Character rings and earrings, set Shields and Manatura on parties, and more!"
},
"button": "Learn more"
},
"title": "Roadmap",
"subtitle": "Next update",
"blurb": "I'm aiming for this update to release between late-January and early-February. I'm losing a week to top 2k in Guild Wars and after that I'm back at my full-time job, so progress will be a bit slower.",

View file

@ -1,4 +1,15 @@
{
"about": {
"segmented_control": {
"about": "サイトについて",
"updates": "変更ログ",
"roadmap": "ロードマップ"
}
},
"accessories": {
"paladin": "盾",
"manadiver": "マナベリ"
},
"alert": {
"incompatible_weapon": "Additional Weaponsに装備できない武器を入れました。"
},
@ -28,6 +39,9 @@
"show_info": "詳細を編集",
"save_info": "詳細を保存",
"hide_info": "詳細を非表示",
"remix": "リミックス",
"save": "保存する",
"saved": "保存",
"menu": "メニュー",
"new": "作成",
"wiki": "gbf.wikiで詳しく見る"
@ -48,6 +62,9 @@
"rarity": "レアリティ"
}
},
"errors": {
"unauthorized": "行ったアクションを実行する権限がありません"
},
"header": {
"anonymous": "無名",
"untitled_team": "{{username}}さんからの無題編成",
@ -339,6 +356,9 @@
},
"page": {
"titles": {
"about": "granblue.teamについて",
"updates": "変更ログ / granblue.team",
"roadmap": "ロードマップ / granblue.team",
"discover": "編成を見出す / granblue.team",
"new": "新しい編成 / granblue.team",
"profile": "@{{username}}さんの作った編成 / granblue.team",
@ -346,6 +366,9 @@
"saved": "保存した編成"
},
"descriptions": {
"about": "granblue.teamについて / グランブルーファンタジーの編成を探したり保存したりできる",
"updates": "granblue.teamの最新変更について",
"roadmap": "granblue.teamの開発予定機能",
"discover": "グランブルーファンタジーの編成をマルチ、属性、作った時間などで探したり保存したりできる",
"new": "グランブルーファンタジーの編成を作成し、騎空士とシェアできるサイトgranblue.team",
"profile": "@{{username}}の編成を調査し、マルチ、属性、または作った時間でフィルターする",
@ -365,12 +388,24 @@
"no_skill": "設定されていません"
}
},
"toasts": {
"copied": "この編成のURLはクリップボードにコピーされました"
},
"tooltips": {
"remix": "この編成をコピーする",
"save": "この編成をアカウントに保存する",
"source": "オリジナルの編成へ"
},
"equipped": "装備した",
"extra_weapons": "Additional Weapons",
"coming_soon": "開発中",
"new_party": "新しい編成",
"no_accessory": "{{accessory}}は装備していません",
"no_title": "無題",
"no_raid": "マルチなし",
"no_user": "無名",
"no_job": "ジョブなし",
"no_value": "値なし",
"remixes": "リミックスされた編成",
"level": "レベル"
}

View file

@ -4,6 +4,14 @@
"title": "ロードマップ"
}
},
"toasts": {
"title": "新アプデ",
"description": {
"content": "グランブルーファンタジーの新アプデのコンテンツが追加しました。",
"feature": "編成をリミックスしたり、キャラの指輪や耳飾りを付けたり、盾やマナベリを装備したりことをできるようにしました。"
},
"button": "詳細をみる"
},
"title": "ロードマップ",
"subtitle": "次回更新予定",
"blurb": "1月下旬〜2月上旬に更新する予定があります。火古戦場に2000位を狙っており、その後は仕事に戻るので開発はちょっとだけ遅くなります。",

View file

@ -119,6 +119,86 @@ select {
}
}
.PageContent {
display: flex;
flex-direction: column;
gap: $unit-4x;
max-width: $grid-width;
margin: $unit-4x auto 0;
h1 {
font-size: $font-xxlarge;
text-align: left;
}
h2 {
font-size: $font-medium;
font-weight: $medium;
margin-bottom: $unit * 3;
}
p {
color: var(--text-secondary);
font-size: $font-regular;
line-height: 1.3;
margin-bottom: $unit;
&:last-of-type {
margin-bottom: 0;
}
}
.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;
h3 {
font-weight: 600;
max-width: 70%;
line-height: 1.3;
}
}
svg {
fill: var(--link-item-image-color);
width: $diameter;
height: auto;
&.ShareIcon {
width: $unit-4x;
}
}
}
h3 {
font-weight: $bold;
}
}
}
.Hovercard {
background: #222;
border-radius: $unit;
@ -288,6 +368,15 @@ i.tag {
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes openModalDesktop {
0% {
opacity: 0;
@ -312,6 +401,24 @@ i.tag {
}
}
@keyframes slideLeft {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slideRight {
from {
transform: translateX(var(--radix-toast-swipe-end-x));
}
to {
transform: translateX(100%);
}
}
@keyframes fadeInFilter {
from {
backdrop-filter: blur(5px) saturate(100%) brightness(80%) opacity(0);

View file

@ -1,6 +1,6 @@
// use with @include
@mixin breakpoint($breakpoint) {
$phone-width: 430px;
$phone-width: 450px;
$phone-height: 920px;
$tablet-width: 1024px;

View file

@ -16,6 +16,11 @@
--accent-blue: #{$accent--blue--light};
--accent-yellow: #{$accent--yellow--light};
--selected-item-bg: #{$selected--item--bg--light};
--selected-item-bg-hover: #{$selected--item--bg--light--hover};
--placeholder-bg: #{$grey-80};
// Light - Menus
--dialog-bg: #{$dialog--bg--light};
@ -143,6 +148,11 @@
--accent-blue: #{$accent--blue--dark};
--accent-yellow: #{$accent--yellow--dark};
--selected-item-bg: #{$selected--item--bg--dark};
--selected-item-bg-hover: #{$selected--item--bg--dark--hover};
--placeholder-bg: #{$grey-40};
// Dark - Dialogs
--dialog-bg: #{$dialog--bg--dark};

View file

@ -83,6 +83,11 @@ $accent--blue--dark: #6195f4;
$accent--yellow--light: #c89d39;
$accent--yellow--dark: #f9cc64;
$selected--item--bg--dark: #f9cc645d;
$selected--item--bg--dark--hover: #fcc33f81;
$selected--item--bg--light: #f9cc645d;
$selected--item--bg--light--hover: #ecbc4c6f;
// Colors -- Elements
$wind-text-00: #023e28;
$wind-text-10: #006a43;
@ -148,8 +153,8 @@ $dialog--bg--dark: $grey-25;
// Color Definitions: Menu
$menu--bg--light: $grey-100;
$menu--bg--dark: $grey-10;
$menu--text--light: $grey-90;
$menu--text--dark: $grey-50;
$menu--text--light: $grey-50;
$menu--text--dark: $grey-60;
$menu--separator--light: $grey-90;
$menu--separator--dark: $grey-05;
$menu--item--bg--light--hover: $grey-85;

5
types/AppUpdate.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
interface AppUpdate {
version: string
update_type: string
updated_at: string
}

2
types/Job.d.ts vendored
View file

@ -14,4 +14,6 @@ interface Job {
proficiency2: number
}
base_job?: Job
accessory: boolean
accessory_type: number
}

11
types/JobAccessory.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
interface JobAccessory {
id: string
granblue_id: string
job: Job
name: {
[key: string]: string
en: string
ja: string
}
rarity: number
}

4
types/Party.d.ts vendored
View file

@ -18,8 +18,10 @@ interface Party {
button_count?: number
turn_count?: number
chain_count?: number
source_party?: Party
job: Job
job_skills: JobSkillObject
accessory: JobAccessory
shortcode: string
extra: boolean
favorited: boolean
@ -27,6 +29,8 @@ interface Party {
weapons: Array<GridWeapon>
summons: Array<GridSummon>
user: User
remix: boolean
remixes: Party[]
created_at: string
updated_at: string
}

3
types/User.d.ts vendored
View file

@ -1,11 +1,10 @@
interface User {
id: string
username: string
granblueId: number
granblueId: string
avatar: {
picture: string
element: string
}
gender: number
private: boolean
}

View file

@ -2,9 +2,12 @@ import { proxy } from 'valtio'
export type UserState = {
id: string
granblueId: string
username: string
picture: string
element: string
avatar: {
picture: string
element: string
}
gender: number
language: string
theme: string

View file

@ -115,6 +115,16 @@ class Api {
return axios.get(resourceUrl, params)
}
jobAccessoriesForJob(jobId: string, params?: {}) {
const resourceUrl = `${this.url}/jobs/${jobId}/accessories`
return axios.get(resourceUrl, params)
}
remix(shortcode: string, params?: {}) {
const resourceUrl = `${this.url}/parties/${shortcode}/remix`
return axios.post(resourceUrl, params)
}
savedTeams(params: {}) {
const resourceUrl = `${this.url}/parties/favorites`
return axios.get(resourceUrl, params)
@ -148,6 +158,11 @@ class Api {
const resourceUrl = `${this.url}/users/info/${id}`
return axios.get(resourceUrl)
}
version() {
const resourceUrl = `${this.url}/version`
return axios.get(resourceUrl)
}
}
const api: Api = new Api({ url: process.env.NEXT_PUBLIC_SIERO_API_URL || 'https://localhost:3000/api/v1'})

View file

@ -16,6 +16,19 @@ const emptyJob: Job = {
proficiency1: 0,
proficiency2: 0,
},
accessory: false,
accessory_type: 0,
}
const emptyJobAccessory: JobAccessory = {
id: '-1',
granblue_id: '-1',
job: emptyJob,
name: {
en: '',
ja: '',
},
rarity: 0,
}
interface AppState {
@ -23,12 +36,14 @@ interface AppState {
party: {
id: string | undefined
shortcode: string | undefined
editable: boolean
detailsVisible: boolean
name: string | undefined
description: string | undefined
job: Job
jobSkills: JobSkillObject
accessory: JobAccessory
raid: Raid | undefined
element: number
fullAuto: boolean
@ -41,6 +56,9 @@ interface AppState {
extra: boolean
user: User | undefined
favorited: boolean
remix: boolean
remixes: Party[]
sourceParty?: Party
created_at: string
updated_at: string
}
@ -67,11 +85,13 @@ interface AppState {
jobs: Job[]
jobSkills: JobSkill[]
weaponKeys: GroupedWeaponKeys
version: AppUpdate
}
export const initialAppState: AppState = {
party: {
id: undefined,
shortcode: '',
editable: false,
detailsVisible: false,
name: undefined,
@ -83,6 +103,7 @@ export const initialAppState: AppState = {
2: undefined,
3: undefined,
},
accessory: emptyJobAccessory,
raid: undefined,
fullAuto: false,
autoGuard: false,
@ -95,6 +116,9 @@ export const initialAppState: AppState = {
extra: false,
user: undefined,
favorited: false,
remix: false,
remixes: [],
sourceParty: undefined,
created_at: '',
updated_at: '',
},
@ -127,6 +151,11 @@ export const initialAppState: AppState = {
gauph: [],
emblem: [],
},
version: {
version: '0.0',
update_type: '',
updated_at: '',
},
}
export const appState = proxy(initialAppState)

View file

@ -0,0 +1,3 @@
export default function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1)
}

View file

@ -6,7 +6,10 @@ export default function changeLanguage(
newLanguage: string
) {
if (newLanguage !== router.locale) {
setCookie('NEXT_LOCALE', newLanguage, { path: '/' })
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 60)
setCookie('NEXT_LOCALE', newLanguage, { path: '/', expires: expiresAt })
router.push(router.asPath, undefined, { locale: newLanguage })
}
}

View file

@ -19,3 +19,9 @@ export enum TeamElement {
Dark,
Light,
}
export enum AboutTabs {
About,
Updates,
Roadmap,
}

View file

@ -0,0 +1,10 @@
import api from './api'
export default async function fetchLatestVersion() {
try {
const response = await api.version()
return response.data as AppUpdate
} catch (error) {
console.error(error)
}
}

View file

@ -0,0 +1,4 @@
export const ACCESSORY_JOB_IDS = [
'683ffee8-4ea2-432d-bc30-4865020ac9f4',
'a5d6fca3-5649-4e12-a6db-5fcec49150ee',
]

20
utils/reportError.tsx Normal file
View file

@ -0,0 +1,20 @@
import { AxiosError } from 'axios'
function handleError(error: any) {
if (error instanceof Error) return error.message
}
function handleAxiosError(error: any) {
const axiosError = error as AxiosError
return axiosError.response
}
export function printError(error: any, type?: string) {
if (type === 'axios') {
const response = handleAxiosError(error)
console.log(`${response?.status} ${response?.statusText}`)
console.log(response?.data)
} else {
console.log(handleError(error))
}
}