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:
parent
363148599a
commit
b8ae43ddaf
149 changed files with 16601 additions and 15104 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -57,6 +57,7 @@ public/images/accessory*
|
|||
public/images/mastery*
|
||||
public/images/updates*
|
||||
public/images/guidebooks*
|
||||
public/images/raids*
|
||||
|
||||
# Typescript v1 declaration files
|
||||
typings/
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
22
components/RaidSelect/index.scss
Normal file
22
components/RaidSelect/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
170
components/RaidSelect/index.tsx
Normal file
170
components/RaidSelect/index.tsx
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<strong>{`${gridAwakening.type.name[locale]}`}</strong>
|
||||
{`Lv${gridAwakening.level}`}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
.description {
|
||||
font-size: $font-regular;
|
||||
line-height: 1.4;
|
||||
white-space: pre-line;
|
||||
|
||||
strong {
|
||||
font-weight: $bold;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
128
components/common/Command/index.tsx
Normal file
128
components/common/Command/index.tsx
Normal 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,
|
||||
}
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
.InputField.TableField .Input {
|
||||
.InputField.TableField .Input[type='number'] {
|
||||
text-align: right;
|
||||
width: $unit-8x;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
.Overlay {
|
||||
isolation: isolate;
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
z-index: 9;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
|
|
|||
30
components/common/Popover/index.scss
Normal file
30
components/common/Popover/index.scss
Normal 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] {
|
||||
}
|
||||
108
components/common/Popover/index.tsx
Normal file
108
components/common/Popover/index.tsx
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
.Segment {
|
||||
color: $grey-55;
|
||||
cursor: pointer;
|
||||
flex-grow: 1;
|
||||
font-size: 1.4rem;
|
||||
font-weight: $normal;
|
||||
min-width: 100px;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
.SelectField.TableField .Right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ const SelectTableField = (props: Props) => {
|
|||
return (
|
||||
<TableField
|
||||
name={props.name}
|
||||
className="SelectField"
|
||||
imageAlt={props.imageAlt}
|
||||
imageClass={props.imageClass}
|
||||
imageSrc={props.imageSrc}
|
||||
|
|
|
|||
|
|
@ -5,4 +5,8 @@
|
|||
text-align: right;
|
||||
width: $unit-8x;
|
||||
}
|
||||
|
||||
.Right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const Switch = (props: Props) => {
|
|||
disabled={disabled}
|
||||
required={required}
|
||||
value={value}
|
||||
tabIndex={props.tabIndex}
|
||||
onCheckedChange={onCheckedChange}
|
||||
>
|
||||
<RadixSwitch.Thumb className={thumbClasses} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
.TableField.SwitchTableField {
|
||||
&.Extra .Switch[data-state='checked'] {
|
||||
background: var(--extra-purple-secondary);
|
||||
}
|
||||
|
||||
.Right {
|
||||
justify-content: end;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
35
components/dialogs/DeleteTeamAlert/index.tsx
Normal file
35
components/dialogs/DeleteTeamAlert/index.tsx
Normal 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
|
||||
57
components/dialogs/RemixTeamAlert/index.tsx
Normal file
57
components/dialogs/RemixTeamAlert/index.tsx
Normal 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'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
|
||||
50
components/extra/ExtraContainer/index.scss
Normal file
50
components/extra/ExtraContainer/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
components/extra/ExtraContainer/index.tsx
Normal file
11
components/extra/ExtraContainer/index.tsx
Normal 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
|
||||
47
components/extra/ExtraWeaponsGrid/index.scss
Normal file
47
components/extra/ExtraWeaponsGrid/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
74
components/extra/ExtraWeaponsGrid/index.tsx
Normal file
74
components/extra/ExtraWeaponsGrid/index.tsx
Normal 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
|
||||
37
components/extra/GuidebookResult/index.scss
Normal file
37
components/extra/GuidebookResult/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
components/extra/GuidebookResult/index.tsx
Normal file
32
components/extra/GuidebookResult/index.tsx
Normal 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
|
||||
109
components/extra/GuidebookUnit/index.scss
Normal file
109
components/extra/GuidebookUnit/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
201
components/extra/GuidebookUnit/index.tsx
Normal file
201
components/extra/GuidebookUnit/index.tsx
Normal 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
|
||||
45
components/extra/GuidebooksGrid/index.scss
Normal file
45
components/extra/GuidebooksGrid/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
69
components/extra/GuidebooksGrid/index.tsx
Normal file
69
components/extra/GuidebooksGrid/index.tsx
Normal 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
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@
|
|||
.JobSkills {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:not(.editable) {
|
||||
gap: $unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
215
components/mastery/AwakeningSelectWithInput/index.tsx
Normal file
215
components/mastery/AwakeningSelectWithInput/index.tsx
Normal 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
|
||||
56
components/party/EditPartyModal/index.scss
Normal file
56
components/party/EditPartyModal/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
477
components/party/EditPartyModal/index.tsx
Normal file
477
components/party/EditPartyModal/index.tsx
Normal 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 Fediel’s 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
|
||||
|
|
@ -5,3 +5,11 @@
|
|||
gap: 8px;
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
nav.RepNavigation {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
margin-bottom: $unit-4x;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 Fediel’s 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() : ''}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
0
components/party/PartyDropdown/index.scss
Normal file
0
components/party/PartyDropdown/index.scss
Normal file
197
components/party/PartyDropdown/index.tsx
Normal file
197
components/party/PartyDropdown/index.tsx
Normal 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
|
||||
240
components/party/PartyHeader/index.scss
Normal file
240
components/party/PartyHeader/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
405
components/party/PartyHeader/index.tsx
Normal file
405
components/party/PartyHeader/index.tsx
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
199
components/raids/RaidCombobox/index.scss
Normal file
199
components/raids/RaidCombobox/index.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
569
components/raids/RaidCombobox/index.tsx
Normal file
569
components/raids/RaidCombobox/index.tsx
Normal 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
|
||||
58
components/raids/RaidItem/index.scss
Normal file
58
components/raids/RaidItem/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
87
components/raids/RaidItem/index.tsx
Normal file
87
components/raids/RaidItem/index.tsx
Normal 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
|
||||
75
components/reps/CharacterRep/index.scss
Normal file
75
components/reps/CharacterRep/index.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
132
components/reps/CharacterRep/index.tsx
Normal file
132
components/reps/CharacterRep/index.tsx
Normal 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
|
||||
73
components/reps/RepSegment/index.scss
Normal file
73
components/reps/RepSegment/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
components/reps/RepSegment/index.tsx
Normal file
34
components/reps/RepSegment/index.tsx
Normal 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
|
||||
45
components/reps/SummonRep/index.scss
Normal file
45
components/reps/SummonRep/index.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
169
components/reps/SummonRep/index.tsx
Normal file
169
components/reps/SummonRep/index.tsx
Normal 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
|
||||
45
components/reps/WeaponRep/index.scss
Normal file
45
components/reps/WeaponRep/index.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
103
components/reps/WeaponRep/index.tsx
Normal file
103
components/reps/WeaponRep/index.tsx
Normal 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
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -70,4 +70,8 @@
|
|||
.SummonUnit .SummonImage .icon svg {
|
||||
fill: var(--subaura-orange-secondary);
|
||||
}
|
||||
|
||||
.SummonUnit .QuickSummon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -452,8 +452,8 @@ const SummonGrid = (props: Props) => {
|
|||
<div>
|
||||
<div id="SummonGrid">
|
||||
{mainSummonElement}
|
||||
{friendSummonElement}
|
||||
{summonGridElement}
|
||||
{friendSummonElement}
|
||||
</div>
|
||||
|
||||
{subAuraSummonElement}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue