-
-
- changeOpen()}
- onClose={onClose}
- triggerClass="modal"
- >
- {generateOptions(props.object)}
-
-
-
-
-
{error}
-
+
+
)
}
diff --git a/components/AxSelect/index.tsx b/components/AxSelect/index.tsx
index 01c9c17e..99ab9c7e 100644
--- a/components/AxSelect/index.tsx
+++ b/components/AxSelect/index.tsx
@@ -7,7 +7,7 @@ import SelectItem from '~components/SelectItem'
import classNames from 'classnames'
-import { axData } from '~utils/axData'
+import ax from '~data/ax'
import './index.scss'
@@ -155,7 +155,7 @@ const AXSelect = (props: Props) => {
if (props.currentSkills[0].modifier > -1 && primaryAxValueInput.current) {
const modifier = props.currentSkills[0].modifier
- const axSkill = axData[props.axType - 1][modifier]
+ const axSkill = ax[props.axType - 1][modifier]
setupInput(axSkill, primaryAxValueInput.current)
}
}
@@ -169,7 +169,7 @@ const AXSelect = (props: Props) => {
props.currentSkills[1].modifier != null
) {
const firstSkill = props.currentSkills[0]
- const primaryAxSkill = axData[props.axType - 1][firstSkill.modifier]
+ const primaryAxSkill = ax[props.axType - 1][firstSkill.modifier]
const secondaryAxSkill = findSecondaryAxSkill(
primaryAxSkill,
props.currentSkills[1]
@@ -185,7 +185,7 @@ const AXSelect = (props: Props) => {
}
function findSecondaryAxSkill(
- axSkill: AxSkill | undefined,
+ axSkill: ItemSkill | undefined,
skillAtIndex: SimpleAxSkill
) {
if (axSkill)
@@ -213,7 +213,7 @@ const AXSelect = (props: Props) => {
}
function generateOptions(modifierSet: number) {
- const axOptions = axData[props.axType - 1]
+ const axOptions = ax[props.axType - 1]
let axOptionElements: React.ReactNode[] = []
if (modifierSet == 0) {
@@ -264,7 +264,7 @@ const AXSelect = (props: Props) => {
secondaryAxModifierSelect.current &&
secondaryAxValueInput.current
) {
- setupInput(axData[props.axType - 1][value], primaryAxValueInput.current)
+ setupInput(ax[props.axType - 1][value], primaryAxValueInput.current)
setPrimaryAxValue(0)
primaryAxValueInput.current.value = ''
@@ -280,7 +280,7 @@ const AXSelect = (props: Props) => {
const value = parseInt(rawValue)
setSecondaryAxModifier(value)
- const primaryAxSkill = axData[props.axType - 1][primaryAxModifier]
+ const primaryAxSkill = ax[props.axType - 1][primaryAxModifier]
const currentAxSkill = primaryAxSkill.secondary
? primaryAxSkill.secondary.find((skill) => skill.id == value)
: undefined
@@ -304,7 +304,7 @@ const AXSelect = (props: Props) => {
}
function handlePrimaryErrors(value: number) {
- const primaryAxSkill = axData[props.axType - 1][primaryAxModifier]
+ const primaryAxSkill = ax[props.axType - 1][primaryAxModifier]
let newErrors = { ...errors }
if (value < primaryAxSkill.minValue) {
@@ -333,7 +333,7 @@ const AXSelect = (props: Props) => {
}
function handleSecondaryErrors(value: number) {
- const primaryAxSkill = axData[props.axType - 1][primaryAxModifier]
+ const primaryAxSkill = ax[props.axType - 1][primaryAxModifier]
let newErrors = { ...errors }
if (primaryAxSkill.secondary) {
@@ -373,7 +373,7 @@ const AXSelect = (props: Props) => {
return newErrors.axValue2.length === 0
}
- function setupInput(ax: AxSkill | undefined, element: HTMLInputElement) {
+ function setupInput(ax: ItemSkill | undefined, element: HTMLInputElement) {
if (ax) {
const rangeString = `${ax.minValue}~${ax.maxValue}${ax.suffix || ''}`
@@ -410,6 +410,7 @@ const AXSelect = (props: Props) => {
onOpenChange={() => openSelect(1)}
onValueChange={handleAX1SelectChange}
triggerClass="modal"
+ overlayVisible={false}
>
{generateOptions(0)}
@@ -439,6 +440,7 @@ const AXSelect = (props: Props) => {
onValueChange={handleAX2SelectChange}
triggerClass="modal"
ref={secondaryAxModifierSelect}
+ overlayVisible={false}
>
{generateOptions(1)}
diff --git a/components/Button/index.scss b/components/Button/index.scss
index 4c6415e5..e0527b66 100644
--- a/components/Button/index.scss
+++ b/components/Button/index.scss
@@ -8,6 +8,8 @@
font-size: $font-button;
font-weight: $normal;
gap: 6px;
+ transition: 0.18s opacity ease-in-out;
+ user-select: none;
&:hover,
&.Blended:hover,
@@ -30,6 +32,24 @@
background: transparent;
}
+ &.IconButton.medium {
+ height: inherit;
+ padding: $unit-half;
+
+ &:hover {
+ background: none;
+ }
+
+ .Text {
+ font-size: $font-small;
+ font-weight: $bold;
+
+ @include breakpoint(phone) {
+ display: none;
+ }
+ }
+ }
+
&.Contained {
background: var(--button-contained-bg);
@@ -42,10 +62,10 @@
stroke: #ff4d4d;
}
- &.Active.Save {
+ &.Save {
color: #ff4d4d;
- .Accessory svg {
+ &.Active .Accessory svg {
fill: #ff4d4d;
stroke: #ff4d4d;
}
@@ -61,6 +81,14 @@
}
}
+ &.Options {
+ box-shadow: 0px 1px 3px rgb(0 0 0 / 14%);
+ position: absolute;
+ left: 8px;
+ top: 8px;
+ z-index: 3;
+ }
+
&:disabled {
background-color: var(--button-bg-disabled);
color: var(--button-text-disabled);
@@ -81,6 +109,17 @@
padding: $unit * 1.5;
}
+ @include breakpoint(phone) {
+ &.destructive {
+ background: $error;
+ color: $grey-100;
+
+ .Accessory svg {
+ fill: $grey-100;
+ }
+ }
+ }
+
&.destructive:hover {
background: $error;
color: $grey-100;
@@ -90,24 +129,27 @@
}
}
- &.save:hover {
- color: #ff4d4d;
-
+ &.Save {
.Accessory svg {
- fill: #ff4d4d;
- stroke: #ff4d4d;
+ fill: none;
+ stroke: var(--button-text);
}
- }
- &.save.Active {
- color: #ff4d4d;
+ &.Saved {
+ color: #ff4d4d;
+
+ .Accessory svg {
+ fill: #ff4d4d;
+ stroke: none;
+ }
+ }
&:hover {
- color: darken(#ff4d4d, 30);
+ color: #ff4d4d;
- .icon svg {
- fill: darken(#ff4d4d, 30);
- stroke: darken(#ff4d4d, 30);
+ .Accessory svg {
+ fill: none;
+ stroke: #ff4d4d;
}
}
}
@@ -129,6 +171,10 @@
display: flex;
+ &.Arrow {
+ margin-top: $unit-half;
+ }
+
svg {
fill: var(--button-text);
height: $dimension;
diff --git a/components/Button/index.tsx b/components/Button/index.tsx
index e87e7ff7..8594c129 100644
--- a/components/Button/index.tsx
+++ b/components/Button/index.tsx
@@ -8,7 +8,10 @@ interface Props
React.ButtonHTMLAttributes
,
HTMLButtonElement
> {
- accessoryIcon?: React.ReactNode
+ leftAccessoryIcon?: React.ReactNode
+ leftAccessoryClassName?: string
+ rightAccessoryIcon?: React.ReactNode
+ rightAccessoryClassName?: string
active?: boolean
blended?: boolean
contained?: boolean
@@ -24,22 +27,45 @@ const defaultProps = {
}
const Button = React.forwardRef(function button(
- { accessoryIcon, active, blended, contained, buttonSize, text, ...props },
+ {
+ leftAccessoryIcon,
+ leftAccessoryClassName,
+ rightAccessoryIcon,
+ rightAccessoryClassName,
+ active,
+ blended,
+ contained,
+ buttonSize,
+ text,
+ ...props
+ },
forwardedRef
) {
- const classes = classNames(
- {
- Button: true,
- Active: active,
- Blended: blended,
- Contained: contained,
- },
- buttonSize,
- props.className
- )
+ const classes = classNames(buttonSize, props.className, {
+ Button: true,
+ Active: active,
+ Blended: blended,
+ Contained: contained,
+ })
- const hasAccessory = () => {
- if (accessoryIcon) return {accessoryIcon}
+ const leftAccessoryClasses = classNames(leftAccessoryClassName, {
+ Accessory: true,
+ Left: true,
+ })
+
+ const rightAccessoryClasses = classNames(rightAccessoryClassName, {
+ Accessory: true,
+ Right: true,
+ })
+
+ const hasLeftAccessory = () => {
+ if (leftAccessoryIcon)
+ return {leftAccessoryIcon}
+ }
+
+ const hasRightAccessory = () => {
+ if (rightAccessoryIcon)
+ return {rightAccessoryIcon}
}
const hasText = () => {
@@ -48,8 +74,9 @@ const Button = React.forwardRef(function button(
return (
- {hasAccessory()}
+ {hasLeftAccessory()}
{hasText()}
+ {hasRightAccessory()}
)
})
diff --git a/components/ChangelogModal/index.scss b/components/ChangelogModal/index.scss
deleted file mode 100644
index 67aac905..00000000
--- a/components/ChangelogModal/index.scss
+++ /dev/null
@@ -1,15 +0,0 @@
-h3.version {
- color: $blue;
- font-weight: $medium;
- font-size: $font-medium;
- margin-bottom: $unit;
-}
-
-.notes {
- color: var(--text-primary);
- list-style-type: disc;
-
- li {
- margin-bottom: $unit-half;
- }
-}
diff --git a/components/ChangelogModal/index.tsx b/components/ChangelogModal/index.tsx
deleted file mode 100644
index 2991f8fa..00000000
--- a/components/ChangelogModal/index.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react'
-import { useTranslation } from 'next-i18next'
-import * as Dialog from '@radix-ui/react-dialog'
-
-import CrossIcon from '~public/icons/Cross.svg'
-
-import './index.scss'
-
-const ChangelogModal = () => {
- const { t } = useTranslation('common')
-
- return (
-
-
-
- {t('modals.changelog.title')}
-
-
-
- event.preventDefault()}
- >
-
-
- {t('menu.changelog')}
-
-
-
-
-
-
-
-
-
-
- 1.0
-
- First release!
- Content update - Mid-December 2022 Flash Gala
- You can embed Youtube videos now
- Better clicking - right-click and open in a new tab
- Manually set dark mode in Account Settings
- Lots of bugs squashed
-
-
-
-
-
-
-
- )
-}
-
-export default ChangelogModal
diff --git a/components/ChangelogUnit/index.scss b/components/ChangelogUnit/index.scss
new file mode 100644
index 00000000..6c6a0e8c
--- /dev/null
+++ b/components/ChangelogUnit/index.scss
@@ -0,0 +1,17 @@
+.ChangelogUnit {
+ display: flex;
+ flex-direction: column;
+ gap: $unit;
+
+ img {
+ border-radius: $input-corner;
+ width: 100%;
+ }
+
+ h4 {
+ font-size: $font-small;
+ font-weight: $medium;
+ text-align: center;
+ line-height: 1.4;
+ }
+}
diff --git a/components/ChangelogUnit/index.tsx b/components/ChangelogUnit/index.tsx
new file mode 100644
index 00000000..98fdb87b
--- /dev/null
+++ b/components/ChangelogUnit/index.tsx
@@ -0,0 +1,94 @@
+import { useRouter } from 'next/router'
+import React, { useEffect, useState } from 'react'
+import api from '~utils/api'
+
+import './index.scss'
+
+interface Props {
+ id: string
+ type: 'character' | 'summon' | 'weapon'
+ image?: '01' | '02' | '03' | '04'
+}
+
+const defaultProps = {
+ active: false,
+ blended: false,
+ contained: false,
+ buttonSize: 'medium' as const,
+ image: '01',
+}
+
+const ChangelogUnit = ({ id, type, image }: Props) => {
+ // Router
+ const router = useRouter()
+ const locale =
+ router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
+
+ // State
+ const [item, setItem] = useState()
+
+ // Hooks
+ useEffect(() => {
+ fetch()
+ }, [])
+
+ async function fetch() {
+ switch (type) {
+ case 'character':
+ const character = await fetchCharacter()
+ setItem(character.data)
+ break
+
+ case 'weapon':
+ const weapon = await fetchWeapon()
+ setItem(weapon.data)
+ break
+
+ case 'summon':
+ const summon = await fetchSummon()
+ setItem(summon.data)
+ break
+ }
+ }
+
+ async function fetchCharacter() {
+ return api.endpoints.characters.getOne({ id: id })
+ }
+
+ async function fetchWeapon() {
+ return api.endpoints.weapons.getOne({ id: id })
+ }
+
+ async function fetchSummon() {
+ return api.endpoints.summons.getOne({ id: id })
+ }
+
+ const imageUrl = () => {
+ let src = ''
+
+ switch (type) {
+ case 'character':
+ src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-grid/${id}_${image}.jpg`
+ break
+ case 'weapon':
+ src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${id}.jpg`
+ break
+ case 'summon':
+ src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${id}.jpg`
+ break
+ }
+
+ return src
+ }
+
+ return (
+
+
+
{item ? item.name[locale] : ''}
+
+ )
+}
+
+ChangelogUnit.defaultProps = defaultProps
+
+export default ChangelogUnit
diff --git a/components/CharacterConflictModal/index.tsx b/components/CharacterConflictModal/index.tsx
index b14fed31..535d6305 100644
--- a/components/CharacterConflictModal/index.tsx
+++ b/components/CharacterConflictModal/index.tsx
@@ -2,7 +2,8 @@ import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { Trans, useTranslation } from 'next-i18next'
-import { Dialog, DialogContent } from '~components/Dialog'
+import { Dialog } from '~components/Dialog'
+import DialogContent from '~components/DialogContent'
import Button from '~components/Button'
import Overlay from '~components/Overlay'
@@ -29,6 +30,9 @@ const CharacterConflictModal = (props: Props) => {
// States
const [open, setOpen] = useState(false)
+ // Refs
+ const footerRef = React.createRef()
+
useEffect(() => {
setOpen(props.open)
}, [setOpen, props.open])
@@ -71,43 +75,53 @@ const CharacterConflictModal = (props: Props) => {
return (
event.preventDefault()}
onEscapeKeyDown={close}
>
-
-
-
-
-
- {props.conflictingCharacters?.map((character, i) => (
-
+
+
+
+
+
+
+ {props.conflictingCharacters?.map((character, i) => (
+
+
+ {character.object.name[locale]}
+
+ ))}
+
+
→
+
+
-
{character.object.name[locale]}
-
- ))}
-
-
→
-
-
-
-
{props.incomingCharacter?.name[locale]}
+
{props.incomingCharacter?.name[locale]}
+
-
+
diff --git a/components/CharacterGrid/index.tsx b/components/CharacterGrid/index.tsx
index 48cc0ca8..9cd2d54f 100644
--- a/components/CharacterGrid/index.tsx
+++ b/components/CharacterGrid/index.tsx
@@ -2,8 +2,9 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { getCookie } from 'cookies-next'
import { useSnapshot } from 'valtio'
+import { useTranslation } from 'next-i18next'
-import { AxiosResponse } from 'axios'
+import { AxiosError, AxiosResponse } from 'axios'
import debounce from 'lodash.debounce'
import Alert from '~components/Alert'
@@ -15,13 +16,13 @@ import type { DetailsObject, JobSkillObject, SearchableObject } from '~types'
import api from '~utils/api'
import { appState } from '~utils/appState'
-import { accountState } from '~utils/accountState'
import './index.scss'
// Props
interface Props {
new: boolean
+ editable: boolean
characters?: GridCharacter[]
createParty: (details?: DetailsObject) => Promise
pushHistory?: (path: string) => void
@@ -31,15 +32,21 @@ const CharacterGrid = (props: Props) => {
// Constants
const numCharacters: number = 5
+ // Localization
+ const { t } = useTranslation('common')
+
// Cookies
const cookie = getCookie('account')
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
+ // Set up state for error handling
+ const [axiosError, setAxiosError] = useState()
+ const [errorAlertOpen, setErrorAlertOpen] = useState(false)
+
// Set up state for view management
const { party, grid } = useSnapshot(appState)
- const [slug, setSlug] = useState()
const [modalOpen, setModalOpen] = useState(false)
// Set up state for conflict management
@@ -55,27 +62,23 @@ const CharacterGrid = (props: Props) => {
2: undefined,
3: undefined,
})
+ const [jobAccessory, setJobAccessory] = useState()
const [errorMessage, setErrorMessage] = useState('')
- // Create a temporary state to store previous character uncap values
+ // Create a temporary state to store previous weapon uncap values and transcendence stages
const [previousUncapValues, setPreviousUncapValues] = useState<{
[key: number]: number | undefined
}>({})
- // Set the editable flag only on first load
- useEffect(() => {
- // If user is logged in and matches
- if (
- (accountData && party.user && accountData.userId === party.user.id) ||
- props.new
- )
- appState.party.editable = true
- else appState.party.editable = false
- }, [props.new, accountData, party])
+ const [previousTranscendenceStages, setPreviousTranscendenceStages] =
+ useState<{
+ [key: number]: number | undefined
+ }>({})
useEffect(() => {
setJob(appState.party.job)
setJobSkills(appState.party.jobSkills)
+ setJobAccessory(appState.party.accessory)
}, [appState])
// Initialize an array of current uncap values for each characters
@@ -101,10 +104,18 @@ const CharacterGrid = (props: Props) => {
.catch((error) => console.error(error))
})
} else {
- if (party.editable)
+ if (props.editable)
saveCharacter(party.id, character, position)
.then((response) => handleCharacterResponse(response.data))
- .catch((error) => console.error(error))
+ .catch((error) => {
+ const axiosError = error as AxiosError
+ const response = axiosError.response
+
+ if (response) {
+ setErrorAlertOpen(true)
+ setAxiosError(response)
+ }
+ })
}
}
@@ -171,8 +182,17 @@ const CharacterGrid = (props: Props) => {
setIncoming(undefined)
}
+ async function removeCharacter(id: string) {
+ try {
+ const response = await api.endpoints.grid_characters.destroy({ id: id })
+ appState.grid.characters[response.data.position] = undefined
+ } catch (error) {
+ console.error(error)
+ }
+ }
+
// Methods: Saving job and job skills
- const saveJob = async function (job?: Job) {
+ async function saveJob(job?: Job) {
const payload = {
party: {
job_id: job ? job.id : -1,
@@ -200,8 +220,8 @@ const CharacterGrid = (props: Props) => {
}
}
- const saveJobSkill = function (skill: JobSkill, position: number) {
- if (party.id && appState.party.editable) {
+ function saveJobSkill(skill: JobSkill, position: number) {
+ if (party.id && props.editable) {
const positionedKey = `skill${position}_id`
let skillObject: {
@@ -239,6 +259,24 @@ const CharacterGrid = (props: Props) => {
}
}
+ async function saveAccessory(accessory: JobAccessory) {
+ const payload = {
+ party: {
+ accessory_id: accessory.id,
+ },
+ }
+
+ if (appState.party.id) {
+ const response = await api.endpoints.parties.update(
+ appState.party.id,
+ payload
+ )
+ const team = response.data.party
+ setJobAccessory(team.accessory)
+ appState.party.accessory = team.accessory
+ }
+ }
+
// Methods: Helpers
function characterUncapLevel(character: Character) {
let uncapLevel
@@ -260,6 +298,7 @@ const CharacterGrid = (props: Props) => {
// Note: Saves, but debouncing is not working properly
async function saveUncap(id: string, position: number, uncapLevel: number) {
storePreviousUncapValue(position)
+ storePreviousTranscendenceStage(position)
try {
if (uncapLevel != previousUncapValues[position])
@@ -271,11 +310,17 @@ const CharacterGrid = (props: Props) => {
// Revert optimistic UI
updateUncapLevel(position, previousUncapValues[position])
+ updateTranscendenceStage(position, previousTranscendenceStages[position])
// Remove optimistic key
- let newPreviousValues = { ...previousUncapValues }
- delete newPreviousValues[position]
- setPreviousUncapValues(newPreviousValues)
+ let newPreviousTranscendenceStages = { ...previousTranscendenceStages }
+ let newPreviousUncapValues = { ...previousUncapValues }
+
+ delete newPreviousTranscendenceStages[position]
+ delete newPreviousUncapValues[position]
+
+ setPreviousTranscendenceStages(newPreviousTranscendenceStages)
+ setPreviousUncapValues(newPreviousUncapValues)
}
}
@@ -284,26 +329,26 @@ const CharacterGrid = (props: Props) => {
position: number,
uncapLevel: number
) {
- if (
- party.user &&
- accountState.account.user &&
- party.user.id === accountState.account.user.id
- ) {
- memoizeAction(id, position, uncapLevel)
+ if (props.editable) {
+ memoizeUncapAction(id, position, uncapLevel)
// Optimistically update UI
updateUncapLevel(position, uncapLevel)
+
+ if (uncapLevel < 6) {
+ updateTranscendenceStage(position, 0)
+ }
}
}
- const memoizeAction = useCallback(
+ const memoizeUncapAction = useCallback(
(id: string, position: number, uncapLevel: number) => {
- debouncedAction(id, position, uncapLevel)
+ debouncedUncapAction(id, position, uncapLevel)
},
[props, previousUncapValues]
)
- const debouncedAction = useMemo(
+ const debouncedUncapAction = useMemo(
() =>
debounce((id, position, number) => {
saveUncap(id, position, number)
@@ -332,11 +377,119 @@ const CharacterGrid = (props: Props) => {
}
}
+ // Methods: Updating transcendence stage
+ // Note: Saves, but debouncing is not working properly
+ async function saveTranscendence(
+ id: string,
+ position: number,
+ stage: number
+ ) {
+ storePreviousUncapValue(position)
+ storePreviousTranscendenceStage(position)
+
+ const payload = {
+ character: {
+ uncap_level: stage > 0 ? 6 : 5,
+ transcendence_step: stage,
+ },
+ }
+
+ try {
+ if (stage != previousTranscendenceStages[position])
+ await api.endpoints.grid_characters
+ .update(id, payload)
+ .then((response) => {
+ storeGridCharacter(response.data)
+ })
+ } catch (error) {
+ console.error(error)
+
+ // Revert optimistic UI
+ updateUncapLevel(position, previousUncapValues[position])
+ updateTranscendenceStage(position, previousTranscendenceStages[position])
+
+ // Remove optimistic key
+ let newPreviousTranscendenceStages = { ...previousTranscendenceStages }
+ let newPreviousUncapValues = { ...previousUncapValues }
+
+ delete newPreviousTranscendenceStages[position]
+ delete newPreviousUncapValues[position]
+
+ setPreviousTranscendenceStages(newPreviousTranscendenceStages)
+ setPreviousUncapValues(newPreviousUncapValues)
+ }
+ }
+
+ function initiateTranscendenceUpdate(
+ id: string,
+ position: number,
+ stage: number
+ ) {
+ if (props.editable) {
+ memoizeTranscendenceAction(id, position, stage)
+
+ // Optimistically update UI
+ updateTranscendenceStage(position, stage)
+
+ if (stage > 0) {
+ updateUncapLevel(position, 6)
+ }
+ }
+ }
+
+ const memoizeTranscendenceAction = useCallback(
+ (id: string, position: number, stage: number) => {
+ debouncedTranscendenceAction(id, position, stage)
+ },
+ [props, previousTranscendenceStages]
+ )
+
+ const debouncedTranscendenceAction = useMemo(
+ () =>
+ debounce((id, position, number) => {
+ saveTranscendence(id, position, number)
+ }, 500),
+ [props, saveTranscendence]
+ )
+
+ const updateTranscendenceStage = (
+ position: number,
+ stage: number | undefined
+ ) => {
+ const character = appState.grid.characters[position]
+ if (character && stage !== undefined) {
+ character.transcendence_step = stage
+ appState.grid.characters[position] = character
+ }
+ }
+
+ function storePreviousTranscendenceStage(position: number) {
+ // Save the current value in case of an unexpected result
+ let newPreviousValues = { ...previousUncapValues }
+
+ if (grid.characters[position]) {
+ newPreviousValues[position] = grid.characters[position]?.uncap_level
+ setPreviousTranscendenceStages(newPreviousValues)
+ }
+ }
+
function cancelAlert() {
setErrorMessage('')
}
// Render: JSX components
+ const errorAlert = () => {
+ return (
+ setErrorAlertOpen(false)}
+ cancelActionText={t('buttons.confirm')}
+ />
+ )
+ }
+
return (
+ {errorAlert()}
)
}
diff --git a/components/CharacterHovercard/index.scss b/components/CharacterHovercard/index.scss
index e69de29b..1f1045dc 100644
--- a/components/CharacterHovercard/index.scss
+++ b/components/CharacterHovercard/index.scss
@@ -0,0 +1,68 @@
+.Character.HovercardContent {
+ .title .Image {
+ position: relative;
+
+ .Perpetuity {
+ position: absolute;
+ background-image: url('/icons/perpetuity/filled.svg');
+ background-size: $unit-3x $unit-3x;
+ z-index: 20;
+ top: $unit-half * -1;
+ right: $unit-3x;
+ width: $unit-3x;
+ height: $unit-3x;
+ }
+ }
+
+ .Mastery {
+ display: flex;
+ flex-direction: column;
+ gap: $unit;
+
+ ul {
+ display: flex;
+ flex-direction: column;
+ gap: $unit-half;
+
+ .ExtendedMastery {
+ align-items: center;
+ display: flex;
+ gap: $unit-half;
+
+ img {
+ width: $unit-3x;
+ }
+
+ strong {
+ font-weight: $bold;
+ }
+ }
+ }
+ }
+
+ .Awakening {
+ display: flex;
+ flex-direction: column;
+ gap: $unit;
+
+ & > div {
+ align-items: center;
+ display: flex;
+ gap: $unit-half;
+
+ img {
+ width: $unit-3x;
+ }
+
+ strong {
+ font-weight: $bold;
+ }
+ }
+ }
+
+ // .Footer {
+ // position: sticky;
+ // bottom: 0;
+ // left: 0;
+ // }
+}
diff --git a/components/CharacterHovercard/index.tsx b/components/CharacterHovercard/index.tsx
index 0f9d16ac..76eb2c4d 100644
--- a/components/CharacterHovercard/index.tsx
+++ b/components/CharacterHovercard/index.tsx
@@ -2,16 +2,29 @@ import React from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
-import * as HoverCard from '@radix-ui/react-hover-card'
-
+import {
+ Hovercard,
+ HovercardContent,
+ HovercardTrigger,
+} from '~components/Hovercard'
+import Button from '~components/Button'
import WeaponLabelIcon from '~components/WeaponLabelIcon'
import UncapIndicator from '~components/UncapIndicator'
+import {
+ overMastery,
+ aetherialMastery,
+ permanentMastery,
+} from '~data/overMastery'
+import { characterAwakening } from '~data/awakening'
+import { ExtendedMastery } from '~types'
+
import './index.scss'
interface Props {
gridCharacter: GridCharacter
children: React.ReactNode
+ onTriggerClick: () => void
}
interface KeyNames {
@@ -43,10 +56,19 @@ const CharacterHovercard = (props: Props) => {
]
const tintElement = Element[props.gridCharacter.object.element]
- const wikiUrl = `https://gbf.wiki/${props.gridCharacter.object.name.en.replaceAll(
- ' ',
- '_'
- )}`
+
+ function goTo() {
+ const urlSafeName = props.gridCharacter.object.name.en.replaceAll(' ', '_')
+ const url = `https://gbf.wiki/${urlSafeName}`
+
+ window.open(url, '_blank')
+ }
+
+ const perpetuity = () => {
+ if (props.gridCharacter && props.gridCharacter.perpetuity) {
+ return
+ }
+ }
function characterImage() {
let imgSrc = ''
@@ -66,59 +88,194 @@ const CharacterHovercard = (props: Props) => {
return imgSrc
}
+ function masteryElement(dictionary: ItemSkill[], mastery: ExtendedMastery) {
+ const canonicalMastery = dictionary.find(
+ (item) => item.id === mastery.modifier
+ )
+
+ if (canonicalMastery) {
+ return (
+
+
+
+ {canonicalMastery.name[locale]}
+ {`+${mastery.strength}${canonicalMastery.suffix}`}
+
+
+ )
+ }
+ }
+
+ const overMasterySection = () => {
+ if (props.gridCharacter && props.gridCharacter.over_mastery) {
+ return (
+
+
+ {t('modals.characters.subtitles.ring')}
+
+
+ {[...Array(4)].map((e, i) => {
+ const ringIndex = i + 1
+ const ringStat: ExtendedMastery =
+ props.gridCharacter.over_mastery[i]
+ if (ringStat && ringStat.modifier && ringStat.modifier > 0) {
+ if (ringIndex === 1 || ringIndex === 2) {
+ return masteryElement(overMastery.a, ringStat)
+ } else if (ringIndex === 3) {
+ return masteryElement(overMastery.b, ringStat)
+ } else {
+ return masteryElement(overMastery.c, ringStat)
+ }
+ }
+ })}
+
+
+ )
+ }
+ }
+
+ const aetherialMasterySection = () => {
+ if (
+ props.gridCharacter &&
+ props.gridCharacter.aetherial_mastery &&
+ props.gridCharacter.aetherial_mastery.modifier > 0
+ ) {
+ return (
+
+
+ {t('modals.characters.subtitles.earring')}
+
+
+ {masteryElement(
+ aetherialMastery,
+ props.gridCharacter.aetherial_mastery
+ )}
+
+
+ )
+ }
+ }
+
+ const permanentMasterySection = () => {
+ if (props.gridCharacter && props.gridCharacter.perpetuity) {
+ return (
+
+
+ {t('modals.characters.subtitles.permanent')}
+
+
+ {[...Array(4)].map((e, i) => {
+ return masteryElement(permanentMastery, {
+ modifier: i + 1,
+ strength: permanentMastery[i].maxValue,
+ })
+ })}
+
+
+ )
+ }
+ }
+
+ const awakeningSection = () => {
+ const gridAwakening = props.gridCharacter.awakening
+ const awakening = characterAwakening.find(
+ (awakening) => awakening.id === gridAwakening?.type
+ )
+
+ if (gridAwakening && awakening) {
+ return (
+
+
+ {t('modals.characters.subtitles.awakening')}
+
+
+ {gridAwakening.type > 1 ? (
+
+ ) : (
+ ''
+ )}
+
+ {`${awakening.name[locale]}`}
+ {`Lv${gridAwakening.level}`}
+
+
+
+ )
+ }
+ }
+
+ const wikiButton = (
+
+ )
+
return (
-
- {props.children}
-
-
-
-
-
{props.gridCharacter.object.name[locale]}
+
+
+ {props.children}
+
+
+
+
+
{props.gridCharacter.object.name[locale]}
+
+ {perpetuity()}
-
-
-
+
+
+
+
+
+ {props.gridCharacter.object.proficiency.proficiency2 ? (
- {props.gridCharacter.object.proficiency.proficiency2 ? (
-
- ) : (
- ''
- )}
-
-
+ ) : (
+ ''
+ )}
+
-
-
- {t('buttons.wiki')}
-
-
-
-
-
+
+ {wikiButton}
+ {awakeningSection()}
+ {overMasterySection()}
+ {aetherialMasterySection()}
+ {permanentMasterySection()}
+
+
)
}
diff --git a/components/CharacterModal/index.scss b/components/CharacterModal/index.scss
new file mode 100644
index 00000000..f2f35bf6
--- /dev/null
+++ b/components/CharacterModal/index.scss
@@ -0,0 +1,78 @@
+.Character.DialogContent {
+ gap: $unit;
+ min-width: 480px;
+
+ @include breakpoint(phone) {
+ min-width: inherit;
+ }
+
+ .DialogHeader {
+ transition: 0.18s padding-top ease-in-out;
+ position: sticky;
+ top: 0;
+
+ &.Scrolled {
+ border-bottom: 1px solid rgba(0, 0, 0, 0.2);
+ box-shadow: 0 1px 12px rgba(0, 0, 0, 0.34);
+ padding-top: $unit-2x;
+ }
+
+ img {
+ transition: 0.2s width ease-in-out;
+ width: $unit-6x !important;
+ }
+
+ .DialogTitle {
+ font-size: $font-large;
+ }
+
+ .SubTitle {
+ display: none;
+ }
+ }
+
+ .mods {
+ display: flex;
+ flex-direction: column;
+ gap: $unit-4x;
+ padding: 0 $unit-4x $unit-2x;
+
+ section {
+ display: flex;
+ flex-direction: column;
+ gap: $unit-half;
+
+ &.inline {
+ align-items: center;
+ flex-direction: row;
+ justify-content: space-between;
+
+ h3 {
+ margin: 0;
+ }
+ }
+
+ h3 {
+ color: $grey-55;
+ font-size: $font-small;
+ margin-bottom: $unit;
+ }
+
+ select {
+ background-color: $grey-90;
+ }
+ }
+
+ .Button {
+ font-size: $font-regular;
+ padding: ($unit * 1.5) ($unit-2x);
+ width: 100%;
+
+ &.btn-disabled {
+ background: $grey-90;
+ color: $grey-70;
+ cursor: not-allowed;
+ }
+ }
+ }
+}
diff --git a/components/CharacterModal/index.tsx b/components/CharacterModal/index.tsx
new file mode 100644
index 00000000..6605c6ee
--- /dev/null
+++ b/components/CharacterModal/index.tsx
@@ -0,0 +1,307 @@
+// Core dependencies
+import React, {
+ PropsWithChildren,
+ useCallback,
+ useEffect,
+ useState,
+} from 'react'
+import { useRouter } from 'next/router'
+import { useTranslation } from 'next-i18next'
+import { AxiosResponse } from 'axios'
+import classNames from 'classnames'
+
+// UI dependencies
+import {
+ Dialog,
+ DialogClose,
+ DialogTitle,
+ DialogTrigger,
+} from '~components/Dialog'
+import DialogContent from '~components/DialogContent'
+import Button from '~components/Button'
+import SelectWithInput from '~components/SelectWithInput'
+import AwakeningSelect from '~components/AwakeningSelect'
+import RingSelect from '~components/RingSelect'
+import Switch from '~components/Switch'
+
+// Utilities
+import api from '~utils/api'
+import { appState } from '~utils/appState'
+import { retrieveCookies } from '~utils/retrieveCookies'
+import elementalizeAetherialMastery from '~utils/elementalizeAetherialMastery'
+
+// Data
+const emptyExtendedMastery: ExtendedMastery = {
+ modifier: 0,
+ strength: 0,
+}
+
+// Styles and icons
+import CrossIcon from '~public/icons/Cross.svg'
+import './index.scss'
+
+// Types
+import {
+ CharacterOverMastery,
+ ExtendedMastery,
+ GridCharacterObject,
+} from '~types'
+
+interface Props {
+ gridCharacter: GridCharacter
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ updateCharacter: (object: GridCharacterObject) => Promise
+}
+
+const CharacterModal = ({
+ gridCharacter,
+ children,
+ open: modalOpen,
+ onOpenChange,
+ updateCharacter,
+}: PropsWithChildren) => {
+ const router = useRouter()
+ const locale =
+ router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
+ const { t } = useTranslation('common')
+
+ // Cookies
+ const cookies = retrieveCookies()
+
+ // UI state
+ const [open, setOpen] = useState(false)
+ const [formValid, setFormValid] = useState(false)
+
+ // Refs
+ const headerRef = React.createRef()
+ const footerRef = React.createRef()
+
+ // Classes
+ const headerClasses = classNames({
+ DialogHeader: true,
+ Short: true,
+ })
+
+ // Callbacks and Hooks
+ useEffect(() => {
+ setOpen(modalOpen)
+ }, [modalOpen])
+
+ // Character properties: Perpetuity
+ const [perpetuity, setPerpetuity] = useState(false)
+
+ // Character properties: Ring
+ const [rings, setRings] = useState({
+ 1: { ...emptyExtendedMastery, modifier: 1 },
+ 2: { ...emptyExtendedMastery, modifier: 2 },
+ 3: emptyExtendedMastery,
+ 4: emptyExtendedMastery,
+ })
+
+ // Character properties: Earrings
+ const [earring, setEarring] = useState(emptyExtendedMastery)
+
+ // Character properties: Awakening
+ const [awakeningType, setAwakeningType] = useState(0)
+ const [awakeningLevel, setAwakeningLevel] = useState(0)
+
+ // Character properties: Transcendence
+ const [transcendenceStep, setTranscendenceStep] = useState(0)
+
+ // Hooks
+ useEffect(() => {
+ if (gridCharacter.aetherial_mastery) {
+ setEarring({
+ modifier: gridCharacter.aetherial_mastery.modifier,
+ strength: gridCharacter.aetherial_mastery.strength,
+ })
+ }
+
+ setAwakeningType(gridCharacter.awakening.type)
+ setAwakeningLevel(gridCharacter.awakening.level)
+ setPerpetuity(gridCharacter.perpetuity)
+ }, [gridCharacter])
+
+ // Prepare the GridWeaponObject to send to the server
+ function prepareObject() {
+ let object: GridCharacterObject = {
+ character: {
+ ring1: {
+ modifier: rings[1].modifier,
+ strength: rings[1].strength,
+ },
+ ring2: {
+ modifier: rings[2].modifier,
+ strength: rings[2].strength,
+ },
+ ring3: {
+ modifier: rings[3].modifier,
+ strength: rings[3].strength,
+ },
+ ring4: {
+ modifier: rings[4].modifier,
+ strength: rings[4].strength,
+ },
+ earring: {
+ modifier: earring.modifier,
+ strength: earring.strength,
+ },
+ awakening: {
+ type: awakeningType,
+ level: awakeningLevel,
+ },
+ transcendence_step: transcendenceStep,
+ perpetuity: perpetuity,
+ },
+ }
+
+ return object
+ }
+
+ // Methods: UI state management
+ function handleOpenChange(open: boolean) {
+ setOpen(open)
+ onOpenChange(open)
+ }
+
+ // Methods: Receive data from components
+ function receiveRingValues(overMastery: CharacterOverMastery) {
+ setRings(overMastery)
+ }
+
+ function receiveEarringValues(
+ earringModifier: number,
+ earringStrength: number
+ ) {
+ setEarring({
+ modifier: earringModifier,
+ strength: earringStrength,
+ })
+ }
+
+ function handleCheckedChange(checked: boolean) {
+ setPerpetuity(checked)
+ }
+
+ async function handleUpdateCharacter() {
+ await updateCharacter(prepareObject())
+
+ setOpen(false)
+ if (onOpenChange) onOpenChange(false)
+ }
+
+ function receiveAwakeningValues(type: number, level: number) {
+ setAwakeningType(type)
+ setAwakeningLevel(level)
+ }
+
+ function receiveValidity(isValid: boolean) {
+ setFormValid(isValid)
+ }
+
+ const ringSelect = () => {
+ return (
+
+ {t('modals.characters.subtitles.ring')}
+
+
+ )
+ }
+
+ const earringSelect = () => {
+ const earringData = elementalizeAetherialMastery(gridCharacter)
+
+ return (
+
+ {t('modals.characters.subtitles.earring')}
+
+
+ )
+ }
+
+ const awakeningSelect = () => {
+ return (
+
+ {t('modals.characters.subtitles.awakening')}
+
+
+ )
+ }
+
+ const perpetuitySwitch = () => {
+ return (
+
+ {t('modals.characters.subtitles.permanent')}
+
+
+ )
+ }
+
+ return (
+
+ {children}
+ event.preventDefault()}
+ onEscapeKeyDown={() => {}}
+ >
+
+
+
+
+ {t('modals.characters.title')}
+
+
+ {gridCharacter.object.name[locale]}
+
+
+
+
+
+
+
+
+
+
+ {perpetuitySwitch()}
+ {ringSelect()}
+ {earringSelect()}
+ {awakeningSelect()}
+
+
+
+
+
+
+ )
+}
+
+export default CharacterModal
diff --git a/components/CharacterUnit/index.scss b/components/CharacterUnit/index.scss
index 0cf54982..5eca6149 100644
--- a/components/CharacterUnit/index.scss
+++ b/components/CharacterUnit/index.scss
@@ -5,6 +5,7 @@
gap: calc($unit / 2);
// min-height: 320px;
// max-width: 200px;
+ position: relative;
margin-bottom: $unit * 4;
&.editable .CharacterImage:hover {
@@ -22,6 +23,17 @@
display: flex;
}
+ .Button {
+ pointer-events: none;
+ opacity: 0;
+ }
+
+ &:hover .Button,
+ .Button.Clicked {
+ pointer-events: initial;
+ opacity: 1;
+ }
+
h3,
ul {
display: none;
@@ -57,9 +69,11 @@
align-items: center;
justify-content: center;
overflow: hidden;
- transition: all 0.18s ease-in-out;
+ transition: $duration-zoom all ease-in-out;
height: auto;
width: 100%;
+ -webkit-user-select: none; /* Safari */
+ user-select: none;
&:hover .icon svg {
fill: var(--icon-secondary-hover);
@@ -72,6 +86,7 @@
z-index: 1;
svg {
+ transition: $duration-color-fade fill ease-in-out;
fill: var(--icon-secondary);
}
}
@@ -82,4 +97,34 @@
font-size: $font-tiny;
}
}
+
+ &:hover .Perpetuity.Empty {
+ opacity: 1;
+ }
+
+ .Perpetuity {
+ position: absolute;
+ background-image: url('/icons/perpetuity/filled.svg');
+ background-size: $unit-4x $unit-4x;
+ z-index: 20;
+ top: $unit * -1;
+ right: $unit-3x;
+ width: $unit-4x;
+ height: $unit-4x;
+ transition: $duration-zoom opacity ease-in-out;
+
+ &:hover {
+ background-image: url('/icons/perpetuity/empty.svg');
+ cursor: pointer;
+ }
+
+ &.Empty {
+ background-image: url('/icons/perpetuity/empty.svg');
+ opacity: 0;
+
+ &:hover {
+ background-image: url('/icons/perpetuity/filled.svg');
+ }
+ }
+ }
}
diff --git a/components/CharacterUnit/index.tsx b/components/CharacterUnit/index.tsx
index 7dbce073..f2d115b7 100644
--- a/components/CharacterUnit/index.tsx
+++ b/components/CharacterUnit/index.tsx
@@ -1,17 +1,35 @@
-import React, { useEffect, useState } from 'react'
+import React, { MouseEvent, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
-import { useTranslation } from 'next-i18next'
-import classnames from 'classnames'
-
-import { appState } from '~utils/appState'
+import { Trans, useTranslation } from 'next-i18next'
+import { AxiosResponse } from 'axios'
+import classNames from 'classnames'
+import Alert from '~components/Alert'
+import Button from '~components/Button'
import CharacterHovercard from '~components/CharacterHovercard'
+import CharacterModal from '~components/CharacterModal'
+import {
+ ContextMenu,
+ ContextMenuTrigger,
+ ContextMenuContent,
+} from '~components/ContextMenu'
+import ContextMenuItem from '~components/ContextMenuItem'
import SearchModal from '~components/SearchModal'
import UncapIndicator from '~components/UncapIndicator'
-import PlusIcon from '~public/icons/Add.svg'
-import type { SearchableObject } from '~types'
+import api from '~utils/api'
+import { appState } from '~utils/appState'
+
+import PlusIcon from '~public/icons/Add.svg'
+import SettingsIcon from '~public/icons/Settings.svg'
+
+// Types
+import type {
+ GridCharacterObject,
+ PerpetuityObject,
+ SearchableObject,
+} from '~types'
import './index.scss'
@@ -19,48 +37,151 @@ interface Props {
gridCharacter?: GridCharacter
position: number
editable: boolean
+ removeCharacter: (id: string) => void
updateObject: (object: SearchableObject, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
+ updateTranscendence: (id: string, position: number, stage: number) => void
}
-const CharacterUnit = (props: Props) => {
+const CharacterUnit = ({
+ gridCharacter,
+ position,
+ editable,
+ removeCharacter: sendCharacterToRemove,
+ updateObject,
+ updateUncap,
+ updateTranscendence,
+}: Props) => {
+ // Translations and locale
const { t } = useTranslation('common')
-
- const { party, grid } = useSnapshot(appState)
-
const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
+ // State snapshot
+ const { party, grid } = useSnapshot(appState)
+
+ // State: UI
+ const [detailsModalOpen, setDetailsModalOpen] = useState(false)
+ const [searchModalOpen, setSearchModalOpen] = useState(false)
+ const [contextMenuOpen, setContextMenuOpen] = useState(false)
+ const [alertOpen, setAlertOpen] = useState(false)
+
+ // State: Other
const [imageUrl, setImageUrl] = useState('')
- const classes = classnames({
+ // Classes
+ const classes = classNames({
CharacterUnit: true,
- editable: props.editable,
- filled: props.gridCharacter !== undefined,
+ editable: editable,
+ filled: gridCharacter !== undefined,
})
- const gridCharacter = props.gridCharacter
+ const buttonClasses = classNames({
+ Options: true,
+ Clicked: contextMenuOpen,
+ })
+
+ // Other
const character = gridCharacter?.object
+ // Hooks
useEffect(() => {
generateImageUrl()
})
+ // Methods: Open layer
+ function openCharacterModal(event: Event) {
+ setDetailsModalOpen(true)
+ }
+
+ function openSearchModal() {
+ if (editable) setSearchModalOpen(true)
+ }
+
+ function openRemoveCharacterAlert() {
+ setAlertOpen(true)
+ }
+
+ // Methods: Handle button clicked
+ function handleButtonClicked() {
+ setContextMenuOpen(!contextMenuOpen)
+ }
+
+ function handlePerpetuityClick() {
+ if (gridCharacter) {
+ let object: PerpetuityObject = {
+ character: { perpetuity: !gridCharacter.perpetuity },
+ }
+
+ updateCharacter(object)
+ }
+ }
+
+ // Methods: Handle open change
+ function handleCharacterModalOpenChange(open: boolean) {
+ setDetailsModalOpen(open)
+ }
+
+ function handleSearchModalOpenChange(open: boolean) {
+ setSearchModalOpen(open)
+ }
+
+ function handleContextMenuOpenChange(open: boolean) {
+ if (!open) setContextMenuOpen(false)
+ }
+
+ // Methods: Mutate data
+
+ // Send the GridWeaponObject to the server
+ async function updateCharacter(
+ object: GridCharacterObject | PerpetuityObject
+ ) {
+ if (gridCharacter)
+ return await api.endpoints.grid_characters
+ .update(gridCharacter.id, object)
+ .then((response) => processResult(response))
+ .catch((error) => processError(error))
+ }
+
+ // Save the server's response to state
+ function processResult(response: AxiosResponse) {
+ const gridCharacter: GridCharacter = response.data
+ appState.grid.characters[gridCharacter.position] = gridCharacter
+ }
+
+ function processError(error: any) {
+ console.error(error)
+ }
+
+ function passUncapData(uncap: number) {
+ if (gridCharacter) updateUncap(gridCharacter.id, position, uncap)
+ }
+
+ function passTranscendenceData(stage: number) {
+ if (gridCharacter) updateTranscendence(gridCharacter.id, position, stage)
+ }
+
+ function removeCharacter() {
+ if (gridCharacter) sendCharacterToRemove(gridCharacter.id)
+ setAlertOpen(false)
+ }
+
+ // Methods: Image string generation
function generateImageUrl() {
let imgSrc = ''
- if (props.gridCharacter) {
- const character = props.gridCharacter.object!
+ if (gridCharacter) {
+ const character = gridCharacter.object!
// Change the image based on the uncap level
let suffix = '01'
- if (props.gridCharacter.uncap_level == 6) suffix = '04'
- else if (props.gridCharacter.uncap_level == 5) suffix = '03'
- else if (props.gridCharacter.uncap_level > 2) suffix = '02'
+ if (gridCharacter.transcendence_step > 0) suffix = '04'
+ else if (gridCharacter.uncap_level >= 5) suffix = '03'
+ else if (gridCharacter.uncap_level > 2) suffix = '02'
// Special casing for Lyria (and Young Cat eventually)
- if (props.gridCharacter.object.granblue_id === '3030182000') {
+ if (gridCharacter.object.granblue_id === '3030182000') {
let element = 1
if (grid.weapons.mainWeapon && grid.weapons.mainWeapon.element) {
element = grid.weapons.mainWeapon.element
@@ -77,61 +198,161 @@ const CharacterUnit = (props: Props) => {
setImageUrl(imgSrc)
}
- function passUncapData(uncap: number) {
- if (props.gridCharacter)
- props.updateUncap(props.gridCharacter.id, props.position, uncap)
+ // Methods: Layer element rendering
+ const characterModal = () => {
+ if (gridCharacter) {
+ return (
+
+ )
+ }
}
- const image = (
-
-
- {props.editable ? (
-
-
-
- ) : (
- ''
- )}
-
- )
+ const contextMenu = () => {
+ if (editable && gridCharacter && gridCharacter.id) {
+ return (
+ <>
+
+
+ }
+ className={buttonClasses}
+ onClick={handleButtonClicked}
+ />
+
+
+
+ {t('context.modify.character')}
+
+
+ {t('context.remove')}
+
+
+
+ {characterModal()}
+ {removeAlert()}
+ >
+ )
+ }
+ }
- const editableImage = (
-
- {image}
-
- )
+ const removeAlert = () => {
+ return (
+ setAlertOpen(false)}
+ cancelActionText={t('buttons.cancel')}
+ message={
+
+ Are you sure you want to remove{' '}
+ {{ character: gridCharacter?.object.name[locale] }} {' '}
+ from your team?
+
+ }
+ />
+ )
+ }
+
+ const searchModal = () => {
+ if (editable) {
+ return (
+
+ )
+ }
+ }
+
+ // Methods: Core element rendering
+ const perpetuity = () => {
+ if (gridCharacter) {
+ const classes = classNames({
+ Perpetuity: true,
+ Empty: !gridCharacter.perpetuity,
+ })
+
+ return
+ }
+ }
+
+ const image = () => {
+ let image = (
+
+ )
+
+ const content = (
+
+ {image}
+ {editable ? (
+
+
+
+ ) : (
+ ''
+ )}
+
+ )
+
+ return gridCharacter ? (
+
+ {content}
+
+ ) : (
+ content
+ )
+ }
const unitContent = (
-
- {props.editable ? editableImage : image}
- {gridCharacter && character ? (
-
- ) : (
- ''
- )}
-
{character?.name[locale]}
-
+ <>
+
+ {contextMenu()}
+ {perpetuity()}
+ {image()}
+ {gridCharacter && character ? (
+
+ ) : (
+ ''
+ )}
+
{character?.name[locale]}
+
+ {searchModal()}
+ >
)
- const withHovercard = (
-
- {unitContent}
-
- )
-
- return gridCharacter && !props.editable ? withHovercard : unitContent
+ return unitContent
}
export default CharacterUnit
diff --git a/components/ContextMenu/index.scss b/components/ContextMenu/index.scss
new file mode 100644
index 00000000..c011889c
--- /dev/null
+++ b/components/ContextMenu/index.scss
@@ -0,0 +1,6 @@
+.ContextMenu {
+ background: var(--menu-bg);
+ border-radius: $input-corner;
+ padding: $unit 0;
+ margin-top: $unit-fourth;
+}
diff --git a/components/ContextMenu/index.tsx b/components/ContextMenu/index.tsx
new file mode 100644
index 00000000..c3787dd9
--- /dev/null
+++ b/components/ContextMenu/index.tsx
@@ -0,0 +1,36 @@
+import React from 'react'
+import classNames from 'classnames'
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+
+import './index.scss'
+
+interface Props
+ extends React.DetailedHTMLProps<
+ React.DialogHTMLAttributes,
+ HTMLDivElement
+ > {
+ align?: 'start' | 'center' | 'end'
+}
+
+export const ContextMenuContent = React.forwardRef(
+ function ContextMenu({ children, ...props }, forwardedRef) {
+ const classes = classNames(
+ {
+ ContextMenu: true,
+ },
+ props.className
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+ }
+)
+
+export const ContextMenu = DropdownMenu.Root
+export const ContextMenuGroup = DropdownMenu.Group
+export const ContextMenuTrigger = DropdownMenu.Trigger
diff --git a/components/ContextMenuItem/index.scss b/components/ContextMenuItem/index.scss
new file mode 100644
index 00000000..ee14622d
--- /dev/null
+++ b/components/ContextMenuItem/index.scss
@@ -0,0 +1,11 @@
+.ContextItem {
+ color: var(--menu-text);
+ font-size: $font-regular;
+ padding: ($unit * 1.5) $unit-2x;
+
+ &:hover {
+ background: var(--menu-bg-item-hover);
+ color: var(--text-primary);
+ cursor: pointer;
+ }
+}
diff --git a/components/ContextMenuItem/index.tsx b/components/ContextMenuItem/index.tsx
new file mode 100644
index 00000000..be1b7ec9
--- /dev/null
+++ b/components/ContextMenuItem/index.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import classNames from 'classnames'
+import { DropdownMenuItem } from '@radix-ui/react-dropdown-menu'
+
+import './index.scss'
+
+interface Props {
+ className?: string
+ onSelect?: (event: Event) => void
+ children: React.ReactNode
+}
+
+const ContextMenuItem = React.forwardRef(
+ function ContextMenu({ children, ...props }, forwardedRef) {
+ const classes = classNames(
+ {
+ ContextItem: true,
+ },
+ props.className
+ )
+
+ return (
+
+ {children}
+
+ )
+ }
+)
+
+export default ContextMenuItem
diff --git a/components/Dialog/index.scss b/components/Dialog/index.scss
index b93fbc55..e69de29b 100644
--- a/components/Dialog/index.scss
+++ b/components/Dialog/index.scss
@@ -1,211 +0,0 @@
-.Dialog {
- $multiplier: 4;
-
- animation: 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal none running
- openModalDesktop;
- background: var(--dialog-bg);
- border-radius: $card-corner;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- gap: $unit * $multiplier;
- height: auto;
- min-width: $unit * 48;
- min-height: $unit-12x;
- min-width: 580px;
- padding: $unit * $multiplier;
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- z-index: 40;
-
- a:hover {
- text-decoration: underline;
- }
-
- @include breakpoint(phone) {
- animation: 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal forwards running
- openModalMobile;
- min-width: inherit;
- min-height: 80vh;
- transform: initial;
- left: 0;
- right: 0;
- top: 0;
- height: auto;
- width: 100%;
- }
-
- .DialogHeader {
- display: flex;
- align-items: center;
- gap: $unit;
- justify-content: space-between;
-
- .left {
- display: flex;
- flex-direction: column;
- flex-grow: 1;
- gap: $unit;
-
- p {
- font-size: $font-small;
- line-height: 1.25;
- }
- }
- }
-
- .DialogClose {
- background: transparent;
- border: none;
-
- &:hover {
- cursor: pointer;
-
- svg {
- fill: $error;
- }
- }
-
- svg {
- fill: $grey-50;
- float: right;
- height: 24px;
- width: 24px;
- }
- }
-
- .DialogTitle {
- color: var(--text-primary);
- font-size: $font-xlarge;
-
- h1 {
- color: var(--text-primary);
- font-size: $font-xlarge;
- font-weight: $medium;
- text-align: left;
- }
- }
-
- .DialogTop {
- display: flex;
- flex-direction: column;
- flex-grow: 1;
- gap: calc($unit / 2);
-
- .SubTitle {
- color: var(--text-secondary);
- font-size: $font-small;
- font-weight: $medium;
- }
- }
-
- .DialogDescription {
- color: var(--text-secondary);
- flex-grow: 1;
- }
-
- .actions {
- display: flex;
- justify-content: flex-end;
- width: 100%;
- }
-
- &.Conflict.Dialog {
- $weapon-diameter: 14rem;
-
- & > p {
- line-height: 1.2;
- max-width: 400px;
-
- strong {
- font-weight: $bold;
- }
-
- &:lang(ja) {
- line-height: 1.4;
- }
- }
-
- .weapon,
- .character {
- display: flex;
- flex-direction: column;
- gap: $unit;
- text-align: center;
- width: $weapon-diameter;
- font-weight: $medium;
-
- img {
- border-radius: 1rem;
- width: $weapon-diameter;
- height: auto;
- }
-
- span {
- line-height: 1.3;
- }
- }
-
- .Diagram {
- display: grid;
- grid-template-columns: 1fr auto 1fr;
- align-items: flex-start;
-
- &.CharacterDiagram {
- align-items: center;
- }
-
- ul {
- align-items: center;
- display: flex;
- flex-direction: column;
- gap: $unit * 2;
- }
-
- .wrapper {
- display: flex;
- justify-content: center;
- width: 100%;
- }
-
- .arrow {
- align-items: center;
- color: $grey-55;
- display: flex;
- font-size: 4rem;
- text-align: center;
- height: $weapon-diameter;
- justify-content: center;
- }
- }
-
- footer {
- display: flex;
- flex-direction: row;
- gap: $unit;
-
- .Button {
- font-size: $font-regular;
- padding: ($unit * 1.5) ($unit * 2);
- width: 100%;
-
- &.btn-disabled {
- background: $grey-90;
- color: $grey-70;
- cursor: not-allowed;
- }
-
- &:not(.btn-disabled) {
- background: $grey-90;
- color: $grey-50;
-
- &:hover {
- background: $grey-80;
- }
- }
- }
- }
- }
-}
diff --git a/components/Dialog/index.tsx b/components/Dialog/index.tsx
index 8f544fa8..e7010d8e 100644
--- a/components/Dialog/index.tsx
+++ b/components/Dialog/index.tsx
@@ -1,46 +1,40 @@
-import React from 'react'
+import React, { PropsWithChildren, useEffect, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
-import classNames from 'classnames'
+import { useLockedBody } from 'usehooks-ts'
import './index.scss'
-import Overlay from '~components/Overlay'
-interface Props
- extends React.DetailedHTMLProps<
- React.DialogHTMLAttributes,
- HTMLDivElement
- > {
- onEscapeKeyDown: (event: KeyboardEvent) => void
- onOpenAutoFocus: (event: Event) => void
+interface Props extends DialogPrimitive.DialogProps {}
+
+export const Dialog = ({ children, ...props }: PropsWithChildren) => {
+ const [locked, setLocked] = useLockedBody(false, 'root')
+ const [open, setOpen] = useState(false)
+
+ useEffect(() => {
+ if (props.open != undefined) {
+ toggleLocked(props.open)
+ setOpen(props.open)
+ }
+ }, [props.open])
+
+ function toggleLocked(open: boolean) {
+ setLocked(open)
+ }
+
+ function handleOpenChange(open: boolean) {
+ if (props.onOpenChange) props.onOpenChange(open)
+ if (props.open === undefined) {
+ toggleLocked(open)
+ }
+ }
+
+ return (
+
+ {children}
+
+ )
}
-export const DialogContent = React.forwardRef(
- function dialog({ children, ...props }, forwardedRef) {
- const classes = classNames(
- {
- Dialog: true,
- },
- props.className
- )
-
- return (
-
-
- {children}
-
-
-
- )
- }
-)
-
-export const Dialog = DialogPrimitive.Root
export const DialogTitle = DialogPrimitive.Title
export const DialogTrigger = DialogPrimitive.Trigger
export const DialogClose = DialogPrimitive.Close
diff --git a/components/DialogContent/index.scss b/components/DialogContent/index.scss
new file mode 100644
index 00000000..26421dbf
--- /dev/null
+++ b/components/DialogContent/index.scss
@@ -0,0 +1,287 @@
+.Dialog {
+ position: fixed;
+ box-sizing: border-box;
+ background: none;
+ border: 0;
+ inset: 0;
+ display: grid;
+ padding: 0;
+ place-items: center;
+ min-height: 100vh;
+ min-width: 100vw;
+ overflow-y: auto;
+ color: inherit;
+ z-index: 40;
+
+ .DialogContent {
+ $multiplier: 4;
+
+ // animation: $duration-modal-open cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal
+ // none running openModalDesktop;
+ background: var(--dialog-bg);
+ border-radius: $card-corner;
+ box-sizing: border-box;
+ border: 0.5px solid rgba(0, 0, 0, 0.18);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
+ display: flex;
+ flex-direction: column;
+ gap: $unit * $multiplier;
+ height: auto;
+ min-width: $unit * 48;
+ // min-height: $unit-12x;
+ overflow-y: scroll;
+ // height: 80vh;
+ max-height: 80vh;
+ min-width: 580px;
+ max-width: 42vw;
+ // padding: $unit * $multiplier;
+ position: relative;
+
+ a:hover {
+ text-decoration: underline;
+ }
+
+ @include breakpoint(phone) {
+ // animation: slideUp;
+ // animation-duration: 3s;
+ // animation-fill-mode: forwards;
+ // animation-play-state: running;
+ // animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ min-width: inherit;
+ min-height: 90vh;
+ transform: initial;
+ left: 0;
+ right: 0;
+ top: 5vh;
+ height: auto;
+ width: 100%;
+ }
+
+ .Scrollable {
+ overflow-y: auto;
+ }
+
+ .DialogHeader {
+ background: var(--dialog-bg);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0);
+ border-bottom: 1px solid rgba(0, 0, 0, 0);
+ display: flex;
+ align-items: center;
+ gap: $unit-2x;
+ justify-content: space-between;
+ padding: $unit-4x ($unit * $multiplier);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+
+ &.Short {
+ padding-top: $unit-3x;
+ padding-bottom: $unit-3x;
+ }
+
+ .left {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ gap: $unit;
+
+ p {
+ font-size: $font-small;
+ line-height: 1.25;
+ }
+ }
+
+ .DialogImage {
+ border-radius: $input-corner;
+ width: $unit-10x;
+ }
+ }
+
+ .DialogClose {
+ background: transparent;
+ border: none;
+
+ &:hover {
+ cursor: pointer;
+
+ svg {
+ fill: $error;
+ }
+ }
+
+ svg {
+ fill: $grey-50;
+ float: right;
+ height: 24px;
+ width: 24px;
+ }
+ }
+
+ .DialogTitle {
+ color: var(--text-primary);
+ font-size: $font-xlarge;
+
+ h1 {
+ color: var(--text-primary);
+ font-size: $font-xlarge;
+ font-weight: $medium;
+ text-align: left;
+ }
+ }
+
+ .DialogTop {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ gap: calc($unit / 2);
+
+ .SubTitle {
+ color: var(--text-secondary);
+ font-size: $font-small;
+ font-weight: $medium;
+ }
+ }
+
+ .DialogDescription {
+ color: var(--text-secondary);
+ flex-grow: 1;
+ }
+
+ .DialogFooter {
+ align-items: flex-end;
+ background: var(--dialog-bg);
+ bottom: 0;
+ box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.16);
+ border-top: 1px solid rgba(0, 0, 0, 0.24);
+ display: flex;
+ flex-direction: column;
+ padding: ($unit * 1.5) ($unit * $multiplier) $unit-3x;
+ position: sticky;
+
+ .Buttons {
+ display: flex;
+ gap: $unit;
+
+ &.Span {
+ width: 100%;
+
+ .Button {
+ width: 100%;
+ }
+ }
+ }
+ }
+
+ .actions {
+ display: flex;
+ justify-content: flex-end;
+ width: 100%;
+ }
+
+ &.Conflict {
+ $weapon-diameter: 14rem;
+
+ .Content {
+ display: flex;
+ flex-direction: column;
+ gap: $unit-4x;
+ padding: $unit-4x $unit-4x $unit-2x $unit-4x;
+
+ & > p {
+ font-size: $font-regular;
+ line-height: 1.4;
+
+ strong {
+ font-weight: $bold;
+ }
+
+ &:lang(ja) {
+ line-height: 1.4;
+ }
+ }
+ }
+
+ .weapon,
+ .character {
+ display: flex;
+ flex-direction: column;
+ gap: $unit;
+ text-align: center;
+ width: $weapon-diameter;
+ font-weight: $medium;
+
+ img {
+ border-radius: 1rem;
+ width: $weapon-diameter;
+ height: auto;
+ }
+
+ span {
+ line-height: 1.3;
+ }
+ }
+
+ .Diagram {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: flex-start;
+
+ &.CharacterDiagram {
+ align-items: center;
+ }
+
+ ul {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ gap: $unit-2x;
+ }
+
+ .wrapper {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ }
+
+ .arrow {
+ align-items: center;
+ color: $grey-55;
+ display: flex;
+ font-size: 4rem;
+ text-align: center;
+ height: $weapon-diameter;
+ justify-content: center;
+ }
+ }
+
+ footer {
+ display: flex;
+ flex-direction: row;
+ gap: $unit;
+
+ .Button {
+ font-size: $font-regular;
+ padding: ($unit * 1.5) ($unit * 2);
+ width: 100%;
+
+ &.btn-disabled {
+ background: $grey-90;
+ color: $grey-70;
+ cursor: not-allowed;
+ }
+
+ &:not(.btn-disabled) {
+ background: $grey-90;
+ color: $grey-50;
+
+ &:hover {
+ background: $grey-80;
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/components/DialogContent/index.tsx b/components/DialogContent/index.tsx
new file mode 100644
index 00000000..455cb7a3
--- /dev/null
+++ b/components/DialogContent/index.tsx
@@ -0,0 +1,144 @@
+import React, { useEffect } from 'react'
+import * as DialogPrimitive from '@radix-ui/react-dialog'
+import classNames from 'classnames'
+import debounce from 'lodash.debounce'
+
+import Overlay from '~components/Overlay'
+import './index.scss'
+
+interface Props
+ extends React.DetailedHTMLProps<
+ React.DialogHTMLAttributes,
+ HTMLDivElement
+ > {
+ headerref?: React.RefObject
+ footerref?: React.RefObject
+ onEscapeKeyDown: (event: KeyboardEvent) => void
+ onOpenAutoFocus: (event: Event) => void
+}
+
+const DialogContent = React.forwardRef(function Dialog(
+ { children, ...props },
+ forwardedRef
+) {
+ // Classes
+ const classes = classNames(props.className, {
+ DialogContent: true,
+ })
+
+ // Handlers
+ function handleScroll(event: React.UIEvent) {
+ const scrollTop = event.currentTarget.scrollTop
+ const scrollHeight = event.currentTarget.scrollHeight
+ const clientHeight = event.currentTarget.clientHeight
+
+ if (props.headerref && props.headerref.current)
+ manipulateHeaderShadow(props.headerref.current, scrollTop)
+
+ if (props.footerref && props.footerref.current)
+ manipulateFooterShadow(
+ props.footerref.current,
+ scrollTop,
+ scrollHeight,
+ clientHeight
+ )
+ }
+
+ function manipulateHeaderShadow(header: HTMLDivElement, scrollTop: number) {
+ const boxShadowBase = '0 2px 8px'
+ const maxValue = 50
+
+ if (scrollTop >= 0) {
+ const input = scrollTop > maxValue ? maxValue : scrollTop
+
+ const boxShadowOpacity = mapRange(input, 0, maxValue, 0.0, 0.16)
+ const borderOpacity = mapRange(input, 0, maxValue, 0.0, 0.24)
+
+ header.style.boxShadow = `${boxShadowBase} rgba(0, 0, 0, ${boxShadowOpacity})`
+ header.style.borderBottomColor = `rgba(0, 0, 0, ${borderOpacity})`
+ }
+ }
+
+ function manipulateFooterShadow(
+ footer: HTMLDivElement,
+ scrollTop: number,
+ scrollHeight: number,
+ clientHeight: number
+ ) {
+ const boxShadowBase = '0 -2px 8px'
+ const minValue = scrollHeight - 200
+ const currentScroll = scrollTop + clientHeight
+
+ if (currentScroll >= minValue) {
+ const input = currentScroll < minValue ? minValue : currentScroll
+
+ const boxShadowOpacity = mapRange(
+ input,
+ minValue,
+ scrollHeight,
+ 0.16,
+ 0.0
+ )
+ const borderOpacity = mapRange(input, minValue, scrollHeight, 0.24, 0.0)
+
+ footer.style.boxShadow = `${boxShadowBase} rgba(0, 0, 0, ${boxShadowOpacity})`
+ footer.style.borderTopColor = `rgba(0, 0, 0, ${borderOpacity})`
+ }
+ }
+
+ const calculateFooterShadow = debounce(() => {
+ const boxShadowBase = '0 -2px 8px'
+ const scrollable = document.querySelector('.Scrollable')
+ const footer = props.footerref
+
+ if (footer && footer.current) {
+ if (scrollable && scrollable.clientHeight >= scrollable.scrollHeight) {
+ footer.current.style.boxShadow = `${boxShadowBase} rgba(0, 0, 0, 0)`
+ footer.current.style.borderTopColor = `rgba(0, 0, 0, 0)`
+ } else {
+ footer.current.style.boxShadow = `${boxShadowBase} rgba(0, 0, 0, 0.16)`
+ footer.current.style.borderTopColor = `rgba(0, 0, 0, 0.24)`
+ }
+ }
+ }, 100)
+
+ useEffect(() => {
+ window.addEventListener('resize', calculateFooterShadow)
+ calculateFooterShadow()
+
+ return () => {
+ window.removeEventListener('resize', calculateFooterShadow)
+ }
+ }, [calculateFooterShadow])
+
+ function mapRange(
+ value: number,
+ low1: number,
+ high1: number,
+ low2: number,
+ high2: number
+ ) {
+ return low2 + ((high2 - low2) * (value - low1)) / (high1 - low1)
+ }
+
+ return (
+
+
+
+
+ {children}
+
+
+
+
+
+ )
+})
+
+export default DialogContent
diff --git a/components/HeaderMenu/index.scss b/components/DropdownMenuContent/index.scss
similarity index 89%
rename from components/HeaderMenu/index.scss
rename to components/DropdownMenuContent/index.scss
index e5584f7f..caf82c8b 100644
--- a/components/HeaderMenu/index.scss
+++ b/components/DropdownMenuContent/index.scss
@@ -1,20 +1,26 @@
.Menu {
+ transform-origin: --radix-dropdown-menu-content-transform-origin;
background: var(--menu-bg);
border-radius: 6px;
+ box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
box-sizing: border-box;
- display: none;
- min-width: 220px;
- position: absolute;
- top: $unit-8x; // This shouldn't be hardcoded. How to calculate it?
- // Also, add space that doesn't make the menu disappear if you move your mouse slowly
- z-index: 10;
+ width: 30vw;
+ max-width: 180px;
+ margin: 0 $unit-2x;
+ z-index: 15;
@include breakpoint(phone) {
- left: $unit-2x;
- right: $unit-2x;
+ min-width: 50vw;
}
}
+.MenuLabel {
+ color: var(--text-tertiary);
+ padding: $unit * 1.5 $unit * 1.5;
+ font-size: $font-small;
+ font-weight: $medium;
+}
+
.MenuItem {
color: var(--text-tertiary);
font-weight: $normal;
diff --git a/components/DropdownMenuContent/index.tsx b/components/DropdownMenuContent/index.tsx
new file mode 100644
index 00000000..37a47f5e
--- /dev/null
+++ b/components/DropdownMenuContent/index.tsx
@@ -0,0 +1,40 @@
+import React, { PropsWithChildren } from 'react'
+import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
+import classNames from 'classnames'
+
+import './index.scss'
+
+interface Props extends DropdownMenuPrimitive.DropdownMenuContentProps {}
+
+export const DropdownMenu = DropdownMenuPrimitive.Root
+export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+export const DropdownMenuLabel = DropdownMenuPrimitive.Label
+export const DropdownMenuItem = DropdownMenuPrimitive.Item
+export const DropdownMenuGroup = DropdownMenuPrimitive.Group
+export const DropdownMenuSeparator = DropdownMenuPrimitive.Separator
+
+export const DropdownMenuContent = React.forwardRef(
+ function dropdownMenuContent(
+ { children, ...props }: PropsWithChildren,
+ forwardedRef
+ ) {
+ const classes = classNames(props.className, {
+ Menu: true,
+ })
+ return (
+
+
+ {children}
+
+
+ )
+ }
+)
+
+DropdownMenuContent.defaultProps = {
+ sideOffset: 4,
+}
diff --git a/components/DurationInput/index.scss b/components/DurationInput/index.scss
index e69de29b..bfa72e11 100644
--- a/components/DurationInput/index.scss
+++ b/components/DurationInput/index.scss
@@ -0,0 +1,35 @@
+.Duration {
+ align-items: center;
+ background: var(--input-bound-bg);
+ border: 2px solid transparent;
+ border-radius: $input-corner;
+ display: flex;
+ padding: 0 calc($unit-2x - 2px);
+
+ &:hover {
+ background: var(--input-bound-bg-hover);
+ }
+
+ &:focus-within {
+ border: 2px solid $blue;
+ outline: none;
+ }
+
+ .Input {
+ background: transparent;
+ border: none;
+ padding: 0;
+ width: initial;
+ height: 100%;
+ padding: calc($unit-2x - 2px) 0;
+
+ &:hover {
+ background: transparent;
+ }
+
+ &:focus,
+ &:focus-visible {
+ border: none;
+ }
+ }
+}
diff --git a/components/DurationInput/index.tsx b/components/DurationInput/index.tsx
index ecccc902..c0dd6918 100644
--- a/components/DurationInput/index.tsx
+++ b/components/DurationInput/index.tsx
@@ -1,8 +1,7 @@
-import React, { useState, ChangeEvent, KeyboardEvent, useEffect } from 'react'
+import React, { useState, ChangeEvent, KeyboardEvent } from 'react'
import classNames from 'classnames'
import Input from '~components/Input'
-
import './index.scss'
interface Props
@@ -15,50 +14,57 @@ interface Props
}
const DurationInput = React.forwardRef(
- function DurationInput(
- { className, placeholder, value, onValueChange },
- forwardedRef
- ) {
+ function DurationInput({ className, value, onValueChange }, forwardedRef) {
+ // State
const [duration, setDuration] = useState('')
+ const [minutesSelected, setMinutesSelected] = useState(false)
+ const [secondsSelected, setSecondsSelected] = useState(false)
- useEffect(() => {
- if (value > 0) setDuration(convertSecondsToString(value))
- }, [value])
+ // Refs
+ const minutesRef = React.createRef()
+ const secondsRef = React.createRef()
- function convertStringToSeconds(string: string) {
- const parts = string.split(':')
- const minutes = parseInt(parts[0])
- const seconds = parseInt(parts[1])
+ // Event handlers: On value change
+ function handleMinutesChange(event: ChangeEvent) {
+ const minutes = parseInt(event.currentTarget.value)
+ const seconds = secondsRef.current
+ ? parseInt(secondsRef.current.value)
+ : 0
- return minutes * 60 + seconds
+ handleChange(minutes, seconds)
}
- function convertSecondsToString(value: number) {
- const minutes = Math.floor(value / 60)
- const seconds = value - minutes * 60
+ function handleSecondsChange(event: ChangeEvent) {
+ const seconds = parseInt(event.currentTarget.value)
+ const minutes = minutesRef.current
+ ? parseInt(minutesRef.current.value)
+ : 0
- const paddedMinutes = padNumber(`${minutes}`, '0', 2)
-
- return `${paddedMinutes}:${seconds}`
+ handleChange(minutes, seconds)
}
- function padNumber(string: string, pad: string, length: number) {
- return (new Array(length + 1).join(pad) + string).slice(-length)
+ function handleChange(minutes: number, seconds: number) {
+ onValueChange(minutes * 60 + seconds)
}
- function handleChange(event: ChangeEvent) {
- const value = event.currentTarget.value
- const durationInSeconds = convertStringToSeconds(value)
- onValueChange(durationInSeconds)
+ // Event handler: Key presses
+ function handleKeyUp(event: KeyboardEvent) {
+ const input = event.currentTarget
+
+ if (input.selectionStart === 0 && input.selectionEnd === 2) {
+ if (input === minutesRef.current) {
+ setMinutesSelected(true)
+ } else if (input === secondsRef.current) {
+ setSecondsSelected(true)
+ }
+ }
}
function handleKeyDown(event: KeyboardEvent) {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
- // Allow the key to be processed normally
return
}
- // Get the current value
const input = event.currentTarget
let value = event.currentTarget.value
@@ -95,16 +101,28 @@ const DurationInput = React.forwardRef(
const isNumber = !isNaN(char)
// Check if the character should be accepted or rejected
- if (!isNumber || value.length >= 5) {
- // Reject the character
- event.preventDefault()
- } else if (value.length === 2) {
- // Insert a colon after the second digit
- input.value = value + ':'
+ if (!isNumber || value.length >= 2) {
+ // Reject the character if the user doesn't have the entire string selected
+ if (!minutesSelected && input === minutesRef.current)
+ event.preventDefault()
+ else if (
+ !secondsSelected &&
+ input === secondsRef.current &&
+ getSeconds() > 9
+ )
+ event.preventDefault()
+ else {
+ setDuration(value)
+ setMinutesSelected(false)
+ setSecondsSelected(false)
+ }
+ } else {
+ setDuration(value)
}
}
}
+ // Methods: Time manipulation
function incrementTime(time: string): string {
// Split the time into minutes and seconds
let [minutes, seconds] = time.split(':').map(Number)
@@ -144,21 +162,54 @@ const DurationInput = React.forwardRef(
return `${minutes}:${seconds}`
}
+ // Methods: Miscellaneous
+
+ function getMinutes() {
+ const minutes = Math.floor(value / 60)
+ return minutes
+ }
+
+ function getSeconds() {
+ const seconds = value % 60
+ return seconds
+ }
+
return (
-
+
+
+ :
+
+
)
}
)
diff --git a/components/ErrorSection/index.scss b/components/ErrorSection/index.scss
new file mode 100644
index 00000000..be07a46c
--- /dev/null
+++ b/components/ErrorSection/index.scss
@@ -0,0 +1,22 @@
+section.Error {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ gap: $unit;
+ margin: 0 auto;
+ max-width: 30vw;
+ justify-content: center;
+ height: 60vh;
+ text-align: center;
+
+ .Code {
+ color: var(--text-secondary);
+ font-size: $font-tiny;
+ font-weight: $bold;
+ }
+
+ .Button {
+ margin-top: $unit-2x;
+ width: fit-content;
+ }
+}
diff --git a/components/ErrorSection/index.tsx b/components/ErrorSection/index.tsx
new file mode 100644
index 00000000..5f8c4cae
--- /dev/null
+++ b/components/ErrorSection/index.tsx
@@ -0,0 +1,48 @@
+import React, { useEffect, useState } from 'react'
+import Link from 'next/link'
+import { useTranslation } from 'next-i18next'
+
+import Button from '~components/Button'
+import { ResponseStatus } from '~types'
+
+import './index.scss'
+
+interface Props {
+ status: ResponseStatus
+}
+
+const ErrorSection = ({ status }: Props) => {
+ // Import translations
+ const { t } = useTranslation('common')
+
+ const [statusText, setStatusText] = useState('')
+
+ useEffect(() => {
+ setStatusText(status.text.replaceAll(' ', '_').toLowerCase())
+ }, [status.text])
+
+ const errorBody = () => {
+ return (
+ <>
+ {status.code}
+ {t(`errors.${statusText}.title`)}
+ {t(`errors.${statusText}.description`)}
+ >
+ )
+ }
+
+ return (
+
+ {errorBody()}
+ {[401, 404].includes(status.code) ? (
+
+
+
+ ) : (
+ ''
+ )}
+
+ )
+}
+
+export default ErrorSection
diff --git a/components/ExtendedMasterySelect/index.scss b/components/ExtendedMasterySelect/index.scss
new file mode 100644
index 00000000..4ce3e5d0
--- /dev/null
+++ b/components/ExtendedMasterySelect/index.scss
@@ -0,0 +1,17 @@
+.SelectSet {
+ display: flex;
+ flex-direction: row;
+ gap: $unit;
+ width: 100%;
+
+ .SelectTrigger.Left {
+ flex-grow: 1;
+ width: 100%;
+ }
+
+ .SelectTrigger.Right {
+ flex-grow: 0;
+ text-align: right;
+ min-width: 12rem;
+ }
+}
diff --git a/components/ExtendedMasterySelect/index.tsx b/components/ExtendedMasterySelect/index.tsx
new file mode 100644
index 00000000..ae97d9ae
--- /dev/null
+++ b/components/ExtendedMasterySelect/index.tsx
@@ -0,0 +1,165 @@
+// Core dependencies
+import React, { useEffect, useState } from 'react'
+import { useRouter } from 'next/router'
+import { useTranslation } from 'next-i18next'
+import classNames from 'classnames'
+
+// UI Dependencies
+import Select from '~components/Select'
+import SelectItem from '~components/SelectItem'
+
+// Styles and icons
+import './index.scss'
+
+// Types
+interface Props {
+ name: string
+ object: 'ring'
+ dataSet: ItemSkill[]
+ leftSelectValue: number
+ leftSelectDisabled: boolean
+ rightSelectValue: number
+ sendValues: (left: number, right: number) => void
+}
+
+const defaultProps = {
+ selectDisabled: false,
+}
+
+const ExtendedMasterySelect = ({
+ name,
+ object,
+ dataSet,
+ leftSelectDisabled,
+ leftSelectValue,
+ rightSelectValue,
+ sendValues,
+}: Props) => {
+ const router = useRouter()
+ const locale =
+ router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
+ const { t } = useTranslation('common')
+
+ // UI state
+ const [leftSelectOpen, setLeftSelectOpen] = useState(false)
+ const [rightSelectOpen, setRightSelectOpen] = useState(false)
+
+ // Field properties
+ // prettier-ignore
+ const [currentItemSkill, setCurrentItemSkill] = useState(undefined)
+ const [currentItemValue, setCurrentItemValue] = useState(rightSelectValue)
+
+ // Hooks
+ // if (currentItemSkill) sendValues(currentItemSkill.id, currentItemValue)
+
+ // Set default values from props
+ useEffect(() => {
+ setCurrentItemSkill(dataSet.find((sk) => sk.id === leftSelectValue))
+ setCurrentItemValue(rightSelectValue)
+ }, [leftSelectValue, rightSelectValue])
+
+ // Methods: UI state management
+ function changeOpen(side: 'left' | 'right') {
+ if (side === 'left' && !leftSelectDisabled) {
+ setLeftSelectOpen(!leftSelectOpen)
+ } else if (side === 'right') {
+ setRightSelectOpen(!rightSelectOpen)
+ }
+ }
+
+ function onClose() {
+ setLeftSelectOpen(false)
+ setRightSelectOpen(false)
+ }
+
+ // Methods: Rendering
+ function generateLeftOptions() {
+ let options: React.ReactNode[] = dataSet.map((skill, i) => {
+ return (
+
+ {skill.name[locale]}
+
+ )
+ })
+
+ return options
+ }
+
+ function generateRightOptions() {
+ if (currentItemSkill && currentItemSkill.values) {
+ let options = currentItemSkill.values.map((value, i) => {
+ return (
+
+ {value}
+ {currentItemSkill.suffix ? currentItemSkill.suffix : ''}
+
+ )
+ })
+
+ options.unshift(
+
+ {t('no_value')}
+
+ )
+
+ return options
+ }
+ }
+
+ // Methods: User input detection
+ function handleLeftSelectChange(rawValue: string) {
+ const value = parseInt(rawValue)
+ const skill = dataSet.find((sk) => sk.id === value)
+
+ setCurrentItemSkill(skill)
+ setCurrentItemValue(0)
+
+ if (skill) sendValues(skill.id, 0)
+ }
+
+ function handleRightSelectChange(rawValue: string) {
+ const value = parseFloat(rawValue)
+ setCurrentItemValue(value)
+
+ if (currentItemSkill) sendValues(currentItemSkill.id, value)
+ }
+
+ return (
+
+ changeOpen('left')}
+ onClose={onClose}
+ triggerClass="Left modal"
+ overlayVisible={false}
+ >
+ {generateLeftOptions()}
+
+
+ 0 ? currentItemValue : 'no-value'}`}
+ open={rightSelectOpen}
+ onValueChange={handleRightSelectChange}
+ onOpenChange={() => changeOpen('right')}
+ onClose={onClose}
+ overlayVisible={false}
+ triggerClass={classNames({
+ Right: true,
+ modal: true,
+ hidden: currentItemSkill?.id === 0,
+ })}
+ >
+ {generateRightOptions()}
+
+
+ )
+}
+
+ExtendedMasterySelect.defaultProps = defaultProps
+
+export default ExtendedMasterySelect
diff --git a/components/ExtraSummons/index.tsx b/components/ExtraSummons/index.tsx
index 4ef55272..58383ede 100644
--- a/components/ExtraSummons/index.tsx
+++ b/components/ExtraSummons/index.tsx
@@ -11,8 +11,10 @@ interface Props {
exists: boolean
found?: boolean
offset: number
+ removeSummon: (id: string) => void
updateObject: (object: SearchableObject, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
+ updateTranscendence: (id: string, position: number, stage: number) => void
}
const ExtraSummons = (props: Props) => {
@@ -31,9 +33,11 @@ const ExtraSummons = (props: Props) => {
editable={props.editable}
position={props.offset + i}
unitType={1}
+ removeSummon={props.removeSummon}
gridSummon={props.grid[props.offset + i]}
updateObject={props.updateObject}
updateUncap={props.updateUncap}
+ updateTranscendence={props.updateTranscendence}
/>
)
diff --git a/components/ExtraWeapons/index.tsx b/components/ExtraWeapons/index.tsx
index 6a4c045f..f3345ccc 100644
--- a/components/ExtraWeapons/index.tsx
+++ b/components/ExtraWeapons/index.tsx
@@ -12,6 +12,7 @@ interface Props {
editable: boolean
found?: boolean
offset: number
+ removeWeapon: (id: string) => void
updateObject: (object: SearchableObject, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
}
@@ -32,6 +33,7 @@ const ExtraWeapons = (props: Props) => {
position={props.offset + i}
unitType={1}
gridWeapon={props.grid[props.offset + i]}
+ removeWeapon={props.removeWeapon}
updateObject={props.updateObject}
updateUncap={props.updateUncap}
/>
diff --git a/components/GridRep/index.scss b/components/GridRep/index.scss
index 5615e2a8..02a480b0 100644
--- a/components/GridRep/index.scss
+++ b/components/GridRep/index.scss
@@ -137,7 +137,7 @@
.Properties {
.full_auto {
- color: var(--full-auto-text);
+ color: var(--full-auto-label-text);
}
}
diff --git a/components/GridRep/index.tsx b/components/GridRep/index.tsx
index 813635de..f9a50e3f 100644
--- a/components/GridRep/index.tsx
+++ b/components/GridRep/index.tsx
@@ -136,18 +136,24 @@ const GridRep = (props: Props) => {
src={`/profile/${props.user.avatar.picture}.png`}
/>
)
- } else return
+ } else
+ return (
+
+ )
}
const linkedAttribution = () => (
-
+
{userImage()}
{props.user ? props.user.username : t('no_user')}
-
+
)
@@ -205,16 +211,14 @@ const GridRep = (props: Props) => {
((props.user && account.user && account.user.id !== props.user.id) ||
!props.user) ? (
-
- }
- active={props.favorited}
- contained={true}
- buttonSize="small"
- onClick={sendSaveData}
- />
-
+ }
+ active={props.favorited}
+ contained={true}
+ buttonSize="small"
+ onClick={sendSaveData}
+ />
) : (
''
diff --git a/components/Header/index.scss b/components/Header/index.scss
index 6599906d..bfa358ea 100644
--- a/components/Header/index.scss
+++ b/components/Header/index.scss
@@ -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);
diff --git a/components/Header/index.tsx b/components/Header/index.tsx
index d1c7250b..91504b11 100644
--- a/components/Header/index.tsx
+++ b/components/Header/index.tsx
@@ -1,23 +1,41 @@
import React, { useEffect, useState } from 'react'
-import { useSnapshot } from 'valtio'
-import { deleteCookie } from 'cookies-next'
+import { subscribe, useSnapshot } from 'valtio'
+import { setCookie, deleteCookie } from 'cookies-next'
import { useRouter } from 'next/router'
-import { useTranslation } from 'next-i18next'
-
+import { Trans, useTranslation } from 'next-i18next'
+import classNames from 'classnames'
import clonedeep from 'lodash.clonedeep'
+import Link from 'next/link'
import api from '~utils/api'
import { accountState, initialAccountState } from '~utils/accountState'
import { appState, initialAppState } from '~utils/appState'
+import { getLocalId } from '~utils/localId'
+import { retrieveLocaleCookies } from '~utils/retrieveCookies'
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuLabel,
+} from '~components/DropdownMenuContent'
+import LoginModal from '~components/LoginModal'
+import SignupModal from '~components/SignupModal'
+import AccountModal from '~components/AccountModal'
+import Toast from '~components/Toast'
import Button from '~components/Button'
-import HeaderMenu from '~components/HeaderMenu'
+import Tooltip from '~components/Tooltip'
+import * as Switch from '@radix-ui/react-switch'
-import AddIcon from '~public/icons/Add.svg'
+import ArrowIcon from '~public/icons/Arrow.svg'
import LinkIcon from '~public/icons/Link.svg'
import MenuIcon from '~public/icons/Menu.svg'
+import RemixIcon from '~public/icons/Remix.svg'
+import PlusIcon from '~public/icons/Add.svg'
import SaveIcon from '~public/icons/Save.svg'
-import classNames from 'classnames'
import './index.scss'
@@ -27,48 +45,124 @@ const Header = () => {
// Router
const router = useRouter()
+ const locale =
+ router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
+ const localeData = retrieveLocaleCookies()
// State management
- const [open, setOpen] = useState(false)
+ const [copyToastOpen, setCopyToastOpen] = useState(false)
+ const [remixToastOpen, setRemixToastOpen] = useState(false)
+ const [loginModalOpen, setLoginModalOpen] = useState(false)
+ const [signupModalOpen, setSignupModalOpen] = useState(false)
+ const [settingsModalOpen, setSettingsModalOpen] = useState(false)
+ const [leftMenuOpen, setLeftMenuOpen] = useState(false)
+ const [rightMenuOpen, setRightMenuOpen] = useState(false)
+ const [languageChecked, setLanguageChecked] = useState(false)
+
+ const [name, setName] = useState('')
+ const [originalName, setOriginalName] = useState('')
// Snapshots
const { account } = useSnapshot(accountState)
- const { party } = useSnapshot(appState)
+ const { party: partySnapshot } = useSnapshot(appState)
- function menuButtonClicked() {
- setOpen(!open)
+ // Subscribe to app state to listen for party name and
+ // unsubscribe when component is unmounted
+ const unsubscribe = subscribe(appState, () => {
+ const newName =
+ appState.party && appState.party.name ? appState.party.name : ''
+ setName(newName)
+ })
+
+ useEffect(() => () => unsubscribe(), [])
+
+ // Hooks
+ useEffect(() => {
+ setLanguageChecked(localeData === 'ja' ? true : false)
+ }, [localeData])
+
+ // Methods: Event handlers (Buttons)
+ function handleLeftMenuButtonClicked() {
+ setLeftMenuOpen(!leftMenuOpen)
}
- function onClickOutsideMenu() {
- setOpen(false)
+ function handleRightMenuButtonClicked() {
+ setRightMenuOpen(!rightMenuOpen)
+ }
+
+ // Methods: Event handlers (Menus)
+ function handleLeftMenuOpenChange(open: boolean) {
+ setLeftMenuOpen(open)
+ }
+
+ function handleRightMenuOpenChange(open: boolean) {
+ setRightMenuOpen(open)
+ }
+
+ function closeLeftMenu() {
+ setLeftMenuOpen(false)
+ }
+
+ function closeRightMenu() {
+ setRightMenuOpen(false)
+ }
+
+ // Methods: Event handlers (Copy toast)
+ function handleCopyToastOpenChanged(open: boolean) {
+ setCopyToastOpen(open)
+ }
+
+ function handleCopyToastCloseClicked() {
+ setCopyToastOpen(false)
+ }
+
+ // Methods: Event handlers (Remix toasts)
+ function handleRemixToastOpenChanged(open: boolean) {
+ setRemixToastOpen(open)
+ }
+
+ function handleRemixToastCloseClicked() {
+ setRemixToastOpen(false)
+ }
+
+ // Methods: Actions
+ function handleNewTeam(event: React.MouseEvent) {
+ event.preventDefault()
+ newTeam()
+ closeRightMenu()
+ }
+
+ function changeLanguage(value: boolean) {
+ const language = value ? 'ja' : 'en'
+ const expiresAt = new Date()
+ expiresAt.setDate(expiresAt.getDate() + 120)
+
+ setCookie('NEXT_LOCALE', language, { path: '/', expires: expiresAt })
+ router.push(router.asPath, undefined, { locale: language })
}
function copyToClipboard() {
- const el = document.createElement('input')
- el.value = window.location.href
- el.id = 'url-input'
- document.body.appendChild(el)
+ const path = router.asPath.split('/')[1]
- el.select()
- document.execCommand('copy')
- el.remove()
- }
+ if (path === 'p') {
+ const el = document.createElement('input')
+ el.value = window.location.href
+ el.id = 'url-input'
+ document.body.appendChild(el)
- function newParty() {
- // Push the root URL
- router.push('/')
+ el.select()
+ document.execCommand('copy')
+ el.remove()
- // Clean state
- const resetState = clonedeep(initialAppState)
- Object.keys(resetState).forEach((key) => {
- appState[key] = resetState[key]
- })
-
- // Set party to be editable
- appState.party.editable = true
+ setCopyToastOpen(true)
+ }
}
function logout() {
+ // Close menu
+ closeRightMenu()
+
+ // Delete cookies
deleteCookie('account')
deleteCookie('user')
@@ -82,106 +176,433 @@ const Header = () => {
return false
}
+ function newTeam() {
+ // Clean state
+ const resetState = clonedeep(initialAppState)
+ Object.keys(resetState).forEach((key) => {
+ appState[key] = resetState[key]
+ })
+
+ // Push the root URL
+ router.push('/new')
+ }
+
+ function remixTeam() {
+ setOriginalName(partySnapshot.name ? partySnapshot.name : t('no_title'))
+
+ if (partySnapshot.shortcode) {
+ const body = getLocalId()
+ api
+ .remix({ shortcode: partySnapshot.shortcode, body: body })
+ .then((response) => {
+ const remix = response.data.party
+ router.push(`/p/${remix.shortcode}`)
+ setRemixToastOpen(true)
+ })
+ }
+ }
+
function toggleFavorite() {
- if (party.favorited) unsaveFavorite()
+ if (partySnapshot.favorited) unsaveFavorite()
else saveFavorite()
}
function saveFavorite() {
- if (party.id)
- api.saveTeam({ id: party.id }).then((response) => {
+ if (partySnapshot.id)
+ api.saveTeam({ id: partySnapshot.id }).then((response) => {
if (response.status == 201) appState.party.favorited = true
})
else console.error('Failed to save team: No party ID')
}
function unsaveFavorite() {
- if (party.id)
- api.unsaveTeam({ id: party.id }).then((response) => {
+ if (partySnapshot.id)
+ api.unsaveTeam({ id: partySnapshot.id }).then((response) => {
if (response.status == 200) appState.party.favorited = false
})
else console.error('Failed to unsave team: No party ID')
}
- const copyButton = () => {
- if (router.route === '/p/[party]')
- return (
+ // Rendering: Elements
+ const pageTitle = () => {
+ let title = ''
+ let hasAccessory = false
+
+ const path = router.asPath.split('/')[1]
+ if (path === 'p') {
+ hasAccessory = true
+ if (appState.party && appState.party.name) {
+ title = appState.party.name
+ } else {
+ title = t('no_title')
+ }
+ } else {
+ title = ''
+ }
+
+ return title !== '' ? (
+
}
blended={true}
- text={t('buttons.copy')}
+ rightAccessoryIcon={
+ path === 'p' && hasAccessory ? (
+
+ ) : undefined
+ }
+ text={title}
onClick={copyToClipboard}
/>
- )
- }
-
- const leftNav = () => {
- return (
-
- }
- className={classNames({ Active: open })}
- blended={true}
- text={t('buttons.menu')}
- onClick={menuButtonClicked}
- />
-
-
+
+ ) : (
+ ''
)
}
- const saveButton = () => {
- if (party.favorited)
- return (
- }
- blended={true}
- text="Saved"
- onClick={toggleFavorite}
+ const profileImage = () => {
+ let image
+
+ const user = accountState.account.user
+ if (accountState.account.authorized && user) {
+ image = (
+
)
- else
- return (
- }
- blended={true}
- text="Save"
- onClick={toggleFavorite}
+ } else {
+ image = (
+
)
+ }
+
+ return image
}
- const rightNav = () => {
+ // Rendering: Buttons
+ const saveButton = () => {
return (
-
- {router.route === '/p/[party]' &&
- account.user &&
- (!party.user || party.user.id !== account.user.id)
- ? saveButton()
- : ''}
-
- {copyButton()}
-
+
}
+ leftAccessoryIcon={ }
+ className={classNames({
+ Save: true,
+ Saved: partySnapshot.favorited,
+ })}
+ blended={true}
+ text={
+ partySnapshot.favorited ? t('buttons.saved') : t('buttons.save')
+ }
+ onClick={toggleFavorite}
+ />
+
+ )
+ }
+
+ const newButton = () => {
+ return (
+
+ }
+ className="New"
blended={true}
text={t('buttons.new')}
- onClick={newParty}
+ onClick={newTeam}
/>
-
+
)
}
+ const remixButton = () => {
+ return (
+
+ }
+ className="Remix"
+ blended={true}
+ text={t('buttons.remix')}
+ onClick={remixTeam}
+ />
+
+ )
+ }
+
+ // Rendering: Toasts
+ const urlCopyToast = () => {
+ return (
+
+ )
+ }
+
+ const remixToast = () => {
+ return (
+
+ You remixed {{ title: originalName }}
+
+ }
+ onOpenChange={handleRemixToastOpenChanged}
+ onCloseClick={handleRemixToastCloseClicked}
+ />
+ )
+ }
+
+ // Rendering: Modals
+ const settingsModal = () => {
+ const user = accountState.account.user
+
+ if (user) {
+ return (
+
+ )
+ }
+ }
+
+ const loginModal = () => {
+ return
+ }
+
+ const signupModal = () => {
+ return (
+
+ )
+ }
+
+ // Rendering: Compositing
+ const left = () => {
+ return (
+
+
+
+
+ }
+ className={classNames({ Active: leftMenuOpen })}
+ blended={true}
+ onClick={handleLeftMenuButtonClicked}
+ />
+
+
+ {leftMenuItems()}
+
+
+
+ {!appState.errorCode ? pageTitle() : ''}
+
+ )
+ }
+
+ const right = () => {
+ return (
+
+ {router.route === '/p/[party]' &&
+ account.user &&
+ (!partySnapshot.user || partySnapshot.user.id !== account.user.id) &&
+ !appState.errorCode
+ ? saveButton()
+ : ''}
+ {router.route === '/p/[party]' && !appState.errorCode
+ ? remixButton()
+ : ''}
+ {newButton()}
+
+
+ }
+ rightAccessoryClassName="Arrow"
+ onClick={handleRightMenuButtonClicked}
+ blended={true}
+ />
+
+
+ {rightMenuItems()}
+
+
+
+ )
+ }
+
+ const leftMenuItems = () => {
+ return (
+ <>
+ {accountState.account.authorized && accountState.account.user ? (
+ <>
+
+
+
+ {t('menu.profile')}
+
+
+
+ {t('menu.saved')}
+
+
+ >
+ ) : (
+ ''
+ )}
+
+
+ {t('menu.teams')}
+
+
+
+ {t('menu.guides')}
+ {t('coming_soon')}
+
+
+
+
+
+
+ {t('about.segmented_control.about')}
+
+
+
+
+ {t('about.segmented_control.updates')}
+
+
+
+
+ {t('about.segmented_control.roadmap')}
+
+
+
+ >
+ )
+ }
+
+ const rightMenuItems = () => {
+ let items
+
+ const account = accountState.account
+ if (account.authorized && account.user) {
+ items = (
+ <>
+
+
+ {account.user ? `@${account.user.username}` : t('no_user')}
+
+
+
+ {t('menu.profile')}
+
+
+
+
+
+ setSettingsModalOpen(true)}
+ >
+ {t('menu.settings')}
+
+
+ {t('menu.logout')}
+
+
+ >
+ )
+ } else {
+ items = (
+ <>
+
+
+ {t('menu.language')}
+
+
+ JP
+ EN
+
+
+
+
+ setLoginModalOpen(true)}
+ >
+ {t('menu.login')}
+
+ setSignupModalOpen(true)}
+ >
+ {t('menu.signup')}
+
+
+ >
+ )
+ }
+
+ return items
+ }
+
return (
)
}
diff --git a/components/HeaderMenu/index.tsx b/components/HeaderMenu/index.tsx
deleted file mode 100644
index 6c229f59..00000000
--- a/components/HeaderMenu/index.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-import React, { useEffect, useState } from 'react'
-import { useRouter } from 'next/router'
-import { useTranslation } from 'next-i18next'
-import { setCookie } from 'cookies-next'
-import classNames from 'classnames'
-import { retrieveCookies, retrieveLocaleCookies } from '~utils/retrieveCookies'
-
-import Link from 'next/link'
-import * as Switch from '@radix-ui/react-switch'
-
-import AboutModal from '~components/AboutModal'
-import AccountModal from '~components/AccountModal'
-import ChangelogModal from '~components/ChangelogModal'
-import RoadmapModal from '~components/RoadmapModal'
-import LoginModal from '~components/LoginModal'
-import SignupModal from '~components/SignupModal'
-
-import './index.scss'
-
-interface Props {
- authenticated: boolean
- open: boolean
- username?: string
- onClickOutside: () => void
- logout?: () => void
-}
-
-const HeaderMenu = (props: Props) => {
- // Setup
- const router = useRouter()
- const data: GranblueCookie | undefined = retrieveCookies()
- const localeData = retrieveLocaleCookies()
- const { t } = useTranslation('common')
-
- // Refs
- const ref: React.RefObject = React.createRef()
-
- useEffect(() => {
- const handleClickOutside = (event: Event) => {
- const target = event.target instanceof Element ? event.target : null
- const isButton = target && target.closest('.Button.Active')
-
- if (
- ref.current &&
- target &&
- !ref.current.contains(target) &&
- !isButton &&
- props.open
- ) {
- props.onClickOutside()
- }
- }
- document.addEventListener('click', handleClickOutside, true)
-
- return () => {
- document.removeEventListener('click', handleClickOutside, true)
- }
- }, [props.onClickOutside])
-
- const [checked, setChecked] = useState(false)
-
- useEffect(() => {
- setChecked(localeData === 'ja' ? true : false)
- }, [localeData])
-
- function handleCheckedChange(value: boolean) {
- const language = value ? 'ja' : 'en'
- setCookie('NEXT_LOCALE', language, { path: '/' })
- router.push(router.asPath, undefined, { locale: language })
- }
-
- const menuClasses = classNames({
- Menu: true,
- auth: props.authenticated,
- open: props.open,
- })
-
- function authItems() {
- return (
-
-
-
-
-
-
{data?.account.username}
-
-
-
-
-
- {t('menu.saved')}
-
-
-
-
- {t('menu.teams')}
-
-
-
-
- {t('menu.guides')}
- {t('coming_soon')}
-
-
-
-
-
-
-
- {t('menu.logout')}
-
-
-
- )
- }
-
- function unauthItems() {
- return (
-
- )
- }
-
- return (
- {props.authenticated ? authItems() : unauthItems()}
- )
-}
-
-export default HeaderMenu
diff --git a/components/Hovercard/index.scss b/components/Hovercard/index.scss
new file mode 100644
index 00000000..c0c803bb
--- /dev/null
+++ b/components/Hovercard/index.scss
@@ -0,0 +1,95 @@
+.HovercardContent {
+ animation: scaleIn $duration-zoom ease-out;
+ transform-origin: var(--radix-hover-card-content-transform-origin);
+ background: var(--dialog-bg);
+ border-radius: $card-corner;
+ color: var(--text-primary);
+ display: flex;
+ flex-direction: column;
+ gap: $unit-2x;
+ max-height: 30vh;
+ overflow-y: scroll;
+ padding: $unit-2x;
+ width: 300px;
+
+ .top {
+ display: flex;
+ flex-direction: column;
+ gap: calc($unit / 2);
+
+ .title {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: $unit * 2;
+
+ h4 {
+ flex-grow: 1;
+ font-size: $font-medium;
+ line-height: 1.2;
+ min-width: 140px;
+ }
+
+ img {
+ height: auto;
+ width: 100px;
+ }
+ }
+
+ .subInfo {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: $unit * 2;
+
+ .icons {
+ display: flex;
+ flex-direction: row;
+ flex-grow: 1;
+ gap: $unit;
+ }
+
+ .UncapIndicator {
+ min-width: 100px;
+ }
+ }
+ }
+
+ section {
+ h5 {
+ font-size: $font-small;
+ font-weight: $medium;
+ opacity: 0.7;
+
+ &.wind {
+ color: $wind-bg-20;
+ }
+
+ &.fire {
+ color: $fire-bg-20;
+ }
+
+ &.water {
+ color: $water-bg-20;
+ }
+
+ &.earth {
+ color: $earth-bg-20;
+ }
+
+ &.dark {
+ color: $dark-bg-10;
+ }
+
+ &.light {
+ color: $light-bg-20;
+ }
+ }
+ }
+
+ a.Button {
+ display: block;
+ padding: $unit * 1.5;
+ text-align: center;
+ }
+}
diff --git a/components/Hovercard/index.tsx b/components/Hovercard/index.tsx
new file mode 100644
index 00000000..3531d059
--- /dev/null
+++ b/components/Hovercard/index.tsx
@@ -0,0 +1,31 @@
+import React, { PropsWithChildren } from 'react'
+import classNames from 'classnames'
+
+import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
+import './index.scss'
+
+interface Props extends HoverCardPrimitive.HoverCardContentProps {}
+
+export const Hovercard = HoverCardPrimitive.Root
+export const HovercardTrigger = HoverCardPrimitive.Trigger
+
+export const HovercardContent = ({
+ children,
+ ...props
+}: PropsWithChildren) => {
+ const classes = classNames(props.className, {
+ HovercardContent: true,
+ })
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/components/Input/index.scss b/components/Input/index.scss
index f26aa1ef..897a2779 100644
--- a/components/Input/index.scss
+++ b/components/Input/index.scss
@@ -2,10 +2,10 @@
-webkit-font-smoothing: antialiased;
background-color: var(--input-bg);
border: 2px solid transparent;
- border-radius: 6px;
+ border-radius: $input-corner;
box-sizing: border-box;
display: block;
- padding: $unit-2x;
+ padding: calc($unit-2x - 2px);
width: 100%;
&[type='number']::-webkit-inner-spin-button {
diff --git a/components/JobAccessoryItem/index.scss b/components/JobAccessoryItem/index.scss
new file mode 100644
index 00000000..1c255ada
--- /dev/null
+++ b/components/JobAccessoryItem/index.scss
@@ -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;
+ }
+}
diff --git a/components/JobAccessoryItem/index.tsx b/components/JobAccessoryItem/index.tsx
new file mode 100644
index 00000000..c547a900
--- /dev/null
+++ b/components/JobAccessoryItem/index.tsx
@@ -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 (
+
+
+ {accessory.name[locale]}
+
+ )
+}
+
+export default JobAccessoryItem
diff --git a/components/JobAccessoryPopover/index.scss b/components/JobAccessoryPopover/index.scss
new file mode 100644
index 00000000..88ac72d4
--- /dev/null
+++ b/components/JobAccessoryPopover/index.scss
@@ -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;
+ }
+ }
+ }
+}
diff --git a/components/JobAccessoryPopover/index.tsx b/components/JobAccessoryPopover/index.tsx
new file mode 100644
index 00000000..8f7ae501
--- /dev/null
+++ b/components/JobAccessoryPopover/index.tsx
@@ -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
+ 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) => {
+ // 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 = (
+ <>
+
+ {capitalizeFirstLetter(
+ job.accessory_type === 1
+ ? `${t('accessories.paladin')}s`
+ : t('accessories.manadiver')
+ )}
+
+
+ {accessories.map((accessory) => (
+
+ ))}
+
+ >
+ )
+
+ const readOnly = currentAccessory ? (
+
+
+ {t('equipped')}{' '}
+ {job.accessory_type === 1
+ ? `${t('accessories.paladin')}s`
+ : t('accessories.manadiver')}
+
+
+
+
{currentAccessory.name[locale]}
+
+
+ ) : (
+
+ {t('no_accessory', {
+ accessory: t(
+ `accessories.${job.accessory_type === 1 ? 'paladin' : 'manadiver'}`
+ ),
+ })}
+
+ )
+
+ return (
+
+ {children}
+
+ {editable ? radioGroup : readOnly}
+
+
+ )
+}
+
+export default JobAccessoryPopover
diff --git a/components/JobDropdown/index.tsx b/components/JobDropdown/index.tsx
index 9e7843ef..7d4b0ab7 100644
--- a/components/JobDropdown/index.tsx
+++ b/components/JobDropdown/index.tsx
@@ -8,7 +8,7 @@ import SelectItem from '~components/SelectItem'
import SelectGroup from '~components/SelectGroup'
import { appState } from '~utils/appState'
-import { jobGroups } from '~utils/jobGroups'
+import { jobGroups } from '~data/jobGroups'
import './index.scss'
@@ -91,7 +91,12 @@ const JobDropdown = React.forwardRef(
.sort((a, b) => a.order - b.order)
.map((item, i) => {
return (
-
+
{item.name[locale]}
)
@@ -109,6 +114,12 @@ const JobDropdown = React.forwardRef(
return (
setOpen(!open)}
diff --git a/components/JobImage/index.scss b/components/JobImage/index.scss
new file mode 100644
index 00000000..fb877b47
--- /dev/null
+++ b/components/JobImage/index.scss
@@ -0,0 +1,80 @@
+.JobImage {
+ $height: 252px;
+ $width: 447px;
+
+ aspect-ratio: 7/9;
+ background: url('/images/background_a.jpg');
+ background-size: 500px 281px;
+ border-radius: $unit;
+ box-sizing: border-box;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
+ display: block;
+ isolation: isolate;
+ flex-grow: 2;
+ flex-shrink: 0;
+ height: $height;
+ margin-right: $unit * 3;
+ max-height: $height;
+ max-width: $width;
+ overflow: hidden;
+ position: relative;
+ transition: box-shadow 0.15s ease-in-out;
+ width: $width;
+ z-index: 1;
+
+ // prettier-ignore
+ @media only screen
+ and (max-width: 800px)
+ and (max-height: 920px)
+ and (-webkit-min-device-pixel-ratio: 2) {
+ margin-right: 0;
+ width: 100%;
+ }
+
+ @include breakpoint(phone) {
+ aspect-ratio: 16/9;
+ margin: 0;
+ width: 100%;
+ height: inherit;
+ }
+
+ img {
+ -webkit-filter: drop-shadow(4px 4px 8px rgba(0, 0, 0, 0.48));
+ filter: drop-shadow(4px 4px 8px rgba(0, 0, 0, 0.48));
+ position: relative;
+ top: $unit * -4;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 100%;
+ z-index: 4;
+ }
+
+ .JobAccessory.Button {
+ align-items: center;
+ border-radius: 99px;
+ justify-content: center;
+ position: relative;
+ padding: $unit * 1.5;
+ top: $unit;
+ left: $unit;
+ height: auto;
+ z-index: 10;
+
+ &:hover .Accessory svg,
+ &.Selected .Accessory svg {
+ fill: var(--button-text-hover);
+ }
+
+ .Accessory svg {
+ fill: var(--button-text);
+ width: $unit-3x;
+ height: auto;
+ }
+ }
+
+ .Overlay {
+ background: none;
+ position: absolute;
+ z-index: 2;
+ }
+}
diff --git a/components/JobImage/index.tsx b/components/JobImage/index.tsx
new file mode 100644
index 00000000..91339331
--- /dev/null
+++ b/components/JobImage/index.tsx
@@ -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()
+
+ // 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 =
+
+ 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 =
+ else if (job && job.accessory_type === 2) icon =
+
+ return (
+
+ )
+ }
+
+ const accessoryPopover = () => {
+ return job && accessories ? (
+
+ {accessoryButton()}
+
+ ) : (
+ ''
+ )
+ }
+ return (
+
+ {hasAccessory ? accessoryPopover() : ''}
+ {job && job.id !== '-1' ? image : ''}
+
+
+ )
+}
+
+export default JobImage
diff --git a/components/JobSection/index.scss b/components/JobSection/index.scss
index dcdbed48..6caa3093 100644
--- a/components/JobSection/index.scss
+++ b/components/JobSection/index.scss
@@ -28,10 +28,20 @@
flex-direction: column;
width: 100%;
- h3 {
- font-size: $font-medium;
- font-weight: $medium;
+ .JobName {
+ align-items: center;
+ display: flex;
+ gap: $unit-half;
padding: $unit 0 $unit * 2;
+
+ h3 {
+ font-size: $font-medium;
+ font-weight: $medium;
+ }
+
+ img {
+ width: $unit-4x;
+ }
}
select {
@@ -43,63 +53,6 @@
}
}
- .JobImage {
- $height: 252px;
- $width: 447px;
-
- aspect-ratio: 7/9;
- background: url('/images/background_a.jpg');
- background-size: 500px 281px;
- border-radius: $unit;
- box-sizing: border-box;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
- display: block;
- isolation: isolate;
- flex-grow: 2;
- flex-shrink: 0;
- height: $height;
- margin-right: $unit * 3;
- max-height: $height;
- max-width: $width;
- overflow: hidden;
- position: relative;
- width: $width;
- transition: box-shadow 0.15s ease-in-out;
-
- // prettier-ignore
- @media only screen
- and (max-width: 800px)
- and (max-height: 920px)
- and (-webkit-min-device-pixel-ratio: 2) {
- margin-right: 0;
- width: 100%;
- }
-
- @include breakpoint(phone) {
- aspect-ratio: 16/9;
- margin: 0;
- width: 100%;
- height: inherit;
- }
-
- img {
- -webkit-filter: drop-shadow(4px 4px 8px rgba(0, 0, 0, 0.48));
- filter: drop-shadow(4px 4px 8px rgba(0, 0, 0, 0.48));
- position: relative;
- top: $unit * -4;
- left: 50%;
- transform: translateX(-50%);
- width: 100%;
- z-index: 2;
- }
-
- .Overlay {
- background: none;
- position: absolute;
- z-index: 1;
- }
- }
-
.JobSkills {
display: flex;
flex-direction: column;
diff --git a/components/JobSection/index.tsx b/components/JobSection/index.tsx
index 13e84a9f..e9323597 100644
--- a/components/JobSection/index.tsx
+++ b/components/JobSection/index.tsx
@@ -4,9 +4,11 @@ import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import JobDropdown from '~components/JobDropdown'
+import JobImage from '~components/JobImage'
import JobSkillItem from '~components/JobSkillItem'
import SearchModal from '~components/SearchModal'
+import api from '~utils/api'
import { appState } from '~utils/appState'
import type { JobSkillObject, SearchableObject } from '~types'
@@ -16,9 +18,11 @@ import './index.scss'
interface Props {
job?: Job
jobSkills: JobSkillObject
+ jobAccessory?: JobAccessory
editable: boolean
saveJob: (job?: Job) => void
saveSkill: (skill: JobSkill, position: number) => void
+ saveAccessory: (accessory: JobAccessory) => void
}
const JobSection = (props: Props) => {
@@ -29,13 +33,19 @@ const JobSection = (props: Props) => {
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
+ // Data state
const [job, setJob] = useState()
const [imageUrl, setImageUrl] = useState('')
const [numSkills, setNumSkills] = useState(4)
const [skills, setSkills] = useState<{ [key: number]: JobSkill | undefined }>(
[]
)
+ const [accessories, setAccessories] = useState([])
+ const [currentAccessory, setCurrentAccessory] = useState<
+ JobAccessory | undefined
+ >()
+ // Refs
const selectRef = React.createRef()
useEffect(() => {
@@ -47,6 +57,7 @@ const JobSection = (props: Props) => {
2: props.jobSkills[2],
3: props.jobSkills[3],
})
+ setCurrentAccessory(props.jobAccessory)
if (selectRef.current && props.job) selectRef.current.value = props.job.id
}, [props])
@@ -61,14 +72,33 @@ const JobSection = (props: Props) => {
appState.party.job = job
if (job.row === '1') setNumSkills(3)
else setNumSkills(4)
+ fetchJobAccessories()
}
}, [job])
+ // Data fetching
+ async function fetchJobAccessories() {
+ if (job && job.accessory) {
+ const response = await api.jobAccessoriesForJob(job.id)
+ const jobAccessories: JobAccessory[] = response.data
+ setAccessories(jobAccessories)
+ }
+ }
+
function receiveJob(job?: Job) {
setJob(job)
props.saveJob(job)
}
+ function handleAccessorySelected(value: string) {
+ const accessory = accessories.find((accessory) => accessory.id === value)
+
+ if (accessory) {
+ setCurrentAccessory(accessory)
+ props.saveAccessory(accessory)
+ }
+ }
+
function generateImageUrl() {
let imgSrc = ''
@@ -84,7 +114,7 @@ const JobSection = (props: Props) => {
const canEditSkill = (skill?: JobSkill) => {
// If there is a job and a skill present in the slot
- if (job) {
+ if (job && job.id !== '-1') {
// If the skill's job is one of the job's main skill
if (skill && skill.job.id === job.id && skill.main) return false
@@ -127,17 +157,37 @@ const JobSection = (props: Props) => {
props.saveSkill(skill, position)
}
+ const emptyJobLabel = (
+
+
{t('no_job')}
+
+ )
+
+ const filledJobLabel = (
+
+
+
{job?.name[locale]}
+
+ )
+
+ function jobLabel() {
+ return job ? filledJobLabel : emptyJobLabel
+ }
+
// Render: JSX components
return (
-
- {party.job && party.job.id !== '-1' ? (
-
- ) : (
- ''
- )}
-
-
+
{props.editable ? (
{
ref={selectRef}
/>
) : (
- {party.job?.name[locale]}
+
+ {party.job ? (
+
+ ) : (
+ ''
+ )}
+
{party.job ? party.job.name[locale] : t('no_job')}
+
)}
diff --git a/components/JobSkillResult/index.tsx b/components/JobSkillResult/index.tsx
index 66d9333b..508a3684 100644
--- a/components/JobSkillResult/index.tsx
+++ b/components/JobSkillResult/index.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
-import { SkillGroup, skillClassification } from '~utils/skillGroups'
+import { SkillGroup, skillClassification } from '~data/skillGroups'
import './index.scss'
diff --git a/components/JobSkillSearchFilterBar/index.tsx b/components/JobSkillSearchFilterBar/index.tsx
index 7370818f..7eadbc2b 100644
--- a/components/JobSkillSearchFilterBar/index.tsx
+++ b/components/JobSkillSearchFilterBar/index.tsx
@@ -44,6 +44,7 @@ const JobSkillSearchFilterBar = (props: Props) => {
value={-1}
triggerClass="Bound"
open={open}
+ overlayVisible={false}
onValueChange={onChange}
onOpenChange={openSelect}
>
diff --git a/components/Layout/index.scss b/components/Layout/index.scss
new file mode 100644
index 00000000..b8403bc0
--- /dev/null
+++ b/components/Layout/index.scss
@@ -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;
+}
diff --git a/components/Layout/index.tsx b/components/Layout/index.tsx
index dafc8b14..3716d012 100644
--- a/components/Layout/index.tsx
+++ b/components/Layout/index.tsx
@@ -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) => {
+ 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) ? (
+
+ ) : (
+ ''
+ )
+ }
-const Layout = ({ children }: Props) => {
return (
<>
+ {updateToast()}
{children}
>
)
diff --git a/components/LoginModal/index.scss b/components/LoginModal/index.scss
index f51192e5..a84d8f17 100644
--- a/components/LoginModal/index.scss
+++ b/components/LoginModal/index.scss
@@ -1,6 +1,11 @@
-.Login.Dialog form {
- display: flex;
- flex-direction: column;
- gap: calc($unit / 2);
- margin-bottom: $unit;
+.Login.DialogContent {
+ gap: $unit;
+ // min-width: $unit * 52;
+
+ .Fields {
+ display: flex;
+ flex-direction: column;
+ gap: $unit;
+ padding: 0 $unit-4x;
+ }
}
diff --git a/components/LoginModal/index.tsx b/components/LoginModal/index.tsx
index 5c4b454b..49d72e61 100644
--- a/components/LoginModal/index.tsx
+++ b/components/LoginModal/index.tsx
@@ -1,22 +1,17 @@
-import React, { useState } from 'react'
+import React, { useEffect, useState } from 'react'
import { setCookie } from 'cookies-next'
import { useRouter } from 'next/router'
import { useTranslation } from 'react-i18next'
import axios, { AxiosError, AxiosResponse } from 'axios'
import api from '~utils/api'
-import setUserToken from '~utils/setUserToken'
+import { setHeaders } from '~utils/userToken'
import { accountState } from '~utils/accountState'
import Button from '~components/Button'
-import Input from '~components/LabelledInput'
-import {
- Dialog,
- DialogTrigger,
- DialogContent,
- DialogClose,
-} from '~components/Dialog'
-
+import Input from '~components/Input'
+import { Dialog, DialogTrigger, DialogClose } from '~components/Dialog'
+import DialogContent from '~components/DialogContent'
import changeLanguage from '~utils/changeLanguage'
import CrossIcon from '~public/icons/Cross.svg'
@@ -31,7 +26,12 @@ interface ErrorMap {
const emailRegex =
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
-const LoginModal = () => {
+interface Props {
+ open: boolean
+ onOpenChange?: (open: boolean) => void
+}
+
+const LoginModal = (props: Props) => {
const router = useRouter()
const { t } = useTranslation('common')
@@ -48,8 +48,13 @@ const LoginModal = () => {
// Set up form refs
const emailInput: React.RefObject = React.createRef()
const passwordInput: React.RefObject = React.createRef()
+ const footerRef: React.RefObject = React.createRef()
const form: React.RefObject[] = [emailInput, passwordInput]
+ useEffect(() => {
+ setOpen(props.open)
+ }, [props.open])
+
function handleChange(event: React.ChangeEvent) {
const { name, value } = event.target
let newErrors = { ...errors }
@@ -137,10 +142,12 @@ const LoginModal = () => {
token: resp.access_token,
}
- setCookie('account', cookieObj, { path: '/' })
+ const expiresAt = new Date()
+ expiresAt.setDate(expiresAt.getDate() + 60)
+ setCookie('account', cookieObj, { path: '/', expires: expiresAt })
// Set Axios default headers
- setUserToken()
+ setHeaders()
}
function storeUserInfo(response: AxiosResponse) {
@@ -148,24 +155,32 @@ const LoginModal = () => {
const user = response.data
// Set user data in the user cookie
+ const expiresAt = new Date()
+ expiresAt.setDate(expiresAt.getDate() + 60)
+
setCookie(
'user',
{
- 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,
@@ -184,6 +199,9 @@ const LoginModal = () => {
email: '',
password: '',
})
+ setFormValid(false)
+
+ if (props.onOpenChange) props.onOpenChange(open)
}
function onEscapeKeyDown(event: KeyboardEvent) {
@@ -197,13 +215,9 @@ const LoginModal = () => {
return (
-
-
- {t('menu.login')}
-
-
@@ -217,29 +231,35 @@ const LoginModal = () => {
diff --git a/components/NewHead/index.tsx b/components/NewHead/index.tsx
new file mode 100644
index 00000000..ba60edc1
--- /dev/null
+++ b/components/NewHead/index.tsx
@@ -0,0 +1,32 @@
+import React from 'react'
+import Head from 'next/head'
+import { useTranslation } from 'next-i18next'
+
+const NewHead = () => {
+ // Import translations
+ const { t } = useTranslation('common')
+
+ return (
+
+ {/* HTML */}
+ {t('page.titles.new')}
+
+
+
+
+ {/* OpenGraph */}
+
+
+
+
+
+ {/* Twitter */}
+
+
+
+
+
+ )
+}
+
+export default NewHead
diff --git a/components/Overlay/index.scss b/components/Overlay/index.scss
index 6059c821..34d953a6 100644
--- a/components/Overlay/index.scss
+++ b/components/Overlay/index.scss
@@ -7,7 +7,15 @@
bottom: 0;
left: 0;
+ &.Job {
+ animation: none;
+ backdrop-filter: blur(5px) saturate(100%) brightness(80%) opacity(1);
+ }
+
&.Visible {
+ animation: 0.24s ease-in fadeInFilter;
+ animation-fill-mode: forwards;
+ backdrop-filter: blur(5px) saturate(100%) brightness(80%) opacity(0);
background: rgba(0, 0, 0, 0.6);
}
}
diff --git a/components/Party/index.tsx b/components/Party/index.tsx
index 14c3f49a..3bc61ce1 100644
--- a/components/Party/index.tsx
+++ b/components/Party/index.tsx
@@ -1,7 +1,9 @@
import React, { useEffect, useState } from 'react'
+import { getCookie } from 'cookies-next'
import { useRouter } from 'next/router'
-import { useSnapshot } from 'valtio'
+import { subscribe, useSnapshot } from 'valtio'
import clonedeep from 'lodash.clonedeep'
+import ls from 'local-storage'
import PartySegmentedControl from '~components/PartySegmentedControl'
import PartyDetails from '~components/PartyDetails'
@@ -10,8 +12,13 @@ import SummonGrid from '~components/SummonGrid'
import CharacterGrid from '~components/CharacterGrid'
import api from '~utils/api'
+import { accountState } from '~utils/accountState'
import { appState, initialAppState } from '~utils/appState'
+import { getLocalId } from '~utils/localId'
import { GridType } from '~utils/enums'
+import { retrieveCookies } from '~utils/retrieveCookies'
+import { setEditKey, unsetEditKey } from '~utils/userToken'
+
import type { DetailsObject } from '~types'
import './index.scss'
@@ -21,16 +28,26 @@ interface Props {
new?: boolean
team?: Party
raids: Raid[][]
+ selectedTab: GridType
pushHistory?: (path: string) => void
}
+const defaultProps = {
+ selectedTab: GridType.Weapon,
+}
+
const Party = (props: Props) => {
// Set up router
const router = useRouter()
// Set up states
const { party } = useSnapshot(appState)
+ const [editable, setEditable] = useState(false)
const [currentTab, setCurrentTab] = useState(GridType.Weapon)
+ const [refresh, setRefresh] = useState(false)
+
+ // Retrieve cookies
+ const cookies = retrieveCookies()
// Reset state on first load
useEffect(() => {
@@ -39,19 +56,67 @@ const Party = (props: Props) => {
if (props.team) storeParty(props.team)
}, [])
+ // Subscribe to app state to listen for account changes and
+ // unsubscribe when component is unmounted
+ const unsubscribe = subscribe(accountState, () => {
+ setRefresh(true)
+ })
+
+ useEffect(() => () => unsubscribe(), [])
+
+ // Set editable on first load
+ useEffect(() => {
+ // Get cookie
+ const cookie = getCookie('account')
+ const accountData: AccountCookie = cookie
+ ? JSON.parse(cookie as string)
+ : null
+
+ let editable = false
+ unsetEditKey()
+
+ if (props.new) editable = true
+
+ if (accountData && props.team && !props.new) {
+ if (accountData.token) {
+ // Authenticated
+ if (props.team.user && accountData.userId === props.team.user.id) {
+ editable = true
+ }
+ } else {
+ // Not authenticated
+ if (!props.team.user && accountData.userId === props.team.local_id) {
+ // Set editable
+ editable = true
+
+ // Also set edit key header
+ setEditKey(props.team.id, props.team.user)
+ }
+ }
+ }
+
+ appState.party.editable = editable
+ setEditable(editable)
+ }, [refresh])
+
+ // Set selected tab from props
+ useEffect(() => {
+ setCurrentTab(props.selectedTab)
+ }, [props.selectedTab])
+
// Methods: Creating a new party
async function createParty(details?: DetailsObject) {
let payload = {}
if (details) payload = formatDetailsObject(details)
return await api.endpoints.parties
- .create(payload)
+ .create({ ...payload, ...getLocalId() })
.then((response) => storeParty(response.data.party))
}
// Methods: Updating the party's details
async function updateDetails(details: DetailsObject) {
- if (!appState.party.id) return await createParty(details)
+ if (!props.team) return await createParty(details)
else updateParty(details)
}
@@ -78,9 +143,9 @@ const Party = (props: Props) => {
async function updateParty(details: DetailsObject) {
const payload = formatDetailsObject(details)
- if (appState.party.id) {
+ if (props.team && props.team.id) {
return await api.endpoints.parties
- .update(appState.party.id, payload)
+ .update(props.team.id, payload)
.then((response) => storeParty(response.data.party))
}
}
@@ -89,21 +154,25 @@ const Party = (props: Props) => {
appState.party.extra = event.target.checked
// Only save if this is a saved party
- if (appState.party.id) {
- api.endpoints.parties.update(appState.party.id, {
+ if (props.team && props.team.id) {
+ api.endpoints.parties.update(props.team.id, {
party: { extra: event.target.checked },
})
}
}
// Deleting the party
- function deleteTeam(event: React.MouseEvent) {
- if (appState.party.editable && appState.party.id) {
+ function deleteTeam() {
+ if (props.team && editable) {
api.endpoints.parties
- .destroy({ id: appState.party.id })
+ .destroy({ id: props.team.id })
.then(() => {
// Push to route
- router.push('/')
+ if (cookies && cookies.account.username) {
+ router.push(`/${cookies.account.username}`)
+ } else {
+ router.push('/')
+ }
// Clean state
const resetState = clonedeep(initialAppState)
@@ -121,7 +190,7 @@ const Party = (props: Props) => {
}
// Methods: Storing party data
- const storeParty = function (team: Party) {
+ const storeParty = function (team: any) {
// Store the important party and state-keeping values in global state
appState.party.name = team.name
appState.party.description = team.description
@@ -129,27 +198,52 @@ const Party = (props: Props) => {
appState.party.updated_at = team.updated_at
appState.party.job = team.job
appState.party.jobSkills = team.job_skills
+ appState.party.accessory = team.accessory
appState.party.id = team.id
+ appState.party.shortcode = team.shortcode
appState.party.extra = team.extra
appState.party.user = team.user
appState.party.favorited = team.favorited
+ appState.party.remix = team.remix
+ appState.party.remixes = team.remixes
+ appState.party.sourceParty = team.source_party
appState.party.created_at = team.created_at
appState.party.updated_at = team.updated_at
appState.party.detailsVisible = false
+ // Store the edit key in local storage
+ if (team.edit_key) {
+ storeEditKey(team.id, team.edit_key)
+ setEditKey(team.id, team.user)
+ }
+
// Populate state
storeCharacters(team.characters)
storeWeapons(team.weapons)
storeSummons(team.summons)
+ // Create a string to send the user back to the tab they're currently on
+ let tab = ''
+ if (currentTab === GridType.Character) {
+ tab = 'characters'
+ } else if (currentTab === GridType.Summon) {
+ tab = 'summons'
+ }
+
// Then, push the browser history to the new party's URL
- if (props.pushHistory) props.pushHistory(`/p/${team.shortcode}`)
+ if (props.pushHistory) {
+ props.pushHistory(`/p/${team.shortcode}/${tab}`)
+ }
return team
}
+ const storeEditKey = (id: string, key: string) => {
+ ls(id, key)
+ }
+
const storeCharacters = (list: Array) => {
list.forEach((object: GridCharacter) => {
if (object.position != null)
@@ -184,17 +278,22 @@ const Party = (props: Props) => {
// Methods: Navigating with segmented control
function segmentClicked(event: React.ChangeEvent) {
+ const path = [
+ router.asPath.split('/').filter((el) => el != '')[1],
+ event.target.value,
+ ].join('/')
+
switch (event.target.value) {
- case 'class':
- setCurrentTab(GridType.Class)
- break
case 'characters':
+ router.replace(path)
setCurrentTab(GridType.Character)
break
case 'weapons':
+ router.replace(path)
setCurrentTab(GridType.Weapon)
break
case 'summons':
+ router.replace(path)
setCurrentTab(GridType.Summon)
break
default:
@@ -214,6 +313,7 @@ const Party = (props: Props) => {
const weaponGrid = (
{
const summonGrid = (
{
const characterGrid = (
{
)
}
+Party.defaultProps = defaultProps
+
export default Party
diff --git a/components/PartyDetails/index.scss b/components/PartyDetails/index.scss
index e9df8d65..a6306701 100644
--- a/components/PartyDetails/index.scss
+++ b/components/PartyDetails/index.scss
@@ -1,22 +1,35 @@
.DetailsWrapper {
display: flex;
flex-direction: column;
+ gap: $unit-2x;
margin: $unit-4x auto 0 auto;
max-width: $grid-width;
@include breakpoint(phone) {
- padding: 0 $unit;
+ .Button:not(.IconButton) {
+ justify-content: center;
+ width: 100%;
+
+ .Text {
+ width: auto;
+ }
+ }
}
.PartyDetails {
+ box-sizing: border-box;
display: none;
- margin: 0 auto;
+ margin: 0 auto $unit-2x;
max-width: $unit * 94;
overflow: hidden;
width: 100%;
+ @include breakpoint(phone) {
+ padding: 0 $unit;
+ }
+
&.Visible {
- margin-bottom: $unit-12x;
+ // margin-bottom: $unit-12x;
}
&.Editable {
@@ -37,6 +50,7 @@
}
.SelectTrigger {
+ padding: $unit-2x;
width: 100%;
}
@@ -45,12 +59,16 @@
grid-template-columns: 1fr 1fr 1fr;
gap: $unit;
+ @include breakpoint(phone) {
+ grid-template-columns: 1fr;
+ }
+
.ToggleSection,
.InputSection {
align-items: center;
display: flex;
background: var(--card-bg);
- border-radius: $card-corner;
+ border-radius: $input-corner;
& > label {
align-items: center;
@@ -133,6 +151,11 @@
flex-direction: row;
gap: $unit;
+ @include breakpoint(phone) {
+ flex-direction: column;
+ width: 100%;
+ }
+
.left {
flex-grow: 1;
}
@@ -141,11 +164,18 @@
display: flex;
flex-direction: row;
gap: $unit;
+
+ @include breakpoint(phone) {
+ .Button {
+ flex-grow: 1;
+ }
+ }
}
}
}
&.ReadOnly {
+ box-sizing: border-box;
line-height: 1.4;
white-space: pre-wrap;
@@ -166,7 +196,8 @@
.Details {
display: flex;
flex-direction: row;
- gap: $unit-half;
+ flex-wrap: wrap;
+ gap: $unit;
margin-bottom: $unit-2x;
}
@@ -257,27 +288,38 @@
}
.PartyInfo {
- align-items: center;
+ box-sizing: border-box;
display: flex;
flex-direction: row;
gap: $unit;
margin: 0 auto;
- margin-bottom: $unit * 2;
max-width: $unit * 94;
width: 100%;
- .Left {
+ @include breakpoint(phone) {
+ flex-direction: column;
+ gap: $unit;
+ padding: 0 $unit;
+ }
+
+ & > .Left {
flex-grow: 1;
- 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);
+ }
}
}
@@ -297,6 +339,18 @@
font-size: $font-small;
}
+ a:visited:not(.fire):not(.water):not(.wind):not(.earth):not(.dark):not(
+ .light
+ ) {
+ color: var(--text-primary);
+ }
+
+ a:hover:not(.fire):not(.water):not(.wind):not(.earth):not(.dark):not(
+ .light
+ ) {
+ color: $blue;
+ }
+
& > *:not(:last-child):after {
content: ' · ';
margin: 0 calc($unit / 2);
@@ -333,3 +387,59 @@
}
}
}
+
+.Remixes {
+ display: flex;
+ flex-direction: column;
+ gap: $unit-2x;
+ margin: 0 auto;
+ width: 720px;
+
+ @include breakpoint(tablet) {
+ gap: $unit;
+ max-width: 720px;
+ margin: 0 auto;
+ }
+
+ @include breakpoint(phone) {
+ max-width: inherit;
+ width: 100%;
+ }
+
+ h3 {
+ font-size: $font-medium;
+ font-weight: $medium;
+
+ @include breakpoint(phone) {
+ padding: 0 $unit;
+ }
+ }
+
+ .GridRepCollection {
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ margin-left: $unit-2x * -1;
+ margin-right: $unit-2x * -1;
+
+ @include breakpoint(tablet) {
+ grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
+ max-width: inherit;
+ width: 100%;
+ }
+
+ @include breakpoint(phone) {
+ grid-template-columns: 1fr;
+ margin-left: $unit * -1;
+ margin-right: $unit * -1;
+ max-width: inherit;
+ width: 100%;
+ }
+
+ .GridRep {
+ min-width: 200px;
+
+ @include breakpoint(phone) {
+ min-width: 360px;
+ }
+ }
+ }
+}
diff --git a/components/PartyDetails/index.tsx b/components/PartyDetails/index.tsx
index ffc3f99e..2a93f342 100644
--- a/components/PartyDetails/index.tsx
+++ b/components/PartyDetails/index.tsx
@@ -1,34 +1,38 @@
import React, { useEffect, useState, ChangeEvent, KeyboardEvent } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
-import { useSnapshot } from 'valtio'
+import { subscribe, useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
+import clonedeep from 'lodash.clonedeep'
import Linkify from 'react-linkify'
import LiteYouTubeEmbed from 'react-lite-youtube-embed'
import classNames from 'classnames'
import reactStringReplace from 'react-string-replace'
-import * as AlertDialog from '@radix-ui/react-alert-dialog'
-
+import Alert from '~components/Alert'
import Button from '~components/Button'
import CharLimitedFieldset from '~components/CharLimitedFieldset'
-import Input from '~components/Input'
import DurationInput from '~components/DurationInput'
+import GridRepCollection from '~components/GridRepCollection'
+import GridRep from '~components/GridRep'
+import Input from '~components/Input'
+import RaidDropdown from '~components/RaidDropdown'
+import Switch from '~components/Switch'
+import Tooltip from '~components/Tooltip'
+import TextFieldset from '~components/TextFieldset'
import Token from '~components/Token'
-import RaidDropdown from '~components/RaidDropdown'
-import TextFieldset from '~components/TextFieldset'
-import Switch from '~components/Switch'
-
+import api from '~utils/api'
import { accountState } from '~utils/accountState'
-import { appState } from '~utils/appState'
+import { appState, initialAppState } from '~utils/appState'
import { formatTimeAgo } from '~utils/timeAgo'
import { youtube } from '~utils/youtube'
import CheckIcon from '~public/icons/Check.svg'
import CrossIcon from '~public/icons/Cross.svg'
import EditIcon from '~public/icons/Edit.svg'
+import RemixIcon from '~public/icons/Remix.svg'
import type { DetailsObject } from 'types'
@@ -40,9 +44,7 @@ interface Props {
new: boolean
editable: boolean
updateCallback: (details: DetailsObject) => void
- deleteCallback: (
- event: React.MouseEvent
- ) => void
+ deleteCallback: () => void
}
const PartyDetails = (props: Props) => {
@@ -60,6 +62,7 @@ const PartyDetails = (props: Props) => {
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
+ const [alertOpen, setAlertOpen] = useState(false)
const [chargeAttack, setChargeAttack] = useState(true)
const [fullAuto, setFullAuto] = useState(false)
@@ -70,6 +73,8 @@ const PartyDetails = (props: Props) => {
const [turnCount, setTurnCount] = useState(undefined)
const [clearTime, setClearTime] = useState(0)
+ const [remixes, setRemixes] = useState([])
+
const [raidSlug, setRaidSlug] = useState('')
const [embeddedDescription, setEmbeddedDescription] =
useState()
@@ -112,12 +117,33 @@ const PartyDetails = (props: Props) => {
setFullAuto(props.party.full_auto)
setChargeAttack(props.party.charge_attack)
setClearTime(props.party.clear_time)
+ setRemixes(props.party.remixes)
if (props.party.turn_count) setTurnCount(props.party.turn_count)
if (props.party.button_count) setButtonCount(props.party.button_count)
if (props.party.chain_count) setChainCount(props.party.chain_count)
}
}, [props.party])
+ // Subscribe to router changes and reset state
+ // if the new route is a new team
+ useEffect(() => {
+ router.events.on('routeChangeStart', (url, { shallow }) => {
+ if (url === '/new' || url === '/') {
+ const party = initialAppState.party
+
+ setName(party.name ? party.name : '')
+ setAutoGuard(party.autoGuard)
+ setFullAuto(party.fullAuto)
+ setChargeAttack(party.chargeAttack)
+ setClearTime(party.clearTime)
+ setRemixes(party.remixes)
+ setTurnCount(party.turnCount)
+ setButtonCount(party.buttonCount)
+ setChainCount(party.chainCount)
+ }
+ })
+ }, [])
+
useEffect(() => {
// Extract the video IDs from the description
if (appState.party.description) {
@@ -293,6 +319,57 @@ const PartyDetails = (props: Props) => {
toggleDetails()
}
+ function handleClick() {
+ setAlertOpen(!alertOpen)
+ }
+
+ function deleteParty() {
+ props.deleteCallback()
+ }
+
+ // Methods: Navigation
+ function goTo(shortcode?: string) {
+ if (shortcode) router.push(`/p/${shortcode}`)
+ }
+
+ // Methods: Favorites
+ function toggleFavorite(teamId: string, favorited: boolean) {
+ if (favorited) unsaveFavorite(teamId)
+ else saveFavorite(teamId)
+ }
+
+ function saveFavorite(teamId: string) {
+ api.saveTeam({ id: teamId }).then((response) => {
+ if (response.status == 201) {
+ const index = remixes.findIndex((p) => p.id === teamId)
+ const party = remixes[index]
+
+ party.favorited = true
+
+ let clonedParties = clonedeep(remixes)
+ clonedParties[index] = party
+
+ setRemixes(clonedParties)
+ }
+ })
+ }
+
+ function unsaveFavorite(teamId: string) {
+ api.unsaveTeam({ id: teamId }).then((response) => {
+ if (response.status == 200) {
+ const index = remixes.findIndex((p) => p.id === teamId)
+ const party = remixes[index]
+
+ party.favorited = false
+
+ let clonedParties = clonedeep(remixes)
+ clonedParties[index] = party
+
+ setRemixes(clonedParties)
+ }
+ })
+ }
+
function extractYoutubeVideoIds(text: string) {
// Initialize an array to store the video IDs
const videoIds = []
@@ -326,7 +403,16 @@ const PartyDetails = (props: Props) => {
src={`/profile/${picture}.png`}
/>
)
- else return
+ else
+ return (
+
+ )
}
const userBlock = (username?: string, picture?: string, element?: string) => {
@@ -342,8 +428,8 @@ const PartyDetails = (props: Props) => {
let username, picture, element
if (accountState.account.authorized && props.new) {
username = accountState.account.user?.username
- picture = accountState.account.user?.picture
- element = accountState.account.user?.element
+ picture = accountState.account.user?.avatar.picture
+ element = accountState.account.user?.avatar.element
} else if (party.user && !props.new) {
username = party.user.username
picture = party.user.avatar.picture
@@ -381,191 +467,200 @@ const PartyDetails = (props: Props) => {
)
}
- const deleteButton = () => {
+ function renderRemixes() {
+ return remixes.map((party, i) => {
+ return (
+
+ )
+ })
+ }
+
+ const deleteAlert = () => {
if (party.editable) {
return (
-
-
-
-
-
- {t('buttons.delete')}
-
-
-
-
-
- {t('modals.delete_team.title')}
-
-
- {t('modals.delete_team.description')}
-
-
-
- {t('modals.delete_team.buttons.cancel')}
-
-
props.deleteCallback(e)}
- >
- {t('modals.delete_team.buttons.confirm')}
-
-
-
-
-
+ setAlertOpen(false)}
+ cancelActionText={t('modals.delete_team.buttons.cancel')}
+ message={t('modals.delete_team.description')}
+ />
)
- } else {
- return ''
}
}
- const editable = (
-
-
-
-
-
-
- {t('party.details.labels.charge_attack')}
-
-
-
-
-
-
-
- {t('party.details.labels.full_auto')}
-
-
-
-
-
-
-
- {t('party.details.labels.auto_guard')}
-
-
-
-
-
-
-
-
-
- {t('party.details.labels.button_chain')}
-
+ const editable = () => {
+ return (
+
+
+
+
+
+
+ {t('party.details.labels.charge_attack')}
+
+
+
+
+
+
+
+ {t('party.details.labels.full_auto')}
+
+
+
+
+
+
+
+ {t('party.details.labels.auto_guard')}
+
+
+
+
+
+
+
-
-
-
-
- {t('party.details.labels.turn_count')}
-
-
-
-
-
- {t('party.details.labels.clear_time')}
-
- handleClearTimeInput(value)}
- />
-
-
-
-
-
+
+
+
+
+ {t('party.details.labels.clear_time')}
+
+ handleClearTimeInput(value)}
+ />
+
+
+
+
+
-
-
- {router.pathname !== '/new' ? deleteButton() : ''}
+
+
+ {router.pathname !== '/new' ? (
+ }
+ className="Blended medium destructive"
+ onClick={handleClick}
+ text={t('buttons.delete')}
+ />
+ ) : (
+ ''
+ )}
+
+
+
+ }
+ text={t('buttons.save_info')}
+ onClick={updateDetails}
+ />
+
-
-
- }
- text={t('buttons.save_info')}
- onClick={updateDetails}
- />
-
-
-
- )
+
+ )
+ }
const clearTimeString = () => {
const minutes = Math.floor(clearTime / 60)
@@ -600,71 +695,128 @@ const PartyDetails = (props: Props) => {
}
}
- const readOnly = (
-
-
- {
-
+ const readOnly = () => {
+ return (
+
+
+
{`${t('party.details.labels.charge_attack')} ${
chargeAttack ? 'On' : 'Off'
}`}
- }
- {fullAuto ? {t('party.details.labels.full_auto')} : ''}
- {autoGuard ? {t('party.details.labels.auto_guard')} : ''}
- {turnCount ? (
-
- {t('party.details.turns.with_count', {
- count: turnCount,
+
+
+ {`${t('party.details.labels.full_auto')} ${
+ fullAuto ? 'On' : 'Off'
+ }`}
- ) : (
- ''
- )}
- {clearTime > 0 ? {clearTimeString()} : ''}
- {buttonChainToken()}
+
+
+ {`${t('party.details.labels.auto_guard')} ${
+ fullAuto ? 'On' : 'Off'
+ }`}
+
+
+ {turnCount ? (
+
+ {t('party.details.turns.with_count', {
+ count: turnCount,
+ })}
+
+ ) : (
+ ''
+ )}
+ {clearTime > 0 ? {clearTimeString()} : ''}
+ {buttonChainToken()}
+
+ {embeddedDescription}
- {embeddedDescription}
-
- )
+ )
+ }
+
+ const remixSection = () => {
+ return (
+
+ {t('remixes')}
+ {{renderRemixes()} }
+
+ )
+ }
return (
-
-
-
-
- {name !== '' ? name : 'Untitled'}
-
-
- {renderUserBlock()}
- {party.raid ? linkedRaidBlock(party.raid) : ''}
- {party.created_at != '' ? (
-
- {formatTimeAgo(new Date(party.created_at), locale)}
-
- ) : (
- ''
- )}
+ <>
+
+
+
+
+
+ {name ? name : t('no_title')}
+
+ {party.remix && party.sourceParty ? (
+
+ }
+ text={t('tokens.remix')}
+ onClick={() => goTo(party.sourceParty?.shortcode)}
+ />
+
+ ) : (
+ ''
+ )}
+
+
+ {renderUserBlock()}
+ {party.raid ? linkedRaidBlock(party.raid) : ''}
+ {party.created_at != '' ? (
+
+ {formatTimeAgo(new Date(party.created_at), locale)}
+
+ ) : (
+ ''
+ )}
+
-
-
{party.editable ? (
-
}
- text={t('buttons.show_info')}
- onClick={toggleDetails}
- />
+
+ }
+ text={t('buttons.show_info')}
+ onClick={toggleDetails}
+ />
+
) : (
-
+ ''
)}
-
- {readOnly}
- {editable}
-
+ {readOnly()}
+ {editable()}
+
+ {deleteAlert()}
+
+ {remixes && remixes.length > 0 ? remixSection() : ''}
+ >
)
}
diff --git a/components/PartyHead/index.tsx b/components/PartyHead/index.tsx
new file mode 100644
index 00000000..7971de81
--- /dev/null
+++ b/components/PartyHead/index.tsx
@@ -0,0 +1,74 @@
+import React from 'react'
+import Head from 'next/head'
+import { useRouter } from 'next/router'
+import { useTranslation } from 'next-i18next'
+
+import generateTitle from '~utils/generateTitle'
+
+interface Props {
+ party: Party
+ meta: { [key: string]: string }
+}
+
+const PartyHead = ({ party, meta }: Props) => {
+ // Import translations
+ const { t } = useTranslation('common')
+
+ // Set up router
+ const router = useRouter()
+ const locale =
+ router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
+
+ return (
+
+ {/* HTML */}
+
+ {generateTitle(meta.element, party.user?.username, party.name)}
+
+
+
+
+
+ {/* OpenGraph */}
+
+
+
+
+
+ {/* Twitter */}
+
+
+
+
+
+ )
+}
+
+export default PartyHead
diff --git a/components/PartySegmentedControl/index.tsx b/components/PartySegmentedControl/index.tsx
index b4bdc495..c8ecf9d0 100644
--- a/components/PartySegmentedControl/index.tsx
+++ b/components/PartySegmentedControl/index.tsx
@@ -20,6 +20,7 @@ interface Props {
}
const PartySegmentedControl = (props: Props) => {
+ // Set up translations
const { t } = useTranslation('common')
const { party, grid } = useSnapshot(appState)
@@ -33,22 +34,16 @@ const PartySegmentedControl = (props: Props) => {
switch (element) {
case 1:
return 'wind'
- break
case 2:
return 'fire'
- break
case 3:
return 'water'
- break
case 4:
return 'earth'
- break
case 5:
return 'dark'
- break
case 6:
return 'light'
- break
}
}
@@ -72,13 +67,6 @@ const PartySegmentedControl = (props: Props) => {
})}
>
- {/* Class */}
-
,
+ HTMLDivElement
+ >,
+ PopoverPrimitive.PopoverContentProps {}
+
+export const Popover = PopoverPrimitive.Root
+export const PopoverAnchor = PopoverPrimitive.Anchor
+export const PopoverTrigger = PopoverPrimitive.Trigger
+
+export const PopoverContent = React.forwardRef(
+ function Popover(
+ { children, ...props }: PropsWithChildren,
+ forwardedRef
+ ) {
+ const classes = classnames(props.className, {
+ Popover: true,
+ })
+
+ return (
+
+
+ {children}
+
+
+
+ )
+ }
+)
+
+PopoverContent.defaultProps = {
+ sideOffset: 8,
+}
diff --git a/components/ProfileHead/index.tsx b/components/ProfileHead/index.tsx
new file mode 100644
index 00000000..293cab5a
--- /dev/null
+++ b/components/ProfileHead/index.tsx
@@ -0,0 +1,60 @@
+import React from 'react'
+import Head from 'next/head'
+import { useTranslation } from 'next-i18next'
+
+interface Props {
+ user: User
+}
+
+const ProfileHead = ({ user }: Props) => {
+ // Import translations
+ const { t } = useTranslation('common')
+
+ return (
+
+ {/* HTML */}
+ {t('page.titles.profile', { username: user.username })}
+
+
+
+
+ {/* OpenGraph */}
+
+
+
+
+
+ {/* Twitter */}
+
+
+
+
+
+ )
+}
+
+export default ProfileHead
diff --git a/components/RaidDropdown/index.tsx b/components/RaidDropdown/index.tsx
index 52bfcc76..a1ef9ad6 100644
--- a/components/RaidDropdown/index.tsx
+++ b/components/RaidDropdown/index.tsx
@@ -8,7 +8,7 @@ import SelectGroup from '~components/SelectGroup'
import api from '~utils/api'
import organizeRaids from '~utils/organizeRaids'
import { appState } from '~utils/appState'
-import { raidGroups } from '~utils/raidGroups'
+import { raidGroups } from '~data/raidGroups'
import './index.scss'
diff --git a/components/RingSelect/index.scss b/components/RingSelect/index.scss
new file mode 100644
index 00000000..8a080fd1
--- /dev/null
+++ b/components/RingSelect/index.scss
@@ -0,0 +1,5 @@
+.Rings {
+ display: flex;
+ flex-direction: column;
+ gap: $unit;
+}
diff --git a/components/RingSelect/index.tsx b/components/RingSelect/index.tsx
new file mode 100644
index 00000000..0c339766
--- /dev/null
+++ b/components/RingSelect/index.tsx
@@ -0,0 +1,150 @@
+// Core dependencies
+import React, { useEffect, useState } from 'react'
+
+// UI dependencies
+import ExtendedMasterySelect from '~components/ExtendedMasterySelect'
+
+// Data
+import { overMastery } from '~data/overMastery'
+
+// Styles and icons
+import './index.scss'
+
+// Types
+import { CharacterOverMastery, ExtendedMastery } from '~types'
+
+const emptyRing: ExtendedMastery = {
+ modifier: 0,
+ strength: 0,
+}
+
+interface Props {
+ gridCharacter: GridCharacter
+ sendValues: (overMastery: CharacterOverMastery) => void
+}
+
+const RingSelect = ({ gridCharacter, sendValues }: Props) => {
+ // Ring value states
+ const [rings, setRings] = useState({
+ 1: { ...emptyRing, modifier: 1 },
+ 2: { ...emptyRing, modifier: 2 },
+ 3: emptyRing,
+ 4: emptyRing,
+ })
+
+ useEffect(() => {
+ if (gridCharacter.over_mastery) {
+ setRings({
+ 1: gridCharacter.over_mastery[0],
+ 2: gridCharacter.over_mastery[1],
+ 3: gridCharacter.over_mastery[2],
+ 4: gridCharacter.over_mastery[3],
+ })
+ }
+ }, [gridCharacter])
+
+ useEffect(() => {
+ sendValues(rings)
+ }, [rings])
+
+ function dataSet(index: number) {
+ const noValue = {
+ name: {
+ en: 'No over mastery bonus',
+ ja: 'EXリミットボーナスなし',
+ },
+ id: 0,
+ slug: 'no-bonus',
+ minValue: 0,
+ maxValue: 0,
+ suffix: '',
+ fractional: false,
+ secondary: [],
+ }
+
+ switch (index) {
+ case 1:
+ return overMastery.a ? [overMastery.a[0]] : []
+ case 2:
+ return overMastery.a ? [overMastery.a[1]] : []
+ case 3:
+ return overMastery.b ? [noValue, ...overMastery.b] : []
+ case 4:
+ return overMastery.c ? [noValue, ...overMastery.c] : []
+ default:
+ return []
+ }
+ }
+
+ function receiveRingValues(index: number, left: number, right: number) {
+ console.log(`Receiving values from ${index}: ${left} ${right}`)
+ if (index == 1 || index == 2) {
+ setSyncedRingValues(index, right)
+ } else if (index == 3 && left == 0) {
+ setRings({
+ ...rings,
+ 3: {
+ modifier: 0,
+ strength: 0,
+ },
+ 4: {
+ modifier: 0,
+ strength: 0,
+ },
+ })
+ } else {
+ setRings({
+ ...rings,
+ [index]: {
+ modifier: left,
+ strength: right,
+ },
+ })
+ }
+ }
+
+ function setSyncedRingValues(index: 1 | 2, value: number) {
+ console.log(`Setting synced value for ${index} with value ${value}`)
+ const atkValues = (dataSet(1)[0] as ItemSkill).values ?? []
+ const hpValues = (dataSet(2)[0] as ItemSkill).values ?? []
+
+ let found = index === 1 ? atkValues.indexOf(value) : hpValues.indexOf(value)
+
+ setRings({
+ ...rings,
+ 1: {
+ modifier: 1,
+ strength: atkValues[found],
+ },
+ 2: {
+ modifier: 2,
+ strength: hpValues[found],
+ },
+ })
+ }
+
+ return (
+
+ {[...Array(4)].map((e, i) => {
+ const ringIndex = i + 1
+ const ringStat = rings[ringIndex]
+ return (
+ {
+ receiveRingValues(ringIndex, left, right)
+ }}
+ />
+ )
+ })}
+
+ )
+}
+
+export default RingSelect
diff --git a/components/RoadmapModal/index.tsx b/components/RoadmapModal/index.tsx
deleted file mode 100644
index feba1991..00000000
--- a/components/RoadmapModal/index.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import React from 'react'
-import Link from 'next/link'
-import { useTranslation } from 'next-i18next'
-import * as Dialog from '@radix-ui/react-dialog'
-
-import CrossIcon from '~public/icons/Cross.svg'
-import ShareIcon from '~public/icons/Share.svg'
-import GithubIcon from '~public/icons/github.svg'
-
-import './index.scss'
-
-const RoadmapModal = () => {
- const { t } = useTranslation('roadmap')
-
- return (
-
-
-
- {t('modals.roadmap.title')}
-
-
-
- event.preventDefault()}
- >
-
- {t('title')}
-
-
-
-
-
-
-
-
-
-
{t('subtitle')}
-
{t('blurb')}
-
{t('link.intro')}
-
-
-
-
-
- {t('roadmap.item1.title')}
- {t('roadmap.item1.description')}
-
-
- {t('roadmap.item2.title')}
- {t('roadmap.item2.description')}
-
-
- {t('roadmap.item3.title')}
- {t('roadmap.item3.description')}
-
-
- {t('roadmap.item4.title')}
- {t('roadmap.item4.description')}
-
-
- {t('roadmap.item5.title')}
- {t('roadmap.item5.description')}
-
-
- {t('roadmap.item6.title')}
- {t('roadmap.item6.description')}
-
-
-
-
-
-
-
- )
-}
-
-export default RoadmapModal
diff --git a/components/RoadmapModal/index.scss b/components/RoadmapPage/index.scss
similarity index 69%
rename from components/RoadmapModal/index.scss
rename to components/RoadmapPage/index.scss
index 7fc37b7b..90f9ca73 100644
--- a/components/RoadmapModal/index.scss
+++ b/components/RoadmapPage/index.scss
@@ -1,39 +1,41 @@
-.Roadmap.Dialog {
- max-height: 60vh;
- overflow-y: scroll;
+.Roadmap.PageContent {
+ padding-bottom: $unit-12x;
+ h3.priority {
+ font-weight: $medium;
+ font-size: $font-large;
+ margin-bottom: $unit-4x;
- .top {
+ &.in_progress {
+ color: $yellow;
+ }
+
+ &.high {
+ color: $red;
+ }
+
+ &.mid {
+ color: $orange-10;
+ }
+
+ &.low {
+ color: $blue;
+ }
+ }
+
+ .notes {
display: flex;
flex-direction: column;
gap: $unit;
-
- h3.priority {
- font-weight: $medium;
- font-size: $font-large;
-
- &.in_progress {
- color: $yellow;
- }
-
- &.high {
- color: $red;
- }
-
- &.mid {
- color: $orange-10;
- }
-
- &.low {
- color: $blue;
- }
- }
+ margin-bottom: $unit-2x;
p {
margin-bottom: $unit;
+ font-size: $font-medium;
}
.LinkItem {
$diameter: $unit-6x;
+ background: var(--dialog-bg);
border: 1px solid var(--link-item-bg);
border-radius: $card-corner;
@@ -79,22 +81,12 @@
}
}
- .Separator {
- background: var(--separator-bg);
- border-radius: 2px;
- margin: $unit-3x 0;
- height: 2px;
- }
- p {
- color: var(--text-secondary);
-
- font-size: $font-regular;
- line-height: 1.3;
- }
-
- .notes {
+ ul {
color: var(--text-primary);
list-style-type: none;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: $unit-3x;
li {
display: flex;
diff --git a/components/RoadmapPage/index.tsx b/components/RoadmapPage/index.tsx
new file mode 100644
index 00000000..2b396078
--- /dev/null
+++ b/components/RoadmapPage/index.tsx
@@ -0,0 +1,56 @@
+import React from 'react'
+import Link from 'next/link'
+
+import { useRouter } from 'next/router'
+import { useTranslation } from 'next-i18next'
+
+import ShareIcon from '~public/icons/Share.svg'
+import GithubIcon from '~public/icons/github.svg'
+
+import './index.scss'
+
+const ROADMAP_ITEMS = 6
+
+const RoadmapPage = () => {
+ const { t: common } = useTranslation('common')
+ const { t: about } = useTranslation('about')
+
+ return (
+
+
{common('about.segmented_control.roadmap')}
+
+ {about('roadmap.blurb')}
+ {about('roadmap.link.intro')}
+
+
+
+
+ {about('roadmap.subtitle')}
+
+
+
+ )
+}
+
+export default RoadmapPage
diff --git a/components/SavedHead/index.tsx b/components/SavedHead/index.tsx
new file mode 100644
index 00000000..75821047
--- /dev/null
+++ b/components/SavedHead/index.tsx
@@ -0,0 +1,26 @@
+import React from 'react'
+import Head from 'next/head'
+import { useTranslation } from 'next-i18next'
+
+const SavedHead = () => {
+ // Import translations
+ const { t } = useTranslation('common')
+
+ return (
+
+ {t('page.titles.saved')}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default SavedHead
diff --git a/components/SearchFilter/index.scss b/components/SearchFilter/index.scss
index 440c9532..1002d056 100644
--- a/components/SearchFilter/index.scss
+++ b/components/SearchFilter/index.scss
@@ -12,6 +12,7 @@ button.DropdownLabel {
padding: $unit ($unit * 1.5) $unit $unit-2x;
div {
+ align-items: center;
display: flex;
gap: $unit-half;
}
diff --git a/components/SearchModal/index.scss b/components/SearchModal/index.scss
index 288a4456..f507f592 100644
--- a/components/SearchModal/index.scss
+++ b/components/SearchModal/index.scss
@@ -1,10 +1,8 @@
-.Search.Dialog {
+.Search.DialogContent {
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 430px;
- height: 480px;
- gap: 0;
padding: 0;
@include breakpoint(phone) {
@@ -14,17 +12,16 @@
min-height: 100vh;
}
- #Header {
- border-bottom: 1px solid transparent;
+ .DialogHeader.Search {
+ align-items: inherit;
display: flex;
flex-direction: column;
gap: $unit;
- padding-bottom: $unit * 2;
-
- &.scrolled {
- border-bottom: 1px solid rgba(0, 0, 0, 0.1);
- box-shadow: 0 0 8px rgba(0, 0, 0, 0.12);
- }
+ padding: 0;
+ padding-bottom: $unit-2x;
+ position: sticky;
+ top: 0;
+ left: 0;
#Bar {
align-items: center;
@@ -63,7 +60,6 @@
#Results {
margin: 0;
- max-height: 356px;
padding: 0 ($unit * 1.5);
overflow-y: scroll;
@@ -94,7 +90,7 @@
}
}
-.Search.Dialog #NoResults {
+.Search.DialogContent #NoResults {
display: flex;
flex-direction: column;
align-items: center;
@@ -102,7 +98,7 @@
flex-grow: 1;
}
-.Search.Dialog #NoResults h2 {
+.Search.DialogContent #NoResults h2 {
color: var(--text-secondary);
font-size: $font-large;
font-weight: 500;
diff --git a/components/SearchModal/index.tsx b/components/SearchModal/index.tsx
index cf1a396a..b8edf073 100644
--- a/components/SearchModal/index.tsx
+++ b/components/SearchModal/index.tsx
@@ -3,16 +3,12 @@ import { getCookie, setCookie } from 'cookies-next'
import { useRouter } from 'next/router'
import { useTranslation } from 'react-i18next'
import InfiniteScroll from 'react-infinite-scroll-component'
+import cloneDeep from 'lodash.clonedeep'
import api from '~utils/api'
-import {
- Dialog,
- DialogTrigger,
- DialogContent,
- DialogClose,
-} from '~components/Dialog'
-
+import { Dialog, DialogTrigger, DialogClose } from '~components/Dialog'
+import DialogContent from '~components/DialogContent'
import Input from '~components/LabelledInput'
import CharacterSearchFilterBar from '~components/CharacterSearchFilterBar'
import WeaponSearchFilterBar from '~components/WeaponSearchFilterBar'
@@ -24,19 +20,18 @@ import WeaponResult from '~components/WeaponResult'
import SummonResult from '~components/SummonResult'
import JobSkillResult from '~components/JobSkillResult'
+import type { DialogProps } from '@radix-ui/react-dialog'
import type { SearchableObject, SearchableObjectArray } from '~types'
import './index.scss'
import CrossIcon from '~public/icons/Cross.svg'
-import cloneDeep from 'lodash.clonedeep'
-interface Props {
+interface Props extends DialogProps {
send: (object: SearchableObject, position: number) => any
placeholderText: string
fromPosition: number
job?: Job
object: 'weapons' | 'characters' | 'summons' | 'job_skills'
- children: React.ReactNode
}
const SearchModal = (props: Props) => {
@@ -47,8 +42,10 @@ const SearchModal = (props: Props) => {
// Set up translation
const { t } = useTranslation('common')
- let searchInput = React.createRef()
- let scrollContainer = React.createRef()
+ // Refs
+ const headerRef = React.createRef()
+ const searchInput = React.createRef()
+ const scrollContainer = React.createRef()
const [firstLoad, setFirstLoad] = useState(true)
const [filters, setFilters] = useState<{ [key: string]: any }>()
@@ -65,6 +62,10 @@ const SearchModal = (props: Props) => {
if (searchInput.current) searchInput.current.focus()
}, [searchInput])
+ useEffect(() => {
+ if (props.open !== undefined) setOpen(props.open)
+ })
+
function inputChanged(event: React.ChangeEvent) {
const text = event.target.value
if (text.length) {
@@ -141,8 +142,14 @@ const SearchModal = (props: Props) => {
}
}
+ const expiresAt = new Date()
+ expiresAt.setDate(expiresAt.getDate() + 60)
+
if (recents && recents.length > 5) recents.pop()
- setCookie(`recent_${props.object}`, recents, { path: '/' })
+ setCookie(`recent_${props.object}`, recents, {
+ path: '/',
+ expires: expiresAt,
+ })
sendData(result)
}
@@ -335,8 +342,10 @@ const SearchModal = (props: Props) => {
setRecordCount(0)
setCurrentPage(1)
setOpen(false)
+ if (props.onOpenChange) props.onOpenChange(false)
} else {
setOpen(true)
+ if (props.onOpenChange) props.onOpenChange(true)
}
}
@@ -354,11 +363,12 @@ const SearchModal = (props: Props) => {
{props.children}
-