Implement raid combobox (#311)

* Add ellipsis icon

* Reduce size of tokens

* Move UpdateToast to toasts folder

* Update variables.scss

* Add reps for grid objects

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

They only render the grid of objects and nothing else.

Eventually PartyRep will use WeaponRep

* Added RepSegment

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

* Modify PartySegmentedControl to use RepSegments

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

* Extract URL copied and Remixed toasts into files

* Extract delete team alert into a file

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

* Added an alert for when remixing teams

* Began refactoring PartyDetails into several files

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

* Remove duplicated code

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

* Small fixes for weapon grid

* Add placeholder image for guidebooks

* Add localizations

* Add Guidebook type and update other types

* Update gitignore

Don't commit guidebook images

* Indicate if a dialog is scrollable

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

* Add ExtraContainer

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

* Move ExtraWeapons to ExtraWeaponsGrid

And put it in ExtraContainer

* Added GuidebooksGrid and GuidebookUnit

These are the display components for Guidebooks in the WeaponGrid

* Visual adjustments to summon grid

* Add Empty class to weapons when unit is unfilled

* Implement GuidebooksGrid in WeaponGrid

* Remove extra switch

* Remove old dependencies and props

* Implement searching for/adding guidebooks to party

* Update styles

* Fix dependency

* Properly determine when extra container should display

* Change to 1-indexing for guidebooks

* Add support for removing guidebooks

* Display guidebook validation error

* Move read only buttons to PartyHeader

Also broke up tokens and made them easier to render

* Add guidebooks to DetailsObject

* Add raid placeholder string to locale

* Update .gitignore

* Update and reorganize localization files

* Update types

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

* Update dependencies

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

* Rename Arrow.svg to Chevron.svg

Also added a new Arrow.svg with a stem

* Add api call for raidGroups and update pages

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

* Update SegmentedControl component

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

* Update Select component

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

* Remove raid prop from Party

* Add Popover component

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

* Add Command component

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

* Add RaidCombobox and RaidItem components

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

* Updates themes and variables

* Replace RaidDropdown with RaidCombobox

* Add small shadow to Tooltip

* Update side offset for Popover

* Update CharLimitedFieldset class name

* Add clear button to Combobox input

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

* ChatGPT helped me refactor RaidCombobox

* Further refactoring of RaidCombobox

* Deploy content update (#309)

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

* Add Nier and Estarriola uncaps (#308)

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

* Update .gitignore

* Add Nier and Estarriola uncaps

* Fix uncaps treated as new characters

* Make combobox keyboard accessible

* Style updates

* Refactor accessibility code

* Add translation for "Selected" text

* Change selects to be poppers for consistency

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

* Set raid groups on teams page

* Implement in FilterBar

* Fix styles for combobox input

* Remove RaidDropdown component

* Update index.scss

* Remove preview when on mobile sizes

* Fix some mobile styles

* Add farming raid option

* Increase height slightly
This commit is contained in:
Justin Edmund 2023-06-16 19:00:57 -07:00 committed by GitHub
parent d765b00120
commit ddd6a9da96
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 12188 additions and 3327 deletions

1
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

@ -31,7 +31,7 @@ import Button from '~components/common/Button'
import Tooltip from '~components/common/Tooltip'
import * as Switch from '@radix-ui/react-switch'
import ArrowIcon from '~public/icons/Arrow.svg'
import ChevronIcon from '~public/icons/Chevron.svg'
import LinkIcon from '~public/icons/Link.svg'
import MenuIcon from '~public/icons/Menu.svg'
import RemixIcon from '~public/icons/Remix.svg'
@ -411,7 +411,7 @@ const Header = () => {
<Button
className={classNames({ Active: rightMenuOpen })}
leftAccessoryIcon={profileImage()}
rightAccessoryIcon={<ArrowIcon />}
rightAccessoryIcon={<ChevronIcon />}
rightAccessoryClassName="Arrow"
onClick={handleRightMenuButtonClicked}
blended={true}

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
.Button {
align-items: center;
background: var(--button-bg);
border: none;
border: 2px solid transparent;
border-radius: $input-corner;
color: var(--button-text);
display: inline-flex;

View file

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

View file

@ -32,7 +32,7 @@ const CharLimitedFieldset = React.forwardRef<HTMLInputElement, Props>(
return (
<fieldset className="Fieldset">
<div className="Limited">
<div className="Joined">
<input
autoComplete="off"
className="Input"

View file

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

View file

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

View file

@ -0,0 +1,106 @@
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
}
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}
>
{icon}
{value}
{arrow}
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Content
className={classNames({ Popover: true }, props.className)}
sideOffset={6}
ref={forwardedRef}
>
{children}
</PopoverPrimitive.Content>
</PopoverPrimitive.Root>
)
})
Popover.defaultProps = {
disabled: false,
}
export default Popover

View file

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

View file

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

View file

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

View file

@ -21,6 +21,15 @@
border-radius: 100px;
}
&.Blended {
background: var(--input-bound-bg);
border-radius: $full-corner;
.Segment input:checked + label {
background: var(--card-bg);
}
}
&.fire {
.Segment input:checked + label {
background: var(--fire-bg);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,7 +29,6 @@ import './index.scss'
interface Props {
new?: boolean
team?: Party
raids: Raid[][]
selectedTab: GridType
pushHistory?: (path: string) => void
}

View file

@ -9,7 +9,7 @@ import Button from '~components/common/Button'
import CharLimitedFieldset from '~components/common/CharLimitedFieldset'
import DurationInput from '~components/common/DurationInput'
import Input from '~components/common/Input'
import RaidDropdown from '~components/RaidDropdown'
import RaidCombobox from '~components/raids/RaidCombobox'
import Switch from '~components/common/Switch'
import Tooltip from '~components/common/Tooltip'
import Token from '~components/common/Token'
@ -227,8 +227,8 @@ const PartyHeader = (props: Props) => {
setOpen(!open)
}
function receiveRaid(slug?: string) {
if (slug) setRaidSlug(slug)
function receiveRaid(raid?: Raid) {
if (raid) setRaidSlug(raid?.slug)
}
function switchValue(value: boolean) {
@ -260,7 +260,8 @@ const PartyHeader = (props: Props) => {
function updateDetails(event: React.MouseEvent) {
const descriptionValue = descriptionInput.current?.value
const raid = raids.find((raid) => raid.slug === raidSlug)
const allRaids = appState.raidGroups.flatMap((group) => group.raids)
const raid = allRaids.find((raid) => raid.slug === raidSlug)
const details: DetailsObject = {
fullAuto: fullAuto,
@ -498,9 +499,9 @@ const PartyHeader = (props: Props) => {
error={errors.name}
ref={nameInput}
/>
<RaidDropdown
<RaidCombobox
showAllRaidsOption={false}
currentRaid={props.party?.raid ? props.party?.raid.slug : undefined}
currentRaid={props.party?.raid ? props.party?.raid : undefined}
onChange={receiveRaid}
/>
<ul className="SwitchToggleGroup DetailToggleGroup">
@ -650,7 +651,7 @@ const PartyHeader = (props: Props) => {
</div>
<div className="attribution">
{renderUserBlock()}
{party.raid ? linkedRaidBlock(party.raid) : ''}
{appState.party.raid ? linkedRaidBlock(appState.party.raid) : ''}
{party.created_at != '' ? (
<time
className="last-updated"

View file

@ -0,0 +1,197 @@
.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: 50vh;
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 {
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-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;
}
.Group,
.Separator {
color: var(--text-tertiary);
}
.Raid.wind {
color: var(--wind-text);
}
.Raid.fire {
color: var(--fire-text);
}
.Raid.water {
color: var(--water-text);
}
.Raid.earth {
color: var(--earth-text);
}
.Raid.dark {
color: var(--dark-text);
}
.Raid.light {
color: var(--light-text);
}
}
}
.Filters .SelectTrigger.Raid {
& > span {
overflow: hidden;
}
.Raid {
display: block;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
}

View file

@ -0,0 +1,568 @@
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
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,
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) {
console.log('We are here with a raid')
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
console.log('Focusing node')
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' }}
value={renderTriggerContent()}
>
<Command className="Raid Combobox">
<div className="Header">
{renderSearchInput()}
{!query && (
<div className="Controls">
{renderSegmentedControl()}
{renderSortButton()}
</div>
)}
</div>
<div
className={classNames({ Raids: true, Searching: query !== '' })}
ref={listRef}
role="listbox"
tabIndex={6}
onKeyDown={handleListKeyDown}
>
{renderUngroupedRaids()}
{renderRaidSections()}
</div>
</Command>
</Popover>
)
}
RaidCombobox.defaultProps = {
minimal: false,
}
export default RaidCombobox

View file

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

View file

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

View file

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

12971
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,7 @@
"@svgr/webpack": "^6.2.0",
"axios": "^0.25.0",
"classnames": "^2.3.1",
"cmdk": "^0.2.0",
"cookies-next": "^2.1.1",
"date-fns": "^2.29.3",
"fast-deep-equal": "^3.1.3",
@ -44,8 +45,8 @@
"next-themes": "^0.2.1",
"next-usequerystate": "^1.7.0",
"pluralize": "^8.0.0",
"react": "17.0.2",
"react-dom": "^17.0.2",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-i18next": "^11.15.5",
"react-infinite-scroll-component": "^6.1.0",
"react-linkify": "^1.0.0-alpha",
@ -61,15 +62,15 @@
"youtube-api-v3-wrapper": "^2.3.0"
},
"devDependencies": {
"@storybook/addon-essentials": "^7.0.2",
"@storybook/addon-interactions": "^7.0.2",
"@storybook/addon-links": "^7.0.2",
"@storybook/addon-mdx-gfm": "^7.0.2",
"@storybook/addon-styling": "^0.3.2",
"@storybook/blocks": "^7.0.2",
"@storybook/nextjs": "^7.0.2",
"@storybook/react": "^7.0.2",
"@storybook/testing-library": "^0.0.14-next.2",
"@storybook/addon-essentials": "latest",
"@storybook/addon-interactions": "latest",
"@storybook/addon-links": "latest",
"@storybook/addon-mdx-gfm": "latest",
"@storybook/addon-styling": "latest",
"@storybook/blocks": "latest",
"@storybook/nextjs": "latest",
"@storybook/react": "latest",
"@storybook/testing-library": "latest",
"@types/lodash.clonedeep": "^4.5.6",
"@types/lodash.debounce": "^4.0.6",
"@types/node": "17.0.11",
@ -86,7 +87,7 @@
"eslint-plugin-storybook": "^0.6.11",
"eslint-plugin-valtio": "^0.4.1",
"sass-loader": "^13.2.2",
"storybook": "^7.0.2",
"storybook": "latest",
"typescript": "^4.5.5"
}
}

View file

@ -9,7 +9,6 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import api from '~utils/api'
import extractFilters from '~utils/extractFilters'
import fetchLatestVersion from '~utils/fetchLatestVersion'
import organizeRaids from '~utils/organizeRaids'
import { setHeaders } from '~utils/userToken'
import useDidMountEffect from '~utils/useDidMountEffect'
import { appState } from '~utils/appState'
@ -356,12 +355,12 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
try {
// Fetch and organize raids
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
let raidGroups: RaidGroup[] = await api
.raidGroups()
.then((response) => response.data)
// Create filter object
const filters: FilterObject = extractFilters(query, raids)
const filters: FilterObject = extractFilters(query, raidGroups)
const params = {
params: { ...filters, ...advancedFilters },
}
@ -393,8 +392,7 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
const context: PageContextObj = {
user: user,
teams: teams,
raids: raids,
sortedRaids: sortedRaids,
raidGroups: raidGroups,
pagination: pagination,
}

View file

@ -12,7 +12,6 @@ import NewHead from '~components/head/NewHead'
import api from '~utils/api'
import fetchLatestVersion from '~utils/fetchLatestVersion'
import organizeRaids from '~utils/organizeRaids'
import { accountCookie, setHeaders } from '~utils/userToken'
import { appState, initialAppState } from '~utils/appState'
import { groupWeaponKeys } from '~utils/groupWeaponKeys'
@ -69,7 +68,7 @@ const NewRoute: React.FC<Props> = ({
useEffect(() => {
if (context && context.jobs && context.jobSkills) {
appState.raids = context.raids
appState.raids = context.raidGroups
appState.jobs = context.jobs
appState.jobSkills = context.jobSkills
appState.weaponKeys = context.weaponKeys
@ -106,12 +105,7 @@ const NewRoute: React.FC<Props> = ({
return (
<React.Fragment key={router.asPath}>
{pageHead()}
<Party
new={true}
raids={context.sortedRaids}
pushHistory={callback}
selectedTab={selectedTab}
/>
<Party new={true} pushHistory={callback} selectedTab={selectedTab} />
</React.Fragment>
)
} else return pageError()
@ -153,9 +147,7 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
try {
// Fetch and organize raids
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
let raidGroups: RaidGroup[] = await api.raidGroups().then((response) => response.data)
// Fetch jobs and job skills
let jobs = await api.endpoints.jobs
@ -174,8 +166,7 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
const context: PageContextObj = {
jobs: jobs,
jobSkills: jobSkills,
raids: raids,
sortedRaids: sortedRaids,
raidGroups: raidGroups,
weaponKeys: weaponKeys,
}

View file

@ -9,7 +9,6 @@ import PartyHead from '~components/party/PartyHead'
import api from '~utils/api'
import elementEmoji from '~utils/elementEmoji'
import fetchLatestVersion from '~utils/fetchLatestVersion'
import organizeRaids from '~utils/organizeRaids'
import { setHeaders } from '~utils/userToken'
import { appState } from '~utils/appState'
import { groupWeaponKeys } from '~utils/groupWeaponKeys'
@ -57,7 +56,7 @@ const PartyRoute: React.FC<Props> = ({
// Set the initial data from props
useEffect(() => {
if (context && !error) {
appState.raids = context.raids
appState.raidGroups = context.raidGroups
appState.jobs = context.jobs ? context.jobs : []
appState.jobSkills = context.jobSkills ? context.jobSkills : []
appState.weaponKeys = context.weaponKeys
@ -85,11 +84,7 @@ const PartyRoute: React.FC<Props> = ({
return (
<React.Fragment key={router.asPath}>
{pageHead()}
<Party
team={context.party}
raids={context.sortedRaids}
selectedTab={selectedTab}
/>
<Party team={context.party} selectedTab={selectedTab} />
</React.Fragment>
)
} else return pageError()
@ -115,9 +110,9 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
try {
// Fetch and organize raids
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
let raidGroups: RaidGroup[] = await api
.raidGroups()
.then((response) => response.data)
// Fetch jobs and job skills
let jobs = await api.endpoints.jobs
@ -148,8 +143,7 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
party: party,
jobs: jobs,
jobSkills: jobSkills,
raids: raids,
sortedRaids: sortedRaids,
raidGroups: raidGroups,
weaponKeys: weaponKeys,
meta: {
element: elementEmoji(party),

View file

@ -11,7 +11,6 @@ import api from '~utils/api'
import { setHeaders } from '~utils/userToken'
import extractFilters from '~utils/extractFilters'
import fetchLatestVersion from '~utils/fetchLatestVersion'
import organizeRaids from '~utils/organizeRaids'
import useDidMountEffect from '~utils/useDidMountEffect'
import { appState } from '~utils/appState'
import { defaultFilterset } from '~utils/defaultFilters'
@ -390,12 +389,12 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
try {
// Fetch and organize raids
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
let raidGroups: RaidGroup[] = await api
.raidGroups()
.then((response) => response.data)
// Create filter object
const filters: FilterObject = extractFilters(query, raids)
const filters: FilterObject = extractFilters(query, raidGroups)
const params = {
params: { ...filters, ...advancedFilters },
}
@ -416,8 +415,7 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
// Consolidate data into context object
const context: PageContextObj = {
teams: teams,
raids: raids,
sortedRaids: sortedRaids,
raidGroups: raidGroups,
pagination: pagination,
}

View file

@ -11,7 +11,6 @@ import api from '~utils/api'
import { setHeaders } from '~utils/userToken'
import extractFilters from '~utils/extractFilters'
import fetchLatestVersion from '~utils/fetchLatestVersion'
import organizeRaids from '~utils/organizeRaids'
import useDidMountEffect from '~utils/useDidMountEffect'
import { appState } from '~utils/appState'
import { defaultFilterset } from '~utils/defaultFilters'
@ -113,6 +112,7 @@ const TeamsRoute: React.FC<Props> = ({
setTotalPages(context.pagination.totalPages)
setRecordCount(context.pagination.count)
replaceResults(context.pagination.count, context.teams)
appState.raidGroups = context.raidGroups
appState.version = version
}
setCurrentPage(1)
@ -388,12 +388,12 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
try {
// Fetch and organize raids
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data))
let raidGroups: RaidGroup[] = await api
.raidGroups()
.then((response) => response.data)
// Create filter object
const filters: FilterObject = extractFilters(query, raids)
const filters: FilterObject = extractFilters(query, raidGroups)
const params = {
params: { ...filters, ...advancedFilters },
}
@ -414,8 +414,7 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
// Consolidate data into context object
const context: PageContextObj = {
teams: teams,
raids: raids,
sortedRaids: sortedRaids,
raidGroups: raidGroups,
pagination: pagination,
}

View file

@ -1,3 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.93618 4.62991C2.73179 4.44423 2.41557 4.4594 2.22989 4.6638C2.04421 4.8682 2.05938 5.18441 2.26378 5.37009L6.65743 9.36145C6.72962 9.42702 6.81576 9.46755 6.90516 9.48353C7.05808 9.51688 7.2243 9.47812 7.34882 9.3647L11.7346 5.36964C11.9388 5.18368 11.9535 4.86745 11.7676 4.6633C11.5816 4.45916 11.2654 4.44441 11.0612 4.63037L7.00447 8.32569L2.93618 4.62991Z" />
<svg viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.84732 2.05019L7.84732 9.92919L10.8934 6.88308C11.2005 6.57597 11.6985 6.57597 12.0056 6.88308C12.3127 7.19019 12.3127 7.68812 12.0056 7.99523L7.56857 12.4322C7.5648 12.4362 7.56098 12.4401 7.55711 12.4439C7.25 12.751 6.75208 12.751 6.44497 12.4439L6.44485 12.4438L1.99638 7.99534C1.68927 7.68823 1.68927 7.19031 1.99638 6.8832C2.30349 6.57609 2.80142 6.57609 3.10853 6.8832L6.27451 10.0492L6.27451 2.05019C6.27451 1.61587 6.6266 1.26379 7.06092 1.26379C7.49524 1.26379 7.84732 1.61587 7.84732 2.05019Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 508 B

After

Width:  |  Height:  |  Size: 625 B

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

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.93618 4.62991C2.73179 4.44423 2.41557 4.4594 2.22989 4.6638C2.04421 4.8682 2.05938 5.18441 2.26378 5.37009L6.65743 9.36145C6.72962 9.42702 6.81576 9.46755 6.90516 9.48353C7.05808 9.51688 7.2243 9.47812 7.34882 9.3647L11.7346 5.36964C11.9388 5.18368 11.9535 4.86745 11.7676 4.6633C11.5816 4.45916 11.2654 4.44441 11.0612 4.63037L7.00447 8.32569L2.93618 4.62991Z" />
</svg>

After

Width:  |  Height:  |  Size: 508 B

View file

Before

Width:  |  Height:  |  Size: 531 B

After

Width:  |  Height:  |  Size: 531 B

View file

@ -46,6 +46,9 @@
"new": "New",
"wiki": "View more on gbf.wiki"
},
"combobox": {
"selected": "Selected"
},
"context": {
"modify": {
"character": "Modify character",
@ -54,25 +57,6 @@
},
"remove": "Remove from grid"
},
"filters": {
"labels": {
"element": "Element",
"series": "Series",
"proficiency": "Proficiency",
"rarity": "Rarity"
}
},
"header": {
"anonymous": "Anonymous",
"untitled_team": "Untitled team by {{username}}",
"new_team": "New team",
"byline": "{{partyName}} by {{username}}"
},
"rarities": {
"r": "R",
"sr": "SR",
"ssr": "SSR"
},
"elements": {
"null": "Null",
"wind": "Wind",
@ -107,64 +91,47 @@
},
"unauthorized": "You don't have permission to perform that action"
},
"proficiencies": {
"sabre": "Sabre",
"dagger": "Dagger",
"spear": "Spear",
"axe": "Axe",
"staff": "Staff",
"gun": "Gun",
"melee": "Melee",
"bow": "Bow",
"harp": "Harp",
"katana": "Katana"
"filters": {
"labels": {
"element": "Element",
"series": "Series",
"proficiency": "Proficiency",
"rarity": "Rarity"
}
},
"series": {
"seraphic": "Seraphic",
"grand": "Grand",
"opus": "Dark Opus",
"draconic": "Draconic",
"primal": "Primal",
"olden_primal": "Olden Primal",
"beast": "Beast",
"omega": "Omega",
"regalia": "Regalia",
"militis": "Militis",
"xeno": "Xeno",
"astral": "Astral",
"rose": "Rose",
"hollowsky": "Hollowsky",
"ultima": "Ultima",
"bahamut": "Bahamut",
"epic": "Epic",
"ennead": "Ennead",
"cosmic": "Cosmic",
"ancestral": "Ancestral",
"superlative": "Superlative",
"vintage": "Vintage",
"class_champion": "Class Champion",
"sephira": "Sephira",
"new_world": "New World Foundation",
"revenant": "Revenant",
"proving": "Proven",
"disaster": "Revans",
"illustrious": "Illustrious",
"world": "World"
"header": {
"anonymous": "Anonymous",
"untitled_team": "Untitled team by {{username}}",
"new_team": "New team",
"byline": "{{partyName}} by {{username}}"
},
"recency": {
"all_time": "All time",
"last_day": "Last day",
"last_week": "Last week",
"last_month": "Last month",
"last_3_months": "Last 3 months",
"last_6_months": "Last 6 months",
"last_year": "Last year"
"job_skills": {
"all": "All skills",
"buffing": "Buffing",
"debuffing": "Debuffing",
"damaging": "Damaging",
"healing": "Healing",
"emp": "Extended Mastery",
"base": "Base Skills",
"state": {
"selectable": "Select a skill",
"no_skill": "No skill"
}
},
"summons": {
"main": "Main Summon",
"friend": "Friend Summon",
"summons": "Summons",
"subaura": "Sub Aura Summons"
"menu": {
"about": "About",
"changelog": "Changelog",
"guides": "Guides",
"language": "Language",
"login": "Log in",
"roadmap": "Roadmap",
"profile": "Your profile",
"new": "New party",
"saved": "Saved",
"settings": "Settings",
"signup": "Sign up",
"teams": "Teams",
"logout": "Logout"
},
"modals": {
"about": {
@ -341,20 +308,26 @@
}
}
},
"menu": {
"about": "About",
"changelog": "Changelog",
"guides": "Guides",
"language": "Language",
"login": "Log in",
"roadmap": "Roadmap",
"profile": "Your profile",
"new": "New party",
"saved": "Saved",
"settings": "Settings",
"signup": "Sign up",
"teams": "Teams",
"logout": "Logout"
"page": {
"titles": {
"about": "About granblue.team",
"updates": "Updates / granblue.team",
"roadmap": "Roadmap / granblue.team",
"discover": "Discover teams / granblue.team",
"new": "Create a new team / granblue.team",
"profile": "@{{username}}'s Teams / granblue.team",
"team": "{{emoji}} {{teamName}} by {{username}} / granblue.team",
"saved": "Your saved teams / granblue.team"
},
"descriptions": {
"about": "More about granblue.team / Save and discover teams to use in Granblue Fantasy",
"updates": "Latest updates to granblue.team",
"roadmap": "Upcoming planned features for granblue.team",
"discover": "Save and discover teams to use in Granblue Fantasy and search by raid, element or recency",
"new": "Create and theorycraft teams to use in Granblue Fantasy and share with the community",
"profile": "Browse @{{username}}'s Teams and filter by raid, element or recency",
"team": "Browse this team for {{raidName}} by {{username}} and others on granblue.team"
}
},
"party": {
"segmented_control": {
@ -384,6 +357,40 @@
}
}
},
"proficiencies": {
"sabre": "Sabre",
"dagger": "Dagger",
"spear": "Spear",
"axe": "Axe",
"staff": "Staff",
"gun": "Gun",
"melee": "Melee",
"bow": "Bow",
"harp": "Harp",
"katana": "Katana"
},
"raids": {
"placeholder": "Select a raid...",
"sections": {
"raids": "Raids",
"solo": "Solo",
"events": "Events"
}
},
"rarities": {
"r": "R",
"sr": "SR",
"ssr": "SSR"
},
"recency": {
"all_time": "All time",
"last_day": "Last day",
"last_week": "Last week",
"last_month": "Last month",
"last_3_months": "Last 3 months",
"last_6_months": "Last 6 months",
"last_year": "Last year"
},
"saved": {
"title": "Your saved Teams",
"loading": "Loading saved teams...",
@ -403,48 +410,53 @@
"summon": "Search for a summon...",
"character": "Search for a character...",
"job_skill": "Search job skills...",
"guidebook": "Search guidebooks..."
"guidebook": "Search guidebooks...",
"raid": "Search battles..."
}
},
"series": {
"seraphic": "Seraphic",
"grand": "Grand",
"opus": "Dark Opus",
"draconic": "Draconic",
"primal": "Primal",
"olden_primal": "Olden Primal",
"beast": "Beast",
"omega": "Omega",
"regalia": "Regalia",
"militis": "Militis",
"xeno": "Xeno",
"astral": "Astral",
"rose": "Rose",
"hollowsky": "Hollowsky",
"ultima": "Ultima",
"bahamut": "Bahamut",
"epic": "Epic",
"ennead": "Ennead",
"cosmic": "Cosmic",
"ancestral": "Ancestral",
"superlative": "Superlative",
"vintage": "Vintage",
"class_champion": "Class Champion",
"sephira": "Sephira",
"new_world": "New World Foundation",
"revenant": "Revenant",
"proving": "Proven",
"disaster": "Revans",
"illustrious": "Illustrious",
"world": "World"
},
"summons": {
"main": "Main Summon",
"friend": "Friend Summon",
"summons": "Summons",
"subaura": "Sub Aura Summons"
},
"teams": {
"title": "Discover Teams",
"loading": "Loading teams...",
"not_found": "No teams found"
},
"page": {
"titles": {
"about": "About granblue.team",
"updates": "Updates / granblue.team",
"roadmap": "Roadmap / granblue.team",
"discover": "Discover teams / granblue.team",
"new": "Create a new team / granblue.team",
"profile": "@{{username}}'s Teams / granblue.team",
"team": "{{emoji}} {{teamName}} by {{username}} / granblue.team",
"saved": "Your saved teams / granblue.team"
},
"descriptions": {
"about": "More about granblue.team / Save and discover teams to use in Granblue Fantasy",
"updates": "Latest updates to granblue.team",
"roadmap": "Upcoming planned features for granblue.team",
"discover": "Save and discover teams to use in Granblue Fantasy and search by raid, element or recency",
"new": "Create and theorycraft teams to use in Granblue Fantasy and share with the community",
"profile": "Browse @{{username}}'s Teams and filter by raid, element or recency",
"team": "Browse this team for {{raidName}} by {{username}} and others on granblue.team"
}
},
"job_skills": {
"all": "All skills",
"buffing": "Buffing",
"debuffing": "Debuffing",
"damaging": "Damaging",
"healing": "Healing",
"emp": "Extended Mastery",
"base": "Base Skills",
"state": {
"selectable": "Select a skill",
"no_skill": "No skill"
}
},
"toasts": {
"copied": "This party's URL was copied to your clipboard",
"remixed": "You remixed {{title}}",

View file

@ -46,6 +46,9 @@
"new": "作成",
"wiki": "gbf.wikiで詳しく見る"
},
"combobox": {
"selected": "選択済み"
},
"context": {
"modify": {
"character": "キャラクターを変更",
@ -54,40 +57,6 @@
},
"remove": "編成から削除"
},
"filters": {
"labels": {
"element": "属性",
"series": "シリーズ",
"proficiency": "武器種",
"rarity": "レアリティ"
}
},
"errors": {
"internal_server_error": {
"title": "サーバーエラー",
"description": "サーバーから届いたエラーは自動的に復されなかったため、再びリクエストを行なってください"
},
"not_found": {
"title": "見つかりませんでした",
"description": "探しているページは見つかりませんでした",
"button": "新しい編成を作成"
},
"validation": {
"guidebooks": "セフィラ導本を複数個装備することはできません"
},
"unauthorized": "行ったアクションを実行する権限がありません"
},
"header": {
"anonymous": "無名",
"untitled_team": "{{username}}さんからの無題編成",
"new_team": "新編成",
"byline": "{{username}}さんからの{{partyName}}"
},
"rarities": {
"r": "R",
"sr": "SR",
"ssr": "SSR"
},
"elements": {
"null": "無",
"wind": "風",
@ -107,64 +76,62 @@
"light": "光属性"
}
},
"proficiencies": {
"sabre": "剣",
"dagger": "短剣",
"spear": "槍",
"axe": "斧",
"staff": "杖",
"gun": "銃",
"melee": "拳",
"bow": "弓",
"harp": "琴",
"katana": "刀"
"errors": {
"internal_server_error": {
"title": "サーバーエラー",
"description": "サーバーから届いたエラーは自動的に復されなかったため、再びリクエストを行なってください"
},
"not_found": {
"title": "見つかりませんでした",
"description": "探しているページは見つかりませんでした",
"button": "新しい編成を作成"
},
"validation": {
"guidebooks": "セフィラ導本を複数個装備することはできません"
},
"unauthorized": "行ったアクションを実行する権限がありません"
},
"series": {
"seraphic": "セラフィックウェポン",
"grand": "リミテッドシリーズ",
"opus": "終末の神器",
"draconic": "ドラコニックウェポン",
"primal": "プライマルシリーズ",
"olden_primal": "オールド・プライマルシリーズ",
"beast": "四象武器",
"omega": "マグナシリーズ",
"regalia": "レガリアシリーズ",
"militis": "ミーレスシリーズ",
"xeno": "六道武器",
"astral": "アストラルウェポン",
"rose": "ローズシリーズ",
"hollowsky": "虚ろなる神器",
"ultima": "オメガウェポン",
"bahamut": "バハムートウェポン",
"epic": "エピックウェポン",
"ennead": "エニアドシリーズ",
"cosmic": "コスモスシリーズ",
"ancestral": "アンセスタルシリーズ",
"superlative": "スペリオシリーズ",
"vintage": "ヴィンテージシリーズ",
"class_champion": "英雄武器",
"sephira": "セフィラン・オールドウェポン",
"new_world": "新世界の礎",
"revenant": "天星器",
"proving": "ブレイブウェポン",
"disaster": "レヴァンスウェポン",
"illustrious": "ルミナスシリーズ",
"world": "ワールドシリーズ"
"filters": {
"labels": {
"element": "属性",
"series": "シリーズ",
"proficiency": "武器種",
"rarity": "レアリティ"
}
},
"recency": {
"all_time": "全ての期間",
"last_day": "1日",
"last_week": "7日",
"last_month": "1ヶ月",
"last_3_months": "3ヶ月",
"last_6_months": "6ヶ月",
"last_year": "1年"
"header": {
"anonymous": "無名",
"untitled_team": "{{username}}さんからの無題編成",
"new_team": "新編成",
"byline": "{{username}}さんからの{{partyName}}"
},
"summons": {
"main": "メイン",
"friend": "フレンド",
"summons": "召喚石",
"subaura": "サブ加護召喚石"
"job_skills": {
"all": "全てのアビリティ",
"buffing": "強化アビリティ",
"debuffing": "弱体アビリティ",
"damaging": "ダメージアビリティ",
"healing": "回復アビリティ",
"emp": "リミットアビリティ",
"base": "ベースアビリティ",
"state": {
"selectable": "アビリティを選択",
"no_skill": "設定されていません"
}
},
"menu": {
"about": "このサイトについて",
"changelog": "変更ログ",
"guides": "攻略",
"language": "言語",
"login": "ログイン",
"profile": "プロフィール",
"roadmap": "ロードマップ",
"new": "新しい編成",
"saved": "保存した編成",
"settings": "アカウント設定",
"signup": "登録",
"teams": "編成一覧",
"logout": "ログアウト"
},
"modals": {
"about": {
@ -342,20 +309,26 @@
}
}
},
"menu": {
"about": "このサイトについて",
"changelog": "変更ログ",
"guides": "攻略",
"language": "言語",
"login": "ログイン",
"profile": "プロフィール",
"roadmap": "ロードマップ",
"new": "新しい編成",
"saved": "保存した編成",
"settings": "アカウント設定",
"signup": "登録",
"teams": "編成一覧",
"logout": "ログアウト"
"page": {
"titles": {
"about": "granblue.teamについて",
"updates": "変更ログ / granblue.team",
"roadmap": "ロードマップ / granblue.team",
"discover": "編成を見出す / granblue.team",
"new": "新しい編成 / granblue.team",
"profile": "@{{username}}さんの作った編成 / granblue.team",
"team": "{{emoji}} {{teamName}}、{{username}}さんから / granblue.team",
"saved": "保存した編成"
},
"descriptions": {
"about": "granblue.teamについて / グランブルーファンタジーの編成を探したり保存したりできる",
"updates": "granblue.teamの最新変更について",
"roadmap": "granblue.teamの開発予定機能",
"discover": "グランブルーファンタジーの編成をマルチ、属性、作った時間などで探したり保存したりできる",
"new": "グランブルーファンタジーの編成を作成し、騎空士とシェアできるサイトgranblue.team",
"profile": "@{{username}}の編成を調査し、マルチ、属性、または作った時間でフィルターする",
"team": "granblue.teamで{{username}}さんが作った{{raidName}}の編成を調査できる"
}
},
"party": {
"segmented_control": {
@ -385,6 +358,40 @@
}
}
},
"proficiencies": {
"sabre": "剣",
"dagger": "短剣",
"spear": "槍",
"axe": "斧",
"staff": "杖",
"gun": "銃",
"melee": "拳",
"bow": "弓",
"harp": "琴",
"katana": "刀"
},
"raids": {
"placeholder": "バトルを選択...",
"sections": {
"raids": "マルチ",
"solo": "ソロ",
"events": "イベント"
}
},
"rarities": {
"r": "R",
"sr": "SR",
"ssr": "SSR"
},
"recency": {
"all_time": "全ての期間",
"last_day": "1日",
"last_week": "7日",
"last_month": "1ヶ月",
"last_3_months": "3ヶ月",
"last_6_months": "6ヶ月",
"last_year": "1年"
},
"saved": {
"title": "保存した編成",
"loading": "ロード中...",
@ -404,48 +411,53 @@
"summon": "召喚石を検索...",
"character": "キャラを検索...",
"job_skill": "ジョブのスキルを検索...",
"guidebook": "導本を検索..."
"guidebook": "導本を検索...",
"raid": "バトルを検索..."
}
},
"series": {
"seraphic": "セラフィックウェポン",
"grand": "リミテッドシリーズ",
"opus": "終末の神器",
"draconic": "ドラコニックウェポン",
"primal": "プライマルシリーズ",
"olden_primal": "オールド・プライマルシリーズ",
"beast": "四象武器",
"omega": "マグナシリーズ",
"regalia": "レガリアシリーズ",
"militis": "ミーレスシリーズ",
"xeno": "六道武器",
"astral": "アストラルウェポン",
"rose": "ローズシリーズ",
"hollowsky": "虚ろなる神器",
"ultima": "オメガウェポン",
"bahamut": "バハムートウェポン",
"epic": "エピックウェポン",
"ennead": "エニアドシリーズ",
"cosmic": "コスモスシリーズ",
"ancestral": "アンセスタルシリーズ",
"superlative": "スペリオシリーズ",
"vintage": "ヴィンテージシリーズ",
"class_champion": "英雄武器",
"sephira": "セフィラン・オールドウェポン",
"new_world": "新世界の礎",
"revenant": "天星器",
"proving": "ブレイブウェポン",
"disaster": "レヴァンスウェポン",
"illustrious": "ルミナスシリーズ",
"world": "ワールドシリーズ"
},
"summons": {
"main": "メイン",
"friend": "フレンド",
"summons": "召喚石",
"subaura": "サブ加護召喚石"
},
"teams": {
"title": "編成一覧",
"loading": "ロード中...",
"not_found": "編成は見つかりませんでした"
},
"page": {
"titles": {
"about": "granblue.teamについて",
"updates": "変更ログ / granblue.team",
"roadmap": "ロードマップ / granblue.team",
"discover": "編成を見出す / granblue.team",
"new": "新しい編成 / granblue.team",
"profile": "@{{username}}さんの作った編成 / granblue.team",
"team": "{{emoji}} {{teamName}}、{{username}}さんから / granblue.team",
"saved": "保存した編成"
},
"descriptions": {
"about": "granblue.teamについて / グランブルーファンタジーの編成を探したり保存したりできる",
"updates": "granblue.teamの最新変更について",
"roadmap": "granblue.teamの開発予定機能",
"discover": "グランブルーファンタジーの編成をマルチ、属性、作った時間などで探したり保存したりできる",
"new": "グランブルーファンタジーの編成を作成し、騎空士とシェアできるサイトgranblue.team",
"profile": "@{{username}}の編成を調査し、マルチ、属性、または作った時間でフィルターする",
"team": "granblue.teamで{{username}}さんが作った{{raidName}}の編成を調査できる"
}
},
"job_skills": {
"all": "全てのアビリティ",
"buffing": "強化アビリティ",
"debuffing": "弱体アビリティ",
"damaging": "ダメージアビリティ",
"healing": "回復アビリティ",
"emp": "リミットアビリティ",
"base": "ベースアビリティ",
"state": {
"selectable": "アビリティを選択",
"no_skill": "設定されていません"
}
},
"toasts": {
"copied": "この編成のURLはクリップボードにコピーされました",
"remixed": "{{title}}をリミックスしました",

View file

@ -66,11 +66,17 @@ a {
button,
input,
textarea {
border: 2px solid transparent;
font-family: system-ui, -apple-system, 'Helvetica Neue', Helvetica, Arial,
sans-serif;
font-size: $font-regular;
}
button:focus-visible {
border: 2px solid $blue;
outline: none;
}
h1,
h2,
h3,
@ -300,3 +306,50 @@ i.tag {
grid-template-columns: 1fr 1fr;
}
}
.Joined {
$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);
&.Bound {
background-color: var(--input-bound-bg);
&:hover {
background-color: var(--input-bound-bg-hover);
}
}
&:focus-within {
border: $offset solid $blue;
// box-shadow: 0 2px rgba(255, 255, 255, 1);
}
.Counter {
color: $grey-55;
font-weight: $bold;
line-height: 42px;
}
.Input {
background: transparent;
border: none;
border-radius: 0;
padding: $unit * 1.5 $unit-2x;
padding-left: calc($unit-2x - $offset);
&:focus {
border: none;
outline: none;
}
}
}

View file

@ -108,6 +108,12 @@
--subaura-orange-secondary: #{$subaura--orange--secondary--light};
--subaura-orange-text: #{$subaura--orange--text--light};
// Light - Pills
--pill-bg: #{$pill--bg--light};
--pill-bg-hover: #{$pill--bg--light--hover};
--pill-text: #{$pill--text--light};
--pill-text-hover: #{$pill--text--light--hover};
// Light - Element Toggle
--toggle-bg: #{$toggle--bg--light};
--toggle-stroke: #{$toggle--stroke--light};
@ -259,6 +265,12 @@
--subaura-orange-secondary: #{$subaura--orange--secondary--dark};
--subaura-orange-text: #{$subaura--orange--text--dark};
// Dark - Pills
--pill-bg: #{$pill--bg--dark};
--pill-bg-hover: #{$pill--bg--dark--hover};
--pill-text: #{$pill--text--dark};
--pill-text-hover: #{$pill--text--dark--hover};
// Dark - Element Toggle
--toggle-bg: #{$toggle--bg--dark};
--toggle-stroke: #{$toggle--stroke--dark};
@ -268,32 +280,32 @@
// Element theming
--wind-bg: #{$wind-bg-10};
--wind-hover-bg: #{$wind-bg-00};
--wind-text: #{$wind-text-10};
--wind-text: #{$wind-text-20};
--wind-hover-text: #{$wind-text-00};
--fire-bg: #{$fire-bg-10};
--fire-hover-bg: #{$fire-bg-00};
--fire-text: #{$fire-text-10};
--fire-text: #{$fire-text-20};
--fire-hover-text: #{$fire-text-00};
--water-bg: #{$water-bg-10};
--water-hover-bg: #{$water-bg-00};
--water-text: #{$water-text-10};
--water-text: #{$water-text-20};
--water-hover-text: #{$water-text-00};
--earth-bg: #{$earth-bg-10};
--earth-hover-bg: #{$earth-bg-00};
--earth-text: #{$earth-text-10};
--earth-text: #{$earth-text-20};
--earth-hover-text: #{$earth-text-00};
--dark-bg: #{$dark-bg-10};
--dark-hover-bg: #{$dark-bg-00};
--dark-text: #{$dark-text-10};
--dark-text: #{$dark-text-20};
--dark-hover-text: #{$dark-text-00};
--light-bg: #{$light-bg-10};
--light-hover-bg: #{$light-bg-00};
--light-text: #{$light-text-10};
--light-text: #{$light-text-20};
--light-hover-text: #{$light-text-00};
// Gradients

View file

@ -56,6 +56,7 @@ $grey-100: white;
// Purple -- Additional Weapons
$purple-00: #25224e;
$purple-05: #373278;
$purple-10: #4f3c79;
$purple-20: #635fb7;
$purple-30: #8c86ff;
@ -256,9 +257,10 @@ $extra--purple--card--bg--light: $purple-80;
$extra--purple--primary--light: $purple-30;
$extra--purple--secondary--light: $purple-40;
$extra--purple--text--light: $purple-10;
$extra--purple--bg--dark: $purple-20;
$extra--purple--card--bg--dark: $purple-40;
$extra--purple--primary--dark: $purple-00;
$extra--purple--primary--dark: $purple-05;
$extra--purple--secondary--dark: $purple-10;
$extra--purple--text--dark: $purple-00;
@ -283,6 +285,17 @@ $full--auto--bg: $yellow-text-20;
$auto--guard--bg: $purple-30;
$auto--guard--text: $purple-10;
// Color Definitions: Pills
$pill--bg--light: $grey-90;
$pill--bg--light--hover: $grey-50;
$pill--text--light: $grey-30;
$pill--text--light--hover: $grey-100;
$pill--bg--dark: $grey-00;
$pill--bg--dark--hover: $grey-50;
$pill--text--dark: $grey-100;
$pill--text--dark--hover: $grey-00;
// Color Definitions: Element Toggle
$toggle--bg--light: $grey-90;
$toggle--bg--dark: $grey-15;
@ -342,9 +355,10 @@ $scale-wide: scale(1.05, 1.05);
$scale-tall: scale(1.012, 1.012);
// Border radius
$full-corner: 500px;
$card-corner: $unit * 1.5;
$input-corner: $unit;
$item-corner: $unit-half;
$item-corner: $unit;
// Shadows
$hover-stroke: 1px solid rgba(0, 0, 0, 0.1);

2
types/Raid.d.ts vendored
View file

@ -1,5 +1,6 @@
interface Raid {
id: string
group: RaidGroup
name: {
[key: string]: string
en: string
@ -7,6 +8,5 @@ interface Raid {
}
slug: string
level: number
group: number
element: number
}

14
types/RaidGroup.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
interface RaidGroup {
id: string
name: {
[key: string]: string
en: string
ja: string
}
raids: Raid[]
difficulty: number
section: number
order: number
extra: boolean
hl: boolean
}

3
types/index.d.ts vendored
View file

@ -83,8 +83,7 @@ interface PageContextObj {
party?: Party
jobs?: Job[]
jobSkills?: JobSkill[]
raids: Raid[]
sortedRaids: Raid[][]
raidGroups: RaidGroup[]
weaponKeys?: GroupedWeaponKeys
pagination?: PaginationObject
meta?: { [key: string]: string }

View file

@ -120,6 +120,11 @@ class Api {
return axios.get(resourceUrl, params)
}
raidGroups(params?: {}) {
const resourceUrl = `${this.url}/raids/groups`
return axios.get(resourceUrl, params)
}
remix({ shortcode, body, params}: { shortcode: string, body?: {}, params?: {} }) {
const resourceUrl = `${this.url}/parties/${shortcode}/remix`
return axios.post(resourceUrl, body, params)

View file

@ -83,7 +83,7 @@ interface AppState {
summons: Summon[]
}
}
raids: Raid[]
raidGroups: RaidGroup[]
jobs: Job[]
jobSkills: JobSkill[]
weaponKeys: GroupedWeaponKeys
@ -149,7 +149,7 @@ export const initialAppState: AppState = {
summons: [],
},
},
raids: [],
raidGroups: [],
jobs: [],
jobSkills: [],
weaponKeys: {

View file

@ -1,6 +1,9 @@
import { elements, allElement } from '~data/elements'
export default (query: { [index: string]: string }, raids: Raid[]) => {
export default (
query: { [index: string]: string },
raidGroups: RaidGroup[]
) => {
// Extract recency filter
const recencyParam: number = parseInt(query.recency)
@ -14,8 +17,9 @@ export default (query: { [index: string]: string }, raids: Raid[]) => {
)
// Extract raid filter
const allRaids = raidGroups.flatMap((group) => group.raids)
const raidParam: string = query.raid
const raid: Raid | undefined = raids.find((r) => r.slug === raidParam)
const raid: Raid | undefined = allRaids.find((r) => r.slug === raidParam)
// Return filter object
return {

View file

@ -1,16 +0,0 @@
export default (raids: Raid[]) => {
const numGroups = Math.max.apply(
Math,
raids.map((raid) => raid.group)
)
let groupedRaids = []
for (let i = 0; i <= numGroups; i++) {
groupedRaids[i] = raids.filter((raid) => raid.group == i)
}
return {
raids: raids,
sortedRaids: groupedRaids,
}
}