June 2023 Update (#316)

* Update the updates page with new items (#306)

* Add Nier and Estarriola uncaps (#308)

* Update the updates page with new items (#306) (#307)

* Update .gitignore

* Add Nier and Estarriola uncaps

* Fix uncaps treated as new characters

* Redesigned team navigation (#310)

* Add ellipsis icon

* Reduce size of tokens

* Move UpdateToast to toasts folder

* Update variables.scss

* Add reps for grid objects

These reps act like the existing PartyRep except for Characters and Summons, as well as a new component just for Weapons.

They only render the grid of objects and nothing else.

Eventually PartyRep will use WeaponRep

* Added RepSegment

This is a Character, Weapon or Summon rep wrapped with an input and label for use in a SegmentedControl

* Modify PartySegmentedControl to use RepSegments

This will not work on mobile yet, where it should gracefully degrade to a normal SegmentedControl with only text

* Extract URL copied and Remixed toasts into files

* Extract delete team alert into a file

Also, to support this:
* Added `Destructive` class to Button
* Added `primaryActionClassName` prop to Alert

* Added an alert for when remixing teams

* Began refactoring PartyDetails into several files

* PartyHeader will live at the top, above the new segmented control
* PartyDetails stays below, only showing remixed teams and the description
* PartyDropdown handles the new ... menu

* Remove duplicated code

This is description and remix code that is still in `PartyDetails`

* Small fixes for weapon grid

* Add placeholder image for guidebooks

* Add localizations

* Add Guidebook type and update other types

* Update gitignore

Don't commit guidebook images

* Indicate if a dialog is scrollable

We had broken paging in the infinite scroll component. Turning off "scrolling" at the dialog levels fixes it without adding scrollbars in environments that persistently show them

* Add ExtraContainer

This is the purple container that will contain additional weapons and sephira guidebooks

* Move ExtraWeapons to ExtraWeaponsGrid

And put it in ExtraContainer

* Added GuidebooksGrid and GuidebookUnit

These are the display components for Guidebooks in the WeaponGrid

* Visual adjustments to summon grid

* Add Empty class to weapons when unit is unfilled

* Implement GuidebooksGrid in WeaponGrid

* Remove extra switch

* Remove old dependencies and props

* Implement searching for/adding guidebooks to party

* Update styles

* Fix dependency

* Properly determine when extra container should display

* Change to 1-indexing for guidebooks

* Add support for removing guidebooks

* Display guidebook validation error

* Move read only buttons to PartyHeader

Also broke up tokens and made them easier to render

* Add guidebooks to DetailsObject

* Remove preview when on mobile sizes

* Implement raid combobox (#311)

* Add ellipsis icon

* Reduce size of tokens

* Move UpdateToast to toasts folder

* Update variables.scss

* Add reps for grid objects

These reps act like the existing PartyRep except for Characters and Summons, as well as a new component just for Weapons.

They only render the grid of objects and nothing else.

Eventually PartyRep will use WeaponRep

* Added RepSegment

This is a Character, Weapon or Summon rep wrapped with an input and label for use in a SegmentedControl

* Modify PartySegmentedControl to use RepSegments

This will not work on mobile yet, where it should gracefully degrade to a normal SegmentedControl with only text

* Extract URL copied and Remixed toasts into files

* Extract delete team alert into a file

Also, to support this:
* Added `Destructive` class to Button
* Added `primaryActionClassName` prop to Alert

* Added an alert for when remixing teams

* Began refactoring PartyDetails into several files

* PartyHeader will live at the top, above the new segmented control
* PartyDetails stays below, only showing remixed teams and the description
* PartyDropdown handles the new ... menu

* Remove duplicated code

This is description and remix code that is still in `PartyDetails`

* Small fixes for weapon grid

* Add placeholder image for guidebooks

* Add localizations

* Add Guidebook type and update other types

* Update gitignore

Don't commit guidebook images

* Indicate if a dialog is scrollable

We had broken paging in the infinite scroll component. Turning off "scrolling" at the dialog levels fixes it without adding scrollbars in environments that persistently show them

* Add ExtraContainer

This is the purple container that will contain additional weapons and sephira guidebooks

* Move ExtraWeapons to ExtraWeaponsGrid

And put it in ExtraContainer

* Added GuidebooksGrid and GuidebookUnit

These are the display components for Guidebooks in the WeaponGrid

* Visual adjustments to summon grid

* Add Empty class to weapons when unit is unfilled

* Implement GuidebooksGrid in WeaponGrid

* Remove extra switch

* Remove old dependencies and props

* Implement searching for/adding guidebooks to party

* Update styles

* Fix dependency

* Properly determine when extra container should display

* Change to 1-indexing for guidebooks

* Add support for removing guidebooks

* Display guidebook validation error

* Move read only buttons to PartyHeader

Also broke up tokens and made them easier to render

* Add guidebooks to DetailsObject

* Add raid placeholder string to locale

* Update .gitignore

* Update and reorganize localization files

* Update types

Added RaidGroup and updated Raid, then updated dependent types and objects

* Update dependencies

* Update react and react-dom to at least 18.0.0
* Install cmdk

* Rename Arrow.svg to Chevron.svg

Also added a new Arrow.svg with a stem

* Add api call for raidGroups and update pages

Pages fetch raids and store them in the app state. We needed to update this to pull raid groups instead

* Update SegmentedControl component

* Add className and blended properties
* Segment gets flex-grow

* Update Select component

* data-placeholder style should match only if true
* Adjust corner radius to match cards instead of inputs
* Fix classNames call in SelectItem

* Remove raid prop from Party

* Add Popover component

* Popover is a wrapper of Radix's Popover component that we will use to wrap the combobox.
* Move styles that were in PopoverContent.scss to Popover.scss

* Add Command component

The Command component is a wrapper over CMDK's Command component. Pretty much every object in that library is wrapped here. We will use this for the guts of our combobox.

* Add RaidCombobox and RaidItem components

* RaidCombobox combines Popover and Command to create an experience where users can browse through raids by section, search them and sort them.
* RaidItem is effectively a copy-paste of SelectItem using CommandItem, adding some raid-specific styles and elements

* Updates themes and variables

* Replace RaidDropdown with RaidCombobox

* Add small shadow to Tooltip

* Update side offset for Popover

* Update CharLimitedFieldset class name

* Add clear button to Combobox input

* It only shows up when there is text in the input
* Clicking it clears the text in the input
* It uses CharLimitedFieldset's classes

* ChatGPT helped me refactor RaidCombobox

* Further refactoring of RaidCombobox

* Deploy content update (#309)

* Update the updates page with new items (#306)

* Add Nier and Estarriola uncaps (#308)

* Update the updates page with new items (#306) (#307)

* Update .gitignore

* Add Nier and Estarriola uncaps

* Fix uncaps treated as new characters

* Make combobox keyboard accessible

* Style updates

* Refactor accessibility code

* Add translation for "Selected" text

* Change selects to be poppers for consistency

We can't make the new Raid combobox appear over the input like the macOS behavior, so we change all selects to be normal popper behavior

* Set raid groups on teams page

* Implement in FilterBar

* Fix styles for combobox input

* Remove RaidDropdown component

* Update index.scss

* Remove preview when on mobile sizes

* Fix some mobile styles

* Add farming raid option

* Increase height slightly

* Small refactor

* Implement Edit team modal (#312)

* Small refactor to CharLimitedFieldset

Some methods were renamed for clarity. <input> props are actually put on the input properly.

* Add tabindex to Popover trigger

* Add tabindex to Switch and SwitchTableField

* Add tabindex to DurationInput

* Add new properties

* Added guidebooks to RaidGroup
* Added auto_summon to Party

* Conditionally render description in TableField

* Improve SwitchTableField

* Add support for passing in classes
* Add support for passing a disabled prop
* Pass description to TableField
* Right-align switch
* Add support for Extra color switch

* Align SliderTableField input to right

* Align SelectTableField input to right

* Update placeholder styles

* Fix empty state on DurationInput

* Remove tabindex from DurationInput

* Update InputTableField

Allow for passing down input properties and remove fixed width

* Fix dialog footer styles

* Update dialog and overlay z-index

* Add styles to TableField

Added styles for numeric inputs, disabled inputs, and generally cleaning things up

* Add guidebooks to RaidCombobox + styles

* Added guidebooks to the dummy raid group
* Fix background color
* Make less tall

* Implement EditPartyModal

EditPartyModal takes functionality that was in PartyHeader and puts it in a modal dialog. This lets us add fields and reduces the complexity of other components. Translations were also added.

* Remove edit functionality

* Add darker shadow to Select

* Properly send raid ID to server

* Show Extra grids based on selected raid

* Fix EX badge colors

* Use child as value in normal textarea

* Remove toggle ability from Extra grids

* Remove edit functionality from PartyDetails

* Fix type error

* Add quick summons (#313)

* Delete yarn.lock

* Add quick summon endpoint

* Add quick summon to GridSummon type

* Add icons

* Add quick summon to SummonUnit

* Quick summon icon is displayed on hover
* Updates the server when clicked

* Fix spacing on WeaponGrid

* Fixes for reactivity and performance (#314)

* Remove editable styles

* Use snapshot for segment reps

Using snapshots lets that data be reactive.

Also removed extra dependencies and fixed a bug in how SummonRep displayed sub-summons

* Don't display QuickSummon on friends, subaura

* Hotfix refreshing when switching tabs

* Another hotfix for tab switching

* Update awakening (#315)

* Add Awakening type and remove old defs

We remove the flat list of awakening data, as we will be pulling data from the database

* Update types to use new Awakening type

* Update WeaponUnit for Grand weapon awakenings

* Update object modals

We needed to update CharacterModal and WeaponModal to display awakenings from the new data format. However, the component used (`SelectWithInput`) was tied to AX Skills in a way that would take exponentially more time to resolve.

Instead, we forked `SelectWithInput` into `AwakeningSelectWithInput` and did our work there.

`AwakeningSelect` was found to be redundant, so it was removed.

* Update hovercards

* Add order to NO_AWAKENING

* Add ability to remove job skills (#317)

* Add Awakening type and remove old defs

We remove the flat list of awakening data, as we will be pulling data from the database

* Update types to use new Awakening type

* Update WeaponUnit for Grand weapon awakenings

* Update object modals

We needed to update CharacterModal and WeaponModal to display awakenings from the new data format. However, the component used (`SelectWithInput`) was tied to AX Skills in a way that would take exponentially more time to resolve.

Instead, we forked `SelectWithInput` into `AwakeningSelectWithInput` and did our work there.

`AwakeningSelect` was found to be redundant, so it was removed.

* Update hovercards

* Add max-height to Select

* Allow styling of Select modal with className prop

* Add Job class to Job select

* Add localizations for removing job skills

* Add endpoint for removing job skills

* Implement removing job skills

We added a (...) button next to each editable job skill that opens a context menu that will allow the user to remove the job skill. An alert is presented to make sure the user is sure before proceeding.

As part of this change, some minor restyling of JobSkillItem was necessary
This commit is contained in:
Justin Edmund 2023-06-19 03:54:03 -07:00 committed by GitHub
parent 363148599a
commit b8ae43ddaf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
149 changed files with 16601 additions and 15104 deletions

1
.gitignore vendored
View file

@ -57,6 +57,7 @@ public/images/accessory*
public/images/mastery*
public/images/updates*
public/images/guidebooks*
public/images/raids*
# Typescript v1 declaration files
typings/

View file

@ -72,7 +72,7 @@
select,
.SelectTrigger {
// background: url("/icons/Arrow.svg"), $grey-90;
// background: url("/icons/Chevron.svg"), $grey-90;
// background-repeat: no-repeat;
// background-position-y: center;
// background-position-x: 95%;

View file

@ -4,7 +4,6 @@ import classNames from 'classnames'
import equals from 'fast-deep-equal'
import FilterModal from '~components/FilterModal'
import RaidDropdown from '~components/RaidDropdown'
import Select from '~components/common/Select'
import SelectItem from '~components/common/SelectItem'
import Button from '~components/common/Button'
@ -15,6 +14,8 @@ import FilterIcon from '~public/icons/Filter.svg'
import './index.scss'
import { getCookie } from 'cookies-next'
import RaidCombobox from '~components/raids/RaidCombobox'
import { appState } from '~utils/appState'
interface Props {
children: React.ReactNode
@ -29,6 +30,8 @@ const FilterBar = (props: Props) => {
// Set up translation
const { t } = useTranslation('common')
const [currentRaid, setCurrentRaid] = useState<Raid>()
const [recencyOpen, setRecencyOpen] = useState(false)
const [elementOpen, setElementOpen] = useState(false)
@ -47,6 +50,16 @@ const FilterBar = (props: Props) => {
FiltersActive: !matchesDefaultFilters,
})
// Convert raid slug to Raid object on mount
useEffect(() => {
const raid = appState.raidGroups
.filter((group) => group.section > 0)
.flatMap((group) => group.raids)
.find((raid) => raid.slug === props.raidSlug)
setCurrentRaid(raid)
}, [props.raidSlug])
useEffect(() => {
// Fetch user's advanced filters
const filtersCookie = getCookie('filters')
@ -76,8 +89,8 @@ const FilterBar = (props: Props) => {
props.onFilter({ recency: recencyValue, ...advancedFilters })
}
function raidSelectChanged(slug?: string) {
props.onFilter({ raidSlug: slug, ...advancedFilters })
function raidSelectChanged(raid?: Raid) {
props.onFilter({ raidSlug: raid?.slug, ...advancedFilters })
}
function handleAdvancedFiltersChanged(filters: FilterSet) {
@ -90,6 +103,25 @@ const FilterBar = (props: Props) => {
setRecencyOpen(name === 'recency' ? !recencyOpen : false)
}
function generateSelectItems() {
const elements = [
{ element: 'all', key: -1, value: -1, text: t('elements.full.all') },
{ element: 'null', key: 0, value: 0, text: t('elements.full.null') },
{ element: 'wind', key: 1, value: 1, text: t('elements.full.wind') },
{ element: 'fire', key: 2, value: 2, text: t('elements.full.fire') },
{ element: 'water', key: 3, value: 3, text: t('elements.full.water') },
{ element: 'earth', key: 4, value: 4, text: t('elements.full.earth') },
{ element: 'dark', key: 5, value: 5, text: t('elements.full.dark') },
{ element: 'light', key: 6, value: 6, text: t('elements.full.light') },
]
return elements.map(({ element, key, value, text }) => (
<SelectItem data-element={element} key={key} value={value}>
{text}
</SelectItem>
))
}
return (
<>
<div className={classes}>
@ -97,47 +129,26 @@ const FilterBar = (props: Props) => {
<div className="Filters">
<Select
value={`${props.element}`}
overlayVisible={false}
open={elementOpen}
onOpenChange={() => onSelectChange('element')}
onValueChange={elementSelectChanged}
onClick={openElementSelect}
>
<SelectItem data-element="all" key={-1} value={-1}>
{t('elements.full.all')}
</SelectItem>
<SelectItem data-element="null" key={0} value={0}>
{t('elements.full.null')}
</SelectItem>
<SelectItem data-element="wind" key={1} value={1}>
{t('elements.full.wind')}
</SelectItem>
<SelectItem data-element="fire" key={2} value={2}>
{t('elements.full.fire')}
</SelectItem>
<SelectItem data-element="water" key={3} value={3}>
{t('elements.full.water')}
</SelectItem>
<SelectItem data-element="earth" key={4} value={4}>
{t('elements.full.earth')}
</SelectItem>
<SelectItem data-element="dark" key={5} value={5}>
{t('elements.full.dark')}
</SelectItem>
<SelectItem data-element="light" key={6} value={6}>
{t('elements.full.light')}
</SelectItem>
{generateSelectItems()}
</Select>
<RaidDropdown
currentRaid={props.raidSlug}
defaultRaid="all"
<RaidCombobox
currentRaid={currentRaid}
showAllRaidsOption={true}
minimal={true}
onChange={raidSelectChanged}
/>
<Select
value={`${props.recency}`}
trigger={'All time'}
overlayVisible={false}
open={recencyOpen}
onOpenChange={() => onSelectChange('recency')}
onValueChange={recencySelectChanged}

View file

@ -199,15 +199,17 @@ const FilterModal = (props: Props) => {
setMinWeaponCount(value)
}
function handleMaxButtonsCountValueChange(value: number) {
setMaxButtonsCount(value)
function handleMaxButtonsCountValueChange(value?: string) {
if (!value) return
setMaxButtonsCount(parseInt(value))
}
function handleMaxTurnsCountValueChange(value: number) {
setMaxTurnsCount(value)
function handleMaxTurnsCountValueChange(value?: string) {
if (!value) return
setMaxTurnsCount(parseInt(value))
}
function handleNameQualityValueChange(value: boolean) {
function handleNameQualityValueChange(value?: boolean) {
setNameQuality(value)
}
@ -414,7 +416,8 @@ const FilterModal = (props: Props) => {
{originalOnlyField()}
</div>
<div className="DialogFooter" ref={footerRef}>
<div className="Buttons Spaced">
<div className="Left"></div>
<div className="Right Buttons Spaced">
<Button
blended={true}
text={t('modals.filters.buttons.clear')}

View file

@ -4,6 +4,7 @@
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
margin: 0 auto;
padding: 0;
padding-top: $unit-fourth;
transition: opacity 0.14s ease-in-out;
justify-items: center;
width: 100%;

View file

@ -31,7 +31,7 @@ import Button from '~components/common/Button'
import Tooltip from '~components/common/Tooltip'
import * as Switch from '@radix-ui/react-switch'
import ArrowIcon from '~public/icons/Arrow.svg'
import ChevronIcon from '~public/icons/Chevron.svg'
import LinkIcon from '~public/icons/Link.svg'
import MenuIcon from '~public/icons/Menu.svg'
import RemixIcon from '~public/icons/Remix.svg'
@ -296,25 +296,6 @@ const Header = () => {
}
// Rendering: Buttons
const saveButton = () => {
return (
<Tooltip content={t('tooltips.save')}>
<Button
leftAccessoryIcon={<SaveIcon />}
className={classNames({
Save: true,
Saved: partySnapshot.favorited,
})}
blended={true}
text={
partySnapshot.favorited ? t('buttons.saved') : t('buttons.save')
}
onClick={toggleFavorite}
/>
</Tooltip>
)
}
const newButton = () => {
return (
<Tooltip content={t('tooltips.new')}>
@ -329,20 +310,6 @@ const Header = () => {
)
}
const remixButton = () => {
return (
<Tooltip content={t('tooltips.remix')}>
<Button
leftAccessoryIcon={<RemixIcon />}
className="Remix"
blended={true}
text={t('buttons.remix')}
onClick={remixTeam}
/>
</Tooltip>
)
}
// Rendering: Toasts
const urlCopyToast = () => {
return (
@ -435,15 +402,6 @@ const Header = () => {
const right = () => {
return (
<section>
{router.route === '/p/[party]' &&
account.user &&
(!partySnapshot.user || partySnapshot.user.id !== account.user.id) &&
!appState.errorCode
? saveButton()
: ''}
{router.route === '/p/[party]' && !appState.errorCode
? remixButton()
: ''}
{newButton()}
<DropdownMenu
open={rightMenuOpen}
@ -453,7 +411,7 @@ const Header = () => {
<Button
className={classNames({ Active: rightMenuOpen })}
leftAccessoryIcon={profileImage()}
rightAccessoryIcon={<ArrowIcon />}
rightAccessoryIcon={<ChevronIcon />}
rightAccessoryClassName="Arrow"
onClick={handleRightMenuButtonClicked}
blended={true}

View file

@ -6,7 +6,7 @@ import { getCookie } from 'cookies-next'
import { appState } from '~utils/appState'
import TopHeader from '~components/Header'
import UpdateToast from '~components/about/UpdateToast'
import UpdateToast from '~components/toasts/UpdateToast'
import './index.scss'

View file

@ -1,144 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import Select from '~components/common/Select'
import SelectItem from '~components/common/SelectItem'
import SelectGroup from '~components/common/SelectGroup'
import api from '~utils/api'
import organizeRaids from '~utils/organizeRaids'
import { appState } from '~utils/appState'
import { raidGroups } from '~data/raidGroups'
import './index.scss'
// Props
interface Props {
showAllRaidsOption: boolean
currentRaid?: string
defaultRaid?: string
onChange?: (slug?: string) => void
onBlur?: (event: React.ChangeEvent<HTMLSelectElement>) => void
}
// Set up empty raid for "All raids"
const allRaidsOption = {
id: '0',
name: {
en: 'All raids',
ja: '全て',
},
slug: 'all',
level: 0,
group: 0,
element: 0,
}
const RaidDropdown = React.forwardRef<HTMLSelectElement, Props>(
function useFieldSet(props, ref) {
// Set up router for locale
const router = useRouter()
const locale = router.locale || 'en'
// Set up local states for storing raids
const [open, setOpen] = useState(false)
const [currentRaid, setCurrentRaid] = useState<Raid | undefined>(undefined)
const [raids, setRaids] = useState<Raid[]>()
const [sortedRaids, setSortedRaids] = useState<Raid[][]>()
function openRaidSelect() {
setOpen(!open)
}
// Organize raids into groups on mount
const organizeAllRaids = useCallback(
(raids: Raid[]) => {
let { sortedRaids } = organizeRaids(raids)
if (props.showAllRaidsOption) {
raids.unshift(allRaidsOption)
sortedRaids[0].unshift(allRaidsOption)
}
setRaids(raids)
setSortedRaids(sortedRaids)
appState.raids = raids
},
[props.showAllRaidsOption]
)
// Fetch all raids on mount
useEffect(() => {
api.endpoints.raids
.getAll()
.then((response) => organizeAllRaids(response.data))
}, [organizeRaids])
// Set current raid on mount
useEffect(() => {
if (raids && props.currentRaid) {
const raid = raids.find((raid) => raid.slug === props.currentRaid)
if (raid) setCurrentRaid(raid)
}
}, [raids, props.currentRaid])
// Enable changing select value
function handleChange(value: string) {
if (props.onChange) props.onChange(value)
if (raids) {
const raid = raids.find((raid) => raid.slug === value)
setCurrentRaid(raid)
}
}
// Render JSX for each raid option, sorted into optgroups
function renderRaidGroup(index: number) {
const options =
sortedRaids &&
sortedRaids.length > 0 &&
sortedRaids[index].length > 0 &&
sortedRaids[index]
.sort((a, b) => {
if (a.element > 0 && b.element > 0) return a.element - b.element
else if (a.name.en.includes('NM') && b.name.en.includes('NM'))
return a.level < b.level ? -1 : 1
else return a.name.en < b.name.en ? -1 : 1
})
.map((item, i) => (
<SelectItem key={i} value={item.slug}>
{item.name[locale]}
</SelectItem>
))
return (
<SelectGroup
key={index}
label={raidGroups[index].name[locale]}
separator={false}
>
{options}
</SelectGroup>
)
}
return (
<React.Fragment>
<Select
value={props.currentRaid}
placeholder={'Select a raid...'}
open={open}
onOpenChange={() => setOpen(!open)}
onClick={openRaidSelect}
onValueChange={handleChange}
>
{Array.from(Array(sortedRaids?.length)).map((x, i) =>
renderRaidGroup(i)
)}
</Select>
</React.Fragment>
)
}
)
export default RaidDropdown

View file

@ -0,0 +1,22 @@
.Raid.Select {
min-width: 420px;
.Top {
display: flex;
flex-direction: column;
gap: $unit;
padding: $unit 0;
.SegmentedControl {
width: 100%;
}
.Input.Bound {
background-color: var(--select-contained-bg);
&:hover {
background-color: var(--select-contained-bg-hover);
}
}
}
}

View file

@ -0,0 +1,170 @@
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import * as RadixSelect from '@radix-ui/react-select'
import classNames from 'classnames'
import Overlay from '~components/common/Overlay'
import ChevronIcon from '~public/icons/Chevron.svg'
import './index.scss'
import SegmentedControl from '~components/common/SegmentedControl'
import Segment from '~components/common/Segment'
import Input from '~components/common/Input'
// Props
interface Props
extends React.DetailedHTMLProps<
React.SelectHTMLAttributes<HTMLSelectElement>,
HTMLSelectElement
> {
altText?: string
currentSegment: number
iconSrc?: string
open: boolean
trigger?: React.ReactNode
children?: React.ReactNode
onOpenChange?: () => void
onValueChange?: (value: string) => void
onSegmentClick: (segment: number) => void
onClose?: () => void
triggerClass?: string
overlayVisible?: boolean
}
const RaidSelect = React.forwardRef<HTMLButtonElement, Props>(function Select(
props: Props,
forwardedRef
) {
// Import translations
const { t } = useTranslation('common')
const searchInput = React.createRef<HTMLInputElement>()
const [open, setOpen] = useState(false)
const [value, setValue] = useState('')
const [query, setQuery] = useState('')
const triggerClasses = classNames(
{
SelectTrigger: true,
Disabled: props.disabled,
},
props.triggerClass
)
useEffect(() => {
setOpen(props.open)
}, [props.open])
useEffect(() => {
if (props.value && props.value !== '') setValue(`${props.value}`)
else setValue('')
}, [props.value])
function onValueChange(newValue: string) {
setValue(`${newValue}`)
if (props.onValueChange) props.onValueChange(newValue)
}
function onCloseAutoFocus() {
setOpen(false)
if (props.onClose) props.onClose()
}
function onEscapeKeyDown() {
setOpen(false)
if (props.onClose) props.onClose()
}
function onPointerDownOutside() {
setOpen(false)
if (props.onClose) props.onClose()
}
return (
<RadixSelect.Root
open={open}
value={value !== '' ? value : undefined}
onValueChange={onValueChange}
onOpenChange={props.onOpenChange}
>
<RadixSelect.Trigger
className={triggerClasses}
placeholder={props.placeholder}
ref={forwardedRef}
>
{props.iconSrc ? <img alt={props.altText} src={props.iconSrc} /> : ''}
<RadixSelect.Value placeholder={props.placeholder} />
{!props.disabled ? (
<RadixSelect.Icon className="SelectIcon">
<ChevronIcon />
</RadixSelect.Icon>
) : (
''
)}
</RadixSelect.Trigger>
<RadixSelect.Portal className="Select">
<>
<Overlay
open={open}
visible={props.overlayVisible != null ? props.overlayVisible : true}
/>
<RadixSelect.Content
className="Raid Select"
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
>
<div className="Top">
<Input
autoComplete="off"
className="Search Bound"
name="query"
placeholder={t('search.placeholders.raid')}
ref={searchInput}
value={query}
onChange={() => {}}
/>
<SegmentedControl blended={true}>
<Segment
groupName="raid_section"
name="events"
selected={props.currentSegment === 1}
onClick={() => props.onSegmentClick(1)}
>
{t('raids.sections.events')}
</Segment>
<Segment
groupName="raid_section"
name="raids"
selected={props.currentSegment === 0}
onClick={() => props.onSegmentClick(0)}
>
{t('raids.sections.raids')}
</Segment>
<Segment
groupName="raid_section"
name="solo"
selected={props.currentSegment === 2}
onClick={() => props.onSegmentClick(2)}
>
{t('raids.sections.solo')}
</Segment>
</SegmentedControl>
</div>
<RadixSelect.Viewport>{props.children}</RadixSelect.Viewport>
</RadixSelect.Content>
</>
</RadixSelect.Portal>
</RadixSelect.Root>
)
})
RaidSelect.defaultProps = {
overlayVisible: true,
}
export default RaidSelect

View file

@ -330,11 +330,14 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
{themeField()}
</div>
<div className="DialogFooter" ref={footerRef}>
<div className="Left"></div>
<div className="Right">
<Button
contained={true}
text={t('modals.settings.buttons.confirm')}
/>
</div>
</div>
</form>
</DialogContent>
</Dialog>

View file

@ -259,6 +259,23 @@ const CharacterGrid = (props: Props) => {
}
}
function removeJobSkill(position: number) {
if (party.id && props.editable) {
api
.removeJobSkill({ partyId: party.id, position: position })
.then((response) => {
// Update the current skills
const newSkills = response.data.job_skills
setJobSkills(newSkills)
appState.party.jobSkills = newSkills
})
.catch((error) => {
const data = error.response.data
console.log(data)
})
}
}
async function saveAccessory(accessory: JobAccessory) {
const payload = {
party: {
@ -506,6 +523,7 @@ const CharacterGrid = (props: Props) => {
editable={props.editable}
saveJob={saveJob}
saveSkill={saveJobSkill}
removeSkill={removeJobSkill}
saveAccessory={saveAccessory}
/>
<CharacterConflictModal

View file

@ -16,7 +16,6 @@ import {
aetherialMastery,
permanentMastery,
} from '~data/overMastery'
import { characterAwakening } from '~data/awakening'
import { ExtendedMastery } from '~types'
import './index.scss'
@ -27,13 +26,6 @@ interface Props {
onTriggerClick: () => void
}
interface KeyNames {
[key: string]: {
en: string
jp: string
}
}
const CharacterHovercard = (props: Props) => {
const router = useRouter()
const { t } = useTranslation('common')
@ -181,27 +173,20 @@ const CharacterHovercard = (props: Props) => {
const awakeningSection = () => {
const gridAwakening = props.gridCharacter.awakening
const awakening = characterAwakening.find(
(awakening) => awakening.id === gridAwakening?.type
)
if (gridAwakening && awakening) {
if (gridAwakening) {
return (
<section className="Awakening">
<h5 className={tintElement}>
{t('modals.characters.subtitles.awakening')}
</h5>
<div>
{gridAwakening.type > 1 ? (
<img
alt={awakening.name[locale]}
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/awakening/character_${gridAwakening.type}.jpg`}
alt={gridAwakening.type.name[locale]}
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/awakening/${gridAwakening.type.slug}.jpg`}
/>
) : (
''
)}
<span>
<strong>{`${awakening.name[locale]}`}</strong>&nbsp;
<strong>{`${gridAwakening.type.name[locale]}`}</strong>&nbsp;
{`Lv${gridAwakening.level}`}
</span>
</div>

View file

@ -1,13 +1,7 @@
// Core dependencies
import React, {
PropsWithChildren,
useCallback,
useEffect,
useState,
} from 'react'
import React, { PropsWithChildren, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import { AxiosResponse } from 'axios'
import classNames from 'classnames'
// UI dependencies
@ -20,14 +14,10 @@ import {
import DialogContent from '~components/common/DialogContent'
import Button from '~components/common/Button'
import SelectWithInput from '~components/common/SelectWithInput'
import AwakeningSelect from '~components/mastery/AwakeningSelect'
import RingSelect from '~components/mastery/RingSelect'
import Switch from '~components/common/Switch'
// Utilities
import api from '~utils/api'
import { appState } from '~utils/appState'
import { retrieveCookies } from '~utils/retrieveCookies'
import elementalizeAetherialMastery from '~utils/elementalizeAetherialMastery'
// Data
@ -36,6 +26,8 @@ const emptyExtendedMastery: ExtendedMastery = {
strength: 0,
}
const MAX_AWAKENING_LEVEL = 9
// Styles and icons
import CrossIcon from '~public/icons/Cross.svg'
import './index.scss'
@ -46,6 +38,7 @@ import {
ExtendedMastery,
GridCharacterObject,
} from '~types'
import AwakeningSelectWithInput from '~components/mastery/AwakeningSelectWithInput'
interface Props {
gridCharacter: GridCharacter
@ -66,9 +59,6 @@ const CharacterModal = ({
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)
@ -103,8 +93,8 @@ const CharacterModal = ({
const [earring, setEarring] = useState<ExtendedMastery>(emptyExtendedMastery)
// Character properties: Awakening
const [awakeningType, setAwakeningType] = useState(0)
const [awakeningLevel, setAwakeningLevel] = useState(0)
const [awakening, setAwakening] = useState<Awakening>()
const [awakeningLevel, setAwakeningLevel] = useState(1)
// Character properties: Transcendence
const [transcendenceStep, setTranscendenceStep] = useState(0)
@ -118,7 +108,7 @@ const CharacterModal = ({
})
}
setAwakeningType(gridCharacter.awakening.type)
setAwakening(gridCharacter.awakening.type)
setAwakeningLevel(gridCharacter.awakening.level)
setPerpetuity(gridCharacter.perpetuity)
}, [gridCharacter])
@ -147,15 +137,16 @@ const CharacterModal = ({
modifier: earring.modifier,
strength: earring.strength,
},
awakening: {
type: awakeningType,
level: awakeningLevel,
},
transcendence_step: transcendenceStep,
perpetuity: perpetuity,
},
}
if (awakening) {
object.character.awakening_id = awakening.id
object.character.awakening_level = awakeningLevel
}
return object
}
@ -191,8 +182,8 @@ const CharacterModal = ({
if (onOpenChange) onOpenChange(false)
}
function receiveAwakeningValues(type: number, level: number) {
setAwakeningType(type)
function receiveAwakeningValues(id: string, level: number) {
setAwakening(gridCharacter.object.awakenings.find((a) => a.id === id))
setAwakeningLevel(level)
}
@ -234,10 +225,16 @@ const CharacterModal = ({
return (
<section>
<h3>{t('modals.characters.subtitles.awakening')}</h3>
<AwakeningSelect
object="character"
type={awakeningType}
level={awakeningLevel}
<AwakeningSelectWithInput
dataSet={gridCharacter.object.awakenings}
awakening={gridCharacter.awakening.type}
level={gridCharacter.awakening.level}
defaultAwakening={
gridCharacter.object.awakenings.find(
(a) => a.slug === 'character-balanced'
)!
}
maxLevel={MAX_AWAKENING_LEVEL}
sendValidity={receiveValidity}
sendValues={receiveAwakeningValues}
/>

View file

@ -30,6 +30,7 @@
.description {
font-size: $font-regular;
line-height: 1.4;
white-space: pre-line;
strong {
font-weight: $bold;

View file

@ -12,6 +12,7 @@ interface Props {
message: string | React.ReactNode
primaryAction?: () => void
primaryActionText?: string
primaryActionClassName?: string
cancelAction: () => void
cancelActionText: string
}
@ -22,7 +23,10 @@ const Alert = (props: Props) => {
<AlertDialog.Portal>
<AlertDialog.Overlay className="Overlay" onClick={props.cancelAction} />
<div className="AlertWrapper">
<AlertDialog.Content className="Alert">
<AlertDialog.Content
className="Alert"
onEscapeKeyDown={props.cancelAction}
>
{props.title ? (
<AlertDialog.Title>{props.title}</AlertDialog.Title>
) : (
@ -42,6 +46,7 @@ const Alert = (props: Props) => {
{props.primaryAction ? (
<AlertDialog.Action asChild>
<Button
className={props.primaryActionClassName}
contained={true}
onClick={props.primaryAction}
text={props.primaryActionText}

View file

@ -1,7 +1,7 @@
.Button {
align-items: center;
background: var(--button-bg);
border: none;
border: 2px solid transparent;
border-radius: $input-corner;
color: var(--button-text);
display: inline-flex;
@ -166,6 +166,15 @@
}
}
&.Destructive {
background: $error;
color: white;
&:hover {
background: darken($error, 15);
}
}
.Accessory {
$dimension: $unit-2x;

View file

@ -1,38 +0,0 @@
.Limited {
$offset: 2px;
align-items: center;
background: var(--input-bg);
border-radius: $input-corner;
border: $offset solid transparent;
box-sizing: border-box;
display: flex;
gap: $unit;
padding-top: 2px;
padding-bottom: 2px;
padding-right: calc($unit-2x - $offset);
&:focus-within {
border: $offset solid $blue;
// box-shadow: 0 2px rgba(255, 255, 255, 1);
}
.Counter {
color: $grey-55;
font-weight: $bold;
line-height: 42px;
}
.Input {
background: transparent;
border: none;
border-radius: 0;
padding: $unit * 1.5 $unit-2x;
padding-left: calc($unit-2x - $offset);
&:focus {
border: none;
outline: none;
}
}
}

View file

@ -1,7 +1,14 @@
import React, { useEffect, useState } from 'react'
import React, {
ForwardRefRenderFunction,
forwardRef,
useEffect,
useState,
} from 'react'
import classNames from 'classnames'
import './index.scss'
interface Props {
interface Props extends React.HTMLProps<HTMLInputElement> {
fieldName: string
placeholder: string
value?: string
@ -11,47 +18,61 @@ interface Props {
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
}
const CharLimitedFieldset = React.forwardRef<HTMLInputElement, Props>(
function useFieldSet(props, ref) {
const fieldType = ['password', 'confirm_password'].includes(props.fieldName)
? 'password'
: 'text'
const [currentCount, setCurrentCount] = useState(0)
useEffect(() => {
setCurrentCount(
props.value ? props.limit - props.value.length : props.limit
const CharLimitedFieldset: ForwardRefRenderFunction<HTMLInputElement, Props> = (
{
fieldName,
placeholder,
value,
limit,
error,
onBlur,
onChange: onInputChange,
...props
},
ref
) => {
// States
const [currentCount, setCurrentCount] = useState(
() => limit - (value || '').length
)
}, [props.limit, props.value])
function onChange(event: React.ChangeEvent<HTMLInputElement>) {
setCurrentCount(props.limit - event.currentTarget.value.length)
if (props.onChange) props.onChange(event)
// Hooks
useEffect(() => {
setCurrentCount(limit - (value || '').length)
}, [limit, value])
// Event handlers
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value: inputValue } = event.currentTarget
setCurrentCount(limit - inputValue.length)
if (onInputChange) {
onInputChange(event)
}
}
// Rendering methods
return (
<fieldset className="Fieldset">
<div className="Limited">
<div className={classNames({ Joined: true }, props.className)}>
<input
{...props}
autoComplete="off"
className="Input"
type={fieldType}
name={props.fieldName}
placeholder={props.placeholder}
defaultValue={props.value || ''}
onBlur={props.onBlur}
onChange={onChange}
maxLength={props.limit}
type={props.type}
name={fieldName}
placeholder={placeholder}
defaultValue={value || ''}
onBlur={onBlur}
onChange={handleInputChange}
maxLength={limit}
ref={ref}
formNoValidate
/>
<span className="Counter">{currentCount}</span>
</div>
{props.error.length > 0 && <p className="InputError">{props.error}</p>}
{error.length > 0 && <p className="InputError">{error}</p>}
</fieldset>
)
}
)
}
export default CharLimitedFieldset
export default forwardRef(CharLimitedFieldset)

View file

@ -0,0 +1,128 @@
import { forwardRef } from 'react'
import classNames from 'classnames'
import { Command as CommandPrimitive } from 'cmdk'
import { Dialog } from '../Dialog'
import { DialogContent, DialogProps } from '@radix-ui/react-dialog'
import './index.scss'
const Command = forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive ref={ref} className={className} {...props} />
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="DialogContent">
<Command>{children}</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div>
<CommandPrimitive.Input
ref={ref}
className={classNames({ CommandInput: true }, className)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={classNames({ CommandList: true }, className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="CommandEmpty" {...props} />
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={classNames({ CommandGroup: true }, className)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={classNames({ CommandSeparator: true }, className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={classNames({ CommandItem: true }, className)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={classNames({ CommandShortcut: true }, className)}
{...props}
/>
)
}
CommandShortcut.displayName = 'CommandShortcut'
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View file

@ -11,7 +11,7 @@
min-width: 100vw;
overflow-y: auto;
color: inherit;
z-index: 40;
z-index: 10;
.DialogContent {
$multiplier: 4;
@ -59,9 +59,13 @@
width: 100%;
}
.Scrollable {
.Container {
overflow-y: hidden;
&.Scrollable {
overflow-y: auto;
}
}
.DialogHeader {
background: var(--dialog-bg);
@ -156,7 +160,8 @@
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;
flex-direction: row;
justify-content: space-between;
padding: ($unit * 1.5) ($unit * $multiplier) $unit-3x;
position: sticky;
@ -174,7 +179,6 @@
&.Spaced {
justify-content: space-between;
width: 100%;
}
}
}

View file

@ -13,12 +13,13 @@ interface Props
> {
headerref?: React.RefObject<HTMLDivElement>
footerref?: React.RefObject<HTMLDivElement>
scrollable?: boolean
onEscapeKeyDown: (event: KeyboardEvent) => void
onOpenAutoFocus: (event: Event) => void
}
const DialogContent = React.forwardRef<HTMLDivElement, Props>(function Dialog(
{ children, ...props },
{ scrollable, children, ...props },
forwardedRef
) {
// Classes
@ -131,7 +132,13 @@ const DialogContent = React.forwardRef<HTMLDivElement, Props>(function Dialog(
onEscapeKeyDown={props.onEscapeKeyDown}
ref={forwardedRef}
>
<div className="Scrollable" onScroll={handleScroll}>
<div
className={classNames({
Container: true,
Scrollable: scrollable,
})}
onScroll={handleScroll}
>
{children}
</div>
</DialogPrimitive.Content>
@ -141,4 +148,8 @@ const DialogContent = React.forwardRef<HTMLDivElement, Props>(function Dialog(
)
})
DialogContent.defaultProps = {
scrollable: true,
}
export default DialogContent

View file

@ -4,6 +4,7 @@
border-radius: 6px;
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
box-sizing: border-box;
overflow: auto;
width: 30vw;
max-width: 180px;
margin: 0 $unit-2x;
@ -130,6 +131,14 @@
}
}
& .destructive {
color: $error;
&:hover {
background: $error;
color: #fff;
}
}
a {
color: $grey-50;
@ -177,12 +186,12 @@
.MenuGroup {
border-bottom: 1px solid var(--menu-separator);
&:first-child .MenuItem:first-child:hover {
&:first-child .MenuItem:first-child {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
&:last-child .MenuItem:last-child:hover {
&:last-child .MenuItem:last-child {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}

View file

@ -14,7 +14,10 @@ interface Props
}
const DurationInput = React.forwardRef<HTMLInputElement, Props>(
function DurationInput({ className, value, onValueChange }, forwardedRef) {
function DurationInput(
{ className, value, onValueChange, ...props },
forwardedRef
) {
// State
const [duration, setDuration] = useState('')
const [minutesSelected, setMinutesSelected] = useState(false)
@ -202,7 +205,7 @@ const DurationInput = React.forwardRef<HTMLInputElement, Props>(
},
className
)}
value={`${getSeconds()}`.padStart(2, '0')}
value={getSeconds() > 0 ? `${getSeconds()}`.padStart(2, '0') : ''}
onChange={handleSecondsChange}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}

View file

@ -23,6 +23,11 @@
&:hover {
background-color: var(--input-bound-bg-hover);
}
&::placeholder {
/* Chrome, Firefox, Opera, Safari 10.1+ */
color: var(--text-tertiary) !important;
}
}
&.AlignRight {
@ -43,7 +48,7 @@
width: 0;
}
::placeholder {
.Input::placeholder {
/* Chrome, Firefox, Opera, Safari 10.1+ */
color: var(--text-secondary) !important;
opacity: 1; /* Firefox */

View file

@ -1,4 +1,3 @@
.InputField.TableField .Input {
.InputField.TableField .Input[type='number'] {
text-align: right;
width: $unit-8x;
}

View file

@ -3,50 +3,60 @@ import Input from '~components/common/Input'
import TableField from '~components/common/TableField'
import './index.scss'
import classNames from 'classnames'
interface Props {
name: string
interface Props
extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
label: string
description?: string
placeholder?: string
value?: number
className?: string
imageAlt?: string
imageClass?: string
imageSrc?: string[]
onValueChange: (value: number) => void
onValueChange: (value?: string) => void
}
const InputTableField = (props: Props) => {
const [value, setValue] = useState(0)
const InputTableField = ({
label,
description,
imageAlt,
imageClass,
imageSrc,
...props
}: Props) => {
const [inputValue, setInputValue] = useState('')
useEffect(() => {
if (props.value) setValue(props.value)
if (props.value) setInputValue(`${props.value}`)
}, [props.value])
useEffect(() => {
props.onValueChange(value)
}, [value])
props.onValueChange(inputValue)
}, [inputValue])
function onInputChange(event: React.ChangeEvent<HTMLInputElement>) {
setValue(parseInt(event.currentTarget?.value))
setInputValue(`${parseInt(event.currentTarget?.value)}`)
}
return (
<TableField
name={props.name}
className="InputField"
imageAlt={props.imageAlt}
imageClass={props.imageClass}
imageSrc={props.imageSrc}
label={props.label}
{...props}
name={props.name || ''}
className={classNames({ InputField: true }, props.className)}
imageAlt={imageAlt}
imageClass={imageClass}
imageSrc={imageSrc}
label={label}
>
<Input
className="Bound"
placeholder={props.placeholder}
type="number"
value={value ? `${value}` : ''}
value={inputValue ? `${inputValue}` : ''}
step={1}
tabIndex={props.tabIndex}
type={props.type}
onChange={onInputChange}
/>
</TableField>

View file

@ -1,7 +1,7 @@
.Overlay {
isolation: isolate;
position: fixed;
z-index: 30;
z-index: 9;
top: 0;
right: 0;
bottom: 0;

View file

@ -0,0 +1,30 @@
.Popover {
animation: scaleIn $duration-zoom ease-out;
background: var(--dialog-bg);
border-radius: $card-corner;
border: 0.5px solid rgba(0, 0, 0, 0.18);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.24);
outline: none;
padding: $unit;
transform-origin: var(--radix-popover-content-transform-origin);
width: var(--radix-popover-trigger-width);
min-width: 440px;
z-index: 5;
@include breakpoint(phone) {
min-width: auto;
}
&.Flush {
padding: 0;
}
.Arrow {
fill: var(--dialog-bg);
filter: drop-shadow(0px 1px 1px rgb(0 0 0 / 0.18));
margin-top: -1px;
}
}
[data-radix-popper-content-wrapper] {
}

View file

@ -0,0 +1,108 @@
import React, {
ComponentProps,
PropsWithChildren,
ReactNode,
useEffect,
useState,
} from 'react'
import classNames from 'classnames'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import ChevronIcon from '~public/icons/Chevron.svg'
import './index.scss'
interface Props extends ComponentProps<'div'> {
open: boolean
disabled?: boolean
icon?: {
src: string
alt: string
}
trigger?: {
className?: string
placeholder?: string
}
triggerTabIndex?: number
value?: {
element: ReactNode
rawValue: string
}
onOpenChange?: () => void
}
const Popover = React.forwardRef<HTMLDivElement, Props>(function Popover(
{ children, ...props }: PropsWithChildren<Props>,
forwardedRef
) {
// Component state
const [open, setOpen] = useState(false)
// Element classes
const triggerClasses = classNames(
{
SelectTrigger: true,
Disabled: props.disabled,
},
props.trigger?.className
)
// Hooks
useEffect(() => {
setOpen(props.open)
}, [props.open])
// Elements
const value = props.value ? (
<span className="Value" data-value={props.value?.rawValue}>
{props.value?.element}
</span>
) : (
<span className="Value Empty">{props.placeholder}</span>
)
const icon = props.icon ? (
<img alt={props.icon.alt} src={props.icon.src} />
) : (
''
)
const arrow = !props.disabled ? (
<i className="SelectIcon">
<ChevronIcon />
</i>
) : (
''
)
return (
<PopoverPrimitive.Root
open={open}
onOpenChange={props.onOpenChange}
modal={true}
>
<PopoverPrimitive.Trigger
className={triggerClasses}
data-placeholder={!props.value}
tabIndex={props.triggerTabIndex}
>
{icon}
{value}
{arrow}
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Content
className={classNames({ Popover: true }, props.className)}
sideOffset={6}
ref={forwardedRef}
>
{children}
</PopoverPrimitive.Content>
</PopoverPrimitive.Root>
)
})
Popover.defaultProps = {
disabled: false,
}
export default Popover

View file

@ -1,17 +0,0 @@
.Popover {
animation: scaleIn $duration-zoom ease-out;
background: var(--dialog-bg);
border-radius: $card-corner;
border: 0.5px solid rgba(0, 0, 0, 0.18);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.24);
outline: none;
padding: $unit;
transform-origin: var(--radix-popover-content-transform-origin);
z-index: 5;
.Arrow {
fill: var(--dialog-bg);
filter: drop-shadow(0px 1px 1px rgb(0 0 0 / 0.18));
margin-top: -1px;
}
}

View file

@ -1,6 +1,7 @@
.Segment {
color: $grey-55;
cursor: pointer;
flex-grow: 1;
font-size: 1.4rem;
font-weight: $normal;
min-width: 100px;

View file

@ -6,11 +6,20 @@ interface Props {
groupName: string
name: string
selected: boolean
tabIndex?: number
children: string
onClick: (event: React.ChangeEvent<HTMLInputElement>) => void
}
const Segment: React.FC<Props> = (props: Props) => {
// Selects the segment when the user presses the spacebar
const handleKeyDown = (event: React.KeyboardEvent<HTMLLabelElement>) => {
if (event.key === ' ') {
event.preventDefault()
event.currentTarget.click()
}
}
return (
<div className="Segment">
<input
@ -21,7 +30,13 @@ const Segment: React.FC<Props> = (props: Props) => {
checked={props.selected}
onChange={props.onClick}
/>
<label htmlFor={props.name}>{props.children}</label>
<label
htmlFor={props.name}
tabIndex={props.tabIndex}
onKeyDown={handleKeyDown}
>
{props.children}
</label>
</div>
)
}

View file

@ -1,11 +1,14 @@
.SegmentedControlWrapper {
display: flex;
justify-content: center;
@include breakpoint(phone) {
width: 100%;
}
}
.SegmentedControl {
background: var(--card-bg);
border-radius: $unit * 3;
// border-radius: $unit * 3;
display: inline-flex;
padding: 3px;
position: relative;
@ -13,6 +16,20 @@
overflow: hidden;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
@include breakpoint(phone) {
background: var(--card-bg);
border-radius: 100px;
}
&.Blended {
background: var(--input-bound-bg);
border-radius: $full-corner;
.Segment input:checked + label {
background: var(--card-bg);
}
}
&.fire {
.Segment input:checked + label {
background: var(--fire-bg);

View file

@ -1,19 +1,38 @@
import React from 'react'
import classNames from 'classnames'
import './index.scss'
interface Props {
className?: string
elementClass?: string
blended?: boolean
tabIndex?: number
}
const SegmentedControl: React.FC<Props> = ({ elementClass, children }) => {
const SegmentedControl: React.FC<Props> = ({
className,
elementClass,
blended,
tabIndex,
children,
}) => {
const classes = classNames(
{
SegmentedControl: true,
Blended: blended,
},
className,
elementClass
)
return (
<div className="SegmentedControlWrapper">
<div className={`SegmentedControl ${elementClass ? elementClass : ''}`}>
{children}
</div>
<div className="SegmentedControlWrapper" tabIndex={tabIndex}>
<div className={classes}>{children}</div>
</div>
)
}
SegmentedControl.defaultProps = {
blended: false,
}
export default SegmentedControl

View file

@ -2,7 +2,7 @@
align-items: center;
background-color: var(--input-bg);
border-radius: $input-corner;
border: none;
border: 2px solid transparent;
display: flex;
gap: $unit;
padding: ($unit * 1.5) $unit-2x;
@ -34,7 +34,7 @@
cursor: not-allowed;
}
&[data-placeholder] > span:not(.SelectIcon) {
&[data-placeholder='true'] > span:not(.SelectIcon) {
color: var(--text-secondary);
}
@ -73,11 +73,13 @@
}
.Select {
background: var(--select-bg);
border-radius: $input-corner;
border: $hover-stroke;
box-shadow: $hover-shadow;
background: var(--dialog-bg);
border-radius: $card-corner;
border: 1px solid rgba(0, 0, 0, 0.24);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
padding: 0 $unit;
min-width: var(--radix-select-trigger-width);
max-height: 40vh;
z-index: 40;
.Scroll.Up,

View file

@ -4,7 +4,7 @@ import classNames from 'classnames'
import Overlay from '~components/common/Overlay'
import ArrowIcon from '~public/icons/Arrow.svg'
import ChevronIcon from '~public/icons/Chevron.svg'
import './index.scss'
@ -86,14 +86,14 @@ const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
<RadixSelect.Value placeholder={props.placeholder} />
{!props.disabled ? (
<RadixSelect.Icon className="SelectIcon">
<ArrowIcon />
<ChevronIcon />
</RadixSelect.Icon>
) : (
''
)}
</RadixSelect.Trigger>
<RadixSelect.Portal className="Select">
<RadixSelect.Portal className="SelectPortal">
<>
<Overlay
open={open}
@ -101,17 +101,19 @@ const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
/>
<RadixSelect.Content
className="Select"
className={classNames({ Select: true }, props.className)}
position="popper"
sideOffset={6}
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
>
<RadixSelect.ScrollUpButton className="Scroll Up">
<ArrowIcon />
<ChevronIcon />
</RadixSelect.ScrollUpButton>
<RadixSelect.Viewport>{props.children}</RadixSelect.Viewport>
<RadixSelect.ScrollDownButton className="Scroll Down">
<ArrowIcon />
<ChevronIcon />
</RadixSelect.ScrollDownButton>
</RadixSelect.Content>
</>

View file

@ -8,7 +8,8 @@
font-size: $font-regular;
padding: ($unit * 1.5) $unit-2x;
&:hover {
&:hover,
&:focus {
background-color: var(--option-bg-hover);
color: var(--text-primary);
cursor: pointer;

View file

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

View file

@ -0,0 +1,3 @@
.SelectField.TableField .Right {
justify-content: flex-end;
}

View file

@ -31,6 +31,7 @@ const SelectTableField = (props: Props) => {
return (
<TableField
name={props.name}
className="SelectField"
imageAlt={props.imageAlt}
imageClass={props.imageClass}
imageSrc={props.imageSrc}

View file

@ -5,4 +5,8 @@
text-align: right;
width: $unit-8x;
}
.Right {
justify-content: flex-end;
}
}

View file

@ -36,6 +36,7 @@ const Switch = (props: Props) => {
disabled={disabled}
required={required}
value={value}
tabIndex={props.tabIndex}
onCheckedChange={onCheckedChange}
>
<RadixSwitch.Thumb className={thumbClasses} />

View file

@ -0,0 +1,9 @@
.TableField.SwitchTableField {
&.Extra .Switch[data-state='checked'] {
background: var(--extra-purple-secondary);
}
.Right {
justify-content: end;
}
}

View file

@ -1,15 +1,18 @@
import { useEffect, useState } from 'react'
import classNames from 'classnames'
import Switch from '~components/common/Switch'
import TableField from '~components/common/TableField'
import './index.scss'
interface Props {
interface Props extends React.HTMLAttributes<HTMLDivElement> {
name: string
label: string
description?: string
disabled?: boolean
value?: boolean
className?: string
tabIndex?: number
imageAlt?: string
imageClass?: string
imageSrc?: string[]
@ -31,10 +34,19 @@ const SwitchTableField = (props: Props) => {
setValue(value)
}
const classes = classNames(
{
SwitchTableField: true,
Disabled: props.disabled,
},
props.className
)
return (
<TableField
name={props.name}
className="SwitchField"
description={props.description}
className={classes}
imageAlt={props.imageAlt}
imageClass={props.imageClass}
imageSrc={props.imageSrc}
@ -43,6 +55,8 @@ const SwitchTableField = (props: Props) => {
<Switch
name={props.name}
checked={value}
disabled={props.disabled}
tabIndex={props.tabIndex}
onCheckedChange={onValueChange}
/>
</TableField>

View file

@ -3,6 +3,7 @@
display: grid;
gap: $unit-2x;
grid-template-columns: 1fr auto;
min-height: $unit-6x;
justify-content: space-between;
padding: $unit-half 0;
width: 100%;
@ -17,7 +18,30 @@
color: var(--accent-blue);
}
&.Numeric .Right > .Input,
&.Numeric .Right > .Duration {
text-align: right;
max-width: $unit-12x;
width: $unit-12x;
}
&.Numeric .Right > .Duration {
justify-content: flex-end;
box-sizing: border-box;
}
&.Disabled {
&:hover .Left .Info h3 {
color: var(--text-tertiary);
}
.Left .Info h3 {
color: var(--text-tertiary);
}
}
.Left {
align-items: center;
display: flex;
flex-direction: row;
gap: $unit;
@ -59,7 +83,6 @@
color: var(--text-secondary);
font-size: $font-small;
line-height: 1.1;
max-width: 300px;
&.jp {
max-width: 270px;
@ -71,6 +94,7 @@
align-items: center;
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: $unit-2x;
width: 100%;

View file

@ -32,7 +32,7 @@ const TableField = (props: Props) => {
<div className="Left">
<div className="Info">
<h3>{props.label}</h3>
<p>{props.description}</p>
{props.description && <p>{props.description}</p>}
</div>
<div className="Image">{image()}</div>
</div>

View file

@ -2,11 +2,11 @@
background: var(--input-bg);
border-radius: 99px;
display: inline-flex;
font-size: $font-small;
font-weight: $medium;
font-size: $font-tiny;
font-weight: $bold;
min-width: 3rem;
text-align: center;
padding: $unit ($unit * 1.5);
padding: $unit-three-fourth ($unit * 1.5);
user-select: none;
&.ChargeAttack.On {

View file

@ -3,6 +3,7 @@
animation: scaleIn $duration-zoom ease-out;
background: var(--dialog-bg);
border-radius: $input-corner;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
color: var(--text-tertiary);
font-size: $font-tiny;
font-weight: $medium;

View file

@ -0,0 +1,35 @@
import React from 'react'
import { useTranslation } from 'next-i18next'
import Alert from '~components/common/Alert'
interface Props {
open: boolean
deleteCallback: () => void
onOpenChange: (open: boolean) => void
}
const DeleteTeamAlert = ({ open, deleteCallback, onOpenChange }: Props) => {
const { t } = useTranslation('common')
function deleteParty() {
deleteCallback()
}
function close() {
onOpenChange(false)
}
return (
<Alert
open={open}
primaryAction={deleteParty}
primaryActionClassName="Destructive"
primaryActionText={t('modals.delete_team.buttons.confirm')}
cancelAction={close}
cancelActionText={t('modals.delete_team.buttons.cancel')}
message={t('modals.delete_team.description')}
/>
)
}
export default DeleteTeamAlert

View file

@ -0,0 +1,57 @@
import React from 'react'
import { Trans, useTranslation } from 'next-i18next'
import Alert from '~components/common/Alert'
interface Props {
creator: boolean
name: string
open: boolean
remixCallback: () => void
onOpenChange: (open: boolean) => void
}
const RemixTeamAlert = ({
creator,
name,
open,
remixCallback,
onOpenChange,
}: Props) => {
const { t } = useTranslation('common')
function remixParty() {
remixCallback()
}
function close() {
onOpenChange(false)
}
return (
<Alert
open={open}
primaryAction={remixParty}
primaryActionText={t('modals.remix_team.buttons.confirm')}
cancelAction={close}
cancelActionText={t('modals.remix_team.buttons.cancel')}
message={
creator ? (
<Trans i18nKey="modals.remix_team.description.creator">
Remixing a team makes a copy of it in your account so you can make
your own changes.\n\nYou&apos;re already the creator of{' '}
<strong>{{ name: name }}</strong>, are you sure you want to remix
it?
</Trans>
) : (
<Trans i18nKey="modals.remix_team.description.viewer">
Remixing a team makes a copy of it in your account so you can make
your own changes.\n\nWould you like to remix{' '}
<strong>{{ name: 'HEY' }}</strong>?
</Trans>
)
}
/>
)
}
export default RemixTeamAlert

View file

@ -0,0 +1,50 @@
.ExtraContainer {
background: var(--extra-purple-bg);
border-radius: $card-corner;
display: flex;
flex-direction: column;
position: relative;
left: $unit;
margin: 20px auto;
max-width: calc($grid-width + 20px);
width: 100%;
.ContainerItem {
display: grid;
grid-template-columns: 1.19fr 3fr;
gap: $unit-2x;
padding: $unit-2x $unit-2x $unit-2x;
&.Disabled {
grid-template-columns: 1fr;
.Header {
flex-direction: row;
justify-content: space-between;
}
}
.Header {
align-items: center;
display: flex;
flex-direction: row;
gap: $unit;
justify-content: center;
min-height: $unit-4x;
width: 100%;
& > h3 {
color: var(--extra-purple-text);
font-size: $font-small;
font-weight: $medium;
line-height: 1.2;
font-weight: 500;
text-align: center;
}
}
&:not(:first-child) {
border-top: 1px solid var(--extra-purple-card-bg);
}
}
}

View file

@ -0,0 +1,11 @@
import React, { PropsWithChildren } from 'react'
import './index.scss'
// Props
interface Props {}
const ExtraContainer = ({ children, ...props }: PropsWithChildren<Props>) => {
return <div className="ExtraContainer">{children}</div>
}
export default ExtraContainer

View file

@ -0,0 +1,47 @@
.ExtraWeapons {
#ExtraWeaponGrid {
display: grid;
gap: $unit-3x;
grid-template-columns: repeat(3, minmax(0, 1fr));
@include breakpoint(tablet) {
gap: $unit-2x;
}
@include breakpoint(phone) {
gap: $unit;
}
.WeaponUnit {
.WeaponImage {
background: var(--extra-purple-card-bg);
.icon svg {
fill: var(--extra-purple-secondary);
}
}
}
}
.ExtraGrid.Weapons {
background: var(--extra-purple-bg);
border-radius: $card-corner;
box-sizing: border-box;
display: grid;
grid-template-columns: 1.42fr 3fr;
justify-content: center;
@include breakpoint(tablet) {
left: auto;
max-width: auto;
width: 100%;
}
@include breakpoint(phone) {
display: flex;
gap: $unit-2x;
padding: $unit-2x;
flex-direction: column;
}
}
}

View file

@ -0,0 +1,74 @@
import React, { useState } from 'react'
import { useTranslation } from 'next-i18next'
import Switch from '~components/common/Switch'
import WeaponUnit from '~components/weapon/WeaponUnit'
import type { SearchableObject } from '~types'
import './index.scss'
import classNames from 'classnames'
// Props
interface Props {
grid: GridArray<GridWeapon>
editable: boolean
found?: boolean
offset: number
removeWeapon: (id: string) => void
updateObject: (object: SearchableObject, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
}
// Constants
const EXTRA_WEAPONS_COUNT = 3
const ExtraWeaponsGrid = ({
grid,
editable,
offset,
removeWeapon,
updateObject,
updateUncap,
}: Props) => {
const { t } = useTranslation('common')
const classes = classNames({
ExtraWeapons: true,
ContainerItem: true,
})
const extraWeapons = (
<ul id="ExtraWeaponGrid">
{Array.from(Array(EXTRA_WEAPONS_COUNT)).map((x, i) => {
const itemClasses = classNames({
Empty: grid[offset + i] === undefined,
})
return (
<li className={itemClasses} key={`grid_unit_${i}`}>
<WeaponUnit
editable={editable}
position={offset + i}
unitType={1}
gridWeapon={grid[offset + i]}
removeWeapon={removeWeapon}
updateObject={updateObject}
updateUncap={updateUncap}
/>
</li>
)
})}
</ul>
)
return (
<div className={classes}>
<div className="Header">
<h3>{t('extra_weapons')}</h3>
</div>
{extraWeapons}
</div>
)
}
export default ExtraWeaponsGrid

View file

@ -0,0 +1,37 @@
.GuidebookResult {
border-radius: 6px;
display: flex;
gap: $unit;
padding: $unit * 1.5;
&:hover {
background: var(--button-contained-bg);
cursor: pointer;
.Info h5 {
color: var(--text-primary);
}
}
img {
background: $grey-80;
border-radius: 6px;
display: inline-block;
height: auto;
width: 90px;
}
.Info {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: $unit-half;
h5 {
color: var(--text-tertiary);
display: inline-block;
font-size: $font-medium;
font-weight: $medium;
}
}
}

View file

@ -0,0 +1,32 @@
import React from 'react'
import { useRouter } from 'next/router'
import './index.scss'
interface Props {
data: Guidebook
onClick: () => void
}
const GuidebookResult = (props: Props) => {
const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const guidebook = props.data
return (
<li className="GuidebookResult" onClick={props.onClick}>
<img
alt={guidebook.name[locale]}
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/guidebooks/book_${guidebook.granblue_id}.png`}
/>
<div className="Info">
<h5>{guidebook.name[locale]}</h5>
<p>{guidebook.description[locale]}</p>
</div>
</li>
)
}
export default GuidebookResult

View file

@ -0,0 +1,109 @@
.GuidebookUnit {
align-items: center;
display: flex;
flex-direction: column;
gap: $unit-half;
position: relative;
width: 100%;
height: auto;
z-index: 0;
@include breakpoint(tablet) {
min-height: auto;
}
.Button {
pointer-events: none;
opacity: 0;
z-index: 10;
}
&:hover .Button,
.Button.Clicked {
pointer-events: initial;
opacity: 1;
}
&.editable .GuidebookImage:hover {
border: $hover-stroke;
box-shadow: $hover-shadow;
cursor: pointer;
transform: $scale-wide;
}
&.empty {
min-height: auto;
}
&.filled h3 {
display: block;
}
&.filled ul {
display: flex;
}
& h3,
& ul {
display: none;
}
h3 {
color: var(--text-primary);
font-size: $font-button;
font-weight: $normal;
line-height: 1.1;
margin: 0;
text-align: center;
}
.GuidebookImage {
background: var(--extra-purple-card-bg);
border: 1px solid rgba(0, 0, 0, 0);
border-radius: $unit;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: calc($unit / 4);
overflow: hidden;
position: relative;
transition: $duration-zoom all ease-in-out;
img {
position: relative;
width: 100%;
z-index: 2;
&.Placeholder {
opacity: 0;
}
}
.icon {
position: absolute;
height: $unit * 3;
width: $unit * 3;
z-index: 1;
svg {
transition: $duration-color-fade fill ease-in-out;
fill: var(--extra-purple-secondary);
}
}
}
.GuidebookName {
font-size: $font-name;
line-height: 1.2;
@include breakpoint(phone) {
font-size: $font-tiny;
}
}
.GuidebookDescription {
font-size: $font-small;
line-height: 1.2;
text-align: center;
}
}

View file

@ -0,0 +1,201 @@
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { Trans, useTranslation } from 'next-i18next'
import classNames from 'classnames'
import Alert from '~components/common/Alert'
import SearchModal from '~components/search/SearchModal'
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
} from '~components/common/ContextMenu'
import ContextMenuItem from '~components/common/ContextMenuItem'
import Button from '~components/common/Button'
import type { SearchableObject } from '~types'
import PlusIcon from '~public/icons/Add.svg'
import SettingsIcon from '~public/icons/Settings.svg'
import './index.scss'
interface Props {
guidebook: Guidebook | undefined
position: number
editable: boolean
removeGuidebook: (position: number) => void
updateObject: (object: SearchableObject, position: number) => void
}
const GuidebookUnit = ({
guidebook,
position,
editable,
removeGuidebook: sendGuidebookToRemove,
updateObject,
}: Props) => {
// Translations and locale
const { t } = useTranslation('common')
const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
// State: UI
const [searchModalOpen, setSearchModalOpen] = useState(false)
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const [alertOpen, setAlertOpen] = useState(false)
// State: Other
const [imageUrl, setImageUrl] = useState('')
// Classes
const classes = classNames({
GuidebookUnit: true,
editable: editable,
filled: guidebook !== undefined,
empty: guidebook == undefined,
})
const buttonClasses = classNames({
Options: true,
Clicked: contextMenuOpen,
})
// Hooks
useEffect(() => {
generateImageUrl()
}, [guidebook])
// Methods: Open layer
function openSearchModal() {
if (editable) setSearchModalOpen(true)
}
function openRemoveGuidebookAlert() {
setAlertOpen(true)
}
// Methods: Handle button clicked
function handleButtonClicked() {
setContextMenuOpen(!contextMenuOpen)
}
// Methods: Handle open change
function handleContextMenuOpenChange(open: boolean) {
if (!open) setContextMenuOpen(false)
}
function handleSearchModalOpenChange(open: boolean) {
setSearchModalOpen(open)
}
// Methods: Mutate data
function removeGuidebook() {
if (guidebook) sendGuidebookToRemove(position)
setAlertOpen(false)
}
// Methods: Image string generation
function generateImageUrl() {
let imgSrc = guidebook
? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/guidebooks/book_${guidebook.granblue_id}.png`
: ''
setImageUrl(imgSrc)
}
const placeholderImageUrl = '/images/placeholders/placeholder-guidebook.png'
// Methods: Layer element rendering
const contextMenu = () => {
if (editable && guidebook) {
return (
<>
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
<ContextMenuTrigger asChild>
<Button
leftAccessoryIcon={<SettingsIcon />}
className={buttonClasses}
onClick={handleButtonClicked}
/>
</ContextMenuTrigger>
<ContextMenuContent align="start">
<ContextMenuItem onSelect={openRemoveGuidebookAlert}>
{t('context.remove')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{removeAlert()}
</>
)
}
}
const removeAlert = () => {
return (
<Alert
open={alertOpen}
primaryAction={removeGuidebook}
primaryActionText={t('modals.guidebooks.buttons.remove')}
cancelAction={() => setAlertOpen(false)}
cancelActionText={t('buttons.cancel')}
message={
<Trans i18nKey="modals.guidebooks.messages.remove">
Are you sure you want to remove{' '}
<strong>{{ guidebook: guidebook?.name[locale] }}</strong> from your
team?
</Trans>
}
/>
)
}
const searchModal = () => {
return (
<SearchModal
placeholderText={t('search.placeholders.guidebook')}
fromPosition={position}
object="guidebooks"
open={searchModalOpen}
onOpenChange={handleSearchModalOpenChange}
send={updateObject}
/>
)
}
// Methods: Core element rendering
const imageElement = (
<div className="GuidebookImage" onClick={openSearchModal}>
<img
alt={guidebook?.name[locale]}
className={classNames({
GridImage: true,
Placeholder: imageUrl === '',
})}
src={imageUrl !== '' ? imageUrl : placeholderImageUrl}
/>
{editable ? (
<span className="icon">
<PlusIcon />
</span>
) : (
''
)}
</div>
)
const unitContent = (
<>
<div className={classes}>
{contextMenu()}
{imageElement}
<h3 className="GuidebookName">{guidebook?.name[locale]}</h3>
</div>
{searchModal()}
</>
)
return unitContent
}
export default GuidebookUnit

View file

@ -0,0 +1,45 @@
.Guidebooks {
#GuidebooksGrid {
display: grid;
gap: $unit-3x;
grid-template-columns: repeat(3, minmax(0, 1fr));
@include breakpoint(tablet) {
gap: $unit-2x;
}
@include breakpoint(phone) {
gap: $unit;
}
.WeaponUnit .WeaponImage {
background: var(--extra-purple-card-bg);
}
.WeaponUnit .WeaponImage .icon svg {
fill: var(--extra-purple-secondary);
}
}
.ExtraGrid.Weapons {
background: var(--extra-purple-bg);
border-radius: $card-corner;
box-sizing: border-box;
display: grid;
grid-template-columns: 1.42fr 3fr;
justify-content: center;
@include breakpoint(tablet) {
left: auto;
max-width: auto;
width: 100%;
}
@include breakpoint(phone) {
display: flex;
gap: $unit-2x;
padding: $unit-2x;
flex-direction: column;
}
}
}

View file

@ -0,0 +1,69 @@
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next'
import Switch from '~components/common/Switch'
import GuidebookUnit from '../GuidebookUnit'
import classNames from 'classnames'
import type { SearchableObject } from '~types'
import './index.scss'
// Props
interface Props {
grid: GuidebookList
editable: boolean
removeGuidebook: (position: number) => void
updateObject: (object: SearchableObject, position: number) => void
}
// Constants
const EXTRA_WEAPONS_COUNT = 3
const GuidebooksGrid = ({
grid,
editable,
removeGuidebook,
updateObject,
}: Props) => {
const { t } = useTranslation('common')
const classes = classNames({
Guidebooks: true,
ContainerItem: true,
})
const guidebooks = (
<ul id="GuidebooksGrid">
{Array.from(Array(EXTRA_WEAPONS_COUNT)).map((x, i) => {
const itemClasses = classNames({
Empty: grid && grid[i] === undefined,
})
return (
<li className={itemClasses} key={`grid_unit_${i}`}>
<GuidebookUnit
editable={editable}
position={i + 1}
guidebook={grid[i + 1]}
removeGuidebook={removeGuidebook}
updateObject={updateObject}
/>
</li>
)
})}
</ul>
)
const guidebookElement = (
<div className={classes}>
<div className="Header">
<h3>{t('sephira_guidebooks')}</h3>
</div>
{guidebooks}
</div>
)
return guidebookElement
}
export default GuidebooksGrid

View file

@ -124,7 +124,9 @@ const JobDropdown = React.forwardRef<HTMLSelectElement, Props>(
onClick={openJobSelect}
onOpenChange={() => setOpen(!open)}
onValueChange={handleChange}
className="Job"
triggerClass="Job"
overlayVisible={false}
>
<SelectItem key={-1} value="no-job">
{t('no_job')}

View file

@ -56,6 +56,9 @@
.JobSkills {
display: flex;
flex-direction: column;
&:not(.editable) {
gap: $unit;
}
}
}

View file

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import classNames from 'classnames'
import JobDropdown from '~components/job/JobDropdown'
import JobImage from '~components/job/JobImage'
@ -22,6 +23,7 @@ interface Props {
editable: boolean
saveJob: (job?: Job) => void
saveSkill: (skill: JobSkill, position: number) => void
removeSkill: (position: number) => void
saveAccessory: (accessory: JobAccessory) => void
}
@ -48,6 +50,12 @@ const JobSection = (props: Props) => {
// Refs
const selectRef = React.createRef<HTMLSelectElement>()
// Classes
const skillContainerClasses = classNames({
JobSkills: true,
editable: props.editable,
})
useEffect(() => {
// Set current job based on ID
setJob(props.job)
@ -126,9 +134,11 @@ const JobSection = (props: Props) => {
return (
<JobSkillItem
skill={skills[index]}
position={index}
editable={canEditSkill(skills[index])}
key={`skill-${index}`}
hasJob={job != undefined && job.id != '-1'}
removeJobSkill={props.removeSkill}
/>
)
}
@ -173,10 +183,6 @@ const JobSection = (props: Props) => {
</div>
)
function jobLabel() {
return job ? filledJobLabel : emptyJobLabel
}
// Render: JSX components
return (
<section id="Job">
@ -209,7 +215,7 @@ const JobSection = (props: Props) => {
</div>
)}
<ul className="JobSkills">
<ul className={skillContainerClasses}>
{[...Array(numSkills)].map((e, i) => (
<li key={`job-${i}`}>
{canEditSkill(skills[i])

View file

@ -1,11 +1,30 @@
.JobSkills {
&.editable .JobSkill {
.Info {
padding: $unit-half * 1.5;
& > img,
& > div.placeholder {
width: $unit-4x;
height: $unit-4x;
}
}
}
}
.JobSkill {
display: flex;
gap: $unit;
align-items: center;
align-items: stretch;
justify-content: space-between;
&.editable .Info:hover {
background-color: var(--button-bg-hover);
}
&.editable:hover {
cursor: pointer;
.Info {
& > img.editable,
& > div.placeholder.editable {
border: $hover-stroke;
@ -22,14 +41,22 @@
fill: var(--icon-secondary-hover);
}
}
}
.Info {
align-items: center;
border-radius: $input-corner;
display: flex;
flex-grow: 1;
gap: $unit;
& > img,
& > div.placeholder {
background: var(--card-bg);
border-radius: calc($unit / 2);
border: 1px solid rgba(0, 0, 0, 0);
width: $unit * 5;
height: $unit * 5;
width: $unit-5x;
height: $unit-5x;
}
& > div.placeholder {
@ -39,10 +66,17 @@
& > svg {
fill: var(--icon-secondary);
width: $unit * 2;
height: $unit * 2;
width: $unit-2x;
height: $unit-2x;
}
}
}
& > .Button {
justify-content: center;
max-width: $unit-6x;
height: auto;
}
p {
color: var(--text-primary);

View file

@ -1,21 +1,43 @@
import React from 'react'
import React, { useState } from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import { Trans, useTranslation } from 'next-i18next'
import classNames from 'classnames'
import PlusIcon from '~public/icons/Add.svg'
import Alert from '~components/common/Alert'
import Button from '~components/common/Button'
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
} from '~components/common/ContextMenu'
import ContextMenuItem from '~components/common/ContextMenuItem'
import EllipsisIcon from '~public/icons/Ellipsis.svg'
import PlusIcon from '~public/icons/Add.svg'
import './index.scss'
// Props
interface Props extends React.ComponentPropsWithoutRef<'div'> {
skill?: JobSkill
position: number
editable: boolean
hasJob: boolean
removeJobSkill: (position: number) => void
}
const JobSkillItem = React.forwardRef<HTMLDivElement, Props>(
function useJobSkillItem({ ...props }, forwardedRef) {
function useJobSkillItem(
{
skill,
position,
editable,
hasJob,
removeJobSkill: sendJobSkillToRemove,
...props
},
forwardedRef
) {
// Set up translation
const router = useRouter()
const { t } = useTranslation('common')
const locale =
@ -23,31 +45,55 @@ const JobSkillItem = React.forwardRef<HTMLDivElement, Props>(
? router.locale
: 'en'
// States: Component
const [alertOpen, setAlertOpen] = useState(false)
const [contextMenuOpen, setContextMenuOpen] = useState(false)
// Classes
const classes = classNames({
JobSkill: true,
editable: props.editable,
editable: editable,
})
const imageClasses = classNames({
placeholder: !props.skill,
editable: props.editable && props.hasJob,
placeholder: !skill,
editable: editable && hasJob,
})
const buttonClasses = classNames({
Clicked: contextMenuOpen,
})
// Methods: Data mutation
function removeJobSkill() {
if (skill) sendJobSkillToRemove(position)
setAlertOpen(false)
}
// Methods: Context menu
function handleButtonClicked() {
setContextMenuOpen(!contextMenuOpen)
}
function handleContextMenuOpenChange(open: boolean) {
if (!open) setContextMenuOpen(false)
}
const skillImage = () => {
let jsx: React.ReactNode
if (props.skill) {
if (skill) {
jsx = (
<img
alt={props.skill.name[locale]}
alt={skill.name[locale]}
className={imageClasses}
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-skills/${props.skill.slug}.png`}
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-skills/${skill.slug}.png`}
/>
)
} else {
jsx = (
<div className={imageClasses}>
{props.editable && props.hasJob ? <PlusIcon /> : ''}
{editable && hasJob ? <PlusIcon /> : ''}
</div>
)
}
@ -58,9 +104,9 @@ const JobSkillItem = React.forwardRef<HTMLDivElement, Props>(
const label = () => {
let jsx: React.ReactNode
if (props.skill) {
jsx = <p>{props.skill.name[locale]}</p>
} else if (props.editable && props.hasJob) {
if (skill) {
jsx = <p>{skill.name[locale]}</p>
} else if (editable && hasJob) {
jsx = <p className="placeholder">{t('job_skills.state.selectable')}</p>
} else {
jsx = <p className="placeholder">{t('job_skills.state.no_skill')}</p>
@ -69,11 +115,56 @@ const JobSkillItem = React.forwardRef<HTMLDivElement, Props>(
return jsx
}
const removeAlert = () => {
return (
<div className={classes} onClick={props.onClick} ref={forwardedRef}>
<Alert
open={alertOpen}
primaryAction={removeJobSkill}
primaryActionText={t('modals.job_skills.buttons.remove')}
cancelAction={() => setAlertOpen(false)}
cancelActionText={t('buttons.cancel')}
message={
<Trans i18nKey="modals.job_skills.messages.remove">
Are you sure you want to remove{' '}
<strong>{{ job_skill: skill?.name[locale] }}</strong> from your
team?
</Trans>
}
/>
)
}
const contextMenu = () => {
return (
<>
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
<ContextMenuTrigger asChild>
<Button
leftAccessoryIcon={<EllipsisIcon />}
className={buttonClasses}
blended={true}
onClick={handleButtonClicked}
/>
</ContextMenuTrigger>
<ContextMenuContent align="start">
<ContextMenuItem onSelect={() => setAlertOpen(true)}>
{t('context.remove_job_skill')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{removeAlert()}
</>
)
}
return (
<div className={classes} ref={forwardedRef}>
<div className="Info" onClick={props.onClick} tabIndex={0}>
{skillImage()}
{label()}
</div>
{skill && editable && contextMenu()}
</div>
)
}
)

View file

@ -1,97 +0,0 @@
import React, { useEffect, useState } from 'react'
import cloneDeep from 'lodash.clonedeep'
import SelectWithInput from '~components/common/SelectWithInput'
import { weaponAwakening, characterAwakening } from '~data/awakening'
import './index.scss'
interface Props {
object: 'character' | 'weapon'
type?: number
level?: number
onOpenChange?: (open: boolean) => void
sendValidity: (isValid: boolean) => void
sendValues: (type: number, level: number) => void
}
const AwakeningSelect = (props: Props) => {
// Data states
const [awakeningType, setAwakeningType] = useState(
props.object === 'weapon' ? 0 : 1
)
const [awakeningLevel, setAwakeningLevel] = useState(1)
// Data
const chooseDataset = () => {
let list: ItemSkill[] = []
switch (props.object) {
case 'character':
list = characterAwakening
break
case 'weapon':
// WARNING: Clonedeep is masking a deeper error
// which is running this method every time this component is rerendered
// causing multiple "No awakening" items to be added
const awakening = cloneDeep(weaponAwakening)
awakening.unshift({
id: 0,
name: {
en: 'No awakening',
ja: '覚醒なし',
},
granblue_id: '',
slug: 'no-awakening',
minValue: 0,
maxValue: 0,
fractional: false,
})
list = awakening
break
}
return list
}
// Set default awakening and level based on object type
useEffect(() => {
const defaultAwakening = props.object === 'weapon' ? 0 : 1
const type = props.type != undefined ? props.type : defaultAwakening
setAwakeningType(type)
setAwakeningLevel(props.level ? props.level : 1)
}, [props.object, props.type, props.level])
// Send validity of form when awakening level changes
useEffect(() => {
props.sendValidity(awakeningLevel > 0)
}, [props.sendValidity, awakeningLevel])
// Classes
function changeOpen(open: boolean) {
if (props.onOpenChange) props.onOpenChange(open)
}
function handleValueChange(type: number, level: number) {
setAwakeningType(type)
setAwakeningLevel(level)
props.sendValues(type, level)
}
return (
<div className="Awakening">
<SelectWithInput
object={`${props.object}_awakening`}
dataSet={chooseDataset()}
selectValue={awakeningType}
inputValue={awakeningLevel}
onOpenChange={changeOpen}
sendValidity={props.sendValidity}
sendValues={handleValueChange}
/>
</div>
)
}
export default AwakeningSelect

View file

@ -1,4 +1,22 @@
.AwakeningSelect .AwakeningSet {
.SelectWithItem {
.InputSet {
display: flex;
flex-direction: row;
gap: $unit;
width: 100%;
.SelectTrigger {
flex-grow: 1;
width: 100%;
}
.Input {
flex-grow: 0;
text-align: right;
width: 13rem;
}
}
.errors {
color: $error;
display: none;
@ -8,30 +26,4 @@
display: block;
}
}
.fields {
display: flex;
flex-direction: row;
gap: $unit;
width: 100%;
.SelectTrigger {
flex-grow: 1;
}
.Label {
display: none;
flex-grow: 0;
&.Visible {
display: block;
width: auto;
}
.Input {
min-width: $unit * 12;
width: inherit;
}
}
}
}

View file

@ -0,0 +1,215 @@
// Core dependencies
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import classNames from 'classnames'
// UI Dependencies
import Input from '~components/common/Input'
import Select from '~components/common/Select'
import SelectItem from '~components/common/SelectItem'
// Styles and icons
import './index.scss'
// Types
interface Props {
dataSet: Awakening[]
defaultAwakening: Awakening
awakening?: Awakening
level?: number
maxLevel: number
selectDisabled: boolean
onOpenChange?: (open: boolean) => void
sendValidity: (isValid: boolean) => void
sendValues: (type: string, level: number) => void
}
const defaultProps = {
selectDisabled: false,
}
const AwakeningSelectWithInput = ({
dataSet,
defaultAwakening,
awakening,
level,
maxLevel,
selectDisabled,
onOpenChange,
sendValidity,
sendValues,
}: Props) => {
// Set up translations
const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const { t } = useTranslation('common')
// State: Component
const [open, setOpen] = useState(false)
const [error, setError] = useState('')
// State: Data
const [currentAwakening, setCurrentAwakening] = useState<Awakening>()
const [currentLevel, setCurrentLevel] = useState(1)
// Refs
const inputRef = React.createRef<HTMLInputElement>()
// Classes
const inputClasses = classNames({
Bound: true,
Hidden: currentAwakening === undefined || currentAwakening.id === '0',
})
const errorClasses = classNames({
errors: true,
visible: error !== '',
})
// Hooks
useEffect(() => {
setCurrentAwakening(awakening)
setCurrentLevel(level ? level : 1)
if (awakening) sendValidity(true)
}, [])
// Methods: UI state management
function changeOpen() {
if (!selectDisabled) {
setOpen(!open)
if (onOpenChange) onOpenChange(!open)
}
}
function onClose() {
if (onOpenChange) onOpenChange(false)
}
// Methods: Rendering
function generateOptions() {
const sortedDataSet = [...dataSet].sort((a, b) => {
return a.order - b.order
})
let options: React.ReactNode[] = sortedDataSet.map((awakening, i) => {
return generateItem(awakening)
})
if (!dataSet.includes(defaultAwakening))
options.unshift(generateItem(defaultAwakening))
return options
}
function generateItem(awakening: Awakening) {
return (
<SelectItem key={awakening.slug} value={awakening.id}>
{awakening.name[locale]}
</SelectItem>
)
}
// Methods: User input detection
function handleSelectChange(id: string) {
const input = inputRef.current
if (input && !handleInputError(parseFloat(input.value))) return
setCurrentAwakening(dataSet.find((awakening) => awakening.id === id))
sendValues(id, currentLevel)
}
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
const input = inputRef.current
if (input && !handleInputError(parseFloat(input.value))) return
setCurrentLevel(parseInt(event.target.value))
sendValues(
currentAwakening ? currentAwakening.id : '0',
parseInt(event.target.value)
)
}
// Methods: Handle error
function handleInputError(value: number) {
let error = ''
if (currentAwakening) {
if (value < 1) {
error = t(`awakening.errors.value_too_low`, {
minValue: 1,
})
} else if (value > maxLevel) {
error = t(`awakening.errors.value_too_high`, {
maxValue: maxLevel,
})
} else if (value % 1 != 0) {
error = t(`awakening.errors.value_not_whole`)
} else if (!value || value <= 0) {
error = t(`awakening.errors.value_empty`)
} else {
error = ''
}
}
setError(error)
if (error.length > 0) {
sendValidity(false)
return false
} else return true
}
const rangeString = () => {
let placeholder = ''
if (awakening) {
const minValue = 1
const maxValue = maxLevel
placeholder = `${minValue}~${maxValue}`
}
return placeholder
}
return (
<div className="SelectWithItem">
<div className="InputSet">
<Select
key="awakening-type"
value={`${awakening ? awakening.id : defaultAwakening.id}`}
open={open}
disabled={selectDisabled}
onValueChange={handleSelectChange}
onOpenChange={changeOpen}
onClose={onClose}
triggerClass="modal"
overlayVisible={false}
>
{generateOptions()}
</Select>
<Input
value={level ? level : 1}
className={inputClasses}
type="number"
placeholder={rangeString()}
min={1}
max={maxLevel}
step="1"
onChange={handleInputChange}
visible={awakening ? 'true' : 'false'}
ref={inputRef}
/>
</div>
<p className={errorClasses}>{error}</p>
</div>
)
}
AwakeningSelectWithInput.defaultProps = defaultProps
export default AwakeningSelectWithInput

View file

@ -0,0 +1,56 @@
.EditTeam.DialogContent {
min-height: 80vh;
.Container.Scrollable {
height: 100%;
display: flex;
flex-direction: column;
flex-grow: 1;
}
.Content {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: $unit-2x;
}
.Fields {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: $unit;
}
.ExtraNotice {
background: var(--extra-purple-bg);
border-radius: $input-corner;
color: var(--extra-purple-text);
font-weight: $medium;
padding: $unit-2x;
}
.DescriptionField {
display: flex;
flex-direction: column;
justify-content: inherit;
gap: $unit;
flex-grow: 1;
.Left {
flex-grow: 0;
}
textarea.Input {
flex-grow: 1;
&::placeholder {
color: var(--text-secondary);
}
}
.Image {
display: none;
}
}
}

View file

@ -0,0 +1,477 @@
import React, { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogTrigger,
DialogClose,
DialogTitle,
} from '~components/common/Dialog'
import DialogContent from '~components/common/DialogContent'
import Button from '~components/common/Button'
import CharLimitedFieldset from '~components/common/CharLimitedFieldset'
import DurationInput from '~components/common/DurationInput'
import InputTableField from '~components/common/InputTableField'
import RaidCombobox from '~components/raids/RaidCombobox'
import SegmentedControl from '~components/common/SegmentedControl'
import Segment from '~components/common/Segment'
import SwitchTableField from '~components/common/SwitchTableField'
import TableField from '~components/common/TableField'
import type { DetailsObject } from 'types'
import type { DialogProps } from '@radix-ui/react-dialog'
import CheckIcon from '~public/icons/Check.svg'
import CrossIcon from '~public/icons/Cross.svg'
import './index.scss'
interface Props extends DialogProps {
party?: Party
updateCallback: (details: DetailsObject) => void
}
const EditPartyModal = ({ party, updateCallback, ...props }: Props) => {
// Set up router
const router = useRouter()
const locale = router.locale
// Set up translation
const { t } = useTranslation('common')
// Refs
const headerRef = React.createRef<HTMLDivElement>()
const footerRef = React.createRef<HTMLDivElement>()
const descriptionInput = useRef<HTMLTextAreaElement>(null)
// States: Component
const [open, setOpen] = useState(false)
const [errors, setErrors] = useState<{ [key: string]: string }>({
name: '',
description: '',
})
const [currentSegment, setCurrentSegment] = useState(0)
// States: Data
const [name, setName] = useState('')
const [raid, setRaid] = useState<Raid>()
const [extra, setExtra] = useState(false)
const [chargeAttack, setChargeAttack] = useState(true)
const [fullAuto, setFullAuto] = useState(false)
const [autoGuard, setAutoGuard] = useState(false)
const [autoSummon, setAutoSummon] = useState(false)
const [buttonCount, setButtonCount] = useState<number | undefined>(undefined)
const [chainCount, setChainCount] = useState<number | undefined>(undefined)
const [turnCount, setTurnCount] = useState<number | undefined>(undefined)
const [clearTime, setClearTime] = useState(0)
// Hooks
useEffect(() => {
if (!party) return
setName(party.name)
setRaid(party.raid)
setAutoGuard(party.auto_guard)
setAutoSummon(party.auto_summon)
setFullAuto(party.full_auto)
setChargeAttack(party.charge_attack)
setClearTime(party.clear_time)
if (party.turn_count) setTurnCount(party.turn_count)
if (party.button_count) setButtonCount(party.button_count)
if (party.chain_count) setChainCount(party.chain_count)
}, [party])
// Methods: Event handlers (Dialog)
function openChange() {
if (open) {
setOpen(false)
if (props.onOpenChange) props.onOpenChange(false)
} else {
setOpen(true)
if (props.onOpenChange) props.onOpenChange(true)
}
}
function onEscapeKeyDown(event: KeyboardEvent) {
event.preventDefault()
openChange()
}
function onOpenAutoFocus(event: Event) {
event.preventDefault()
}
// Methods: Event handlers (Fields)
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
const { name, value } = event.target
setName(value)
let newErrors = errors
setErrors(newErrors)
}
function handleChargeAttackChanged(checked: boolean) {
setChargeAttack(checked)
}
function handleFullAutoChanged(checked: boolean) {
setFullAuto(checked)
}
function handleAutoGuardChanged(checked: boolean) {
setAutoGuard(checked)
}
function handleAutoSummonChanged(checked: boolean) {
setAutoSummon(checked)
}
function handleExtraChanged(checked: boolean) {
setExtra(checked)
}
function handleClearTimeChanged(value: number) {
if (!isNaN(value)) setClearTime(value)
}
function handleTurnCountChanged(value?: string) {
if (!value) return
const numericalValue = parseInt(value)
if (!isNaN(numericalValue)) setTurnCount(numericalValue)
}
function handleButtonCountChanged(value?: string) {
if (!value) return
const numericalValue = parseInt(value)
if (!isNaN(numericalValue)) setButtonCount(numericalValue)
}
function handleChainCountChanged(value?: string) {
if (!value) return
const numericalValue = parseInt(value)
if (!isNaN(numericalValue)) setChainCount(numericalValue)
}
function handleTextAreaChanged(
event: React.ChangeEvent<HTMLTextAreaElement>
) {
event.preventDefault()
const { name, value } = event.target
let newErrors = errors
setErrors(newErrors)
}
function receiveRaid(raid?: Raid) {
if (raid) {
setRaid(raid)
if (raid.group.extra) setExtra(true)
else setExtra(false)
}
}
// Methods: Data methods
function updateDetails(event: React.MouseEvent) {
const descriptionValue = descriptionInput.current?.value
const details: DetailsObject = {
fullAuto: fullAuto,
autoGuard: autoGuard,
autoSummon: autoSummon,
chargeAttack: chargeAttack,
clearTime: clearTime,
buttonCount: buttonCount,
turnCount: turnCount,
chainCount: chainCount,
name: name,
description: descriptionValue,
raid: raid,
extra: extra,
}
updateCallback(details)
openChange()
}
// Methods: Rendering methods
const segmentedControl = () => {
return (
<SegmentedControl blended={true}>
<Segment
groupName="edit_nav"
name="core"
selected={currentSegment === 0}
tabIndex={0}
onClick={() => setCurrentSegment(0)}
>
{t('modals.edit_team.segments.basic_info')}
</Segment>
<Segment
groupName="edit_nav"
name="properties"
selected={currentSegment === 1}
tabIndex={0}
onClick={() => setCurrentSegment(1)}
>
{t('modals.edit_team.segments.properties')}
</Segment>
</SegmentedControl>
)
}
const nameField = () => {
return (
<CharLimitedFieldset
className="Bound"
fieldName="name"
placeholder="Name your team"
value={name}
limit={50}
onChange={handleInputChange}
error={errors.name}
/>
)
}
const raidField = () => {
return (
<RaidCombobox
showAllRaidsOption={false}
currentRaid={raid}
onChange={receiveRaid}
/>
)
}
const extraNotice = () => {
if (extra) {
return (
<div className="ExtraNotice">
<span className="ExtraNoticeText">
{raid && raid.group.guidebooks
? t('modals.edit_team.extra_notice_guidebooks')
: t('modals.edit_team.extra_notice')}
</span>
</div>
)
}
}
const descriptionField = () => {
return (
<div className="DescriptionField">
<textarea
className="Input Bound"
name="description"
placeholder={
'Write your notes here\n\n\nWatch out for the 50% trigger!\nMake sure to click Fediels 3 first\nGood luck with RNG!'
}
onChange={handleTextAreaChanged}
ref={descriptionInput}
>
{party ? party.description : ''}
</textarea>
</div>
)
}
const chargeAttackField = () => {
return (
<SwitchTableField
name="charge_attack"
label={t('modals.edit_team.labels.charge_attack')}
value={chargeAttack}
onValueChange={handleChargeAttackChanged}
/>
)
}
const fullAutoField = () => {
return (
<SwitchTableField
name="full_auto"
label={t('modals.edit_team.labels.full_auto')}
value={fullAuto}
onValueChange={handleFullAutoChanged}
/>
)
}
const autoGuardField = () => {
return (
<SwitchTableField
name="auto_guard"
label={t('modals.edit_team.labels.auto_guard')}
value={autoGuard}
onValueChange={handleAutoGuardChanged}
/>
)
}
const autoSummonField = () => {
return (
<SwitchTableField
name="auto_summon"
label={t('modals.edit_team.labels.auto_summon')}
value={autoSummon}
onValueChange={handleAutoSummonChanged}
/>
)
}
const extraField = () => {
return (
<SwitchTableField
name="extra"
className="Extra"
label={t('modals.edit_team.labels.extra')}
description={t('modals.edit_team.descriptions.extra')}
value={extra}
disabled={true}
onValueChange={handleExtraChanged}
/>
)
}
const clearTimeField = () => {
return (
<TableField
className="Numeric"
name="clear_time"
label={t('modals.edit_team.labels.clear_time')}
>
<DurationInput
name="clear_time"
className="Bound"
value={clearTime}
onValueChange={(value: number) => handleClearTimeChanged(value)}
/>
</TableField>
)
}
const turnCountField = () => {
return (
<InputTableField
name="turn_count"
className="Numeric"
label={t('modals.edit_team.labels.turn_count')}
placeholder="0"
type="number"
value={turnCount}
onValueChange={handleTurnCountChanged}
/>
)
}
const buttonCountField = () => {
return (
<InputTableField
name="button_count"
className="Numeric"
label={t('modals.edit_team.labels.button_count')}
placeholder="0"
type="number"
value={buttonCount}
onValueChange={handleButtonCountChanged}
/>
)
}
const chainCountField = () => {
return (
<InputTableField
name="chain_count"
className="Numeric"
label={t('modals.edit_team.labels.chain_count')}
placeholder="0"
type="number"
value={chainCount}
onValueChange={handleChainCountChanged}
/>
)
}
const infoPage = () => {
return (
<>
{nameField()}
{raidField()}
{extraNotice()}
{descriptionField()}
</>
)
}
const propertiesPage = () => {
return (
<>
{chargeAttackField()}
{fullAutoField()}
{autoSummonField()}
{autoGuardField()}
{extraField()}
{clearTimeField()}
{turnCountField()}
{buttonCountField()}
{chainCountField()}
</>
)
}
return (
<Dialog open={open} onOpenChange={openChange}>
<DialogTrigger asChild>{props.children}</DialogTrigger>
<DialogContent
className="EditTeam"
headerref={headerRef}
footerref={footerRef}
onEscapeKeyDown={onEscapeKeyDown}
onOpenAutoFocus={onOpenAutoFocus}
>
<div className="DialogHeader" ref={headerRef}>
<div className="DialogTop">
<DialogTitle className="DialogTitle">
{t('modals.edit_team.title')}
</DialogTitle>
</div>
<DialogClose className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</DialogClose>
</div>
<div className="Content">
{segmentedControl()}
<div className="Fields">
{currentSegment === 0 && infoPage()}
{currentSegment === 1 && propertiesPage()}
</div>
</div>
<div className="DialogFooter" ref={footerRef}>
<div className="Left"></div>
<div className="Right Buttons Spaced">
<Button
contained={true}
text={t('buttons.cancel')}
onClick={openChange}
/>
<Button
contained={true}
rightAccessoryIcon={<CheckIcon />}
text={t('modals.edit_team.buttons.confirm')}
onClick={updateDetails}
/>
</div>
</div>
</DialogContent>
</Dialog>
)
}
export default EditPartyModal

View file

@ -5,3 +5,11 @@
gap: 8px;
line-height: 34px;
}
nav.RepNavigation {
display: flex;
gap: 0;
justify-content: center;
margin-bottom: $unit-4x;
width: 100%;
}

View file

@ -2,10 +2,13 @@ import React, { useEffect, useState } from 'react'
import { getCookie } from 'cookies-next'
import { useRouter } from 'next/router'
import { subscribe, useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import clonedeep from 'lodash.clonedeep'
import Alert from '~components/common/Alert'
import PartySegmentedControl from '~components/party/PartySegmentedControl'
import PartyDetails from '~components/party/PartyDetails'
import PartyHeader from '~components/party/PartyHeader'
import WeaponGrid from '~components/weapon/WeaponGrid'
import SummonGrid from '~components/summon/SummonGrid'
import CharacterGrid from '~components/character/CharacterGrid'
@ -26,7 +29,6 @@ import './index.scss'
interface Props {
new?: boolean
team?: Party
raids: Raid[][]
selectedTab: GridType
pushHistory?: (path: string) => void
}
@ -39,11 +41,15 @@ const Party = (props: Props) => {
// Set up router
const router = useRouter()
// Localization
const { t } = useTranslation('common')
// Set up states
const { party } = useSnapshot(appState)
const [editable, setEditable] = useState(false)
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
const [refresh, setRefresh] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
// Retrieve cookies
const cookies = retrieveCookies()
@ -113,6 +119,23 @@ const Party = (props: Props) => {
.then((response) => storeParty(response.data.party))
}
async function updateParty(details: DetailsObject) {
const payload = formatDetailsObject(details)
if (props.team && props.team.id) {
return await api.endpoints.parties
.update(props.team.id, payload)
.then((response) => storeParty(response.data.party))
.catch((error) => {
const data = error.response.data
if (data.errors && Object.keys(data.errors).includes('guidebooks')) {
const message = t('errors.validation.guidebooks')
setErrorMessage(message)
}
})
}
}
// Methods: Updating the party's details
async function updateDetails(details: DetailsObject) {
if (!props.team) return await createParty(details)
@ -122,40 +145,93 @@ const Party = (props: Props) => {
function formatDetailsObject(details: DetailsObject) {
const payload: { [key: string]: any } = {}
if (details.name) payload.name = details.name
if (details.description) payload.description = details.description
const mappings: { [key: string]: string } = {
name: 'name',
description: 'description',
chargeAttack: 'charge_attack',
fullAuto: 'full_auto',
autoGuard: 'auto_guard',
autoSummon: 'auto_summon',
clearTime: 'clear_time',
buttonCount: 'button_count',
chainCount: 'chain_count',
turnCount: 'turn_count',
extra: 'extra',
job: 'job_id',
guidebook1_id: 'guidebook1_id',
guidebook2_id: 'guidebook2_id',
guidebook3_id: 'guidebook3_id',
}
Object.entries(mappings).forEach(([key, value]) => {
if (details[key]) {
payload[value] = details[key]
}
})
if (details.raid) payload.raid_id = details.raid.id
if (details.chargeAttack) payload.charge_attack = details.chargeAttack
if (details.fullAuto) payload.full_auto = details.fullAuto
if (details.autoGuard) payload.auto_guard = details.autoGuard
if (details.clearTime) payload.clear_time = details.clearTime
if (details.buttonCount) payload.button_count = details.buttonCount
if (details.chainCount) payload.chain_count = details.chainCount
if (details.turnCount) payload.turn_count = details.turnCount
if (details.extra) payload.extra = details.extra
if (details.job) payload.job_id = details.job.id
if (Object.keys(payload).length > 1) return { party: payload }
else return {}
}
async function updateParty(details: DetailsObject) {
const payload = formatDetailsObject(details)
if (props.team && props.team.id) {
return await api.endpoints.parties
.update(props.team.id, payload)
.then((response) => storeParty(response.data.party))
if (Object.keys(payload).length >= 1) {
return { party: payload }
} else {
return {}
}
}
function checkboxChanged(event: React.ChangeEvent<HTMLInputElement>) {
appState.party.extra = event.target.checked
function cancelAlert() {
setErrorMessage('')
}
function checkboxChanged(enabled: boolean) {
appState.party.extra = enabled
// Only save if this is a saved party
if (props.team && props.team.id) {
api.endpoints.parties.update(props.team.id, {
party: { extra: event.target.checked },
party: { extra: enabled },
})
}
}
function updateGuidebook(book: Guidebook | undefined, position: number) {
let id: string | undefined = ''
if (book) id = book.id
else if (!book) id = 'undefined'
else id = undefined
const details: DetailsObject = {
guidebook1_id: position === 1 ? id : undefined,
guidebook2_id: position === 2 ? id : undefined,
guidebook3_id: position === 3 ? id : undefined,
}
if (props.team && props.team.id) {
updateParty(details)
} else {
createParty(details)
}
}
// Remixing the party
function remixTeam() {
// setOriginalName(partySnapshot.name ? partySnapshot.name : t('no_title'))
if (props.team && props.team.shortcode) {
const body = getLocalId()
api
.remix({ shortcode: props.team.shortcode, body: body })
.then((response) => {
const remix = response.data.party
// Store the edit key in local storage
if (remix.edit_key) {
storeEditKey(remix.id, remix.edit_key)
setEditKey(remix.id, remix.user)
}
router.push(`/p/${remix.shortcode}`)
// setRemixToastOpen(true)
})
}
}
@ -202,6 +278,7 @@ const Party = (props: Props) => {
appState.party.id = team.id
appState.party.shortcode = team.shortcode
appState.party.extra = team.extra
appState.party.guidebooks = team.guidebooks
appState.party.user = team.user
appState.party.favorited = team.favorited
appState.party.remix = team.remix
@ -274,44 +351,59 @@ const Party = (props: Props) => {
// Methods: Navigating with segmented control
function segmentClicked(event: React.ChangeEvent<HTMLInputElement>) {
const path = [
// Enable when using Next.js Router
'p',
router.asPath.split('/').filter((el) => el != '')[1],
event.target.value,
].join('/')
switch (event.target.value) {
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:
break
}
// Ideally, we would use the Next.js Router to replace the URL,
// but something about shallow routing isn't working so the page is refreshing.
// A consequence is that the browser push stack gets fucked
// router.replace(path, undefined, { shallow: true })
history.pushState({}, '', '/' + path)
}
// Render: JSX components
const navigation = (
<PartySegmentedControl
selectedTab={currentTab}
onClick={segmentClicked}
onCheckboxChange={checkboxChanged}
const errorAlert = () => {
return (
<Alert
open={errorMessage.length > 0}
message={errorMessage}
cancelAction={cancelAlert}
cancelActionText={t('buttons.confirm')}
/>
)
}
const navigation = (
<PartySegmentedControl selectedTab={currentTab} onClick={segmentClicked} />
)
const weaponGrid = (
<WeaponGrid
new={props.new || false}
editable={editable}
weapons={props.team?.weapons}
guidebooks={props.team?.guidebooks}
createParty={createParty}
pushHistory={props.pushHistory}
updateExtra={checkboxChanged}
updateGuidebook={updateGuidebook}
/>
)
@ -348,14 +440,26 @@ const Party = (props: Props) => {
return (
<React.Fragment>
{errorAlert()}
<PartyHeader
party={props.team}
new={props.new || false}
editable={party.editable}
deleteCallback={deleteTeam}
remixCallback={remixTeam}
updateCallback={updateDetails}
/>
{navigation}
<section id="Party">{currentGrid()}</section>
<PartyDetails
party={props.team}
new={props.new || false}
editable={party.editable}
updateCallback={updateDetails}
deleteCallback={deleteTeam}
/>
</React.Fragment>
)

View file

@ -1,7 +1,5 @@
import React, { useEffect, useState, ChangeEvent, KeyboardEvent } from 'react'
import Link from 'next/link'
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { subscribe, useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import clonedeep from 'lodash.clonedeep'
@ -10,30 +8,13 @@ import LiteYouTubeEmbed from 'react-lite-youtube-embed'
import classNames from 'classnames'
import reactStringReplace from 'react-string-replace'
import Alert from '~components/common/Alert'
import Button from '~components/common/Button'
import CharLimitedFieldset from '~components/common/CharLimitedFieldset'
import DurationInput from '~components/common/DurationInput'
import GridRepCollection from '~components/GridRepCollection'
import GridRep from '~components/GridRep'
import Input from '~components/common/Input'
import RaidDropdown from '~components/RaidDropdown'
import Switch from '~components/common/Switch'
import Tooltip from '~components/common/Tooltip'
import TextFieldset from '~components/common/TextFieldset'
import Token from '~components/common/Token'
import api from '~utils/api'
import { accountState } from '~utils/accountState'
import { appState, initialAppState } from '~utils/appState'
import { formatTimeAgo } from '~utils/timeAgo'
import { appState } from '~utils/appState'
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'
import './index.scss'
@ -44,38 +25,18 @@ interface Props {
new: boolean
editable: boolean
updateCallback: (details: DetailsObject) => void
deleteCallback: () => void
}
const PartyDetails = (props: Props) => {
const { party, raids } = useSnapshot(appState)
const { t } = useTranslation('common')
const router = useRouter()
const locale = router.locale || 'en'
const youtubeUrlRegex =
/(?:https:\/\/www\.youtube\.com\/watch\?v=|https:\/\/youtu\.be\/)([\w-]+)/g
const nameInput = React.createRef<HTMLInputElement>()
const descriptionInput = React.createRef<HTMLTextAreaElement>()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [alertOpen, setAlertOpen] = useState(false)
const [chargeAttack, setChargeAttack] = useState(true)
const [fullAuto, setFullAuto] = useState(false)
const [autoGuard, setAutoGuard] = useState(false)
const [buttonCount, setButtonCount] = useState<number | undefined>(undefined)
const [chainCount, setChainCount] = useState<number | undefined>(undefined)
const [turnCount, setTurnCount] = useState<number | undefined>(undefined)
const [clearTime, setClearTime] = useState(0)
const [remixes, setRemixes] = useState<Party[]>([])
const [raidSlug, setRaidSlug] = useState('')
const [embeddedDescription, setEmbeddedDescription] =
useState<React.ReactNode>()
@ -85,65 +46,6 @@ const PartyDetails = (props: Props) => {
Visible: !open,
})
const editableClasses = classNames({
PartyDetails: true,
Editable: true,
Visible: open,
})
const userClass = classNames({
user: true,
empty: !party.user,
})
const linkClass = classNames({
wind: party && party.element == 1,
fire: party && party.element == 2,
water: party && party.element == 3,
earth: party && party.element == 4,
dark: party && party.element == 5,
light: party && party.element == 6,
})
const [errors, setErrors] = useState<{ [key: string]: string }>({
name: '',
description: '',
})
useEffect(() => {
if (props.party) {
setName(props.party.name)
setAutoGuard(props.party.auto_guard)
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) {
@ -177,161 +79,39 @@ const PartyDetails = (props: Props) => {
}
}, [appState.party.description])
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
const { name, value } = event.target
setName(value)
let newErrors = errors
setErrors(newErrors)
}
function handleTextAreaChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
event.preventDefault()
const { name, value } = event.target
let newErrors = errors
setErrors(newErrors)
}
function handleChargeAttackChanged(checked: boolean) {
setChargeAttack(checked)
}
function handleFullAutoChanged(checked: boolean) {
setFullAuto(checked)
}
function handleAutoGuardChanged(checked: boolean) {
setAutoGuard(checked)
}
function handleClearTimeInput(value: number) {
if (!isNaN(value)) setClearTime(value)
}
function handleTurnCountInput(event: React.ChangeEvent<HTMLInputElement>) {
const value = parseInt(event.currentTarget.value)
if (!isNaN(value)) setTurnCount(value)
}
function handleButtonCountInput(event: ChangeEvent<HTMLInputElement>) {
const value = parseInt(event.currentTarget.value)
if (!isNaN(value)) setButtonCount(value)
}
function handleChainCountInput(event: ChangeEvent<HTMLInputElement>) {
const value = parseInt(event.currentTarget.value)
if (!isNaN(value)) setChainCount(value)
}
function handleInputKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
// Allow the key to be processed normally
return
}
// Get the current value
const input = event.currentTarget
let value = event.currentTarget.value
// Check if the key that was pressed is the backspace key
if (event.key === 'Backspace') {
// Remove the colon if the value is "12:"
if (value.length === 4) {
value = value.slice(0, -1)
}
// Allow the backspace key to be processed normally
input.value = value
return
}
// Check if the key that was pressed is the tab key
if (event.key === 'Tab') {
// Allow the tab key to be processed normally
return
}
// Get the character that was entered and check if it is numeric
const char = parseInt(event.key)
const isNumber = !isNaN(char)
// Check if the character should be accepted or rejected
const numberValue = parseInt(`${value}${char}`)
const minValue = parseInt(event.currentTarget.min)
const maxValue = parseInt(event.currentTarget.max)
if (!isNumber || numberValue < minValue || numberValue > maxValue) {
// Reject the character if it isn't a number,
// or if it exceeds the min and max values
event.preventDefault()
}
}
async function fetchYoutubeData(videoId: string) {
return await youtube
.getVideoById(videoId, { maxResults: 1 })
.then((data) => data.items[0].snippet.localized.title)
}
function toggleDetails() {
// Enabling this code will make live updates not work,
// but I'm not sure why it's here, so we're not going to remove it.
// if (name !== party.name) {
// const resetName = party.name ? party.name : ''
// setName(resetName)
// if (nameInput.current) nameInput.current.value = resetName
// }
setOpen(!open)
}
function receiveRaid(slug?: string) {
if (slug) setRaidSlug(slug)
}
function switchValue(value: boolean) {
if (value) return 'on'
else return 'off'
}
function updateDetails(event: React.MouseEvent) {
const descriptionValue = descriptionInput.current?.value
const raid = raids.find((raid) => raid.slug === raidSlug)
const details: DetailsObject = {
fullAuto: fullAuto,
autoGuard: autoGuard,
chargeAttack: chargeAttack,
clearTime: clearTime,
buttonCount: buttonCount,
turnCount: turnCount,
chainCount: chainCount,
name: name,
description: descriptionValue,
raid: raid,
}
props.updateCallback(details)
toggleDetails()
}
function handleClick() {
setAlertOpen(!alertOpen)
}
function deleteParty() {
props.deleteCallback()
}
// Methods: Navigation
function goTo(shortcode?: string) {
if (shortcode) router.push(`/p/${shortcode}`)
}
function extractYoutubeVideoIds(text: string) {
// Initialize an array to store the video IDs
const videoIds = []
// Use the regular expression to find all the Youtube URLs in the text
let match
while ((match = youtubeUrlRegex.exec(text)) !== null) {
// Extract the video ID from the URL
const videoId = match[1]
// Add the video ID to the array, along with the character position of the URL
videoIds.push({
id: videoId,
url: match[0],
position: match.index,
})
}
// Return the array of video IDs
return videoIds
}
// Methods: Favorites
function toggleFavorite(teamId: string, favorited: boolean) {
if (favorited) unsaveFavorite(teamId)
@ -370,103 +150,6 @@ const PartyDetails = (props: Props) => {
})
}
function extractYoutubeVideoIds(text: string) {
// Initialize an array to store the video IDs
const videoIds = []
// Use the regular expression to find all the Youtube URLs in the text
let match
while ((match = youtubeUrlRegex.exec(text)) !== null) {
// Extract the video ID from the URL
const videoId = match[1]
// Add the video ID to the array, along with the character position of the URL
videoIds.push({
id: videoId,
url: match[0],
position: match.index,
})
}
// Return the array of video IDs
return videoIds
}
const userImage = (picture?: string, element?: string) => {
if (picture && element)
return (
<img
alt={picture}
className={`profile ${element}`}
srcSet={`/profile/${picture}.png,
/profile/${picture}@2x.png 2x`}
src={`/profile/${picture}.png`}
/>
)
else
return (
<img
alt={t('no_user')}
className={`profile anonymous`}
srcSet={`/profile/npc.png,
/profile/npc@2x.png 2x`}
src={`/profile/npc.png`}
/>
)
}
const userBlock = (username?: string, picture?: string, element?: string) => {
return (
<div className={userClass}>
{userImage(picture, element)}
{username ? username : t('no_user')}
</div>
)
}
const renderUserBlock = () => {
let username, picture, element
if (accountState.account.authorized && props.new) {
username = accountState.account.user?.username
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
element = party.user.avatar.element
}
if (username && picture && element) {
return linkedUserBlock(username, picture, element)
} else if (!props.new) {
return userBlock()
}
}
const linkedUserBlock = (
username?: string,
picture?: string,
element?: string
) => {
return (
<div>
<Link href={`/${username}`} passHref>
<a className={linkClass}>{userBlock(username, picture, element)}</a>
</Link>
</div>
)
}
const linkedRaidBlock = (raid: Raid) => {
return (
<div>
<Link href={`/teams?raid=${raid.slug}`} passHref>
<a className={`Raid ${linkClass}`}>{raid.name[locale]}</a>
</Link>
</div>
)
}
function renderRemixes() {
return remixes.map((party, i) => {
return (
@ -490,264 +173,9 @@ const PartyDetails = (props: Props) => {
})
}
const deleteAlert = () => {
if (party.editable) {
return (
<Alert
open={alertOpen}
primaryAction={deleteParty}
primaryActionText={t('modals.delete_team.buttons.confirm')}
cancelAction={() => setAlertOpen(false)}
cancelActionText={t('modals.delete_team.buttons.cancel')}
message={t('modals.delete_team.description')}
/>
)
}
}
const editable = () => {
return (
<section className={editableClasses}>
<CharLimitedFieldset
fieldName="name"
placeholder="Name your team"
value={props.party?.name}
limit={50}
onChange={handleInputChange}
error={errors.name}
ref={nameInput}
/>
<RaidDropdown
showAllRaidsOption={false}
currentRaid={props.party?.raid ? props.party?.raid.slug : undefined}
onChange={receiveRaid}
/>
<ul className="SwitchToggleGroup DetailToggleGroup">
<li className="Ougi ToggleSection">
<label htmlFor="ougi">
<span>{t('party.details.labels.charge_attack')}</span>
<div>
<Switch
name="charge_attack"
onCheckedChange={handleChargeAttackChanged}
value={switchValue(chargeAttack)}
checked={chargeAttack}
/>
</div>
</label>
</li>
<li className="FullAuto ToggleSection">
<label htmlFor="full_auto">
<span>{t('party.details.labels.full_auto')}</span>
<div>
<Switch
onCheckedChange={handleFullAutoChanged}
name="full_auto"
value={switchValue(fullAuto)}
checked={fullAuto}
/>
</div>
</label>
</li>
<li className="AutoGuard ToggleSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.auto_guard')}</span>
<div>
<Switch
onCheckedChange={handleAutoGuardChanged}
name="auto_guard"
value={switchValue(autoGuard)}
disabled={!fullAuto}
checked={autoGuard}
/>
</div>
</label>
</li>
</ul>
<ul className="InputToggleGroup DetailToggleGroup">
<li className="InputSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.button_chain')}</span>
<div className="Input Bound">
<Input
name="buttons"
type="number"
placeholder="0"
value={`${buttonCount}`}
min="0"
max="99"
onChange={handleButtonCountInput}
onKeyDown={handleInputKeyDown}
/>
<span>b</span>
<Input
name="chains"
type="number"
placeholder="0"
min="0"
max="99"
value={`${chainCount}`}
onChange={handleChainCountInput}
onKeyDown={handleInputKeyDown}
/>
<span>c</span>
</div>
</label>
</li>
<li className="InputSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.turn_count')}</span>
<Input
name="turn_count"
className="AlignRight Bound"
type="number"
step="1"
min="1"
max="999"
placeholder="0"
value={`${turnCount}`}
onChange={handleTurnCountInput}
onKeyDown={handleInputKeyDown}
/>
</label>
</li>
<li className="InputSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.clear_time')}</span>
<div>
<DurationInput
name="clear_time"
className="Bound"
placeholder="00:00"
value={clearTime}
onValueChange={(value: number) => handleClearTimeInput(value)}
/>
</div>
</label>
</li>
</ul>
<TextFieldset
fieldName="name"
placeholder={
'Write your notes here\n\n\nWatch out for the 50% trigger!\nMake sure to click Fediels 3 first\nGood luck with RNG!'
}
value={props.party?.description}
onChange={handleTextAreaChange}
error={errors.description}
ref={descriptionInput}
/>
<div className="bottom">
<div className="left">
{router.pathname !== '/new' ? (
<Button
leftAccessoryIcon={<CrossIcon />}
className="Blended medium destructive"
onClick={handleClick}
text={t('buttons.delete')}
/>
) : (
''
)}
</div>
<div className="right">
<Button text={t('buttons.cancel')} onClick={toggleDetails} />
<Button
leftAccessoryIcon={<CheckIcon className="Check" />}
text={t('buttons.save_info')}
onClick={updateDetails}
/>
</div>
</div>
</section>
)
}
const clearTimeString = () => {
const minutes = Math.floor(clearTime / 60)
const seconds = clearTime - minutes * 60
if (minutes > 0)
return `${minutes}${t('party.details.suffix.minutes')} ${seconds}${t(
'party.details.suffix.seconds'
)}`
else return `${seconds}${t('party.details.suffix.seconds')}`
}
const buttonChainToken = () => {
if (buttonCount || chainCount) {
let string = ''
if (buttonCount && buttonCount > 0) {
string += `${buttonCount}b`
}
if (!buttonCount && chainCount && chainCount > 0) {
string += `0${t('party.details.suffix.buttons')}${chainCount}${t(
'party.details.suffix.chains'
)}`
} else if (buttonCount && chainCount && chainCount > 0) {
string += `${chainCount}${t('party.details.suffix.chains')}`
} else if (buttonCount && !chainCount) {
string += `0${t('party.details.suffix.chains')}`
}
return <Token>{string}</Token>
}
}
const readOnly = () => {
return (
<section className={readOnlyClasses}>
<section className="Details">
<Token
className={classNames({
ChargeAttack: true,
On: chargeAttack,
Off: !chargeAttack,
})}
>
{`${t('party.details.labels.charge_attack')} ${
chargeAttack ? 'On' : 'Off'
}`}
</Token>
<Token
className={classNames({
FullAuto: true,
On: fullAuto,
Off: !fullAuto,
})}
>
{`${t('party.details.labels.full_auto')} ${
fullAuto ? 'On' : 'Off'
}`}
</Token>
<Token
className={classNames({
AutoGuard: true,
On: autoGuard,
Off: !autoGuard,
})}
>
{`${t('party.details.labels.auto_guard')} ${
autoGuard ? 'On' : 'Off'
}`}
</Token>
{turnCount ? (
<Token>
{t('party.details.turns.with_count', {
count: turnCount,
})}
</Token>
) : (
''
)}
{clearTime > 0 ? <Token>{clearTimeString()}</Token> : ''}
{buttonChainToken()}
</section>
<Linkify>{embeddedDescription}</Linkify>
</section>
)
@ -764,58 +192,7 @@ const PartyDetails = (props: Props) => {
return (
<>
<section className="DetailsWrapper">
<div className="PartyInfo">
<div className="Left">
<div className="Header">
<h1 className={name ? '' : 'empty'}>
{name ? name : t('no_title')}
</h1>
{party.remix && party.sourceParty ? (
<Tooltip content={t('tooltips.source')}>
<Button
className="IconButton Blended"
leftAccessoryIcon={<RemixIcon />}
text={t('tokens.remix')}
onClick={() => goTo(party.sourceParty?.shortcode)}
/>
</Tooltip>
) : (
''
)}
</div>
<div className="attribution">
{renderUserBlock()}
{party.raid ? linkedRaidBlock(party.raid) : ''}
{party.created_at != '' ? (
<time
className="last-updated"
dateTime={new Date(party.created_at).toString()}
>
{formatTimeAgo(new Date(party.created_at), locale)}
</time>
) : (
''
)}
</div>
</div>
{party.editable ? (
<div className="Right">
<Button
leftAccessoryIcon={<EditIcon />}
text={t('buttons.show_info')}
onClick={toggleDetails}
/>
</div>
) : (
''
)}
</div>
{readOnly()}
{editable()}
{deleteAlert()}
</section>
<section className="DetailsWrapper">{readOnly()}</section>
{remixes && remixes.length > 0 ? remixSection() : ''}
</>
)

View file

@ -0,0 +1,197 @@
// Libraries
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { subscribe, useSnapshot } from 'valtio'
import { Trans, useTranslation } from 'next-i18next'
import Link from 'next/link'
import classNames from 'classnames'
// Dependencies: Common
import Button from '~components/common/Button'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
} from '~components/common/DropdownMenuContent'
// Dependencies: Toasts
import RemixedToast from '~components/toasts/RemixedToast'
import UrlCopiedToast from '~components/toasts/UrlCopiedToast'
// Dependencies: Alerts
import DeleteTeamAlert from '~components/dialogs/DeleteTeamAlert'
import RemixTeamAlert from '~components/dialogs/RemixTeamAlert'
// Dependencies: Utils
import api from '~utils/api'
import { accountState } from '~utils/accountState'
import { appState } from '~utils/appState'
import { getLocalId } from '~utils/localId'
import { retrieveLocaleCookies } from '~utils/retrieveCookies'
import { setEditKey, storeEditKey } from '~utils/userToken'
// Dependencies: Icons
import EllipsisIcon from '~public/icons/Ellipsis.svg'
// Dependencies: Props
interface Props {
editable: boolean
deleteTeamCallback: () => void
remixTeamCallback: () => void
}
const PartyDropdown = ({
editable,
deleteTeamCallback,
remixTeamCallback,
}: Props) => {
// Localization
const { t } = useTranslation('common')
// Router
const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const localeData = retrieveLocaleCookies()
const [open, setOpen] = useState(false)
const [deleteAlertOpen, setDeleteAlertOpen] = useState(false)
const [remixAlertOpen, setRemixAlertOpen] = useState(false)
const [copyToastOpen, setCopyToastOpen] = useState(false)
const [remixToastOpen, setRemixToastOpen] = useState(false)
const [name, setName] = useState('')
const [originalName, setOriginalName] = useState('')
// Snapshots
const { account } = useSnapshot(accountState)
const { party: partySnapshot } = useSnapshot(appState)
// 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(), [])
// Methods: Event handlers (Buttons)
function handleButtonClicked() {
setOpen(!open)
}
// Methods: Event handlers (Menus)
function handleOpenChange(open: boolean) {
setOpen(open)
}
function closeMenu() {
setOpen(false)
}
// Method: Actions
function copyToClipboard() {
if (router.asPath.split('/')[1] === 'p') {
navigator.clipboard.writeText(window.location.href)
setCopyToastOpen(true)
}
}
// Methods: Event handlers
// Alerts / Delete team
function openDeleteTeamAlert() {
setDeleteAlertOpen(true)
}
function handleDeleteTeamAlertChange(open: boolean) {
setDeleteAlertOpen(open)
}
// Alerts / Remix team
function openRemixTeamAlert() {
setRemixAlertOpen(true)
}
function handleRemixTeamAlertChange(open: boolean) {
setRemixAlertOpen(open)
}
// Toasts / Copy URL
function handleCopyToastOpenChanged(open: boolean) {
setCopyToastOpen(open)
}
function handleCopyToastCloseClicked() {
setCopyToastOpen(false)
}
// Toasts / Remix team
function handleRemixToastOpenChanged(open: boolean) {
setRemixToastOpen(open)
}
function handleRemixToastCloseClicked() {
setRemixToastOpen(false)
}
const editableItems = () => {
return (
<>
<DropdownMenuGroup className="MenuGroup">
<DropdownMenuItem className="MenuItem" onClick={copyToClipboard}>
<span>Copy link to team</span>
</DropdownMenuItem>
<DropdownMenuItem className="MenuItem" onClick={openRemixTeamAlert}>
<span>Remix team</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuGroup className="MenuGroup">
<DropdownMenuItem className="MenuItem" onClick={openDeleteTeamAlert}>
<span className="destructive">Delete team</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)
}
return (
<>
<div id="DropdownWrapper">
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
leftAccessoryIcon={<EllipsisIcon />}
className={classNames({ Active: open })}
blended={true}
onClick={handleButtonClicked}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>{editableItems()}</DropdownMenuContent>
</DropdownMenu>
</div>
<DeleteTeamAlert
open={deleteAlertOpen}
onOpenChange={handleDeleteTeamAlertChange}
deleteCallback={deleteTeamCallback}
/>
<RemixTeamAlert
creator={editable}
name={partySnapshot.name ? partySnapshot.name : t('no_title')}
open={remixAlertOpen}
onOpenChange={handleRemixTeamAlertChange}
remixCallback={remixTeamCallback}
/>
</>
)
}
export default PartyDropdown

View file

@ -0,0 +1,240 @@
.DetailsWrapper {
display: flex;
flex-direction: column;
gap: $unit-2x;
margin: $unit-4x auto 0 auto;
max-width: $grid-width;
@include breakpoint(phone) {
.Button:not(.IconButton) {
justify-content: center;
width: 100%;
.Text {
width: auto;
}
}
}
.PartyDetails {
box-sizing: border-box;
display: block;
line-height: 1.4;
white-space: pre-wrap;
margin: 0 auto $unit-2x;
max-width: $unit * 94;
overflow: hidden;
width: 100%;
@include breakpoint(phone) {
padding: 0 $unit;
}
a:hover {
text-decoration: underline;
}
p {
font-size: $font-regular;
line-height: $font-regular * 1.2;
white-space: pre-line;
}
.Tokens {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: $unit;
margin-bottom: $unit-2x;
}
.YoutubeWrapper {
background-color: var(--card-bg);
border-radius: $card-corner;
margin: $unit 0;
position: relative;
display: block;
contain: content;
background-position: center center;
background-size: cover;
cursor: pointer;
width: 60%;
height: 60%;
@include breakpoint(tablet) {
width: 100%;
height: 100%;
}
/* gradient */
&::before {
content: '';
display: block;
position: absolute;
top: 0;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADGCAYAAAAT+OqFAAAAdklEQVQoz42QQQ7AIAgEF/T/D+kbq/RWAlnQyyazA4aoAB4FsBSA/bFjuF1EOL7VbrIrBuusmrt4ZZORfb6ehbWdnRHEIiITaEUKa5EJqUakRSaEYBJSCY2dEstQY7AuxahwXFrvZmWl2rh4JZ07z9dLtesfNj5q0FU3A5ObbwAAAABJRU5ErkJggg==);
background-position: top;
background-repeat: repeat-x;
height: 60px;
padding-bottom: 50px;
width: 100%;
transition: all 0.2s cubic-bezier(0, 0, 0.2, 1);
}
/* responsive iframe with a 16:9 aspect ratio
thanks https://css-tricks.com/responsive-iframes/
*/
&::after {
content: '';
display: block;
padding-bottom: calc(100% / (16 / 9));
}
&:hover > .PlayerButton {
opacity: 1;
}
& > iframe {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
/* Play button */
& > .PlayerButton {
background: none;
border: none;
background-image: url('/icons/youtube.svg');
width: 68px;
height: 68px;
opacity: 0.8;
transition: all 0.2s cubic-bezier(0, 0, 0.2, 1);
}
& > .PlayerButton,
& > .PlayerButton:before {
position: absolute;
top: 50%;
left: 50%;
transform: translate3d(-50%, -50%, 0);
}
/* Post-click styles */
&.lyt-activated {
cursor: unset;
}
&.lyt-activated::before,
&.lyt-activated > .PlayerButton {
opacity: 0;
pointer-events: none;
}
}
}
.PartyInfo {
box-sizing: border-box;
display: flex;
flex-direction: row;
gap: $unit;
margin: 0 auto;
max-width: $unit * 94;
width: 100%;
@include breakpoint(phone) {
flex-direction: column;
gap: $unit;
padding: 0 $unit;
}
& > .Right {
display: flex;
gap: $unit-half;
}
& > .Left {
flex-grow: 1;
.Header {
align-items: center;
display: flex;
gap: $unit;
margin-bottom: $unit;
h1 {
font-size: $font-xlarge;
font-weight: $normal;
text-align: left;
color: var(--text-primary);
&.empty {
color: var(--text-secondary);
}
}
}
.attribution {
align-items: center;
display: flex;
flex-direction: row;
& > div {
align-items: center;
display: inline-flex;
font-size: $font-small;
height: 26px;
}
time {
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);
}
}
}
.user {
align-items: center;
display: inline-flex;
gap: calc($unit / 2);
margin-top: 1px;
img,
.no-user {
$diameter: 24px;
border-radius: calc($diameter / 2);
height: $diameter;
width: $diameter;
}
img.gran {
background-color: #cee7fe;
}
img.djeeta {
background-color: #ffe1fe;
}
.no-user {
background: $grey-80;
}
}
}
}

View file

@ -0,0 +1,405 @@
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import classNames from 'classnames'
import Button from '~components/common/Button'
import Tooltip from '~components/common/Tooltip'
import Token from '~components/common/Token'
import EditPartyModal from '~components/party/EditPartyModal'
import PartyDropdown from '~components/party/PartyDropdown'
import { accountState } from '~utils/accountState'
import { appState, initialAppState } from '~utils/appState'
import { formatTimeAgo } from '~utils/timeAgo'
import EditIcon from '~public/icons/Edit.svg'
import RemixIcon from '~public/icons/Remix.svg'
import SaveIcon from '~public/icons/Save.svg'
import type { DetailsObject } from 'types'
import './index.scss'
import api from '~utils/api'
// Props
interface Props {
party?: Party
new: boolean
editable: boolean
deleteCallback: () => void
remixCallback: () => void
updateCallback: (details: DetailsObject) => void
}
const PartyHeader = (props: Props) => {
const { party } = useSnapshot(appState)
const { t } = useTranslation('common')
const router = useRouter()
const locale = router.locale || 'en'
const { party: partySnapshot } = useSnapshot(appState)
const [name, setName] = useState('')
const [chargeAttack, setChargeAttack] = useState(true)
const [fullAuto, setFullAuto] = useState(false)
const [autoGuard, setAutoGuard] = useState(false)
const [buttonCount, setButtonCount] = useState<number | undefined>(undefined)
const [chainCount, setChainCount] = useState<number | undefined>(undefined)
const [turnCount, setTurnCount] = useState<number | undefined>(undefined)
const [clearTime, setClearTime] = useState(0)
const classes = classNames({
PartyDetails: true,
})
const userClass = classNames({
user: true,
empty: !party.user,
})
const linkClass = classNames({
wind: party && party.element == 1,
fire: party && party.element == 2,
water: party && party.element == 3,
earth: party && party.element == 4,
dark: party && party.element == 5,
light: party && party.element == 6,
})
useEffect(() => {
if (props.party) {
setName(props.party.name)
setAutoGuard(props.party.auto_guard)
setFullAuto(props.party.full_auto)
setChargeAttack(props.party.charge_attack)
setClearTime(props.party.clear_time)
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)
setTurnCount(party.turnCount)
setButtonCount(party.buttonCount)
setChainCount(party.chainCount)
}
})
}, [])
// Actions: Favorites
function toggleFavorite() {
if (appState.party.favorited) unsaveFavorite()
else saveFavorite()
}
function saveFavorite() {
if (appState.party.id)
api.saveTeam({ id: appState.party.id }).then((response) => {
if (response.status == 201) appState.party.favorited = true
})
else console.error('Failed to save team: No party ID')
}
function unsaveFavorite() {
if (appState.party.id)
api.unsaveTeam({ id: appState.party.id }).then((response) => {
if (response.status == 200) appState.party.favorited = false
})
else console.error('Failed to unsave team: No party ID')
}
// Methods: Navigation
function goTo(shortcode?: string) {
if (shortcode) router.push(`/p/${shortcode}`)
}
const userImage = (picture?: string, element?: string) => {
if (picture && element)
return (
<img
alt={picture}
className={`profile ${element}`}
srcSet={`/profile/${picture}.png,
/profile/${picture}@2x.png 2x`}
src={`/profile/${picture}.png`}
/>
)
else
return (
<img
alt={t('no_user')}
className={`profile anonymous`}
srcSet={`/profile/npc.png,
/profile/npc@2x.png 2x`}
src={`/profile/npc.png`}
/>
)
}
const userBlock = (username?: string, picture?: string, element?: string) => {
return (
<div className={userClass}>
{userImage(picture, element)}
{username ? username : t('no_user')}
</div>
)
}
const renderUserBlock = () => {
let username, picture, element
if (accountState.account.authorized && props.new) {
username = accountState.account.user?.username
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
element = party.user.avatar.element
}
if (username && picture && element) {
return linkedUserBlock(username, picture, element)
} else if (!props.new) {
return userBlock()
}
}
const linkedUserBlock = (
username?: string,
picture?: string,
element?: string
) => {
return (
<div>
<Link href={`/${username}`} passHref>
<a className={linkClass}>{userBlock(username, picture, element)}</a>
</Link>
</div>
)
}
const linkedRaidBlock = (raid: Raid) => {
return (
<div>
<Link href={`/teams?raid=${raid.slug}`} passHref>
<a className={`Raid ${linkClass}`}>{raid.name[locale]}</a>
</Link>
</div>
)
}
// Render: Tokens
const chargeAttackToken = (
<Token
className={classNames({
ChargeAttack: true,
On: chargeAttack,
Off: !chargeAttack,
})}
>
{`${t('party.details.labels.charge_attack')} ${
chargeAttack ? 'On' : 'Off'
}`}
</Token>
)
const fullAutoToken = (
<Token
className={classNames({
FullAuto: true,
On: fullAuto,
Off: !fullAuto,
})}
>
{`${t('party.details.labels.full_auto')} ${fullAuto ? 'On' : 'Off'}`}
</Token>
)
const autoGuardToken = (
<Token
className={classNames({
AutoGuard: true,
On: autoGuard,
Off: !autoGuard,
})}
>
{`${t('party.details.labels.auto_guard')} ${autoGuard ? 'On' : 'Off'}`}
</Token>
)
const turnCountToken = (
<Token>
{t('party.details.turns.with_count', {
count: turnCount,
})}
</Token>
)
const buttonChainToken = () => {
if (buttonCount || chainCount) {
let string = ''
if (buttonCount && buttonCount > 0) {
string += `${buttonCount}b`
}
if (!buttonCount && chainCount && chainCount > 0) {
string += `0${t('party.details.suffix.buttons')}${chainCount}${t(
'party.details.suffix.chains'
)}`
} else if (buttonCount && chainCount && chainCount > 0) {
string += `${chainCount}${t('party.details.suffix.chains')}`
} else if (buttonCount && !chainCount) {
string += `0${t('party.details.suffix.chains')}`
}
return <Token>{string}</Token>
}
}
const clearTimeToken = () => {
const minutes = Math.floor(clearTime / 60)
const seconds = clearTime - minutes * 60
let string = ''
if (minutes > 0)
string = `${minutes}${t('party.details.suffix.minutes')} ${seconds}${t(
'party.details.suffix.seconds'
)}`
else string = `${seconds}${t('party.details.suffix.seconds')}`
return <Token>{string}</Token>
}
function renderTokens() {
return (
<section className="Tokens">
{chargeAttackToken}
{fullAutoToken}
{autoGuardToken}
{turnCount ? turnCountToken : ''}
{clearTime > 0 ? clearTimeToken() : ''}
{buttonChainToken()}
</section>
)
}
// Render: Buttons
const saveButton = () => {
return (
<Tooltip content={t('tooltips.save')}>
<Button
leftAccessoryIcon={<SaveIcon />}
className={classNames({
Save: true,
Saved: partySnapshot.favorited,
})}
text={
appState.party.favorited ? t('buttons.saved') : t('buttons.save')
}
onClick={toggleFavorite}
/>
</Tooltip>
)
}
const remixButton = () => {
return (
<Tooltip content={t('tooltips.remix')}>
<Button
leftAccessoryIcon={<RemixIcon />}
className="Remix"
text={t('buttons.remix')}
onClick={props.remixCallback}
/>
</Tooltip>
)
}
return (
<>
<section className="DetailsWrapper">
<div className="PartyInfo">
<div className="Left">
<div className="Header">
<h1 className={name ? '' : 'empty'}>
{name ? name : t('no_title')}
</h1>
{party.remix && party.sourceParty ? (
<Tooltip content={t('tooltips.source')}>
<Button
className="IconButton Blended"
leftAccessoryIcon={<RemixIcon />}
text={t('tokens.remix')}
onClick={() => goTo(party.sourceParty?.shortcode)}
/>
</Tooltip>
) : (
''
)}
</div>
<div className="attribution">
{renderUserBlock()}
{appState.party.raid ? linkedRaidBlock(appState.party.raid) : ''}
{party.created_at != '' ? (
<time
className="last-updated"
dateTime={new Date(party.created_at).toString()}
>
{formatTimeAgo(new Date(party.created_at), locale)}
</time>
) : (
''
)}
</div>
</div>
{party.editable ? (
<div className="Right">
<EditPartyModal
party={props.party}
updateCallback={props.updateCallback}
>
<Button
leftAccessoryIcon={<EditIcon />}
text={t('buttons.show_info')}
/>
</EditPartyModal>
<PartyDropdown
editable={props.editable}
deleteTeamCallback={props.deleteCallback}
remixTeamCallback={props.remixCallback}
/>
</div>
) : (
<div className="Right">
{saveButton()}
{remixButton()}
</div>
)}
</div>
<section className={classes}>{renderTokens()}</section>
</section>
</>
)
}
export default PartyHeader

View file

@ -22,7 +22,12 @@
width: 100%;
}
@include breakpoint(phone) {
padding: 0;
}
.SegmentedControl {
gap: $unit;
flex-grow: 1;
// prettier-ignore
@ -31,6 +36,7 @@
and (max-height: 920px)
and (-webkit-min-device-pixel-ratio: 2) {
flex-grow: 1;
gap: 0;
width: 100%;
display: grid;
grid-template-columns: auto auto auto;

View file

@ -1,22 +1,29 @@
import React from 'react'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import classNames from 'classnames'
import { appState } from '~utils/appState'
import { accountState } from '~utils/accountState'
import SegmentedControl from '~components/common/SegmentedControl'
import Segment from '~components/common/Segment'
import ToggleSwitch from '~components/common/ToggleSwitch'
import RepSegment from '~components/reps/RepSegment'
import CharacterRep from '~components/reps/CharacterRep'
import WeaponRep from '~components/reps/WeaponRep'
import SummonRep from '~components/reps/SummonRep'
import { GridType } from '~utils/enums'
import './index.scss'
import classNames from 'classnames'
// Fix for valtio readonly array
declare module 'valtio' {
function useSnapshot<T extends object>(p: T): T
}
interface Props {
selectedTab: GridType
onClick: (event: React.ChangeEvent<HTMLInputElement>) => void
onCheckboxChange: (event: React.ChangeEvent<HTMLInputElement>) => void
}
const PartySegmentedControl = (props: Props) => {
@ -25,7 +32,7 @@ const PartySegmentedControl = (props: Props) => {
const { party, grid } = useSnapshot(appState)
function getElement() {
const getElement = () => {
let element: number = 0
if (party.element == 0 && grid.weapons.mainWeapon)
element = grid.weapons.mainWeapon.element
@ -47,17 +54,56 @@ const PartySegmentedControl = (props: Props) => {
}
}
const extraToggle = (
<div className="ExtraSwitch">
<span className="Text">Extra</span>
<ToggleSwitch
name="ExtraSwitch"
editable={party.editable}
checked={party.extra}
onChange={props.onCheckboxChange}
const characterSegment = () => {
return (
<RepSegment
controlGroup="grid"
inputName="characters"
name={t('party.segmented_control.characters')}
selected={props.selectedTab === GridType.Character}
onClick={props.onClick}
>
<CharacterRep
job={party.job}
element={party.element}
gender={
accountState.account.user ? accountState.account.user.gender : 0
}
grid={grid.characters}
/>
</div>
</RepSegment>
)
}
const weaponSegment = () => {
{
return (
<RepSegment
controlGroup="grid"
inputName="weapons"
name="Weapons"
selected={props.selectedTab === GridType.Weapon}
onClick={props.onClick}
>
<WeaponRep grid={grid.weapons} />
</RepSegment>
)
}
}
const summonSegment = () => {
return (
<RepSegment
controlGroup="grid"
inputName="summons"
name="Summons"
selected={props.selectedTab === GridType.Summon}
onClick={props.onClick}
>
<SummonRep grid={grid.summons} />
</RepSegment>
)
}
return (
<div
@ -67,39 +113,10 @@ const PartySegmentedControl = (props: Props) => {
})}
>
<SegmentedControl elementClass={getElement()}>
<Segment
groupName="grid"
name="characters"
selected={props.selectedTab == GridType.Character}
onClick={props.onClick}
>
{t('party.segmented_control.characters')}
</Segment>
<Segment
groupName="grid"
name="weapons"
selected={props.selectedTab == GridType.Weapon}
onClick={props.onClick}
>
{t('party.segmented_control.weapons')}
</Segment>
<Segment
groupName="grid"
name="summons"
selected={props.selectedTab == GridType.Summon}
onClick={props.onClick}
>
{t('party.segmented_control.summons')}
</Segment>
{characterSegment()}
{weaponSegment()}
{summonSegment()}
</SegmentedControl>
{(() => {
if (party.editable && props.selectedTab == GridType.Weapon) {
return extraToggle
}
})()}
</div>
)
}

View file

@ -0,0 +1,199 @@
.Combobox.Raid {
box-sizing: border-box;
.Header {
background: var(--dialog-bg);
border-top-left-radius: $card-corner;
border-top-right-radius: $card-corner;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $unit;
padding: $unit;
width: 100%;
.Clear.Button {
background: none;
padding: ($unit * 0.75) $unit-half $unit-half;
display: none;
&:hover svg {
fill: var(--text-primary);
}
&.Visible {
display: block;
}
svg {
fill: var(--text-tertiary);
width: $unit-2x;
height: $unit-2x;
}
}
.Controls {
display: flex;
gap: $unit;
.Button.Blended.small {
padding: $unit ($unit * 1.25);
&:hover {
background: var(--button-contained-bg);
}
@include breakpoint(phone) {
width: auto;
}
}
.Flipped {
transform: rotate(180deg);
}
.SegmentedControlWrapper {
flex-grow: 1;
.SegmentedControl {
width: 100%;
}
}
}
}
.Raids {
border-bottom-left-radius: $card-corner;
border-bottom-right-radius: $card-corner;
height: 36vh;
overflow-y: scroll;
padding: 0 $unit;
@include breakpoint(phone) {
height: 28vh;
}
&.Searching {
.CommandGroup {
padding-top: 0;
padding-bottom: 0;
.Label {
display: none;
}
.SelectItem {
margin: 0;
}
}
.CommandGroup.Hidden {
display: block;
}
}
.CommandGroup {
&.Hidden {
display: none;
}
.Label {
align-items: center;
color: var(--text-tertiary);
display: flex;
flex-direction: row;
flex-shrink: 0;
font-size: $font-small;
font-weight: $medium;
gap: $unit;
padding: $unit $unit-2x $unit-half ($unit * 1.5);
.Separator {
background: var(--select-separator);
border-radius: 1px;
display: block;
flex-grow: 1;
height: 2px;
}
}
}
}
}
.DetailsWrapper .PartyDetails.Editable .Raid.SelectTrigger,
.EditTeam .Raid.SelectTrigger {
background: var(--input-bound-bg);
display: flex;
padding-top: 10px;
padding-bottom: 11px;
min-height: 51px;
.Value {
display: flex;
gap: $unit-half;
width: 100%;
.Info {
display: flex;
align-items: center;
gap: $unit-half;
flex-grow: 1;
}
.ExtraIndicator {
background: var(--extra-purple-secondary);
border-radius: $full-corner;
color: $grey-100;
display: flex;
font-weight: $bold;
font-size: $font-tiny;
width: $unit-3x;
height: $unit-3x;
justify-content: center;
align-items: center;
}
.Group,
.Separator {
color: var(--text-tertiary);
}
.Raid.wind {
color: var(--wind-text);
}
.Raid.fire {
color: var(--fire-text);
}
.Raid.water {
color: var(--water-text);
}
.Raid.earth {
color: var(--earth-text);
}
.Raid.dark {
color: var(--dark-text);
}
.Raid.light {
color: var(--light-text);
}
}
}
.Filters .SelectTrigger.Raid {
& > span {
overflow: hidden;
}
.Raid {
display: block;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
}

View file

@ -0,0 +1,569 @@
import { createRef, useCallback, useEffect, useState, useRef } from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import { Command, CommandGroup, CommandInput } from 'cmdk'
import Popover from '~components/common/Popover'
import SegmentedControl from '~components/common/SegmentedControl'
import Segment from '~components/common/Segment'
import RaidItem from '~components/raids/RaidItem'
import Tooltip from '~components/common/Tooltip'
import api from '~utils/api'
import { appState } from '~utils/appState'
interface Props {
showAllRaidsOption: boolean
currentRaid?: Raid
defaultRaid?: Raid
minimal?: boolean
tabIndex?: number
onChange?: (raid?: Raid) => void
onBlur?: (event: React.ChangeEvent<HTMLSelectElement>) => void
}
import Button from '~components/common/Button'
import ArrowIcon from '~public/icons/Arrow.svg'
import CrossIcon from '~public/icons/Cross.svg'
import './index.scss'
const NUM_SECTIONS = 3
const NUM_ELEMENTS = 5
enum Sort {
ASCENDING,
DESCENDING,
}
// Set up empty raid for "All raids"
const untitledGroup: RaidGroup = {
id: '0',
name: {
en: '',
ja: '',
},
section: 0,
order: 0,
extra: false,
guidebooks: false,
raids: [],
difficulty: 0,
hl: false,
}
// Set up empty raid for "All raids"
const allRaidsOption: Raid = {
id: '0',
name: {
en: 'All battles',
ja: '全てのバトル',
},
group: untitledGroup,
slug: 'all',
level: 0,
element: 0,
}
const RaidCombobox = (props: Props) => {
// Set up router for locale
const router = useRouter()
const locale = router.locale || 'en'
// Set up translations
const { t } = useTranslation('common')
// Component state
const [open, setOpen] = useState(false)
const [sort, setSort] = useState<Sort>(Sort.DESCENDING)
const [scrolled, setScrolled] = useState(false)
// Data state
const [currentSection, setCurrentSection] = useState(1)
const [query, setQuery] = useState('')
const [sections, setSections] = useState<RaidGroup[][]>()
const [currentRaid, setCurrentRaid] = useState<Raid>()
const [tabIndex, setTabIndex] = useState(NUM_ELEMENTS + 1)
// Data
const [farmingRaid, setFarmingRaid] = useState<Raid>()
// Refs
const listRef = createRef<HTMLDivElement>()
const inputRef = createRef<HTMLInputElement>()
const sortButtonRef = createRef<HTMLButtonElement>()
// ----------------------------------------------
// Methods: Lifecycle Hooks
// ----------------------------------------------
// Fetch all raids on mount
useEffect(() => {
api.raidGroups().then((response) => sortGroups(response.data))
}, [])
// Set current raid and section when the component mounts
useEffect(() => {
if (appState.party.raid) {
setCurrentRaid(appState.party.raid)
setCurrentSection(appState.party.raid.group.section)
} else if (props.showAllRaidsOption && !currentRaid) {
setCurrentRaid(allRaidsOption)
}
}, [])
// Set current raid and section when the current raid changes
useEffect(() => {
if (props.currentRaid) {
setCurrentRaid(props.currentRaid)
setCurrentSection(props.currentRaid.group.section)
}
}, [props.currentRaid])
// Scroll to the top of the list when the user switches tabs
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = 0
}
}, [currentSection])
useEffect(() => {
setTabIndex(NUM_ELEMENTS + 1)
}, [currentSection])
// ----------------------------------------------
// Methods: Event Handlers
// ----------------------------------------------
// Handle Escape key press event
const handleEscapeKeyPressed = useCallback(() => {
if (listRef.current) {
listRef.current.focus()
}
}, [open, currentRaid, sortButtonRef])
// Handle Arrow key press event by focusing the list item above or below the current one based on the direction
const handleArrowKeyPressed = useCallback(
(direction: 'Up' | 'Down') => {
const current = listRef.current?.querySelector(
'.Raid:focus'
) as HTMLElement | null
if (current) {
let next: Element | null | undefined
if (direction === 'Down' && !current.nextElementSibling) {
const nextParent =
current.parentElement?.parentElement?.nextElementSibling
next = nextParent?.querySelector('.Raid')
} else if (direction === 'Up' && !current.previousElementSibling) {
const previousParent =
current.parentElement?.parentElement?.previousElementSibling
next = previousParent?.querySelector('.Raid:last-child')
} else {
next =
direction === 'Up'
? current.previousElementSibling
: current.nextElementSibling
}
if (next) {
;(next as HTMLElement).focus()
}
}
},
[open, currentRaid, listRef]
)
// Scroll to an item in the list when it is selected
const scrollToItem = useCallback(
(node) => {
if (!scrolled && open && currentRaid && listRef.current && node) {
const { top: listTop } = listRef.current.getBoundingClientRect()
const { top: itemTop } = node.getBoundingClientRect()
listRef.current.scrollTop = itemTop - listTop
node.focus()
setScrolled(true)
}
},
[scrolled, open, currentRaid, listRef]
)
// Reverse the sort order
function reverseSort() {
if (sort === Sort.ASCENDING) setSort(Sort.DESCENDING)
else setSort(Sort.ASCENDING)
}
// Sorts the raid groups into sections
const sortGroups = useCallback(
(groups: RaidGroup[]) => {
const sections: [RaidGroup[], RaidGroup[], RaidGroup[]] = [[], [], []]
groups.forEach((group) => {
if (group.section > 0) sections[group.section - 1].push(group)
})
setFarmingRaid(groups[0].raids[0])
setSections(sections)
},
[setSections]
)
const handleSortButtonKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>
) => {
// If the tab key is pressed without the Shift key, focus the raid list
if (event.key === 'Tab' && !event.shiftKey) {
if (listRef.current) {
listRef.current.focus()
}
}
}
const handleListKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Tab' && !event.shiftKey) {
event.preventDefault()
if (inputRef.current) {
inputRef.current.focus()
}
} else if (event.key === 'Tab' && event.shiftKey) {
event.preventDefault()
if (sortButtonRef.current) {
sortButtonRef.current.focus()
}
}
// If the enter key is pressed, focus the first raid item in the list
else if (event.key === 'Enter') {
event.preventDefault()
if (listRef.current) {
const raid = listRef.current.querySelector('.Raid')
if (raid) {
;(raid as HTMLElement).focus()
}
}
}
}
// Handle value change for the raid selection
function handleValueChange(raid: Raid) {
setCurrentRaid(raid)
setOpen(false)
setScrolled(false)
if (props.onChange) props.onChange(raid)
}
// Toggle the open state of the combobox
function toggleOpen() {
if (open) {
if (currentRaid && currentRaid.slug !== 'all') {
setCurrentSection(currentRaid.group.section)
}
setScrolled(false)
}
setOpen(!open)
}
// Clear the search query
function clearSearch() {
setQuery('')
}
// ----------------------------------------------
// Methods: Rendering
// ----------------------------------------------
// Renders each raid section
function renderRaidSections() {
return Array.from({ length: NUM_SECTIONS }, (_, i) => renderRaidSection(i))
}
// Renders the specified raid section
function renderRaidSection(section: number) {
const currentSection = sections?.[section]
if (!currentSection) return
const sortedGroups = currentSection.sort((a, b) => {
return sort === Sort.ASCENDING ? a.order - b.order : b.order - a.order
})
return sortedGroups.map((group, i) => renderRaidGroup(section, i))
}
// Renders the specified raid group
function renderRaidGroup(section: number, index: number) {
if (!sections?.[section]?.[index]) return
const group = sections[section][index]
const options = generateRaidItems(group.raids)
const groupClassName = classNames({
CommandGroup: true,
Hidden: group.section !== currentSection,
})
const heading = (
<div className="Label">
{group.name[locale]}
<div className="Separator" />
</div>
)
return (
<CommandGroup
data-section={group.section}
className={groupClassName}
key={group.name[locale].toLowerCase().replace(' ', '-')}
heading={heading}
>
{options}
</CommandGroup>
)
}
// Render the ungrouped raid group
function renderUngroupedRaids() {
let ungroupedRaids = farmingRaid ? [farmingRaid] : []
if (props.showAllRaidsOption) {
ungroupedRaids.push(allRaidsOption)
}
const options = generateRaidItems(ungroupedRaids)
return (
<CommandGroup
data-section={untitledGroup.section}
className={classNames({
CommandGroup: true,
})}
key="ungrouped-raids"
>
{options}
</CommandGroup>
)
}
// Generates a list of RaidItem components from the specified raids
function generateRaidItems(raids: Raid[]) {
return raids
.sort((a, b) => {
if (a.element > 0 && b.element > 0) return a.element - b.element
if (a.name.en.includes('NM') && b.name.en.includes('NM'))
return a.level - b.level
return a.name.en.localeCompare(b.name.en)
})
.map((item, i) => renderRaidItem(item, i))
}
// Renders a RaidItem component for the specified raid
function renderRaidItem(raid: Raid, key: number) {
const isSelected = currentRaid?.id === raid.id
const isRef = isSelected ? scrollToItem : undefined
const imageUrl = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/raids/${raid.slug}.png`
return (
<RaidItem
className={isSelected ? 'Selected' : ''}
icon={{ alt: raid.name[locale], src: imageUrl }}
extra={raid.group.extra}
key={key}
selected={isSelected}
ref={isRef}
role="listitem"
tabIndex={0}
value={raid.slug}
onEscapeKeyPressed={handleEscapeKeyPressed}
onArrowKeyPressed={handleArrowKeyPressed}
onSelect={() => handleValueChange(raid)}
>
{raid.name[locale]}
</RaidItem>
)
}
// Renders a SegmentedControl component for selecting raid sections.
function renderSegmentedControl() {
return (
<SegmentedControl blended={true}>
<Segment
groupName="raid_section"
name="events"
selected={currentSection === 2}
tabIndex={2}
onClick={() => setCurrentSection(2)}
>
{t('raids.sections.events')}
</Segment>
<Segment
groupName="raid_section"
name="raids"
selected={currentSection === 1}
tabIndex={3}
onClick={() => setCurrentSection(1)}
>
{t('raids.sections.raids')}
</Segment>
<Segment
groupName="raid_section"
name="solo"
selected={currentSection === 3}
tabIndex={4}
onClick={() => setCurrentSection(3)}
>
{t('raids.sections.solo')}
</Segment>
</SegmentedControl>
)
}
// Renders a Button for sorting raids and a Tooltip for explaining what it does.
function renderSortButton() {
return (
<Tooltip
content={
sort === Sort.ASCENDING
? 'Lower difficulty battles first'
: 'Higher difficulty battles first'
}
>
<Button
blended={true}
buttonSize="small"
leftAccessoryIcon={<ArrowIcon />}
leftAccessoryClassName={sort === Sort.DESCENDING ? 'Flipped' : ''}
onClick={reverseSort}
onKeyDown={handleSortButtonKeyDown}
ref={sortButtonRef}
tabIndex={5}
/>
</Tooltip>
)
}
// Renders the content for the Popover trigger.
function renderTriggerContent() {
if (currentRaid) {
const element = (
<>
{!props.minimal ? (
<div className="Info">
<span className="Group">{currentRaid.group.name[locale]}</span>
<span className="Separator">/</span>
<span className={classNames({ Raid: true }, linkClass)}>
{currentRaid.name[locale]}
</span>
</div>
) : (
<span className={classNames({ Raid: true }, linkClass)}>
{currentRaid.name[locale]}
</span>
)}
{currentRaid.group.extra && !props.minimal && (
<i className="ExtraIndicator">EX</i>
)}
</>
)
return {
element,
rawValue: currentRaid.id,
}
}
return undefined
}
// Renders the search input for the raid combobox
function renderSearchInput() {
return (
<div className="Bound Joined">
<CommandInput
className="Input"
placeholder={t('search.placeholders.raid')}
tabIndex={1}
ref={inputRef}
value={query}
onValueChange={setQuery}
/>
<div
className={classNames({
Button: true,
Clear: true,
Visible: query.length > 0,
})}
onClick={clearSearch}
>
<CrossIcon />
</div>
</div>
)
}
// ----------------------------------------------
// Methods: Utility
// ----------------------------------------------
function slugToRaid(slug: string) {
return appState.raidGroups
.filter((group) => group.section > 0)
.flatMap((group) => group.raids)
.find((raid) => raid.slug === slug)
}
const linkClass = classNames({
wind: currentRaid && currentRaid.element == 1,
fire: currentRaid && currentRaid.element == 2,
water: currentRaid && currentRaid.element == 3,
earth: currentRaid && currentRaid.element == 4,
dark: currentRaid && currentRaid.element == 5,
light: currentRaid && currentRaid.element == 6,
})
// ----------------------------------------------
// Render
// ----------------------------------------------
return (
<Popover
className="Flush"
open={open}
onOpenChange={toggleOpen}
placeholder={t('raids.placeholder')}
trigger={{ className: 'Raid' }}
triggerTabIndex={props.tabIndex}
value={renderTriggerContent()}
>
<Command className="Raid Combobox">
<div className="Header">
{renderSearchInput()}
{!query && (
<div className="Controls">
{renderSegmentedControl()}
{renderSortButton()}
</div>
)}
</div>
<div
className={classNames({ Raids: true, Searching: query !== '' })}
ref={listRef}
role="listbox"
tabIndex={6}
onKeyDown={handleListKeyDown}
>
{renderUngroupedRaids()}
{renderRaidSections()}
</div>
</Command>
</Popover>
)
}
RaidCombobox.defaultProps = {
minimal: false,
}
export default RaidCombobox

View file

@ -0,0 +1,58 @@
.SelectItem.Raid {
padding-top: $unit;
padding-bottom: $unit;
padding-left: $unit;
&:hover {
.ExtraIndicator {
background: var(--extra-purple-secondary);
color: white;
}
.Selected {
background-color: var(--pill-bg-hover);
color: var(--pill-text-hover);
}
}
&.Selected .ExtraIndicator {
background: var(--extra-purple-secondary);
color: white;
}
.Text {
flex-grow: 1;
}
.ExtraIndicator {
background: var(--extra-purple-bg);
border-radius: $full-corner;
color: var(--extra-purple-text);
display: flex;
font-weight: $bold;
font-size: $font-tiny;
width: $unit-3x;
height: $unit-3x;
justify-content: center;
align-items: center;
}
.Selected {
background-color: var(--pill-bg);
color: var(--pill-text);
border-radius: $full-corner;
display: flex;
align-items: center;
font-size: $font-tiny;
font-weight: $bold;
padding: 0 $unit;
height: $unit-3x;
}
img {
background: var(--input-bound-bg);
border-radius: $unit-half;
width: $unit-10x;
height: 56px;
}
}

View file

@ -0,0 +1,87 @@
import React, { ComponentProps, PropsWithChildren } from 'react'
import { useTranslation } from 'next-i18next'
import { CommandItem } from '~components/common/Command'
import classNames from 'classnames'
import './index.scss'
interface Props extends ComponentProps<'div'> {
className?: string
icon?: {
alt: string
src: string
}
extra: boolean
selected: boolean
tabIndex?: number
value: string | number
onSelect: () => void
onArrowKeyPressed?: (direction: 'Up' | 'Down') => void
onEscapeKeyPressed?: () => void
}
const RaidItem = React.forwardRef<HTMLDivElement, PropsWithChildren<Props>>(
function Item(
{
icon,
value,
extra,
selected,
tabIndex,
children,
onEscapeKeyPressed,
onArrowKeyPressed,
...props
}: PropsWithChildren<Props>,
forwardedRef
) {
const { t } = useTranslation('common')
const classes = classNames(
{ SelectItem: true, Raid: true },
props.className
)
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape' && onEscapeKeyPressed) {
event.preventDefault()
onEscapeKeyPressed()
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault()
if (onArrowKeyPressed) {
console.log(event.key)
onArrowKeyPressed(event.key === 'ArrowUp' ? 'Up' : 'Down')
}
}
if (event.key === 'Enter') {
event.preventDefault()
props.onSelect()
}
}
return (
<CommandItem
{...props}
className={classes}
tabIndex={tabIndex}
value={`${value}`}
onClick={props.onSelect}
onKeyDown={handleKeyDown}
ref={forwardedRef}
>
{icon ? <img alt={icon.alt} src={icon.src} /> : ''}
<span className="Text">{children}</span>
{selected ? <i className="Selected">{t('combobox.selected')}</i> : ''}
{extra ? <i className="ExtraIndicator">EX</i> : ''}
</CommandItem>
)
}
)
RaidItem.defaultProps = {
extra: false,
selected: false,
}
export default RaidItem

View file

@ -0,0 +1,75 @@
.CharacterRep {
aspect-ratio: 2/0.99;
border-radius: $card-corner;
grid-gap: $unit-half; /* add a gap of 8px between grid items */
height: $rep-height;
.Character {
background: var(--card-bg);
border-radius: 4px;
}
.GridCharacters {
display: grid; /* make the right-images container a grid */
grid-template-columns: repeat(
4,
1fr
); /* create 3 columns, each taking up 1 fraction */
gap: $unit-half;
}
.Grid.Character {
aspect-ratio: 16 / 33;
box-sizing: border-box;
display: grid;
overflow: hidden;
&.MC {
border-color: transparent;
border-width: 1px;
border-style: solid;
aspect-ratio: 32 / 66;
img {
position: relative;
width: 100%;
height: 100%;
}
&.fire {
background: var(--fire-hover-bg);
border-color: var(--fire-bg);
}
&.water {
background: var(--water-hover-bg);
border-color: var(--water-bg);
}
&.wind {
background: var(--wind-hover-bg);
border-color: var(--wind-bg);
}
&.earth {
background: var(--earth-hover-bg);
border-color: var(--earth-bg);
}
&.light {
background: var(--light-hover-bg);
border-color: var(--light-bg);
}
&.dark {
background: var(--dark-hover-bg);
border-color: var(--dark-bg);
}
}
}
.Grid.Character img[src*='jpg'] {
border-radius: 4px;
width: 100%;
}
}

View file

@ -0,0 +1,132 @@
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import 'fix-date'
import './index.scss'
interface Props {
job?: Job
gender?: number
element?: number
grid: GridArray<GridCharacter>
}
const CHARACTERS_COUNT = 3
const CharacterRep = (props: Props) => {
// Localization for alt tags
const router = useRouter()
const { t } = useTranslation('common')
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
// Component state
const [characters, setCharacters] = useState<GridArray<Character>>({})
const [grid, setGrid] = useState<GridArray<GridCharacter>>({})
// On grid update
useEffect(() => {
const newCharacters = Array(CHARACTERS_COUNT)
const gridCharacters = Array(CHARACTERS_COUNT)
if (props.grid) {
for (const [key, value] of Object.entries(props.grid)) {
if (value) {
newCharacters[value.position] = value.object
gridCharacters[value.position] = value
}
}
}
setCharacters(newCharacters)
setGrid(gridCharacters)
}, [props.grid])
// Convert element to string
function numberToElement() {
switch (props.element) {
case 1:
return 'wind'
case 2:
return 'fire'
case 3:
return 'water'
case 4:
return 'earth'
case 5:
return 'dark'
case 6:
return 'light'
default:
return ''
}
}
// Methods: Image generation
function generateMCImage() {
let source = ''
if (props.job) {
const slug = props.job.name.en.replaceAll(' ', '-').toLowerCase()
const gender = props.gender == 1 ? 'b' : 'a'
source = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-portraits/${slug}_${gender}.png`
}
return props.job && props.job.id !== '-1' ? (
<img alt={props.job ? props.job?.name[locale] : ''} src={source} />
) : (
''
)
}
function generateGridImage(position: number) {
let url = ''
const character = characters[position]
const gridCharacter = grid[position]
if (character && gridCharacter) {
// Change the image based on the uncap level
let suffix = '01'
if (gridCharacter.transcendence_step > 0) suffix = '04'
else if (gridCharacter.uncap_level >= 5) suffix = '03'
else if (gridCharacter.uncap_level > 2) suffix = '02'
if (character.element == 0) {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-main/${character.granblue_id}_${props.element}.jpg`
} else {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-main/${character.granblue_id}_${suffix}.jpg`
}
}
return characters[position] ? (
<img alt={characters[position]?.name[locale]} src={url} />
) : (
''
)
}
// Render
return (
<div className="CharacterRep Rep">
<ul className="GridCharacters">
<li
key="characters-job"
className={`Grid Character MC ${numberToElement()}`}
>
{generateMCImage()}
</li>
{Array.from(Array(CHARACTERS_COUNT)).map((x, i) => {
return (
<li key={`characters-${i}`} className="Grid Character">
{generateGridImage(i)}
</li>
)
})}
</ul>
</div>
)
}
export default CharacterRep

View file

@ -0,0 +1,73 @@
.RepSegment {
border-radius: $card-corner;
color: $grey-55;
cursor: pointer;
font-size: 1.4rem;
font-weight: $normal;
min-width: 100px;
&:hover label {
background: var(--button-bg);
color: var(--text-primary);
.Wrapper .Rep {
opacity: 1;
}
}
& input {
display: none;
&:checked + label {
background: var(--button-bg);
color: var(--text-primary);
.Rep {
opacity: 1;
}
}
}
& label {
border-radius: $card-corner;
display: block;
font-size: $font-small;
font-weight: $medium;
text-align: center;
white-space: nowrap;
overflow: hidden;
padding: $unit;
padding-bottom: $unit * 1.5;
text-overflow: ellipsis;
cursor: pointer;
&:before {
background: #fff;
}
@include breakpoint(phone) {
border-radius: 100px;
padding-bottom: $unit;
}
.Wrapper {
display: flex;
flex-direction: column;
gap: $unit;
.Rep {
transition: $duration-opacity-fade opacity ease-in;
opacity: 0.5;
}
}
}
@include breakpoint(phone) {
min-width: initial;
width: 100%;
.Rep {
display: none;
}
}
}

View file

@ -0,0 +1,34 @@
import React, { PropsWithChildren } from 'react'
import './index.scss'
interface Props {
controlGroup: string
inputName: string
name: string
selected: boolean
onClick: (event: React.ChangeEvent<HTMLInputElement>) => void
}
const RepSegment = ({ children, ...props }: PropsWithChildren<Props>) => {
return (
<div className="RepSegment">
<input
name={props.controlGroup}
id={props.inputName}
value={props.inputName}
type="radio"
checked={props.selected}
onChange={props.onClick}
/>
<label htmlFor={props.inputName}>
<div className="Wrapper">
{children}
<div className="Title">{props.name}</div>
</div>
</label>
</div>
)
}
export default RepSegment

View file

@ -0,0 +1,45 @@
.SummonRep {
aspect-ratio: 2/1.045;
border-radius: $card-corner;
display: grid;
grid-template-columns: 1fr 2.25fr; /* left column takes up 1 fraction, right column takes up 3 fractions */
grid-gap: $unit-half; /* add a gap of 8px between grid items */
height: $rep-height;
.Summon {
background: var(--card-bg);
border-radius: 4px;
}
.Main.Summon {
aspect-ratio: 56/97;
display: grid;
grid-column: 1 / 2; /* spans one column */
}
.GridSummons {
display: grid; /* make the right-images container a grid */
grid-template-columns: repeat(
2,
1fr
); /* create 3 columns, each taking up 1 fraction */
grid-template-rows: repeat(
2,
1fr
); /* create 3 rows, each taking up 1 fraction */
gap: $unit-half;
// column-gap: $unit;
// row-gap: $unit-2x;
}
.Grid.Summon {
aspect-ratio: 184 / 138;
display: grid;
}
.Main.Summon img[src*='jpg'],
.Grid.Summon img[src*='jpg'] {
border-radius: 4px;
width: 100%;
}
}

View file

@ -0,0 +1,169 @@
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import './index.scss'
interface Props {
grid: {
mainSummon: GridSummon | undefined
friendSummon: GridSummon | undefined
allSummons: GridArray<GridSummon>
}
}
const SUMMONS_COUNT = 4
const SummonRep = (props: Props) => {
// Localization for alt tags
const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
// Component state
const [mainSummon, setMainSummon] = useState<GridSummon>()
const [summons, setSummons] = useState<GridArray<Summon>>({})
const [grid, setGrid] = useState<GridArray<GridSummon>>({})
// On grid update
useEffect(() => {
const newSummons = Array(SUMMONS_COUNT)
const gridSummons = Array(SUMMONS_COUNT)
if (props.grid.mainSummon) {
setMainSummon(props.grid.mainSummon)
}
if (props.grid.allSummons) {
for (const [key, value] of Object.entries(props.grid.allSummons)) {
if (value) {
newSummons[value.position] = value.object
gridSummons[value.position] = value
}
}
}
setSummons(newSummons)
setGrid(gridSummons)
}, [props.grid])
// Methods: Image generation
function generateMainImage() {
let url = ''
const upgradedSummons = [
'2040094000',
'2040100000',
'2040080000',
'2040098000',
'2040090000',
'2040084000',
'2040003000',
'2040056000',
'2040020000',
'2040034000',
'2040028000',
'2040027000',
'2040046000',
'2040047000',
]
if (mainSummon) {
// Change the image based on the uncap level
let suffix = ''
if (mainSummon.object.uncap.xlb && mainSummon.uncap_level == 6) {
if (
mainSummon.transcendence_step >= 1 &&
mainSummon.transcendence_step < 5
) {
suffix = '_03'
} else if (mainSummon.transcendence_step === 5) {
suffix = '_04'
}
} else if (
upgradedSummons.indexOf(mainSummon.object.granblue_id.toString()) !=
-1 &&
mainSummon.uncap_level == 5
) {
suffix = '_02'
}
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-main/${mainSummon.object.granblue_id}${suffix}.jpg`
}
return mainSummon ? (
<img alt={mainSummon.object.name[locale]} src={url} />
) : (
''
)
}
function generateGridImage(position: number) {
let url = ''
const summon = summons[position]
const gridSummon = grid[position]
const upgradedSummons = [
'2040094000',
'2040100000',
'2040080000',
'2040098000',
'2040090000',
'2040084000',
'2040003000',
'2040056000',
'2040020000',
'2040034000',
'2040028000',
'2040027000',
'2040046000',
'2040047000',
]
if (summon && gridSummon) {
// Change the image based on the uncap level
let suffix = ''
if (gridSummon.object.uncap.xlb && gridSummon.uncap_level == 6) {
if (
gridSummon.transcendence_step >= 1 &&
gridSummon.transcendence_step < 5
) {
suffix = '_03'
} else if (gridSummon.transcendence_step === 5) {
suffix = '_04'
}
} else if (
upgradedSummons.indexOf(summon.granblue_id.toString()) != -1 &&
gridSummon.uncap_level == 5
) {
suffix = '_02'
}
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${summon.granblue_id}${suffix}.jpg`
}
return summons[position] ? (
<img alt={summons[position]?.name[locale]} src={url} />
) : (
''
)
}
// Render
return (
<div className="SummonRep Rep">
<div className="Main Summon">{generateMainImage()}</div>
<ul className="GridSummons">
{Array.from(Array(SUMMONS_COUNT)).map((x, i) => {
return (
<li key={`summons-${i}`} className="Grid Summon">
{generateGridImage(i)}
</li>
)
})}
</ul>
</div>
)
}
export default SummonRep

View file

@ -0,0 +1,45 @@
.WeaponRep {
aspect-ratio: 2/0.955;
border-radius: $card-corner;
display: grid;
grid-template-columns: 1fr 3.39fr; /* left column takes up 1 fraction, right column takes up 3 fractions */
grid-gap: $unit-half; /* add a gap of 8px between grid items */
height: $rep-height;
.Weapon {
background: var(--card-bg);
border-radius: 4px;
}
.Mainhand.Weapon {
aspect-ratio: 73/153;
display: grid;
grid-column: 1 / 2; /* spans one column */
}
.GridWeapons {
display: grid; /* make the right-images container a grid */
grid-template-columns: repeat(
3,
1fr
); /* create 3 columns, each taking up 1 fraction */
grid-template-rows: repeat(
3,
1fr
); /* create 3 rows, each taking up 1 fraction */
gap: $unit-half;
// column-gap: $unit;
// row-gap: $unit-2x;
}
.Grid.Weapon {
aspect-ratio: 280 / 160;
display: grid;
}
.Mainhand.Weapon img[src*='jpg'],
.Grid.Weapon img[src*='jpg'] {
border-radius: 4px;
width: 100%;
}
}

View file

@ -0,0 +1,103 @@
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import './index.scss'
interface Props {
grid: {
mainWeapon: GridWeapon | undefined
allWeapons: GridArray<GridWeapon>
}
}
const WEAPONS_COUNT = 9
const WeaponRep = (props: Props) => {
// Localization for alt tags
const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
// Component state
const [mainhand, setMainhand] = useState<GridWeapon>()
const [weapons, setWeapons] = useState<GridArray<Weapon>>({})
const [grid, setGrid] = useState<GridArray<GridWeapon>>({})
// On grid update
useEffect(() => {
const newWeapons = Array(WEAPONS_COUNT)
const gridWeapons = Array(WEAPONS_COUNT)
if (props.grid.mainWeapon) {
setMainhand(props.grid.mainWeapon)
} else {
setMainhand(undefined)
}
if (props.grid.allWeapons) {
for (const [key, value] of Object.entries(props.grid.allWeapons)) {
if (value) {
newWeapons[value.position] = value.object
gridWeapons[value.position] = value
}
}
}
setWeapons(newWeapons)
setGrid(gridWeapons)
}, [props.grid])
// Methods: Image generation
function generateMainhandImage() {
let url = ''
if (mainhand && mainhand.object) {
if (mainhand.object.element == 0 && mainhand.element) {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${mainhand.object.granblue_id}_${mainhand.element}.jpg`
} else {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${mainhand.object.granblue_id}.jpg`
}
}
return mainhand ? <img alt={mainhand.object.name[locale]} src={url} /> : ''
}
function generateGridImage(position: number) {
let url = ''
const weapon = weapons[position]
const gridWeapon = grid[position]
if (weapon && gridWeapon) {
if (weapon.element == 0 && gridWeapon.element) {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}_${gridWeapon.element}.jpg`
} else {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}.jpg`
}
}
return weapons[position] ? (
<img alt={weapons[position]?.name[locale]} src={url} />
) : (
''
)
}
// Render
return (
<div className="WeaponRep Rep">
<div className="Mainhand Weapon">{generateMainhandImage()}</div>
<ul className="GridWeapons">
{Array.from(Array(WEAPONS_COUNT)).map((x, i) => {
return (
<li key={`weapons-${i}`} className="Grid Weapon">
{generateGridImage(i)}
</li>
)
})}
</ul>
</div>
)
}
export default WeaponRep

View file

@ -2,7 +2,7 @@ import React from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import ArrowIcon from '~public/icons/Arrow.svg'
import ChevronIcon from '~public/icons/Chevron.svg'
import './index.scss'
interface Props {
@ -22,7 +22,7 @@ const SearchFilter = (props: Props) => {
<span className="count">{props.numSelected}</span>
</div>
<span className="icon">
<ArrowIcon />
<ChevronIcon />
</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content className="Dropdown" sideOffset={4}>

View file

@ -61,6 +61,11 @@
#Results {
margin: 0;
padding: 0 ($unit * 1.5);
padding-bottom: $unit * 1.5;
// Infinite scroll
overflow-y: auto;
max-height: 500px;
@include breakpoint(phone) {
max-height: inherit;

View file

@ -19,6 +19,7 @@ import CharacterResult from '~components/character/CharacterResult'
import WeaponResult from '~components/weapon/WeaponResult'
import SummonResult from '~components/summon/SummonResult'
import JobSkillResult from '~components/job/JobSkillResult'
import GuidebookResult from '~components/extra/GuidebookResult'
import type { DialogProps } from '@radix-ui/react-dialog'
import type { SearchableObject, SearchableObjectArray } from '~types'
@ -31,7 +32,7 @@ interface Props extends DialogProps {
placeholderText: string
fromPosition: number
job?: Job
object: 'weapons' | 'characters' | 'summons' | 'job_skills'
object: 'weapons' | 'characters' | 'summons' | 'job_skills' | 'guidebooks'
}
const SearchModal = (props: Props) => {
@ -184,7 +185,7 @@ const SearchModal = (props: Props) => {
} else if (open && currentPage == 1) {
fetchResults({ replace: true })
}
}, [currentPage])
}, [open, currentPage])
useEffect(() => {
// Filters changed
@ -219,6 +220,17 @@ const SearchModal = (props: Props) => {
}
}, [query])
useEffect(() => {
if (open && props.object === 'guidebooks') {
setCurrentPage(1)
fetchResults({ replace: true })
}
}, [query, open])
function incrementPage() {
setCurrentPage(currentPage + 1)
}
function renderResults() {
let jsx
@ -235,12 +247,15 @@ const SearchModal = (props: Props) => {
case 'job_skills':
jsx = renderJobSkillSearchResults(results)
break
case 'guidebooks':
jsx = renderGuidebookSearchResults(results)
break
}
return (
<InfiniteScroll
dataLength={results && results.length > 0 ? results.length : 0}
next={() => setCurrentPage(currentPage + 1)}
next={incrementPage}
hasMore={totalPages > currentPage}
scrollableTarget="Results"
loader={<div className="footer">Loading...</div>}
@ -334,6 +349,27 @@ const SearchModal = (props: Props) => {
return jsx
}
function renderGuidebookSearchResults(results: { [key: string]: any }) {
let jsx: React.ReactNode
const castResults: Guidebook[] = results as Guidebook[]
if (castResults && Object.keys(castResults).length > 0) {
jsx = castResults.map((result: Guidebook) => {
return (
<GuidebookResult
key={result.id}
data={result}
onClick={() => {
storeRecentResult(result)
}}
/>
)
})
}
return jsx
}
function openChange() {
if (open) {
setQuery('')
@ -365,6 +401,7 @@ const SearchModal = (props: Props) => {
<DialogContent
className="Search"
headerref={headerRef}
scrollable={false}
onEscapeKeyDown={onEscapeKeyDown}
onOpenAutoFocus={onOpenAutoFocus}
>

View file

@ -70,4 +70,8 @@
.SummonUnit .SummonImage .icon svg {
fill: var(--subaura-orange-secondary);
}
.SummonUnit .QuickSummon {
display: none;
}
}

View file

@ -1,6 +1,6 @@
#SummonGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)) 2fr;
grid-template-columns: 1.17fr 2fr 1.17fr;
gap: $unit-3x;
justify-content: center;
margin: 0 auto;

View file

@ -452,8 +452,8 @@ const SummonGrid = (props: Props) => {
<div>
<div id="SummonGrid">
{mainSummonElement}
{friendSummonElement}
{summonGridElement}
{friendSummonElement}
</div>
{subAuraSummonElement}

View file

@ -112,4 +112,51 @@
opacity: 0;
}
}
&:hover .QuickSummon.Empty {
opacity: 1;
}
&.main .QuickSummon {
$diameter: $unit-6x;
background-size: $diameter $diameter;
top: -2%;
right: 28%;
width: $diameter;
height: $diameter;
}
&.friend .QuickSummon {
display: none;
}
&.grid .QuickSummon {
$diameter: $unit-5x;
background-size: $diameter $diameter;
top: -5%;
right: 22%;
width: $diameter;
height: $diameter;
}
.QuickSummon {
position: absolute;
background-image: url('/icons/quick_summon/filled.svg');
z-index: 20;
transition: $duration-zoom opacity ease-in-out;
&:hover {
background-image: url('/icons/quick_summon/empty.svg');
cursor: pointer;
}
&.Empty {
background-image: url('/icons/quick_summon/empty.svg');
opacity: 0;
&:hover {
background-image: url('/icons/quick_summon/filled.svg');
}
}
}
}

Some files were not shown because too many files have changed in this diff Show more