Compare commits
15 commits
main
...
quick-summ
| Author | SHA1 | Date | |
|---|---|---|---|
| e345cedd34 | |||
| 291d5a124b | |||
| 631baa3d54 | |||
| 3a1b25a398 | |||
| 8d5fb106e4 | |||
| 875635f2d9 | |||
| 835cdfff6f | |||
| 938e34f21c | |||
| d8f70ff8a0 | |||
| ddd6a9da96 | |||
| 9c449b72c2 | |||
| d765b00120 | |||
| f7c895d0ca | |||
| b6895b61a8 | |||
| 8398112065 |
128 changed files with 16183 additions and 14734 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -57,6 +57,7 @@ public/images/accessory*
|
||||||
public/images/mastery*
|
public/images/mastery*
|
||||||
public/images/updates*
|
public/images/updates*
|
||||||
public/images/guidebooks*
|
public/images/guidebooks*
|
||||||
|
public/images/raids*
|
||||||
|
|
||||||
# Typescript v1 declaration files
|
# Typescript v1 declaration files
|
||||||
typings/
|
typings/
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@
|
||||||
|
|
||||||
select,
|
select,
|
||||||
.SelectTrigger {
|
.SelectTrigger {
|
||||||
// background: url("/icons/Arrow.svg"), $grey-90;
|
// background: url("/icons/Chevron.svg"), $grey-90;
|
||||||
// background-repeat: no-repeat;
|
// background-repeat: no-repeat;
|
||||||
// background-position-y: center;
|
// background-position-y: center;
|
||||||
// background-position-x: 95%;
|
// background-position-x: 95%;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import classNames from 'classnames'
|
||||||
import equals from 'fast-deep-equal'
|
import equals from 'fast-deep-equal'
|
||||||
|
|
||||||
import FilterModal from '~components/FilterModal'
|
import FilterModal from '~components/FilterModal'
|
||||||
import RaidDropdown from '~components/RaidDropdown'
|
|
||||||
import Select from '~components/common/Select'
|
import Select from '~components/common/Select'
|
||||||
import SelectItem from '~components/common/SelectItem'
|
import SelectItem from '~components/common/SelectItem'
|
||||||
import Button from '~components/common/Button'
|
import Button from '~components/common/Button'
|
||||||
|
|
@ -15,6 +14,8 @@ import FilterIcon from '~public/icons/Filter.svg'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import { getCookie } from 'cookies-next'
|
import { getCookie } from 'cookies-next'
|
||||||
|
import RaidCombobox from '~components/raids/RaidCombobox'
|
||||||
|
import { appState } from '~utils/appState'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
|
@ -29,6 +30,8 @@ const FilterBar = (props: Props) => {
|
||||||
// Set up translation
|
// Set up translation
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
|
const [currentRaid, setCurrentRaid] = useState<Raid>()
|
||||||
|
|
||||||
const [recencyOpen, setRecencyOpen] = useState(false)
|
const [recencyOpen, setRecencyOpen] = useState(false)
|
||||||
const [elementOpen, setElementOpen] = useState(false)
|
const [elementOpen, setElementOpen] = useState(false)
|
||||||
|
|
||||||
|
|
@ -47,6 +50,16 @@ const FilterBar = (props: Props) => {
|
||||||
FiltersActive: !matchesDefaultFilters,
|
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(() => {
|
useEffect(() => {
|
||||||
// Fetch user's advanced filters
|
// Fetch user's advanced filters
|
||||||
const filtersCookie = getCookie('filters')
|
const filtersCookie = getCookie('filters')
|
||||||
|
|
@ -76,8 +89,8 @@ const FilterBar = (props: Props) => {
|
||||||
props.onFilter({ recency: recencyValue, ...advancedFilters })
|
props.onFilter({ recency: recencyValue, ...advancedFilters })
|
||||||
}
|
}
|
||||||
|
|
||||||
function raidSelectChanged(slug?: string) {
|
function raidSelectChanged(raid?: Raid) {
|
||||||
props.onFilter({ raidSlug: slug, ...advancedFilters })
|
props.onFilter({ raidSlug: raid?.slug, ...advancedFilters })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAdvancedFiltersChanged(filters: FilterSet) {
|
function handleAdvancedFiltersChanged(filters: FilterSet) {
|
||||||
|
|
@ -90,6 +103,25 @@ const FilterBar = (props: Props) => {
|
||||||
setRecencyOpen(name === 'recency' ? !recencyOpen : false)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
|
|
@ -97,47 +129,26 @@ const FilterBar = (props: Props) => {
|
||||||
<div className="Filters">
|
<div className="Filters">
|
||||||
<Select
|
<Select
|
||||||
value={`${props.element}`}
|
value={`${props.element}`}
|
||||||
|
overlayVisible={false}
|
||||||
open={elementOpen}
|
open={elementOpen}
|
||||||
onOpenChange={() => onSelectChange('element')}
|
onOpenChange={() => onSelectChange('element')}
|
||||||
onValueChange={elementSelectChanged}
|
onValueChange={elementSelectChanged}
|
||||||
onClick={openElementSelect}
|
onClick={openElementSelect}
|
||||||
>
|
>
|
||||||
<SelectItem data-element="all" key={-1} value={-1}>
|
{generateSelectItems()}
|
||||||
{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>
|
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<RaidDropdown
|
<RaidCombobox
|
||||||
currentRaid={props.raidSlug}
|
currentRaid={currentRaid}
|
||||||
defaultRaid="all"
|
|
||||||
showAllRaidsOption={true}
|
showAllRaidsOption={true}
|
||||||
|
minimal={true}
|
||||||
onChange={raidSelectChanged}
|
onChange={raidSelectChanged}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={`${props.recency}`}
|
value={`${props.recency}`}
|
||||||
trigger={'All time'}
|
trigger={'All time'}
|
||||||
|
overlayVisible={false}
|
||||||
open={recencyOpen}
|
open={recencyOpen}
|
||||||
onOpenChange={() => onSelectChange('recency')}
|
onOpenChange={() => onSelectChange('recency')}
|
||||||
onValueChange={recencySelectChanged}
|
onValueChange={recencySelectChanged}
|
||||||
|
|
|
||||||
|
|
@ -199,15 +199,17 @@ const FilterModal = (props: Props) => {
|
||||||
setMinWeaponCount(value)
|
setMinWeaponCount(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMaxButtonsCountValueChange(value: number) {
|
function handleMaxButtonsCountValueChange(value?: string) {
|
||||||
setMaxButtonsCount(value)
|
if (!value) return
|
||||||
|
setMaxButtonsCount(parseInt(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMaxTurnsCountValueChange(value: number) {
|
function handleMaxTurnsCountValueChange(value?: string) {
|
||||||
setMaxTurnsCount(value)
|
if (!value) return
|
||||||
|
setMaxTurnsCount(parseInt(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNameQualityValueChange(value: boolean) {
|
function handleNameQualityValueChange(value?: boolean) {
|
||||||
setNameQuality(value)
|
setNameQuality(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -414,7 +416,8 @@ const FilterModal = (props: Props) => {
|
||||||
{originalOnlyField()}
|
{originalOnlyField()}
|
||||||
</div>
|
</div>
|
||||||
<div className="DialogFooter" ref={footerRef}>
|
<div className="DialogFooter" ref={footerRef}>
|
||||||
<div className="Buttons Spaced">
|
<div className="Left"></div>
|
||||||
|
<div className="Right Buttons Spaced">
|
||||||
<Button
|
<Button
|
||||||
blended={true}
|
blended={true}
|
||||||
text={t('modals.filters.buttons.clear')}
|
text={t('modals.filters.buttons.clear')}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
padding-top: $unit-fourth;
|
||||||
transition: opacity 0.14s ease-in-out;
|
transition: opacity 0.14s ease-in-out;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ import Button from '~components/common/Button'
|
||||||
import Tooltip from '~components/common/Tooltip'
|
import Tooltip from '~components/common/Tooltip'
|
||||||
import * as Switch from '@radix-ui/react-switch'
|
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 LinkIcon from '~public/icons/Link.svg'
|
||||||
import MenuIcon from '~public/icons/Menu.svg'
|
import MenuIcon from '~public/icons/Menu.svg'
|
||||||
import RemixIcon from '~public/icons/Remix.svg'
|
import RemixIcon from '~public/icons/Remix.svg'
|
||||||
|
|
@ -296,25 +296,6 @@ const Header = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rendering: Buttons
|
// 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 = () => {
|
const newButton = () => {
|
||||||
return (
|
return (
|
||||||
<Tooltip content={t('tooltips.new')}>
|
<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
|
// Rendering: Toasts
|
||||||
const urlCopyToast = () => {
|
const urlCopyToast = () => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -435,15 +402,6 @@ const Header = () => {
|
||||||
const right = () => {
|
const right = () => {
|
||||||
return (
|
return (
|
||||||
<section>
|
<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()}
|
{newButton()}
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={rightMenuOpen}
|
open={rightMenuOpen}
|
||||||
|
|
@ -453,7 +411,7 @@ const Header = () => {
|
||||||
<Button
|
<Button
|
||||||
className={classNames({ Active: rightMenuOpen })}
|
className={classNames({ Active: rightMenuOpen })}
|
||||||
leftAccessoryIcon={profileImage()}
|
leftAccessoryIcon={profileImage()}
|
||||||
rightAccessoryIcon={<ArrowIcon />}
|
rightAccessoryIcon={<ChevronIcon />}
|
||||||
rightAccessoryClassName="Arrow"
|
rightAccessoryClassName="Arrow"
|
||||||
onClick={handleRightMenuButtonClicked}
|
onClick={handleRightMenuButtonClicked}
|
||||||
blended={true}
|
blended={true}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { getCookie } from 'cookies-next'
|
||||||
import { appState } from '~utils/appState'
|
import { appState } from '~utils/appState'
|
||||||
|
|
||||||
import TopHeader from '~components/Header'
|
import TopHeader from '~components/Header'
|
||||||
import UpdateToast from '~components/about/UpdateToast'
|
import UpdateToast from '~components/toasts/UpdateToast'
|
||||||
|
|
||||||
import './index.scss'
|
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,10 +330,13 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
||||||
{themeField()}
|
{themeField()}
|
||||||
</div>
|
</div>
|
||||||
<div className="DialogFooter" ref={footerRef}>
|
<div className="DialogFooter" ref={footerRef}>
|
||||||
<Button
|
<div className="Left"></div>
|
||||||
contained={true}
|
<div className="Right">
|
||||||
text={t('modals.settings.buttons.confirm')}
|
<Button
|
||||||
/>
|
contained={true}
|
||||||
|
text={t('modals.settings.buttons.confirm')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
.description {
|
.description {
|
||||||
font-size: $font-regular;
|
font-size: $font-regular;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
white-space: pre-line;
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
font-weight: $bold;
|
font-weight: $bold;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ interface Props {
|
||||||
message: string | React.ReactNode
|
message: string | React.ReactNode
|
||||||
primaryAction?: () => void
|
primaryAction?: () => void
|
||||||
primaryActionText?: string
|
primaryActionText?: string
|
||||||
|
primaryActionClassName?: string
|
||||||
cancelAction: () => void
|
cancelAction: () => void
|
||||||
cancelActionText: string
|
cancelActionText: string
|
||||||
}
|
}
|
||||||
|
|
@ -22,7 +23,10 @@ const Alert = (props: Props) => {
|
||||||
<AlertDialog.Portal>
|
<AlertDialog.Portal>
|
||||||
<AlertDialog.Overlay className="Overlay" onClick={props.cancelAction} />
|
<AlertDialog.Overlay className="Overlay" onClick={props.cancelAction} />
|
||||||
<div className="AlertWrapper">
|
<div className="AlertWrapper">
|
||||||
<AlertDialog.Content className="Alert">
|
<AlertDialog.Content
|
||||||
|
className="Alert"
|
||||||
|
onEscapeKeyDown={props.cancelAction}
|
||||||
|
>
|
||||||
{props.title ? (
|
{props.title ? (
|
||||||
<AlertDialog.Title>{props.title}</AlertDialog.Title>
|
<AlertDialog.Title>{props.title}</AlertDialog.Title>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -42,6 +46,7 @@ const Alert = (props: Props) => {
|
||||||
{props.primaryAction ? (
|
{props.primaryAction ? (
|
||||||
<AlertDialog.Action asChild>
|
<AlertDialog.Action asChild>
|
||||||
<Button
|
<Button
|
||||||
|
className={props.primaryActionClassName}
|
||||||
contained={true}
|
contained={true}
|
||||||
onClick={props.primaryAction}
|
onClick={props.primaryAction}
|
||||||
text={props.primaryActionText}
|
text={props.primaryActionText}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
.Button {
|
.Button {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--button-bg);
|
background: var(--button-bg);
|
||||||
border: none;
|
border: 2px solid transparent;
|
||||||
border-radius: $input-corner;
|
border-radius: $input-corner;
|
||||||
color: var(--button-text);
|
color: var(--button-text);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
@ -166,6 +166,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.Destructive {
|
||||||
|
background: $error;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken($error, 15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.Accessory {
|
.Accessory {
|
||||||
$dimension: $unit-2x;
|
$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'
|
import './index.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props extends React.HTMLProps<HTMLInputElement> {
|
||||||
fieldName: string
|
fieldName: string
|
||||||
placeholder: string
|
placeholder: string
|
||||||
value?: string
|
value?: string
|
||||||
|
|
@ -11,47 +18,61 @@ interface Props {
|
||||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
|
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CharLimitedFieldset = React.forwardRef<HTMLInputElement, Props>(
|
const CharLimitedFieldset: ForwardRefRenderFunction<HTMLInputElement, Props> = (
|
||||||
function useFieldSet(props, ref) {
|
{
|
||||||
const fieldType = ['password', 'confirm_password'].includes(props.fieldName)
|
fieldName,
|
||||||
? 'password'
|
placeholder,
|
||||||
: 'text'
|
value,
|
||||||
|
limit,
|
||||||
|
error,
|
||||||
|
onBlur,
|
||||||
|
onChange: onInputChange,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
// States
|
||||||
|
const [currentCount, setCurrentCount] = useState(
|
||||||
|
() => limit - (value || '').length
|
||||||
|
)
|
||||||
|
|
||||||
const [currentCount, setCurrentCount] = useState(0)
|
// Hooks
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentCount(limit - (value || '').length)
|
||||||
|
}, [limit, value])
|
||||||
|
|
||||||
useEffect(() => {
|
// Event handlers
|
||||||
setCurrentCount(
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
props.value ? props.limit - props.value.length : props.limit
|
const { value: inputValue } = event.currentTarget
|
||||||
)
|
setCurrentCount(limit - inputValue.length)
|
||||||
}, [props.limit, props.value])
|
if (onInputChange) {
|
||||||
|
onInputChange(event)
|
||||||
function onChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
setCurrentCount(props.limit - event.currentTarget.value.length)
|
|
||||||
if (props.onChange) props.onChange(event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
|
||||||
<fieldset className="Fieldset">
|
|
||||||
<div className="Limited">
|
|
||||||
<input
|
|
||||||
autoComplete="off"
|
|
||||||
className="Input"
|
|
||||||
type={fieldType}
|
|
||||||
name={props.fieldName}
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
defaultValue={props.value || ''}
|
|
||||||
onBlur={props.onBlur}
|
|
||||||
onChange={onChange}
|
|
||||||
maxLength={props.limit}
|
|
||||||
ref={ref}
|
|
||||||
formNoValidate
|
|
||||||
/>
|
|
||||||
<span className="Counter">{currentCount}</span>
|
|
||||||
</div>
|
|
||||||
{props.error.length > 0 && <p className="InputError">{props.error}</p>}
|
|
||||||
</fieldset>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
export default CharLimitedFieldset
|
// Rendering methods
|
||||||
|
return (
|
||||||
|
<fieldset className="Fieldset">
|
||||||
|
<div className={classNames({ Joined: true }, props.className)}>
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
autoComplete="off"
|
||||||
|
className="Input"
|
||||||
|
type={props.type}
|
||||||
|
name={fieldName}
|
||||||
|
placeholder={placeholder}
|
||||||
|
defaultValue={value || ''}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
maxLength={limit}
|
||||||
|
ref={ref}
|
||||||
|
formNoValidate
|
||||||
|
/>
|
||||||
|
<span className="Counter">{currentCount}</span>
|
||||||
|
</div>
|
||||||
|
{error.length > 0 && <p className="InputError">{error}</p>}
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
min-width: 100vw;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
z-index: 40;
|
z-index: 10;
|
||||||
|
|
||||||
.DialogContent {
|
.DialogContent {
|
||||||
$multiplier: 4;
|
$multiplier: 4;
|
||||||
|
|
@ -59,8 +59,12 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Scrollable {
|
.Container {
|
||||||
overflow-y: auto;
|
overflow-y: hidden;
|
||||||
|
|
||||||
|
&.Scrollable {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.DialogHeader {
|
.DialogHeader {
|
||||||
|
|
@ -156,7 +160,8 @@
|
||||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.16);
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.16);
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.24);
|
border-top: 1px solid rgba(0, 0, 0, 0.24);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
padding: ($unit * 1.5) ($unit * $multiplier) $unit-3x;
|
padding: ($unit * 1.5) ($unit * $multiplier) $unit-3x;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
|
|
||||||
|
|
@ -174,7 +179,6 @@
|
||||||
|
|
||||||
&.Spaced {
|
&.Spaced {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,13 @@ interface Props
|
||||||
> {
|
> {
|
||||||
headerref?: React.RefObject<HTMLDivElement>
|
headerref?: React.RefObject<HTMLDivElement>
|
||||||
footerref?: React.RefObject<HTMLDivElement>
|
footerref?: React.RefObject<HTMLDivElement>
|
||||||
|
scrollable?: boolean
|
||||||
onEscapeKeyDown: (event: KeyboardEvent) => void
|
onEscapeKeyDown: (event: KeyboardEvent) => void
|
||||||
onOpenAutoFocus: (event: Event) => void
|
onOpenAutoFocus: (event: Event) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<HTMLDivElement, Props>(function Dialog(
|
const DialogContent = React.forwardRef<HTMLDivElement, Props>(function Dialog(
|
||||||
{ children, ...props },
|
{ scrollable, children, ...props },
|
||||||
forwardedRef
|
forwardedRef
|
||||||
) {
|
) {
|
||||||
// Classes
|
// Classes
|
||||||
|
|
@ -131,7 +132,13 @@ const DialogContent = React.forwardRef<HTMLDivElement, Props>(function Dialog(
|
||||||
onEscapeKeyDown={props.onEscapeKeyDown}
|
onEscapeKeyDown={props.onEscapeKeyDown}
|
||||||
ref={forwardedRef}
|
ref={forwardedRef}
|
||||||
>
|
>
|
||||||
<div className="Scrollable" onScroll={handleScroll}>
|
<div
|
||||||
|
className={classNames({
|
||||||
|
Container: true,
|
||||||
|
Scrollable: scrollable,
|
||||||
|
})}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
|
|
@ -141,4 +148,8 @@ const DialogContent = React.forwardRef<HTMLDivElement, Props>(function Dialog(
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
DialogContent.defaultProps = {
|
||||||
|
scrollable: true,
|
||||||
|
}
|
||||||
|
|
||||||
export default DialogContent
|
export default DialogContent
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
|
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
overflow: auto;
|
||||||
width: 30vw;
|
width: 30vw;
|
||||||
max-width: 180px;
|
max-width: 180px;
|
||||||
margin: 0 $unit-2x;
|
margin: 0 $unit-2x;
|
||||||
|
|
@ -130,6 +131,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& .destructive {
|
||||||
|
color: $error;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $error;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
a {
|
a {
|
||||||
color: $grey-50;
|
color: $grey-50;
|
||||||
|
|
||||||
|
|
@ -177,12 +186,12 @@
|
||||||
.MenuGroup {
|
.MenuGroup {
|
||||||
border-bottom: 1px solid var(--menu-separator);
|
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-left-radius: 6px;
|
||||||
border-top-right-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-left-radius: 6px;
|
||||||
border-bottom-right-radius: 6px;
|
border-bottom-right-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,10 @@ interface Props
|
||||||
}
|
}
|
||||||
|
|
||||||
const DurationInput = React.forwardRef<HTMLInputElement, Props>(
|
const DurationInput = React.forwardRef<HTMLInputElement, Props>(
|
||||||
function DurationInput({ className, value, onValueChange }, forwardedRef) {
|
function DurationInput(
|
||||||
|
{ className, value, onValueChange, ...props },
|
||||||
|
forwardedRef
|
||||||
|
) {
|
||||||
// State
|
// State
|
||||||
const [duration, setDuration] = useState('')
|
const [duration, setDuration] = useState('')
|
||||||
const [minutesSelected, setMinutesSelected] = useState(false)
|
const [minutesSelected, setMinutesSelected] = useState(false)
|
||||||
|
|
@ -202,7 +205,7 @@ const DurationInput = React.forwardRef<HTMLInputElement, Props>(
|
||||||
},
|
},
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
value={`${getSeconds()}`.padStart(2, '0')}
|
value={getSeconds() > 0 ? `${getSeconds()}`.padStart(2, '0') : ''}
|
||||||
onChange={handleSecondsChange}
|
onChange={handleSecondsChange}
|
||||||
onKeyUp={handleKeyUp}
|
onKeyUp={handleKeyUp}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--input-bound-bg-hover);
|
background-color: var(--input-bound-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
/* Chrome, Firefox, Opera, Safari 10.1+ */
|
||||||
|
color: var(--text-tertiary) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.AlignRight {
|
&.AlignRight {
|
||||||
|
|
@ -43,7 +48,7 @@
|
||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
::placeholder {
|
.Input::placeholder {
|
||||||
/* Chrome, Firefox, Opera, Safari 10.1+ */
|
/* Chrome, Firefox, Opera, Safari 10.1+ */
|
||||||
color: var(--text-secondary) !important;
|
color: var(--text-secondary) !important;
|
||||||
opacity: 1; /* Firefox */
|
opacity: 1; /* Firefox */
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
.InputField.TableField .Input {
|
.InputField.TableField .Input[type='number'] {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
width: $unit-8x;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,50 +3,60 @@ import Input from '~components/common/Input'
|
||||||
import TableField from '~components/common/TableField'
|
import TableField from '~components/common/TableField'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
interface Props {
|
interface Props
|
||||||
name: string
|
extends React.DetailedHTMLProps<
|
||||||
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
HTMLInputElement
|
||||||
|
> {
|
||||||
label: string
|
label: string
|
||||||
description?: string
|
description?: string
|
||||||
placeholder?: string
|
|
||||||
value?: number
|
|
||||||
className?: string
|
|
||||||
imageAlt?: string
|
imageAlt?: string
|
||||||
imageClass?: string
|
imageClass?: string
|
||||||
imageSrc?: string[]
|
imageSrc?: string[]
|
||||||
onValueChange: (value: number) => void
|
onValueChange: (value?: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputTableField = (props: Props) => {
|
const InputTableField = ({
|
||||||
const [value, setValue] = useState(0)
|
label,
|
||||||
|
description,
|
||||||
|
imageAlt,
|
||||||
|
imageClass,
|
||||||
|
imageSrc,
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.value) setValue(props.value)
|
if (props.value) setInputValue(`${props.value}`)
|
||||||
}, [props.value])
|
}, [props.value])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
props.onValueChange(value)
|
props.onValueChange(inputValue)
|
||||||
}, [value])
|
}, [inputValue])
|
||||||
|
|
||||||
function onInputChange(event: React.ChangeEvent<HTMLInputElement>) {
|
function onInputChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
setValue(parseInt(event.currentTarget?.value))
|
setInputValue(`${parseInt(event.currentTarget?.value)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableField
|
<TableField
|
||||||
name={props.name}
|
{...props}
|
||||||
className="InputField"
|
name={props.name || ''}
|
||||||
imageAlt={props.imageAlt}
|
className={classNames({ InputField: true }, props.className)}
|
||||||
imageClass={props.imageClass}
|
imageAlt={imageAlt}
|
||||||
imageSrc={props.imageSrc}
|
imageClass={imageClass}
|
||||||
label={props.label}
|
imageSrc={imageSrc}
|
||||||
|
label={label}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
className="Bound"
|
className="Bound"
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
type="number"
|
value={inputValue ? `${inputValue}` : ''}
|
||||||
value={value ? `${value}` : ''}
|
|
||||||
step={1}
|
step={1}
|
||||||
|
tabIndex={props.tabIndex}
|
||||||
|
type={props.type}
|
||||||
onChange={onInputChange}
|
onChange={onInputChange}
|
||||||
/>
|
/>
|
||||||
</TableField>
|
</TableField>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
.Overlay {
|
.Overlay {
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 30;
|
z-index: 9;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 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 {
|
.Segment {
|
||||||
color: $grey-55;
|
color: $grey-55;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
flex-grow: 1;
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
font-weight: $normal;
|
font-weight: $normal;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,20 @@ interface Props {
|
||||||
groupName: string
|
groupName: string
|
||||||
name: string
|
name: string
|
||||||
selected: boolean
|
selected: boolean
|
||||||
|
tabIndex?: number
|
||||||
children: string
|
children: string
|
||||||
onClick: (event: React.ChangeEvent<HTMLInputElement>) => void
|
onClick: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Segment: React.FC<Props> = (props: Props) => {
|
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 (
|
return (
|
||||||
<div className="Segment">
|
<div className="Segment">
|
||||||
<input
|
<input
|
||||||
|
|
@ -21,7 +30,13 @@ const Segment: React.FC<Props> = (props: Props) => {
|
||||||
checked={props.selected}
|
checked={props.selected}
|
||||||
onChange={props.onClick}
|
onChange={props.onClick}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={props.name}>{props.children}</label>
|
<label
|
||||||
|
htmlFor={props.name}
|
||||||
|
tabIndex={props.tabIndex}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
.SegmentedControlWrapper {
|
.SegmentedControlWrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.SegmentedControl {
|
.SegmentedControl {
|
||||||
background: var(--card-bg);
|
// border-radius: $unit * 3;
|
||||||
border-radius: $unit * 3;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -13,6 +16,20 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
-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 {
|
&.fire {
|
||||||
.Segment input:checked + label {
|
.Segment input:checked + label {
|
||||||
background: var(--fire-bg);
|
background: var(--fire-bg);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,38 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import classNames from 'classnames'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
className?: string
|
||||||
elementClass?: 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 (
|
return (
|
||||||
<div className="SegmentedControlWrapper">
|
<div className="SegmentedControlWrapper" tabIndex={tabIndex}>
|
||||||
<div className={`SegmentedControl ${elementClass ? elementClass : ''}`}>
|
<div className={classes}>{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SegmentedControl.defaultProps = {
|
||||||
|
blended: false,
|
||||||
|
}
|
||||||
|
|
||||||
export default SegmentedControl
|
export default SegmentedControl
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: var(--input-bg);
|
background-color: var(--input-bg);
|
||||||
border-radius: $input-corner;
|
border-radius: $input-corner;
|
||||||
border: none;
|
border: 2px solid transparent;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
padding: ($unit * 1.5) $unit-2x;
|
padding: ($unit * 1.5) $unit-2x;
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-placeholder] > span:not(.SelectIcon) {
|
&[data-placeholder='true'] > span:not(.SelectIcon) {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,13 +73,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.Select {
|
.Select {
|
||||||
background: var(--select-bg);
|
background: var(--dialog-bg);
|
||||||
border-radius: $input-corner;
|
border-radius: $card-corner;
|
||||||
border: $hover-stroke;
|
border: 1px solid rgba(0, 0, 0, 0.24);
|
||||||
box-shadow: $hover-shadow;
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
|
||||||
padding: 0 $unit;
|
padding: 0 $unit;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
|
min-width: var(--radix-select-trigger-width);
|
||||||
.Scroll.Up,
|
.Scroll.Up,
|
||||||
.Scroll.Down {
|
.Scroll.Down {
|
||||||
padding: $unit 0;
|
padding: $unit 0;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import classNames from 'classnames'
|
||||||
|
|
||||||
import Overlay from '~components/common/Overlay'
|
import Overlay from '~components/common/Overlay'
|
||||||
|
|
||||||
import ArrowIcon from '~public/icons/Arrow.svg'
|
import ChevronIcon from '~public/icons/Chevron.svg'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
|
|
@ -86,7 +86,7 @@ const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
|
||||||
<RadixSelect.Value placeholder={props.placeholder} />
|
<RadixSelect.Value placeholder={props.placeholder} />
|
||||||
{!props.disabled ? (
|
{!props.disabled ? (
|
||||||
<RadixSelect.Icon className="SelectIcon">
|
<RadixSelect.Icon className="SelectIcon">
|
||||||
<ArrowIcon />
|
<ChevronIcon />
|
||||||
</RadixSelect.Icon>
|
</RadixSelect.Icon>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
|
|
@ -102,16 +102,18 @@ const Select = React.forwardRef<HTMLButtonElement, Props>(function Select(
|
||||||
|
|
||||||
<RadixSelect.Content
|
<RadixSelect.Content
|
||||||
className="Select"
|
className="Select"
|
||||||
|
position="popper"
|
||||||
|
sideOffset={6}
|
||||||
onCloseAutoFocus={onCloseAutoFocus}
|
onCloseAutoFocus={onCloseAutoFocus}
|
||||||
onEscapeKeyDown={onEscapeKeyDown}
|
onEscapeKeyDown={onEscapeKeyDown}
|
||||||
onPointerDownOutside={onPointerDownOutside}
|
onPointerDownOutside={onPointerDownOutside}
|
||||||
>
|
>
|
||||||
<RadixSelect.ScrollUpButton className="Scroll Up">
|
<RadixSelect.ScrollUpButton className="Scroll Up">
|
||||||
<ArrowIcon />
|
<ChevronIcon />
|
||||||
</RadixSelect.ScrollUpButton>
|
</RadixSelect.ScrollUpButton>
|
||||||
<RadixSelect.Viewport>{props.children}</RadixSelect.Viewport>
|
<RadixSelect.Viewport>{props.children}</RadixSelect.Viewport>
|
||||||
<RadixSelect.ScrollDownButton className="Scroll Down">
|
<RadixSelect.ScrollDownButton className="Scroll Down">
|
||||||
<ArrowIcon />
|
<ChevronIcon />
|
||||||
</RadixSelect.ScrollDownButton>
|
</RadixSelect.ScrollDownButton>
|
||||||
</RadixSelect.Content>
|
</RadixSelect.Content>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
font-size: $font-regular;
|
font-size: $font-regular;
|
||||||
padding: ($unit * 1.5) $unit-2x;
|
padding: ($unit * 1.5) $unit-2x;
|
||||||
|
|
||||||
&:hover {
|
&:hover,
|
||||||
|
&:focus {
|
||||||
background-color: var(--option-bg-hover);
|
background-color: var(--option-bg-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ const SelectItem = React.forwardRef<HTMLDivElement, Props>(function selectItem(
|
||||||
const { altText, iconSrc, ...rest } = props
|
const { altText, iconSrc, ...rest } = props
|
||||||
return (
|
return (
|
||||||
<Select.Item
|
<Select.Item
|
||||||
className={classNames('SelectItem', props.className)}
|
|
||||||
{...rest}
|
{...rest}
|
||||||
|
className={classNames({ SelectItem: true }, props.className)}
|
||||||
ref={forwardedRef}
|
ref={forwardedRef}
|
||||||
value={`${value}`}
|
value={`${value}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
.SelectField.TableField .Right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@ const SelectTableField = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<TableField
|
<TableField
|
||||||
name={props.name}
|
name={props.name}
|
||||||
|
className="SelectField"
|
||||||
imageAlt={props.imageAlt}
|
imageAlt={props.imageAlt}
|
||||||
imageClass={props.imageClass}
|
imageClass={props.imageClass}
|
||||||
imageSrc={props.imageSrc}
|
imageSrc={props.imageSrc}
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,8 @@
|
||||||
text-align: right;
|
text-align: right;
|
||||||
width: $unit-8x;
|
width: $unit-8x;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ const Switch = (props: Props) => {
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
required={required}
|
required={required}
|
||||||
value={value}
|
value={value}
|
||||||
|
tabIndex={props.tabIndex}
|
||||||
onCheckedChange={onCheckedChange}
|
onCheckedChange={onCheckedChange}
|
||||||
>
|
>
|
||||||
<RadixSwitch.Thumb className={thumbClasses} />
|
<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 { useEffect, useState } from 'react'
|
||||||
|
import classNames from 'classnames'
|
||||||
import Switch from '~components/common/Switch'
|
import Switch from '~components/common/Switch'
|
||||||
import TableField from '~components/common/TableField'
|
import TableField from '~components/common/TableField'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
name: string
|
name: string
|
||||||
label: string
|
label: string
|
||||||
description?: string
|
description?: string
|
||||||
|
disabled?: boolean
|
||||||
value?: boolean
|
value?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
tabIndex?: number
|
||||||
imageAlt?: string
|
imageAlt?: string
|
||||||
imageClass?: string
|
imageClass?: string
|
||||||
imageSrc?: string[]
|
imageSrc?: string[]
|
||||||
|
|
@ -31,10 +34,19 @@ const SwitchTableField = (props: Props) => {
|
||||||
setValue(value)
|
setValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const classes = classNames(
|
||||||
|
{
|
||||||
|
SwitchTableField: true,
|
||||||
|
Disabled: props.disabled,
|
||||||
|
},
|
||||||
|
props.className
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableField
|
<TableField
|
||||||
name={props.name}
|
name={props.name}
|
||||||
className="SwitchField"
|
description={props.description}
|
||||||
|
className={classes}
|
||||||
imageAlt={props.imageAlt}
|
imageAlt={props.imageAlt}
|
||||||
imageClass={props.imageClass}
|
imageClass={props.imageClass}
|
||||||
imageSrc={props.imageSrc}
|
imageSrc={props.imageSrc}
|
||||||
|
|
@ -43,6 +55,8 @@ const SwitchTableField = (props: Props) => {
|
||||||
<Switch
|
<Switch
|
||||||
name={props.name}
|
name={props.name}
|
||||||
checked={value}
|
checked={value}
|
||||||
|
disabled={props.disabled}
|
||||||
|
tabIndex={props.tabIndex}
|
||||||
onCheckedChange={onValueChange}
|
onCheckedChange={onValueChange}
|
||||||
/>
|
/>
|
||||||
</TableField>
|
</TableField>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: $unit-2x;
|
gap: $unit-2x;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
|
min-height: $unit-6x;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: $unit-half 0;
|
padding: $unit-half 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -17,7 +18,30 @@
|
||||||
color: var(--accent-blue);
|
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 {
|
.Left {
|
||||||
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
|
|
@ -59,7 +83,6 @@
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: $font-small;
|
font-size: $font-small;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
max-width: 300px;
|
|
||||||
|
|
||||||
&.jp {
|
&.jp {
|
||||||
max-width: 270px;
|
max-width: 270px;
|
||||||
|
|
@ -71,6 +94,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: $unit-2x;
|
gap: $unit-2x;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ const TableField = (props: Props) => {
|
||||||
<div className="Left">
|
<div className="Left">
|
||||||
<div className="Info">
|
<div className="Info">
|
||||||
<h3>{props.label}</h3>
|
<h3>{props.label}</h3>
|
||||||
<p>{props.description}</p>
|
{props.description && <p>{props.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="Image">{image()}</div>
|
<div className="Image">{image()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
font-size: $font-small;
|
font-size: $font-tiny;
|
||||||
font-weight: $medium;
|
font-weight: $bold;
|
||||||
min-width: 3rem;
|
min-width: 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: $unit ($unit * 1.5);
|
padding: $unit-three-fourth ($unit * 1.5);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
&.ChargeAttack.On {
|
&.ChargeAttack.On {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
animation: scaleIn $duration-zoom ease-out;
|
animation: scaleIn $duration-zoom ease-out;
|
||||||
background: var(--dialog-bg);
|
background: var(--dialog-bg);
|
||||||
border-radius: $input-corner;
|
border-radius: $input-corner;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: $font-tiny;
|
font-size: $font-tiny;
|
||||||
font-weight: $medium;
|
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
|
||||||
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;
|
gap: 8px;
|
||||||
line-height: 34px;
|
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 { getCookie } from 'cookies-next'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { subscribe, useSnapshot } from 'valtio'
|
import { subscribe, useSnapshot } from 'valtio'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
import clonedeep from 'lodash.clonedeep'
|
import clonedeep from 'lodash.clonedeep'
|
||||||
|
|
||||||
|
import Alert from '~components/common/Alert'
|
||||||
import PartySegmentedControl from '~components/party/PartySegmentedControl'
|
import PartySegmentedControl from '~components/party/PartySegmentedControl'
|
||||||
import PartyDetails from '~components/party/PartyDetails'
|
import PartyDetails from '~components/party/PartyDetails'
|
||||||
|
import PartyHeader from '~components/party/PartyHeader'
|
||||||
import WeaponGrid from '~components/weapon/WeaponGrid'
|
import WeaponGrid from '~components/weapon/WeaponGrid'
|
||||||
import SummonGrid from '~components/summon/SummonGrid'
|
import SummonGrid from '~components/summon/SummonGrid'
|
||||||
import CharacterGrid from '~components/character/CharacterGrid'
|
import CharacterGrid from '~components/character/CharacterGrid'
|
||||||
|
|
@ -26,7 +29,6 @@ import './index.scss'
|
||||||
interface Props {
|
interface Props {
|
||||||
new?: boolean
|
new?: boolean
|
||||||
team?: Party
|
team?: Party
|
||||||
raids: Raid[][]
|
|
||||||
selectedTab: GridType
|
selectedTab: GridType
|
||||||
pushHistory?: (path: string) => void
|
pushHistory?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
@ -39,11 +41,15 @@ const Party = (props: Props) => {
|
||||||
// Set up router
|
// Set up router
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Localization
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
// Set up states
|
// Set up states
|
||||||
const { party } = useSnapshot(appState)
|
const { party } = useSnapshot(appState)
|
||||||
const [editable, setEditable] = useState(false)
|
const [editable, setEditable] = useState(false)
|
||||||
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
|
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
|
||||||
const [refresh, setRefresh] = useState(false)
|
const [refresh, setRefresh] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
|
||||||
// Retrieve cookies
|
// Retrieve cookies
|
||||||
const cookies = retrieveCookies()
|
const cookies = retrieveCookies()
|
||||||
|
|
@ -113,6 +119,23 @@ const Party = (props: Props) => {
|
||||||
.then((response) => storeParty(response.data.party))
|
.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
|
// Methods: Updating the party's details
|
||||||
async function updateDetails(details: DetailsObject) {
|
async function updateDetails(details: DetailsObject) {
|
||||||
if (!props.team) return await createParty(details)
|
if (!props.team) return await createParty(details)
|
||||||
|
|
@ -122,44 +145,97 @@ const Party = (props: Props) => {
|
||||||
function formatDetailsObject(details: DetailsObject) {
|
function formatDetailsObject(details: DetailsObject) {
|
||||||
const payload: { [key: string]: any } = {}
|
const payload: { [key: string]: any } = {}
|
||||||
|
|
||||||
if (details.name) payload.name = details.name
|
const mappings: { [key: string]: string } = {
|
||||||
if (details.description) payload.description = details.description
|
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.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 }
|
if (Object.keys(payload).length >= 1) {
|
||||||
else return {}
|
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkboxChanged(event: React.ChangeEvent<HTMLInputElement>) {
|
function cancelAlert() {
|
||||||
appState.party.extra = event.target.checked
|
setErrorMessage('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkboxChanged(enabled: boolean) {
|
||||||
|
appState.party.extra = enabled
|
||||||
|
|
||||||
// Only save if this is a saved party
|
// Only save if this is a saved party
|
||||||
if (props.team && props.team.id) {
|
if (props.team && props.team.id) {
|
||||||
api.endpoints.parties.update(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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Deleting the party
|
// Deleting the party
|
||||||
function deleteTeam() {
|
function deleteTeam() {
|
||||||
if (props.team && editable) {
|
if (props.team && editable) {
|
||||||
|
|
@ -202,6 +278,7 @@ const Party = (props: Props) => {
|
||||||
appState.party.id = team.id
|
appState.party.id = team.id
|
||||||
appState.party.shortcode = team.shortcode
|
appState.party.shortcode = team.shortcode
|
||||||
appState.party.extra = team.extra
|
appState.party.extra = team.extra
|
||||||
|
appState.party.guidebooks = team.guidebooks
|
||||||
appState.party.user = team.user
|
appState.party.user = team.user
|
||||||
appState.party.favorited = team.favorited
|
appState.party.favorited = team.favorited
|
||||||
appState.party.remix = team.remix
|
appState.party.remix = team.remix
|
||||||
|
|
@ -280,29 +357,35 @@ const Party = (props: Props) => {
|
||||||
|
|
||||||
switch (event.target.value) {
|
switch (event.target.value) {
|
||||||
case 'characters':
|
case 'characters':
|
||||||
router.replace(path)
|
|
||||||
setCurrentTab(GridType.Character)
|
setCurrentTab(GridType.Character)
|
||||||
break
|
break
|
||||||
case 'weapons':
|
case 'weapons':
|
||||||
router.replace(path)
|
|
||||||
setCurrentTab(GridType.Weapon)
|
setCurrentTab(GridType.Weapon)
|
||||||
break
|
break
|
||||||
case 'summons':
|
case 'summons':
|
||||||
router.replace(path)
|
|
||||||
setCurrentTab(GridType.Summon)
|
setCurrentTab(GridType.Summon)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.replace(path, undefined, { shallow: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render: JSX components
|
// Render: JSX components
|
||||||
|
const errorAlert = () => {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
open={errorMessage.length > 0}
|
||||||
|
message={errorMessage}
|
||||||
|
cancelAction={cancelAlert}
|
||||||
|
cancelActionText={t('buttons.confirm')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const navigation = (
|
const navigation = (
|
||||||
<PartySegmentedControl
|
<PartySegmentedControl selectedTab={currentTab} onClick={segmentClicked} />
|
||||||
selectedTab={currentTab}
|
|
||||||
onClick={segmentClicked}
|
|
||||||
onCheckboxChange={checkboxChanged}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const weaponGrid = (
|
const weaponGrid = (
|
||||||
|
|
@ -310,8 +393,11 @@ const Party = (props: Props) => {
|
||||||
new={props.new || false}
|
new={props.new || false}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
weapons={props.team?.weapons}
|
weapons={props.team?.weapons}
|
||||||
|
guidebooks={props.team?.guidebooks}
|
||||||
createParty={createParty}
|
createParty={createParty}
|
||||||
pushHistory={props.pushHistory}
|
pushHistory={props.pushHistory}
|
||||||
|
updateExtra={checkboxChanged}
|
||||||
|
updateGuidebook={updateGuidebook}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -348,14 +434,26 @@ const Party = (props: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
|
{errorAlert()}
|
||||||
|
|
||||||
|
<PartyHeader
|
||||||
|
party={props.team}
|
||||||
|
new={props.new || false}
|
||||||
|
editable={party.editable}
|
||||||
|
deleteCallback={deleteTeam}
|
||||||
|
remixCallback={remixTeam}
|
||||||
|
updateCallback={updateDetails}
|
||||||
|
/>
|
||||||
|
|
||||||
{navigation}
|
{navigation}
|
||||||
|
|
||||||
<section id="Party">{currentGrid()}</section>
|
<section id="Party">{currentGrid()}</section>
|
||||||
|
|
||||||
<PartyDetails
|
<PartyDetails
|
||||||
party={props.team}
|
party={props.team}
|
||||||
new={props.new || false}
|
new={props.new || false}
|
||||||
editable={party.editable}
|
editable={party.editable}
|
||||||
updateCallback={updateDetails}
|
updateCallback={updateDetails}
|
||||||
deleteCallback={deleteTeam}
|
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import React, { useEffect, useState, ChangeEvent, KeyboardEvent } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import Link from 'next/link'
|
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { subscribe, useSnapshot } from 'valtio'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
import { useTranslation } from 'next-i18next'
|
||||||
import clonedeep from 'lodash.clonedeep'
|
import clonedeep from 'lodash.clonedeep'
|
||||||
|
|
||||||
|
|
@ -10,30 +8,13 @@ import LiteYouTubeEmbed from 'react-lite-youtube-embed'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import reactStringReplace from 'react-string-replace'
|
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 GridRepCollection from '~components/GridRepCollection'
|
||||||
import GridRep from '~components/GridRep'
|
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 api from '~utils/api'
|
||||||
import { accountState } from '~utils/accountState'
|
import { appState } from '~utils/appState'
|
||||||
import { appState, initialAppState } from '~utils/appState'
|
|
||||||
import { formatTimeAgo } from '~utils/timeAgo'
|
|
||||||
import { youtube } from '~utils/youtube'
|
import { 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 type { DetailsObject } from 'types'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
@ -44,38 +25,18 @@ interface Props {
|
||||||
new: boolean
|
new: boolean
|
||||||
editable: boolean
|
editable: boolean
|
||||||
updateCallback: (details: DetailsObject) => void
|
updateCallback: (details: DetailsObject) => void
|
||||||
deleteCallback: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PartyDetails = (props: Props) => {
|
const PartyDetails = (props: Props) => {
|
||||||
const { party, raids } = useSnapshot(appState)
|
|
||||||
|
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const locale = router.locale || 'en'
|
|
||||||
|
|
||||||
const youtubeUrlRegex =
|
const youtubeUrlRegex =
|
||||||
/(?:https:\/\/www\.youtube\.com\/watch\?v=|https:\/\/youtu\.be\/)([\w-]+)/g
|
/(?: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 [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 [remixes, setRemixes] = useState<Party[]>([])
|
||||||
|
|
||||||
const [raidSlug, setRaidSlug] = useState('')
|
|
||||||
const [embeddedDescription, setEmbeddedDescription] =
|
const [embeddedDescription, setEmbeddedDescription] =
|
||||||
useState<React.ReactNode>()
|
useState<React.ReactNode>()
|
||||||
|
|
||||||
|
|
@ -85,65 +46,6 @@ const PartyDetails = (props: Props) => {
|
||||||
Visible: !open,
|
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(() => {
|
useEffect(() => {
|
||||||
// Extract the video IDs from the description
|
// Extract the video IDs from the description
|
||||||
if (appState.party.description) {
|
if (appState.party.description) {
|
||||||
|
|
@ -177,161 +79,39 @@ const PartyDetails = (props: Props) => {
|
||||||
}
|
}
|
||||||
}, [appState.party.description])
|
}, [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) {
|
async function fetchYoutubeData(videoId: string) {
|
||||||
return await youtube
|
return await youtube
|
||||||
.getVideoById(videoId, { maxResults: 1 })
|
.getVideoById(videoId, { maxResults: 1 })
|
||||||
.then((data) => data.items[0].snippet.localized.title)
|
.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
|
// Methods: Navigation
|
||||||
function goTo(shortcode?: string) {
|
function goTo(shortcode?: string) {
|
||||||
if (shortcode) router.push(`/p/${shortcode}`)
|
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
|
// Methods: Favorites
|
||||||
function toggleFavorite(teamId: string, favorited: boolean) {
|
function toggleFavorite(teamId: string, favorited: boolean) {
|
||||||
if (favorited) unsaveFavorite(teamId)
|
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() {
|
function renderRemixes() {
|
||||||
return remixes.map((party, i) => {
|
return remixes.map((party, i) => {
|
||||||
return (
|
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 = () => {
|
const readOnly = () => {
|
||||||
return (
|
return (
|
||||||
<section className={readOnlyClasses}>
|
<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>
|
<Linkify>{embeddedDescription}</Linkify>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
@ -764,58 +192,7 @@ const PartyDetails = (props: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="DetailsWrapper">
|
<section className="DetailsWrapper">{readOnly()}</section>
|
||||||
<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>
|
|
||||||
{remixes && remixes.length > 0 ? remixSection() : ''}
|
{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
|
||||||
394
components/party/PartyHeader/index.scss
Normal file
394
components/party/PartyHeader/index.scss
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
.DetailsWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
margin: $unit-4x auto 0 auto;
|
||||||
|
max-width: $grid-width;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
.Button:not(.IconButton) {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.Text {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.PartyDetails {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: none;
|
||||||
|
margin: 0 auto $unit-2x;
|
||||||
|
max-width: $unit * 94;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
padding: 0 $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Visible {
|
||||||
|
// margin-bottom: $unit-12x;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Editable {
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
&.Visible {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: $unit * 22;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.SelectTrigger {
|
||||||
|
padding: $unit-2x;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.DetailToggleGroup {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToggleSection,
|
||||||
|
.InputSection {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: $input-corner;
|
||||||
|
|
||||||
|
& > label {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
font-size: $font-regular;
|
||||||
|
gap: $unit;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
& > span {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToggleSection {
|
||||||
|
padding: ($unit * 1.5) $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.InputSection {
|
||||||
|
padding: $unit-half $unit-2x;
|
||||||
|
padding-right: $unit-half;
|
||||||
|
|
||||||
|
.Input {
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.Input {
|
||||||
|
align-items: center;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
padding: $unit;
|
||||||
|
|
||||||
|
&:has(> input:focus) {
|
||||||
|
border: 2px solid $blue;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: $unit 0;
|
||||||
|
text-align: right;
|
||||||
|
width: 2rem;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
span {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Input {
|
||||||
|
border-radius: 7px;
|
||||||
|
max-width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: $unit-half;
|
||||||
|
justify-content: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
.Button {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ReadOnly {
|
||||||
|
box-sizing: border-box;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
|
||||||
|
&.Visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
406
components/party/PartyHeader/index.tsx
Normal file
406
components/party/PartyHeader/index.tsx
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
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,
|
||||||
|
ReadOnly: 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%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.SegmentedControl {
|
.SegmentedControl {
|
||||||
|
gap: $unit;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
|
|
@ -31,6 +36,7 @@
|
||||||
and (max-height: 920px)
|
and (max-height: 920px)
|
||||||
and (-webkit-min-device-pixel-ratio: 2) {
|
and (-webkit-min-device-pixel-ratio: 2) {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
gap: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto auto auto;
|
grid-template-columns: auto auto auto;
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,20 @@ import { useTranslation } from 'next-i18next'
|
||||||
import { appState } from '~utils/appState'
|
import { appState } from '~utils/appState'
|
||||||
|
|
||||||
import SegmentedControl from '~components/common/SegmentedControl'
|
import SegmentedControl from '~components/common/SegmentedControl'
|
||||||
import Segment from '~components/common/Segment'
|
|
||||||
import ToggleSwitch from '~components/common/ToggleSwitch'
|
|
||||||
|
|
||||||
import { GridType } from '~utils/enums'
|
import { GridType } from '~utils/enums'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
import RepSegment from '~components/reps/RepSegment'
|
||||||
|
import CharacterRep from '~components/reps/CharacterRep'
|
||||||
|
import { accountState } from '~utils/accountState'
|
||||||
|
import WeaponRep from '~components/reps/WeaponRep'
|
||||||
|
import SummonRep from '~components/reps/SummonRep'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectedTab: GridType
|
selectedTab: GridType
|
||||||
onClick: (event: React.ChangeEvent<HTMLInputElement>) => void
|
onClick: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
onCheckboxChange: (event: React.ChangeEvent<HTMLInputElement>) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PartySegmentedControl = (props: Props) => {
|
const PartySegmentedControl = (props: Props) => {
|
||||||
|
|
@ -47,17 +49,56 @@ const PartySegmentedControl = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const extraToggle = (
|
const characterSegment = () => {
|
||||||
<div className="ExtraSwitch">
|
return (
|
||||||
<span className="Text">Extra</span>
|
<RepSegment
|
||||||
<ToggleSwitch
|
controlGroup="grid"
|
||||||
name="ExtraSwitch"
|
inputName="characters"
|
||||||
editable={party.editable}
|
name={t('party.segmented_control.characters')}
|
||||||
checked={party.extra}
|
selected={props.selectedTab == GridType.Character}
|
||||||
onChange={props.onCheckboxChange}
|
onClick={props.onClick}
|
||||||
/>
|
>
|
||||||
</div>
|
<CharacterRep
|
||||||
)
|
job={appState.party?.job}
|
||||||
|
element={appState.party?.element}
|
||||||
|
gender={
|
||||||
|
accountState.account.user ? accountState.account.user.gender : 0
|
||||||
|
}
|
||||||
|
grid={appState.grid.characters}
|
||||||
|
/>
|
||||||
|
</RepSegment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const weaponSegment = () => {
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<RepSegment
|
||||||
|
controlGroup="grid"
|
||||||
|
inputName="weapons"
|
||||||
|
name="Weapons"
|
||||||
|
selected={props.selectedTab == GridType.Weapon}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<WeaponRep grid={appState.grid.weapons} />
|
||||||
|
</RepSegment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summonSegment = () => {
|
||||||
|
return (
|
||||||
|
<RepSegment
|
||||||
|
controlGroup="grid"
|
||||||
|
inputName="summons"
|
||||||
|
name="Summons"
|
||||||
|
selected={props.selectedTab == GridType.Summon}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<SummonRep grid={appState.grid.summons} />
|
||||||
|
</RepSegment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -67,39 +108,10 @@ const PartySegmentedControl = (props: Props) => {
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<SegmentedControl elementClass={getElement()}>
|
<SegmentedControl elementClass={getElement()}>
|
||||||
<Segment
|
{characterSegment()}
|
||||||
groupName="grid"
|
{weaponSegment()}
|
||||||
name="characters"
|
{summonSegment()}
|
||||||
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>
|
|
||||||
</SegmentedControl>
|
</SegmentedControl>
|
||||||
|
|
||||||
{(() => {
|
|
||||||
if (party.editable && props.selectedTab == GridType.Weapon) {
|
|
||||||
return extraToggle
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
</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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
172
components/reps/SummonRep/index.tsx
Normal file
172
components/reps/SummonRep/index.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import 'fix-date'
|
||||||
|
|
||||||
|
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 { t } = useTranslation('common')
|
||||||
|
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 + 1}`} className="Grid Summon">
|
||||||
|
{generateGridImage(i + 1)}
|
||||||
|
</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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
components/reps/WeaponRep/index.tsx
Normal file
106
components/reps/WeaponRep/index.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import 'fix-date'
|
||||||
|
|
||||||
|
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 { t } = useTranslation('common')
|
||||||
|
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 * 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'
|
import './index.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -22,7 +22,7 @@ const SearchFilter = (props: Props) => {
|
||||||
<span className="count">{props.numSelected}</span>
|
<span className="count">{props.numSelected}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="icon">
|
<span className="icon">
|
||||||
<ArrowIcon />
|
<ChevronIcon />
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content className="Dropdown" sideOffset={4}>
|
<DropdownMenu.Content className="Dropdown" sideOffset={4}>
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,11 @@
|
||||||
#Results {
|
#Results {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 ($unit * 1.5);
|
padding: 0 ($unit * 1.5);
|
||||||
|
padding-bottom: $unit * 1.5;
|
||||||
|
|
||||||
|
// Infinite scroll
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 500px;
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
@include breakpoint(phone) {
|
||||||
max-height: inherit;
|
max-height: inherit;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import CharacterResult from '~components/character/CharacterResult'
|
||||||
import WeaponResult from '~components/weapon/WeaponResult'
|
import WeaponResult from '~components/weapon/WeaponResult'
|
||||||
import SummonResult from '~components/summon/SummonResult'
|
import SummonResult from '~components/summon/SummonResult'
|
||||||
import JobSkillResult from '~components/job/JobSkillResult'
|
import JobSkillResult from '~components/job/JobSkillResult'
|
||||||
|
import GuidebookResult from '~components/extra/GuidebookResult'
|
||||||
|
|
||||||
import type { DialogProps } from '@radix-ui/react-dialog'
|
import type { DialogProps } from '@radix-ui/react-dialog'
|
||||||
import type { SearchableObject, SearchableObjectArray } from '~types'
|
import type { SearchableObject, SearchableObjectArray } from '~types'
|
||||||
|
|
@ -31,7 +32,7 @@ interface Props extends DialogProps {
|
||||||
placeholderText: string
|
placeholderText: string
|
||||||
fromPosition: number
|
fromPosition: number
|
||||||
job?: Job
|
job?: Job
|
||||||
object: 'weapons' | 'characters' | 'summons' | 'job_skills'
|
object: 'weapons' | 'characters' | 'summons' | 'job_skills' | 'guidebooks'
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchModal = (props: Props) => {
|
const SearchModal = (props: Props) => {
|
||||||
|
|
@ -184,7 +185,7 @@ const SearchModal = (props: Props) => {
|
||||||
} else if (open && currentPage == 1) {
|
} else if (open && currentPage == 1) {
|
||||||
fetchResults({ replace: true })
|
fetchResults({ replace: true })
|
||||||
}
|
}
|
||||||
}, [currentPage])
|
}, [open, currentPage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Filters changed
|
// Filters changed
|
||||||
|
|
@ -219,6 +220,17 @@ const SearchModal = (props: Props) => {
|
||||||
}
|
}
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && props.object === 'guidebooks') {
|
||||||
|
setCurrentPage(1)
|
||||||
|
fetchResults({ replace: true })
|
||||||
|
}
|
||||||
|
}, [query, open])
|
||||||
|
|
||||||
|
function incrementPage() {
|
||||||
|
setCurrentPage(currentPage + 1)
|
||||||
|
}
|
||||||
|
|
||||||
function renderResults() {
|
function renderResults() {
|
||||||
let jsx
|
let jsx
|
||||||
|
|
||||||
|
|
@ -235,12 +247,15 @@ const SearchModal = (props: Props) => {
|
||||||
case 'job_skills':
|
case 'job_skills':
|
||||||
jsx = renderJobSkillSearchResults(results)
|
jsx = renderJobSkillSearchResults(results)
|
||||||
break
|
break
|
||||||
|
case 'guidebooks':
|
||||||
|
jsx = renderGuidebookSearchResults(results)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
dataLength={results && results.length > 0 ? results.length : 0}
|
dataLength={results && results.length > 0 ? results.length : 0}
|
||||||
next={() => setCurrentPage(currentPage + 1)}
|
next={incrementPage}
|
||||||
hasMore={totalPages > currentPage}
|
hasMore={totalPages > currentPage}
|
||||||
scrollableTarget="Results"
|
scrollableTarget="Results"
|
||||||
loader={<div className="footer">Loading...</div>}
|
loader={<div className="footer">Loading...</div>}
|
||||||
|
|
@ -334,6 +349,27 @@ const SearchModal = (props: Props) => {
|
||||||
return jsx
|
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() {
|
function openChange() {
|
||||||
if (open) {
|
if (open) {
|
||||||
setQuery('')
|
setQuery('')
|
||||||
|
|
@ -365,6 +401,7 @@ const SearchModal = (props: Props) => {
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="Search"
|
className="Search"
|
||||||
headerref={headerRef}
|
headerref={headerRef}
|
||||||
|
scrollable={false}
|
||||||
onEscapeKeyDown={onEscapeKeyDown}
|
onEscapeKeyDown={onEscapeKeyDown}
|
||||||
onOpenAutoFocus={onOpenAutoFocus}
|
onOpenAutoFocus={onOpenAutoFocus}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#SummonGrid {
|
#SummonGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr)) 2fr;
|
grid-template-columns: 1.17fr 2fr 1.17fr;
|
||||||
gap: $unit-3x;
|
gap: $unit-3x;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
|
||||||
|
|
@ -452,8 +452,8 @@ const SummonGrid = (props: Props) => {
|
||||||
<div>
|
<div>
|
||||||
<div id="SummonGrid">
|
<div id="SummonGrid">
|
||||||
{mainSummonElement}
|
{mainSummonElement}
|
||||||
{friendSummonElement}
|
|
||||||
{summonGridElement}
|
{summonGridElement}
|
||||||
|
{friendSummonElement}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{subAuraSummonElement}
|
{subAuraSummonElement}
|
||||||
|
|
|
||||||
|
|
@ -112,4 +112,48 @@
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover .QuickSummon.Empty {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.main .QuickSummon,
|
||||||
|
&.friend .QuickSummon {
|
||||||
|
$diameter: $unit-6x;
|
||||||
|
background-size: $diameter $diameter;
|
||||||
|
top: -2%;
|
||||||
|
right: 28%;
|
||||||
|
width: $diameter;
|
||||||
|
height: $diameter;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import React, { MouseEvent, useEffect, useState } from 'react'
|
import React, { MouseEvent, useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { Trans, useTranslation } from 'next-i18next'
|
import { Trans, useTranslation } from 'next-i18next'
|
||||||
|
import { AxiosResponse } from 'axios'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
import api from '~utils/api'
|
||||||
|
import { appState } from '~utils/appState'
|
||||||
|
|
||||||
import Alert from '~components/common/Alert'
|
import Alert from '~components/common/Alert'
|
||||||
import Button from '~components/common/Button'
|
import Button from '~components/common/Button'
|
||||||
import {
|
import {
|
||||||
|
|
@ -93,6 +97,10 @@ const SummonUnit = ({
|
||||||
setContextMenuOpen(!contextMenuOpen)
|
setContextMenuOpen(!contextMenuOpen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleQuickSummonClick() {
|
||||||
|
if (gridSummon) updateQuickSummon(!gridSummon.quick_summon)
|
||||||
|
}
|
||||||
|
|
||||||
// Methods: Handle open change
|
// Methods: Handle open change
|
||||||
function handleContextMenuOpenChange(open: boolean) {
|
function handleContextMenuOpenChange(open: boolean) {
|
||||||
if (!open) setContextMenuOpen(false)
|
if (!open) setContextMenuOpen(false)
|
||||||
|
|
@ -103,6 +111,38 @@ const SummonUnit = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Methods: Mutate data
|
// Methods: Mutate data
|
||||||
|
|
||||||
|
// Send the GridSummonObject to the server
|
||||||
|
async function updateQuickSummon(value: boolean) {
|
||||||
|
if (gridSummon)
|
||||||
|
return await api
|
||||||
|
.updateQuickSummon({ id: gridSummon.id, value: value })
|
||||||
|
.then((response) => processResult(response))
|
||||||
|
.catch((error) => processError(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the server's response to state
|
||||||
|
function processResult(response: AxiosResponse) {
|
||||||
|
// TODO: We will have to update multiple grid summons at once
|
||||||
|
// because there can only be one at once.
|
||||||
|
// If a user sets a quick summon while one is already set,
|
||||||
|
// the previous one will be unset.
|
||||||
|
const gridSummons: GridSummon[] = response.data.summons
|
||||||
|
for (const gridSummon of gridSummons) {
|
||||||
|
if (gridSummon.main) {
|
||||||
|
appState.grid.summons.mainSummon = gridSummon
|
||||||
|
} else if (gridSummon.friend) {
|
||||||
|
appState.grid.summons.friendSummon = gridSummon
|
||||||
|
} else {
|
||||||
|
appState.grid.summons.allSummons[gridSummon.position] = gridSummon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processError(error: any) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
|
||||||
function passUncapData(uncap: number) {
|
function passUncapData(uncap: number) {
|
||||||
if (gridSummon) updateUncap(gridSummon.id, position, uncap)
|
if (gridSummon) updateUncap(gridSummon.id, position, uncap)
|
||||||
}
|
}
|
||||||
|
|
@ -230,6 +270,17 @@ const SummonUnit = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Methods: Core element rendering
|
// Methods: Core element rendering
|
||||||
|
const quickSummon = () => {
|
||||||
|
if (gridSummon) {
|
||||||
|
const classes = classNames({
|
||||||
|
QuickSummon: true,
|
||||||
|
Empty: !gridSummon.quick_summon,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <i className={classes} onClick={handleQuickSummonClick} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const image = () => {
|
const image = () => {
|
||||||
let image = (
|
let image = (
|
||||||
<img
|
<img
|
||||||
|
|
@ -268,6 +319,7 @@ const SummonUnit = ({
|
||||||
<>
|
<>
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
{contextMenu()}
|
{contextMenu()}
|
||||||
|
{quickSummon()}
|
||||||
{image()}
|
{image()}
|
||||||
{gridSummon ? (
|
{gridSummon ? (
|
||||||
<UncapIndicator
|
<UncapIndicator
|
||||||
|
|
|
||||||
49
components/toasts/RemixedToast/index.tsx
Normal file
49
components/toasts/RemixedToast/index.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import React from 'react'
|
||||||
|
import Toast from '~components/common/Toast'
|
||||||
|
import { Trans, useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
partyName: string
|
||||||
|
open: boolean
|
||||||
|
onActionClick?: () => void
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onCloseClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RemixedToast = ({
|
||||||
|
partyName,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onCloseClick,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
|
// Methods: Event handlers
|
||||||
|
function handleOpenChange() {
|
||||||
|
onOpenChange(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseClick() {
|
||||||
|
onCloseClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
altText={t('toasts.remixed', { title: partyName })}
|
||||||
|
open={open}
|
||||||
|
duration={2400}
|
||||||
|
type="foreground"
|
||||||
|
content={
|
||||||
|
<Trans i18nKey="toasts.remixed">
|
||||||
|
You remixed <strong>{{ title: partyName }}</strong>
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
onCloseClick={handleCloseClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RemixedToast
|
||||||
39
components/toasts/UrlCopiedToast/index.tsx
Normal file
39
components/toasts/UrlCopiedToast/index.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import React from 'react'
|
||||||
|
import Toast from '~components/common/Toast'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onActionClick?: () => void
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onCloseClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const UrlCopiedToast = ({ open, onOpenChange, onCloseClick }: Props) => {
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
|
// Methods: Event handlers
|
||||||
|
function handleOpenChange() {
|
||||||
|
onOpenChange(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseClick() {
|
||||||
|
onCloseClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
altText={t('toasts.copied')}
|
||||||
|
open={open}
|
||||||
|
duration={2400}
|
||||||
|
type="foreground"
|
||||||
|
content={t('toasts.copied')}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
onCloseClick={handleCloseClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UrlCopiedToast
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
.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;
|
|
||||||
margin: 20px auto;
|
|
||||||
max-width: calc($grid-width + 20px);
|
|
||||||
padding: $unit-2x $unit-2x $unit-2x 0;
|
|
||||||
position: relative;
|
|
||||||
left: $unit;
|
|
||||||
|
|
||||||
@include breakpoint(tablet) {
|
|
||||||
left: auto;
|
|
||||||
max-width: auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
display: flex;
|
|
||||||
gap: $unit-2x;
|
|
||||||
padding: $unit-2x;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > span {
|
|
||||||
color: var(--extra-purple-text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
justify-content: center;
|
|
||||||
line-height: 1.2;
|
|
||||||
font-weight: 500;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ExtraWeapons {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import WeaponUnit from '~components/weapon/WeaponUnit'
|
|
||||||
|
|
||||||
import type { SearchableObject } from '~types'
|
|
||||||
|
|
||||||
import './index.scss'
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExtraWeapons = (props: Props) => {
|
|
||||||
const numWeapons: number = 3
|
|
||||||
const { t } = useTranslation('common')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ExtraGrid Weapons">
|
|
||||||
<span>{t('extra_weapons')}</span>
|
|
||||||
<ul id="ExtraWeapons">
|
|
||||||
{Array.from(Array(numWeapons)).map((x, i) => {
|
|
||||||
return (
|
|
||||||
<li key={`grid_unit_${i}`}>
|
|
||||||
<WeaponUnit
|
|
||||||
editable={props.editable}
|
|
||||||
position={props.offset + i}
|
|
||||||
unitType={1}
|
|
||||||
gridWeapon={props.grid[props.offset + i]}
|
|
||||||
removeWeapon={props.removeWeapon}
|
|
||||||
updateObject={props.updateObject}
|
|
||||||
updateUncap={props.updateUncap}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ExtraWeapons
|
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li:not(.Empty) {
|
||||||
list-style: none;
|
// aspect-ratio: 1 / 1.035;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,13 @@ import { useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
import { AxiosError, AxiosResponse } from 'axios'
|
import { AxiosError, AxiosResponse } from 'axios'
|
||||||
import debounce from 'lodash.debounce'
|
import debounce from 'lodash.debounce'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
import Alert from '~components/common/Alert'
|
import Alert from '~components/common/Alert'
|
||||||
import WeaponUnit from '~components/weapon/WeaponUnit'
|
import WeaponUnit from '~components/weapon/WeaponUnit'
|
||||||
import ExtraWeapons from '~components/weapon/ExtraWeapons'
|
import ExtraWeaponsGrid from '~components/extra/ExtraWeaponsGrid'
|
||||||
|
import ExtraContainer from '~components/extra/ExtraContainer'
|
||||||
|
import GuidebooksGrid from '~components/extra/GuidebooksGrid'
|
||||||
import WeaponConflictModal from '~components/weapon/WeaponConflictModal'
|
import WeaponConflictModal from '~components/weapon/WeaponConflictModal'
|
||||||
|
|
||||||
import api from '~utils/api'
|
import api from '~utils/api'
|
||||||
|
|
@ -24,8 +27,11 @@ interface Props {
|
||||||
new: boolean
|
new: boolean
|
||||||
editable: boolean
|
editable: boolean
|
||||||
weapons?: GridWeapon[]
|
weapons?: GridWeapon[]
|
||||||
|
guidebooks?: GuidebookList
|
||||||
createParty: (details: DetailsObject) => Promise<Party>
|
createParty: (details: DetailsObject) => Promise<Party>
|
||||||
pushHistory?: (path: string) => void
|
pushHistory?: (path: string) => void
|
||||||
|
updateExtra: (enabled: boolean) => void
|
||||||
|
updateGuidebook: (book: Guidebook | undefined, position: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const WeaponGrid = (props: Props) => {
|
const WeaponGrid = (props: Props) => {
|
||||||
|
|
@ -115,6 +121,13 @@ const WeaponGrid = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function receiveGuidebookFromSearch(
|
||||||
|
object: SearchableObject,
|
||||||
|
position: number
|
||||||
|
) {
|
||||||
|
props.updateGuidebook(object as Guidebook, position)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleWeaponResponse(data: any) {
|
async function handleWeaponResponse(data: any) {
|
||||||
if (data.hasOwnProperty('conflicts')) {
|
if (data.hasOwnProperty('conflicts')) {
|
||||||
if (data.incoming) setIncoming(data.incoming)
|
if (data.incoming) setIncoming(data.incoming)
|
||||||
|
|
@ -236,6 +249,10 @@ const WeaponGrid = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeGuidebook(position: number) {
|
||||||
|
props.updateGuidebook(undefined, position)
|
||||||
|
}
|
||||||
|
|
||||||
// Methods: Updating uncap level
|
// Methods: Updating uncap level
|
||||||
// Note: Saves, but debouncing is not working properly
|
// Note: Saves, but debouncing is not working properly
|
||||||
async function saveUncap(id: string, position: number, uncapLevel: number) {
|
async function saveUncap(id: string, position: number, uncapLevel: number) {
|
||||||
|
|
@ -318,6 +335,12 @@ const WeaponGrid = (props: Props) => {
|
||||||
setPreviousUncapValues(newPreviousValues)
|
setPreviousUncapValues(newPreviousValues)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Methods: Convenience
|
||||||
|
const displayExtraContainer =
|
||||||
|
props.editable ||
|
||||||
|
appState.party.extra ||
|
||||||
|
Object.values(appState.party.guidebooks).every((el) => el === undefined)
|
||||||
|
|
||||||
// Render: JSX components
|
// Render: JSX components
|
||||||
const mainhandElement = (
|
const mainhandElement = (
|
||||||
<WeaponUnit
|
<WeaponUnit
|
||||||
|
|
@ -333,8 +356,12 @@ const WeaponGrid = (props: Props) => {
|
||||||
)
|
)
|
||||||
|
|
||||||
const weaponGridElement = Array.from(Array(numWeapons)).map((x, i) => {
|
const weaponGridElement = Array.from(Array(numWeapons)).map((x, i) => {
|
||||||
|
const itemClasses = classNames({
|
||||||
|
Empty: appState.grid.weapons.allWeapons[i] === undefined,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={`grid_unit_${i}`}>
|
<li className={itemClasses} key={`grid_unit_${i}`}>
|
||||||
<WeaponUnit
|
<WeaponUnit
|
||||||
gridWeapon={appState.grid.weapons.allWeapons[i]}
|
gridWeapon={appState.grid.weapons.allWeapons[i]}
|
||||||
editable={props.editable}
|
editable={props.editable}
|
||||||
|
|
@ -348,15 +375,27 @@ const WeaponGrid = (props: Props) => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const extraGridElement = (
|
const extraElement = (
|
||||||
<ExtraWeapons
|
<ExtraContainer>
|
||||||
grid={appState.grid.weapons.allWeapons}
|
{appState.party.raid && appState.party.raid.group.extra && (
|
||||||
editable={props.editable}
|
<ExtraWeaponsGrid
|
||||||
offset={numWeapons}
|
grid={appState.grid.weapons.allWeapons}
|
||||||
removeWeapon={removeWeapon}
|
editable={props.editable}
|
||||||
updateObject={receiveWeaponFromSearch}
|
offset={numWeapons}
|
||||||
updateUncap={initiateUncapUpdate}
|
removeWeapon={removeWeapon}
|
||||||
/>
|
updateObject={receiveWeaponFromSearch}
|
||||||
|
updateUncap={initiateUncapUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{appState.party.raid && appState.party.raid.group.guidebooks && (
|
||||||
|
<GuidebooksGrid
|
||||||
|
grid={appState.party.guidebooks}
|
||||||
|
editable={props.editable}
|
||||||
|
removeGuidebook={removeGuidebook}
|
||||||
|
updateObject={receiveGuidebookFromSearch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ExtraContainer>
|
||||||
)
|
)
|
||||||
|
|
||||||
const conflictModal = () => {
|
const conflictModal = () => {
|
||||||
|
|
@ -409,9 +448,7 @@ const WeaponGrid = (props: Props) => {
|
||||||
<ul id="Weapons">{weaponGridElement}</ul>
|
<ul id="Weapons">{weaponGridElement}</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(() => {
|
{displayExtraContainer ? extraElement : ''}
|
||||||
return party.extra ? extraGridElement : ''
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.WeaponName {
|
.WeaponName {
|
||||||
|
font-size: $font-name;
|
||||||
|
line-height: 1.2;
|
||||||
@include breakpoint(phone) {
|
@include breakpoint(phone) {
|
||||||
font-size: $font-tiny;
|
font-size: $font-tiny;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12971
package-lock.json
generated
12971
package-lock.json
generated
File diff suppressed because it is too large
Load diff
25
package.json
25
package.json
|
|
@ -28,6 +28,7 @@
|
||||||
"@svgr/webpack": "^6.2.0",
|
"@svgr/webpack": "^6.2.0",
|
||||||
"axios": "^0.25.0",
|
"axios": "^0.25.0",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
|
"cmdk": "^0.2.0",
|
||||||
"cookies-next": "^2.1.1",
|
"cookies-next": "^2.1.1",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
|
@ -44,8 +45,8 @@
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"next-usequerystate": "^1.7.0",
|
"next-usequerystate": "^1.7.0",
|
||||||
"pluralize": "^8.0.0",
|
"pluralize": "^8.0.0",
|
||||||
"react": "17.0.2",
|
"react": "^18.0.0",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^18.0.0",
|
||||||
"react-i18next": "^11.15.5",
|
"react-i18next": "^11.15.5",
|
||||||
"react-infinite-scroll-component": "^6.1.0",
|
"react-infinite-scroll-component": "^6.1.0",
|
||||||
"react-linkify": "^1.0.0-alpha",
|
"react-linkify": "^1.0.0-alpha",
|
||||||
|
|
@ -61,15 +62,15 @@
|
||||||
"youtube-api-v3-wrapper": "^2.3.0"
|
"youtube-api-v3-wrapper": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@storybook/addon-essentials": "^7.0.2",
|
"@storybook/addon-essentials": "latest",
|
||||||
"@storybook/addon-interactions": "^7.0.2",
|
"@storybook/addon-interactions": "latest",
|
||||||
"@storybook/addon-links": "^7.0.2",
|
"@storybook/addon-links": "latest",
|
||||||
"@storybook/addon-mdx-gfm": "^7.0.2",
|
"@storybook/addon-mdx-gfm": "latest",
|
||||||
"@storybook/addon-styling": "^0.3.2",
|
"@storybook/addon-styling": "latest",
|
||||||
"@storybook/blocks": "^7.0.2",
|
"@storybook/blocks": "latest",
|
||||||
"@storybook/nextjs": "^7.0.2",
|
"@storybook/nextjs": "latest",
|
||||||
"@storybook/react": "^7.0.2",
|
"@storybook/react": "latest",
|
||||||
"@storybook/testing-library": "^0.0.14-next.2",
|
"@storybook/testing-library": "latest",
|
||||||
"@types/lodash.clonedeep": "^4.5.6",
|
"@types/lodash.clonedeep": "^4.5.6",
|
||||||
"@types/lodash.debounce": "^4.0.6",
|
"@types/lodash.debounce": "^4.0.6",
|
||||||
"@types/node": "17.0.11",
|
"@types/node": "17.0.11",
|
||||||
|
|
@ -86,7 +87,7 @@
|
||||||
"eslint-plugin-storybook": "^0.6.11",
|
"eslint-plugin-storybook": "^0.6.11",
|
||||||
"eslint-plugin-valtio": "^0.4.1",
|
"eslint-plugin-valtio": "^0.4.1",
|
||||||
"sass-loader": "^13.2.2",
|
"sass-loader": "^13.2.2",
|
||||||
"storybook": "^7.0.2",
|
"storybook": "latest",
|
||||||
"typescript": "^4.5.5"
|
"typescript": "^4.5.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue