From b8ae43ddaf5cbbb4748695dc44efd5bb6d55a948 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Mon, 19 Jun 2023 03:54:03 -0700 Subject: [PATCH] June 2023 Update (#316) * Update the updates page with new items (#306) * Add Nier and Estarriola uncaps (#308) * Update the updates page with new items (#306) (#307) * Update .gitignore * Add Nier and Estarriola uncaps * Fix uncaps treated as new characters * Redesigned team navigation (#310) * Add ellipsis icon * Reduce size of tokens * Move UpdateToast to toasts folder * Update variables.scss * Add reps for grid objects These reps act like the existing PartyRep except for Characters and Summons, as well as a new component just for Weapons. They only render the grid of objects and nothing else. Eventually PartyRep will use WeaponRep * Added RepSegment This is a Character, Weapon or Summon rep wrapped with an input and label for use in a SegmentedControl * Modify PartySegmentedControl to use RepSegments This will not work on mobile yet, where it should gracefully degrade to a normal SegmentedControl with only text * Extract URL copied and Remixed toasts into files * Extract delete team alert into a file Also, to support this: * Added `Destructive` class to Button * Added `primaryActionClassName` prop to Alert * Added an alert for when remixing teams * Began refactoring PartyDetails into several files * PartyHeader will live at the top, above the new segmented control * PartyDetails stays below, only showing remixed teams and the description * PartyDropdown handles the new ... menu * Remove duplicated code This is description and remix code that is still in `PartyDetails` * Small fixes for weapon grid * Add placeholder image for guidebooks * Add localizations * Add Guidebook type and update other types * Update gitignore Don't commit guidebook images * Indicate if a dialog is scrollable We had broken paging in the infinite scroll component. Turning off "scrolling" at the dialog levels fixes it without adding scrollbars in environments that persistently show them * Add ExtraContainer This is the purple container that will contain additional weapons and sephira guidebooks * Move ExtraWeapons to ExtraWeaponsGrid And put it in ExtraContainer * Added GuidebooksGrid and GuidebookUnit These are the display components for Guidebooks in the WeaponGrid * Visual adjustments to summon grid * Add Empty class to weapons when unit is unfilled * Implement GuidebooksGrid in WeaponGrid * Remove extra switch * Remove old dependencies and props * Implement searching for/adding guidebooks to party * Update styles * Fix dependency * Properly determine when extra container should display * Change to 1-indexing for guidebooks * Add support for removing guidebooks * Display guidebook validation error * Move read only buttons to PartyHeader Also broke up tokens and made them easier to render * Add guidebooks to DetailsObject * Remove preview when on mobile sizes * Implement raid combobox (#311) * Add ellipsis icon * Reduce size of tokens * Move UpdateToast to toasts folder * Update variables.scss * Add reps for grid objects These reps act like the existing PartyRep except for Characters and Summons, as well as a new component just for Weapons. They only render the grid of objects and nothing else. Eventually PartyRep will use WeaponRep * Added RepSegment This is a Character, Weapon or Summon rep wrapped with an input and label for use in a SegmentedControl * Modify PartySegmentedControl to use RepSegments This will not work on mobile yet, where it should gracefully degrade to a normal SegmentedControl with only text * Extract URL copied and Remixed toasts into files * Extract delete team alert into a file Also, to support this: * Added `Destructive` class to Button * Added `primaryActionClassName` prop to Alert * Added an alert for when remixing teams * Began refactoring PartyDetails into several files * PartyHeader will live at the top, above the new segmented control * PartyDetails stays below, only showing remixed teams and the description * PartyDropdown handles the new ... menu * Remove duplicated code This is description and remix code that is still in `PartyDetails` * Small fixes for weapon grid * Add placeholder image for guidebooks * Add localizations * Add Guidebook type and update other types * Update gitignore Don't commit guidebook images * Indicate if a dialog is scrollable We had broken paging in the infinite scroll component. Turning off "scrolling" at the dialog levels fixes it without adding scrollbars in environments that persistently show them * Add ExtraContainer This is the purple container that will contain additional weapons and sephira guidebooks * Move ExtraWeapons to ExtraWeaponsGrid And put it in ExtraContainer * Added GuidebooksGrid and GuidebookUnit These are the display components for Guidebooks in the WeaponGrid * Visual adjustments to summon grid * Add Empty class to weapons when unit is unfilled * Implement GuidebooksGrid in WeaponGrid * Remove extra switch * Remove old dependencies and props * Implement searching for/adding guidebooks to party * Update styles * Fix dependency * Properly determine when extra container should display * Change to 1-indexing for guidebooks * Add support for removing guidebooks * Display guidebook validation error * Move read only buttons to PartyHeader Also broke up tokens and made them easier to render * Add guidebooks to DetailsObject * Add raid placeholder string to locale * Update .gitignore * Update and reorganize localization files * Update types Added RaidGroup and updated Raid, then updated dependent types and objects * Update dependencies * Update react and react-dom to at least 18.0.0 * Install cmdk * Rename Arrow.svg to Chevron.svg Also added a new Arrow.svg with a stem * Add api call for raidGroups and update pages Pages fetch raids and store them in the app state. We needed to update this to pull raid groups instead * Update SegmentedControl component * Add className and blended properties * Segment gets flex-grow * Update Select component * data-placeholder style should match only if true * Adjust corner radius to match cards instead of inputs * Fix classNames call in SelectItem * Remove raid prop from Party * Add Popover component * Popover is a wrapper of Radix's Popover component that we will use to wrap the combobox. * Move styles that were in PopoverContent.scss to Popover.scss * Add Command component The Command component is a wrapper over CMDK's Command component. Pretty much every object in that library is wrapped here. We will use this for the guts of our combobox. * Add RaidCombobox and RaidItem components * RaidCombobox combines Popover and Command to create an experience where users can browse through raids by section, search them and sort them. * RaidItem is effectively a copy-paste of SelectItem using CommandItem, adding some raid-specific styles and elements * Updates themes and variables * Replace RaidDropdown with RaidCombobox * Add small shadow to Tooltip * Update side offset for Popover * Update CharLimitedFieldset class name * Add clear button to Combobox input * It only shows up when there is text in the input * Clicking it clears the text in the input * It uses CharLimitedFieldset's classes * ChatGPT helped me refactor RaidCombobox * Further refactoring of RaidCombobox * Deploy content update (#309) * Update the updates page with new items (#306) * Add Nier and Estarriola uncaps (#308) * Update the updates page with new items (#306) (#307) * Update .gitignore * Add Nier and Estarriola uncaps * Fix uncaps treated as new characters * Make combobox keyboard accessible * Style updates * Refactor accessibility code * Add translation for "Selected" text * Change selects to be poppers for consistency We can't make the new Raid combobox appear over the input like the macOS behavior, so we change all selects to be normal popper behavior * Set raid groups on teams page * Implement in FilterBar * Fix styles for combobox input * Remove RaidDropdown component * Update index.scss * Remove preview when on mobile sizes * Fix some mobile styles * Add farming raid option * Increase height slightly * Small refactor * Implement Edit team modal (#312) * Small refactor to CharLimitedFieldset Some methods were renamed for clarity. props are actually put on the input properly. * Add tabindex to Popover trigger * Add tabindex to Switch and SwitchTableField * Add tabindex to DurationInput * Add new properties * Added guidebooks to RaidGroup * Added auto_summon to Party * Conditionally render description in TableField * Improve SwitchTableField * Add support for passing in classes * Add support for passing a disabled prop * Pass description to TableField * Right-align switch * Add support for Extra color switch * Align SliderTableField input to right * Align SelectTableField input to right * Update placeholder styles * Fix empty state on DurationInput * Remove tabindex from DurationInput * Update InputTableField Allow for passing down input properties and remove fixed width * Fix dialog footer styles * Update dialog and overlay z-index * Add styles to TableField Added styles for numeric inputs, disabled inputs, and generally cleaning things up * Add guidebooks to RaidCombobox + styles * Added guidebooks to the dummy raid group * Fix background color * Make less tall * Implement EditPartyModal EditPartyModal takes functionality that was in PartyHeader and puts it in a modal dialog. This lets us add fields and reduces the complexity of other components. Translations were also added. * Remove edit functionality * Add darker shadow to Select * Properly send raid ID to server * Show Extra grids based on selected raid * Fix EX badge colors * Use child as value in normal textarea * Remove toggle ability from Extra grids * Remove edit functionality from PartyDetails * Fix type error * Add quick summons (#313) * Delete yarn.lock * Add quick summon endpoint * Add quick summon to GridSummon type * Add icons * Add quick summon to SummonUnit * Quick summon icon is displayed on hover * Updates the server when clicked * Fix spacing on WeaponGrid * Fixes for reactivity and performance (#314) * Remove editable styles * Use snapshot for segment reps Using snapshots lets that data be reactive. Also removed extra dependencies and fixed a bug in how SummonRep displayed sub-summons * Don't display QuickSummon on friends, subaura * Hotfix refreshing when switching tabs * Another hotfix for tab switching * Update awakening (#315) * Add Awakening type and remove old defs We remove the flat list of awakening data, as we will be pulling data from the database * Update types to use new Awakening type * Update WeaponUnit for Grand weapon awakenings * Update object modals We needed to update CharacterModal and WeaponModal to display awakenings from the new data format. However, the component used (`SelectWithInput`) was tied to AX Skills in a way that would take exponentially more time to resolve. Instead, we forked `SelectWithInput` into `AwakeningSelectWithInput` and did our work there. `AwakeningSelect` was found to be redundant, so it was removed. * Update hovercards * Add order to NO_AWAKENING * Add ability to remove job skills (#317) * Add Awakening type and remove old defs We remove the flat list of awakening data, as we will be pulling data from the database * Update types to use new Awakening type * Update WeaponUnit for Grand weapon awakenings * Update object modals We needed to update CharacterModal and WeaponModal to display awakenings from the new data format. However, the component used (`SelectWithInput`) was tied to AX Skills in a way that would take exponentially more time to resolve. Instead, we forked `SelectWithInput` into `AwakeningSelectWithInput` and did our work there. `AwakeningSelect` was found to be redundant, so it was removed. * Update hovercards * Add max-height to Select * Allow styling of Select modal with className prop * Add Job class to Job select * Add localizations for removing job skills * Add endpoint for removing job skills * Implement removing job skills We added a (...) button next to each editable job skill that opens a context menu that will allow the user to remove the job skill. An alert is presented to make sure the user is sure before proceeding. As part of this change, some minor restyling of JobSkillItem was necessary --- .gitignore | 1 + components/FilterBar/index.scss | 2 +- components/FilterBar/index.tsx | 71 +- components/FilterModal/index.tsx | 15 +- components/GridRepCollection/index.scss | 1 + components/Header/index.tsx | 46 +- components/Layout/index.tsx | 2 +- components/RaidDropdown/index.tsx | 144 - components/RaidSelect/index.scss | 22 + components/RaidSelect/index.tsx | 170 + components/auth/AccountModal/index.tsx | 11 +- components/character/CharacterGrid/index.tsx | 18 + .../character/CharacterHovercard/index.tsx | 27 +- components/character/CharacterModal/index.tsx | 51 +- components/common/Alert/index.scss | 1 + components/common/Alert/index.tsx | 7 +- components/common/Button/index.scss | 11 +- .../common/CharLimitedFieldset/index.scss | 38 - .../common/CharLimitedFieldset/index.tsx | 103 +- .../Command}/index.scss | 0 components/common/Command/index.tsx | 128 + components/common/DialogContent/index.scss | 14 +- components/common/DialogContent/index.tsx | 15 +- .../common/DropdownMenuContent/index.scss | 13 +- components/common/DurationInput/index.tsx | 7 +- components/common/Input/index.scss | 7 +- components/common/InputTableField/index.scss | 3 +- components/common/InputTableField/index.tsx | 50 +- components/common/Overlay/index.scss | 2 +- components/common/Popover/index.scss | 30 + components/common/Popover/index.tsx | 108 + components/common/PopoverContent/index.scss | 17 - components/common/Segment/index.scss | 1 + components/common/Segment/index.tsx | 17 +- components/common/SegmentedControl/index.scss | 21 +- components/common/SegmentedControl/index.tsx | 31 +- components/common/Select/index.scss | 14 +- components/common/Select/index.tsx | 14 +- components/common/SelectItem/index.scss | 3 +- components/common/SelectItem/index.tsx | 2 +- components/common/SelectTableField/index.scss | 3 + components/common/SelectTableField/index.tsx | 1 + components/common/SliderTableField/index.scss | 4 + components/common/Switch/index.tsx | 1 + components/common/SwitchTableField/index.scss | 9 + components/common/SwitchTableField/index.tsx | 18 +- components/common/TableField/index.scss | 26 +- components/common/TableField/index.tsx | 2 +- components/common/Token/index.scss | 6 +- components/common/Tooltip/index.scss | 1 + components/dialogs/DeleteTeamAlert/index.tsx | 35 + components/dialogs/RemixTeamAlert/index.tsx | 57 + components/extra/ExtraContainer/index.scss | 50 + components/extra/ExtraContainer/index.tsx | 11 + components/extra/ExtraWeaponsGrid/index.scss | 47 + components/extra/ExtraWeaponsGrid/index.tsx | 74 + components/extra/GuidebookResult/index.scss | 37 + components/extra/GuidebookResult/index.tsx | 32 + components/extra/GuidebookUnit/index.scss | 109 + components/extra/GuidebookUnit/index.tsx | 201 + components/extra/GuidebooksGrid/index.scss | 45 + components/extra/GuidebooksGrid/index.tsx | 69 + components/job/JobDropdown/index.tsx | 2 + components/job/JobSection/index.scss | 5 +- components/job/JobSection/index.tsx | 16 +- components/job/JobSkillItem/index.scss | 94 +- components/job/JobSkillItem/index.tsx | 127 +- components/mastery/AwakeningSelect/index.tsx | 97 - .../index.scss | 46 +- .../AwakeningSelectWithInput/index.tsx | 215 + components/party/EditPartyModal/index.scss | 56 + components/party/EditPartyModal/index.tsx | 477 + components/party/Party/index.scss | 8 + components/party/Party/index.tsx | 174 +- components/party/PartyDetails/index.tsx | 673 +- components/party/PartyDropdown/index.scss | 0 components/party/PartyDropdown/index.tsx | 197 + components/party/PartyHeader/index.scss | 240 + components/party/PartyHeader/index.tsx | 405 + .../party/PartySegmentedControl/index.scss | 6 + .../party/PartySegmentedControl/index.tsx | 113 +- components/raids/RaidCombobox/index.scss | 199 + components/raids/RaidCombobox/index.tsx | 569 + components/raids/RaidItem/index.scss | 58 + components/raids/RaidItem/index.tsx | 87 + components/reps/CharacterRep/index.scss | 75 + components/reps/CharacterRep/index.tsx | 132 + components/reps/RepSegment/index.scss | 73 + components/reps/RepSegment/index.tsx | 34 + components/reps/SummonRep/index.scss | 45 + components/reps/SummonRep/index.tsx | 169 + components/reps/WeaponRep/index.scss | 45 + components/reps/WeaponRep/index.tsx | 103 + components/search/SearchFilter/index.tsx | 4 +- components/search/SearchModal/index.scss | 5 + components/search/SearchModal/index.tsx | 43 +- components/summon/ExtraSummons/index.scss | 4 + components/summon/SummonGrid/index.scss | 2 +- components/summon/SummonGrid/index.tsx | 2 +- components/summon/SummonUnit/index.scss | 47 + components/summon/SummonUnit/index.tsx | 52 + components/toasts/RemixedToast/index.tsx | 49 + .../{about => toasts}/UpdateToast/index.scss | 0 .../{about => toasts}/UpdateToast/index.tsx | 0 components/toasts/UrlCopiedToast/index.tsx | 39 + components/weapon/ExtraWeapons/index.scss | 59 - components/weapon/ExtraWeapons/index.tsx | 48 - components/weapon/WeaponGrid/index.scss | 9 +- components/weapon/WeaponGrid/index.tsx | 71 +- components/weapon/WeaponHovercard/index.tsx | 12 +- components/weapon/WeaponModal/index.tsx | 28 +- components/weapon/WeaponUnit/index.scss | 2 + components/weapon/WeaponUnit/index.tsx | 18 +- data/awakening.tsx | 104 +- package-lock.json | 12971 ++++++++++++---- package.json | 25 +- pages/[username].tsx | 12 +- pages/new/index.tsx | 17 +- pages/p/[party].tsx | 18 +- pages/saved.tsx | 12 +- pages/teams.tsx | 13 +- public/icons/Arrow.svg | 4 +- public/icons/Chevron.svg | 3 + .../icons/{ArrowDark.svg => ChevronDark.svg} | 0 public/icons/Ellipsis.svg | 5 + public/icons/quick_summon/empty.svg | 27 + public/icons/quick_summon/filled.svg | 65 + .../placeholders/placeholder-guidebook.png | Bin 0 -> 361 bytes public/locales/en/common.json | 323 +- public/locales/ja/common.json | 343 +- styles/globals.scss | 53 + styles/themes.scss | 24 +- styles/variables.scss | 26 +- types/Awakening.d.ts | 11 + types/Character.d.ts | 1 + types/GridCharacter.d.ts | 2 +- types/GridSummon.d.ts | 1 + types/GridWeapon.d.ts | 2 +- types/Guidebook.d.ts | 14 + types/Party.d.ts | 13 +- types/Raid.d.ts | 2 +- types/RaidGroup.d.ts | 15 + types/Weapon.d.ts | 3 +- types/index.d.ts | 20 +- utils/api.tsx | 18 +- utils/appState.tsx | 10 +- utils/extractFilters.tsx | 8 +- utils/organizeRaids.tsx | 16 - yarn.lock | 10414 ------------- 149 files changed, 16601 insertions(+), 15104 deletions(-) delete mode 100644 components/RaidDropdown/index.tsx create mode 100644 components/RaidSelect/index.scss create mode 100644 components/RaidSelect/index.tsx rename components/{RaidDropdown => common/Command}/index.scss (100%) create mode 100644 components/common/Command/index.tsx create mode 100644 components/common/Popover/index.scss create mode 100644 components/common/Popover/index.tsx create mode 100644 components/dialogs/DeleteTeamAlert/index.tsx create mode 100644 components/dialogs/RemixTeamAlert/index.tsx create mode 100644 components/extra/ExtraContainer/index.scss create mode 100644 components/extra/ExtraContainer/index.tsx create mode 100644 components/extra/ExtraWeaponsGrid/index.scss create mode 100644 components/extra/ExtraWeaponsGrid/index.tsx create mode 100644 components/extra/GuidebookResult/index.scss create mode 100644 components/extra/GuidebookResult/index.tsx create mode 100644 components/extra/GuidebookUnit/index.scss create mode 100644 components/extra/GuidebookUnit/index.tsx create mode 100644 components/extra/GuidebooksGrid/index.scss create mode 100644 components/extra/GuidebooksGrid/index.tsx delete mode 100644 components/mastery/AwakeningSelect/index.tsx rename components/mastery/{AwakeningSelect => AwakeningSelectWithInput}/index.scss (54%) create mode 100644 components/mastery/AwakeningSelectWithInput/index.tsx create mode 100644 components/party/EditPartyModal/index.scss create mode 100644 components/party/EditPartyModal/index.tsx create mode 100644 components/party/PartyDropdown/index.scss create mode 100644 components/party/PartyDropdown/index.tsx create mode 100644 components/party/PartyHeader/index.scss create mode 100644 components/party/PartyHeader/index.tsx create mode 100644 components/raids/RaidCombobox/index.scss create mode 100644 components/raids/RaidCombobox/index.tsx create mode 100644 components/raids/RaidItem/index.scss create mode 100644 components/raids/RaidItem/index.tsx create mode 100644 components/reps/CharacterRep/index.scss create mode 100644 components/reps/CharacterRep/index.tsx create mode 100644 components/reps/RepSegment/index.scss create mode 100644 components/reps/RepSegment/index.tsx create mode 100644 components/reps/SummonRep/index.scss create mode 100644 components/reps/SummonRep/index.tsx create mode 100644 components/reps/WeaponRep/index.scss create mode 100644 components/reps/WeaponRep/index.tsx create mode 100644 components/toasts/RemixedToast/index.tsx rename components/{about => toasts}/UpdateToast/index.scss (100%) rename components/{about => toasts}/UpdateToast/index.tsx (100%) create mode 100644 components/toasts/UrlCopiedToast/index.tsx delete mode 100644 components/weapon/ExtraWeapons/index.scss delete mode 100644 components/weapon/ExtraWeapons/index.tsx create mode 100644 public/icons/Chevron.svg rename public/icons/{ArrowDark.svg => ChevronDark.svg} (100%) create mode 100644 public/icons/Ellipsis.svg create mode 100644 public/icons/quick_summon/empty.svg create mode 100644 public/icons/quick_summon/filled.svg create mode 100644 public/images/placeholders/placeholder-guidebook.png create mode 100644 types/Awakening.d.ts create mode 100644 types/Guidebook.d.ts create mode 100644 types/RaidGroup.d.ts delete mode 100644 utils/organizeRaids.tsx delete mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore index e3403051..9bb7f6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ public/images/accessory* public/images/mastery* public/images/updates* public/images/guidebooks* +public/images/raids* # Typescript v1 declaration files typings/ diff --git a/components/FilterBar/index.scss b/components/FilterBar/index.scss index b8bde51e..5cfb852f 100644 --- a/components/FilterBar/index.scss +++ b/components/FilterBar/index.scss @@ -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%; diff --git a/components/FilterBar/index.tsx b/components/FilterBar/index.tsx index 11b5332f..5d94deb9 100644 --- a/components/FilterBar/index.tsx +++ b/components/FilterBar/index.tsx @@ -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() + 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 }) => ( + + {text} + + )) + } + return ( <>
@@ -97,47 +129,26 @@ const FilterBar = (props: Props) => {
- setOpen(!open)} - onClick={openRaidSelect} - onValueChange={handleChange} - > - {Array.from(Array(sortedRaids?.length)).map((x, i) => - renderRaidGroup(i) - )} - - - ) - } -) - -export default RaidDropdown diff --git a/components/RaidSelect/index.scss b/components/RaidSelect/index.scss new file mode 100644 index 00000000..e6eaa60f --- /dev/null +++ b/components/RaidSelect/index.scss @@ -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); + } + } + } +} diff --git a/components/RaidSelect/index.tsx b/components/RaidSelect/index.tsx new file mode 100644 index 00000000..8d114b59 --- /dev/null +++ b/components/RaidSelect/index.tsx @@ -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 + > { + 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(function Select( + props: Props, + forwardedRef +) { + // Import translations + const { t } = useTranslation('common') + + const searchInput = React.createRef() + + 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 ( + + + {props.iconSrc ? {props.altText} : ''} + + {!props.disabled ? ( + + + + ) : ( + '' + )} + + + + <> + + + +
+ {}} + /> + + props.onSegmentClick(1)} + > + {t('raids.sections.events')} + + props.onSegmentClick(0)} + > + {t('raids.sections.raids')} + + props.onSegmentClick(2)} + > + {t('raids.sections.solo')} + + +
+ {props.children} +
+ +
+
+ ) +}) + +RaidSelect.defaultProps = { + overlayVisible: true, +} + +export default RaidSelect diff --git a/components/auth/AccountModal/index.tsx b/components/auth/AccountModal/index.tsx index 9b37b831..93394c9a 100644 --- a/components/auth/AccountModal/index.tsx +++ b/components/auth/AccountModal/index.tsx @@ -330,10 +330,13 @@ const AccountModal = React.forwardRef( {themeField()}
-
diff --git a/components/character/CharacterGrid/index.tsx b/components/character/CharacterGrid/index.tsx index 958d374e..048ba655 100644 --- a/components/character/CharacterGrid/index.tsx +++ b/components/character/CharacterGrid/index.tsx @@ -259,6 +259,23 @@ const CharacterGrid = (props: Props) => { } } + function removeJobSkill(position: number) { + if (party.id && props.editable) { + api + .removeJobSkill({ partyId: party.id, position: position }) + .then((response) => { + // Update the current skills + const newSkills = response.data.job_skills + setJobSkills(newSkills) + appState.party.jobSkills = newSkills + }) + .catch((error) => { + const data = error.response.data + console.log(data) + }) + } + } + async function saveAccessory(accessory: JobAccessory) { const payload = { party: { @@ -506,6 +523,7 @@ const CharacterGrid = (props: Props) => { editable={props.editable} saveJob={saveJob} saveSkill={saveJobSkill} + removeSkill={removeJobSkill} saveAccessory={saveAccessory} /> void } -interface KeyNames { - [key: string]: { - en: string - jp: string - } -} - const CharacterHovercard = (props: Props) => { const router = useRouter() const { t } = useTranslation('common') @@ -181,27 +173,20 @@ const CharacterHovercard = (props: Props) => { const awakeningSection = () => { const gridAwakening = props.gridCharacter.awakening - const awakening = characterAwakening.find( - (awakening) => awakening.id === gridAwakening?.type - ) - if (gridAwakening && awakening) { + if (gridAwakening) { return (
{t('modals.characters.subtitles.awakening')}
- {gridAwakening.type > 1 ? ( - {awakening.name[locale]} - ) : ( - '' - )} + {gridAwakening.type.name[locale]} - {`${awakening.name[locale]}`}  + {`${gridAwakening.type.name[locale]}`}  {`Lv${gridAwakening.level}`}
diff --git a/components/character/CharacterModal/index.tsx b/components/character/CharacterModal/index.tsx index c23fc881..29568c76 100644 --- a/components/character/CharacterModal/index.tsx +++ b/components/character/CharacterModal/index.tsx @@ -1,13 +1,7 @@ // Core dependencies -import React, { - PropsWithChildren, - useCallback, - useEffect, - useState, -} from 'react' +import React, { PropsWithChildren, useEffect, useState } from 'react' import { useRouter } from 'next/router' import { useTranslation } from 'next-i18next' -import { AxiosResponse } from 'axios' import classNames from 'classnames' // UI dependencies @@ -20,14 +14,10 @@ import { import DialogContent from '~components/common/DialogContent' import Button from '~components/common/Button' import SelectWithInput from '~components/common/SelectWithInput' -import AwakeningSelect from '~components/mastery/AwakeningSelect' import RingSelect from '~components/mastery/RingSelect' import Switch from '~components/common/Switch' // Utilities -import api from '~utils/api' -import { appState } from '~utils/appState' -import { retrieveCookies } from '~utils/retrieveCookies' import elementalizeAetherialMastery from '~utils/elementalizeAetherialMastery' // Data @@ -36,6 +26,8 @@ const emptyExtendedMastery: ExtendedMastery = { strength: 0, } +const MAX_AWAKENING_LEVEL = 9 + // Styles and icons import CrossIcon from '~public/icons/Cross.svg' import './index.scss' @@ -46,6 +38,7 @@ import { ExtendedMastery, GridCharacterObject, } from '~types' +import AwakeningSelectWithInput from '~components/mastery/AwakeningSelectWithInput' interface Props { gridCharacter: GridCharacter @@ -66,9 +59,6 @@ const CharacterModal = ({ router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' const { t } = useTranslation('common') - // Cookies - const cookies = retrieveCookies() - // UI state const [open, setOpen] = useState(false) const [formValid, setFormValid] = useState(false) @@ -103,8 +93,8 @@ const CharacterModal = ({ const [earring, setEarring] = useState(emptyExtendedMastery) // Character properties: Awakening - const [awakeningType, setAwakeningType] = useState(0) - const [awakeningLevel, setAwakeningLevel] = useState(0) + const [awakening, setAwakening] = useState() + const [awakeningLevel, setAwakeningLevel] = useState(1) // Character properties: Transcendence const [transcendenceStep, setTranscendenceStep] = useState(0) @@ -118,7 +108,7 @@ const CharacterModal = ({ }) } - setAwakeningType(gridCharacter.awakening.type) + setAwakening(gridCharacter.awakening.type) setAwakeningLevel(gridCharacter.awakening.level) setPerpetuity(gridCharacter.perpetuity) }, [gridCharacter]) @@ -147,15 +137,16 @@ const CharacterModal = ({ modifier: earring.modifier, strength: earring.strength, }, - awakening: { - type: awakeningType, - level: awakeningLevel, - }, transcendence_step: transcendenceStep, perpetuity: perpetuity, }, } + if (awakening) { + object.character.awakening_id = awakening.id + object.character.awakening_level = awakeningLevel + } + return object } @@ -191,8 +182,8 @@ const CharacterModal = ({ if (onOpenChange) onOpenChange(false) } - function receiveAwakeningValues(type: number, level: number) { - setAwakeningType(type) + function receiveAwakeningValues(id: string, level: number) { + setAwakening(gridCharacter.object.awakenings.find((a) => a.id === id)) setAwakeningLevel(level) } @@ -234,10 +225,16 @@ const CharacterModal = ({ return (

{t('modals.characters.subtitles.awakening')}

- a.slug === 'character-balanced' + )! + } + maxLevel={MAX_AWAKENING_LEVEL} sendValidity={receiveValidity} sendValues={receiveAwakeningValues} /> diff --git a/components/common/Alert/index.scss b/components/common/Alert/index.scss index aba79f93..536edae8 100644 --- a/components/common/Alert/index.scss +++ b/components/common/Alert/index.scss @@ -30,6 +30,7 @@ .description { font-size: $font-regular; line-height: 1.4; + white-space: pre-line; strong { font-weight: $bold; diff --git a/components/common/Alert/index.tsx b/components/common/Alert/index.tsx index dc6d49c9..c6a34b48 100644 --- a/components/common/Alert/index.tsx +++ b/components/common/Alert/index.tsx @@ -12,6 +12,7 @@ interface Props { message: string | React.ReactNode primaryAction?: () => void primaryActionText?: string + primaryActionClassName?: string cancelAction: () => void cancelActionText: string } @@ -22,7 +23,10 @@ const Alert = (props: Props) => {
- + {props.title ? ( {props.title} ) : ( @@ -42,6 +46,7 @@ const Alert = (props: Props) => { {props.primaryAction ? (
) - function jobLabel() { - return job ? filledJobLabel : emptyJobLabel - } - // Render: JSX components return (
@@ -209,7 +215,7 @@ const JobSection = (props: Props) => { )} -
    +
      {[...Array(numSkills)].map((e, i) => (
    • {canEditSkill(skills[i]) diff --git a/components/job/JobSkillItem/index.scss b/components/job/JobSkillItem/index.scss index 9a15bb62..df2fa67b 100644 --- a/components/job/JobSkillItem/index.scss +++ b/components/job/JobSkillItem/index.scss @@ -1,47 +1,81 @@ +.JobSkills { + &.editable .JobSkill { + .Info { + padding: $unit-half * 1.5; + + & > img, + & > div.placeholder { + width: $unit-4x; + height: $unit-4x; + } + } + } +} + .JobSkill { display: flex; - gap: $unit; - align-items: center; + align-items: stretch; + justify-content: space-between; + + &.editable .Info:hover { + background-color: var(--button-bg-hover); + } &.editable:hover { cursor: pointer; - & > img.editable, - & > div.placeholder.editable { - border: $hover-stroke; - box-shadow: $hover-shadow; - cursor: pointer; - transform: $scale-tall; - } + .Info { + & > img.editable, + & > div.placeholder.editable { + border: $hover-stroke; + box-shadow: $hover-shadow; + cursor: pointer; + transform: $scale-tall; + } - & p.placeholder { - color: var(--text-tertiary-hover); - } + & p.placeholder { + color: var(--text-tertiary-hover); + } - & svg { - fill: var(--icon-secondary-hover); + & svg { + fill: var(--icon-secondary-hover); + } } } - & > img, - & > div.placeholder { - background: var(--card-bg); - border-radius: calc($unit / 2); - border: 1px solid rgba(0, 0, 0, 0); - width: $unit * 5; - height: $unit * 5; - } - - & > div.placeholder { - display: flex; + .Info { align-items: center; - justify-content: center; + border-radius: $input-corner; + display: flex; + flex-grow: 1; + gap: $unit; - & > svg { - fill: var(--icon-secondary); - width: $unit * 2; - height: $unit * 2; + & > img, + & > div.placeholder { + background: var(--card-bg); + border-radius: calc($unit / 2); + border: 1px solid rgba(0, 0, 0, 0); + width: $unit-5x; + height: $unit-5x; } + + & > div.placeholder { + display: flex; + align-items: center; + justify-content: center; + + & > svg { + fill: var(--icon-secondary); + width: $unit-2x; + height: $unit-2x; + } + } + } + + & > .Button { + justify-content: center; + max-width: $unit-6x; + height: auto; } p { diff --git a/components/job/JobSkillItem/index.tsx b/components/job/JobSkillItem/index.tsx index bac8f729..a1ded0d3 100644 --- a/components/job/JobSkillItem/index.tsx +++ b/components/job/JobSkillItem/index.tsx @@ -1,21 +1,43 @@ -import React from 'react' +import React, { useState } from 'react' import { useRouter } from 'next/router' -import { useTranslation } from 'next-i18next' - +import { Trans, useTranslation } from 'next-i18next' import classNames from 'classnames' -import PlusIcon from '~public/icons/Add.svg' +import Alert from '~components/common/Alert' +import Button from '~components/common/Button' +import { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, +} from '~components/common/ContextMenu' +import ContextMenuItem from '~components/common/ContextMenuItem' + +import EllipsisIcon from '~public/icons/Ellipsis.svg' +import PlusIcon from '~public/icons/Add.svg' import './index.scss' // Props interface Props extends React.ComponentPropsWithoutRef<'div'> { skill?: JobSkill + position: number editable: boolean hasJob: boolean + removeJobSkill: (position: number) => void } const JobSkillItem = React.forwardRef( - function useJobSkillItem({ ...props }, forwardedRef) { + function useJobSkillItem( + { + skill, + position, + editable, + hasJob, + removeJobSkill: sendJobSkillToRemove, + ...props + }, + forwardedRef + ) { + // Set up translation const router = useRouter() const { t } = useTranslation('common') const locale = @@ -23,31 +45,55 @@ const JobSkillItem = React.forwardRef( ? router.locale : 'en' + // States: Component + const [alertOpen, setAlertOpen] = useState(false) + const [contextMenuOpen, setContextMenuOpen] = useState(false) + + // Classes const classes = classNames({ JobSkill: true, - editable: props.editable, + editable: editable, }) const imageClasses = classNames({ - placeholder: !props.skill, - editable: props.editable && props.hasJob, + placeholder: !skill, + editable: editable && hasJob, }) + const buttonClasses = classNames({ + Clicked: contextMenuOpen, + }) + + // Methods: Data mutation + function removeJobSkill() { + if (skill) sendJobSkillToRemove(position) + setAlertOpen(false) + } + + // Methods: Context menu + function handleButtonClicked() { + setContextMenuOpen(!contextMenuOpen) + } + + function handleContextMenuOpenChange(open: boolean) { + if (!open) setContextMenuOpen(false) + } + const skillImage = () => { let jsx: React.ReactNode - if (props.skill) { + if (skill) { jsx = ( {props.skill.name[locale]} ) } else { jsx = (
      - {props.editable && props.hasJob ? : ''} + {editable && hasJob ? : ''}
      ) } @@ -58,9 +104,9 @@ const JobSkillItem = React.forwardRef( const label = () => { let jsx: React.ReactNode - if (props.skill) { - jsx =

      {props.skill.name[locale]}

      - } else if (props.editable && props.hasJob) { + if (skill) { + jsx =

      {skill.name[locale]}

      + } else if (editable && hasJob) { jsx =

      {t('job_skills.state.selectable')}

      } else { jsx =

      {t('job_skills.state.no_skill')}

      @@ -69,10 +115,55 @@ const JobSkillItem = React.forwardRef( return jsx } + const removeAlert = () => { + return ( + setAlertOpen(false)} + cancelActionText={t('buttons.cancel')} + message={ + + Are you sure you want to remove{' '} + {{ job_skill: skill?.name[locale] }} from your + team? + + } + /> + ) + } + + const contextMenu = () => { + return ( + <> + + +
- ) - } - - 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 {string} - } - } - const readOnly = () => { return (
-
- - {`${t('party.details.labels.charge_attack')} ${ - chargeAttack ? 'On' : 'Off' - }`} - - - - {`${t('party.details.labels.full_auto')} ${ - fullAuto ? 'On' : 'Off' - }`} - - - - {`${t('party.details.labels.auto_guard')} ${ - autoGuard ? 'On' : 'Off' - }`} - - - {turnCount ? ( - - {t('party.details.turns.with_count', { - count: turnCount, - })} - - ) : ( - '' - )} - {clearTime > 0 ? {clearTimeString()} : ''} - {buttonChainToken()} -
{embeddedDescription}
) @@ -764,58 +192,7 @@ const PartyDetails = (props: Props) => { return ( <> -
-
-
-
-

- {name ? name : t('no_title')} -

- {party.remix && party.sourceParty ? ( - -
-
- {renderUserBlock()} - {party.raid ? linkedRaidBlock(party.raid) : ''} - {party.created_at != '' ? ( - - ) : ( - '' - )} -
-
- {party.editable ? ( -
-
- ) : ( - '' - )} -
- {readOnly()} - {editable()} - - {deleteAlert()} -
+
{readOnly()}
{remixes && remixes.length > 0 ? remixSection() : ''} ) diff --git a/components/party/PartyDropdown/index.scss b/components/party/PartyDropdown/index.scss new file mode 100644 index 00000000..e69de29b diff --git a/components/party/PartyDropdown/index.tsx b/components/party/PartyDropdown/index.tsx new file mode 100644 index 00000000..189f867c --- /dev/null +++ b/components/party/PartyDropdown/index.tsx @@ -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 ( + <> + + + Copy link to team + + + Remix team + + + + + Delete team + + + + ) + } + + return ( + <> + + + + + + + ) +} + +export default PartyDropdown diff --git a/components/party/PartyHeader/index.scss b/components/party/PartyHeader/index.scss new file mode 100644 index 00000000..e617331c --- /dev/null +++ b/components/party/PartyHeader/index.scss @@ -0,0 +1,240 @@ +.DetailsWrapper { + display: flex; + flex-direction: column; + gap: $unit-2x; + margin: $unit-4x auto 0 auto; + max-width: $grid-width; + + @include breakpoint(phone) { + .Button:not(.IconButton) { + justify-content: center; + width: 100%; + + .Text { + width: auto; + } + } + } + + .PartyDetails { + box-sizing: border-box; + display: block; + line-height: 1.4; + white-space: pre-wrap; + margin: 0 auto $unit-2x; + max-width: $unit * 94; + overflow: hidden; + width: 100%; + + @include breakpoint(phone) { + padding: 0 $unit; + } + + a:hover { + text-decoration: underline; + } + + p { + font-size: $font-regular; + line-height: $font-regular * 1.2; + white-space: pre-line; + } + + .Tokens { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: $unit; + margin-bottom: $unit-2x; + } + + .YoutubeWrapper { + background-color: var(--card-bg); + border-radius: $card-corner; + margin: $unit 0; + position: relative; + display: block; + contain: content; + background-position: center center; + background-size: cover; + cursor: pointer; + width: 60%; + height: 60%; + + @include breakpoint(tablet) { + width: 100%; + height: 100%; + } + + /* gradient */ + &::before { + content: ''; + display: block; + position: absolute; + top: 0; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADGCAYAAAAT+OqFAAAAdklEQVQoz42QQQ7AIAgEF/T/D+kbq/RWAlnQyyazA4aoAB4FsBSA/bFjuF1EOL7VbrIrBuusmrt4ZZORfb6ehbWdnRHEIiITaEUKa5EJqUakRSaEYBJSCY2dEstQY7AuxahwXFrvZmWl2rh4JZ07z9dLtesfNj5q0FU3A5ObbwAAAABJRU5ErkJggg==); + background-position: top; + background-repeat: repeat-x; + height: 60px; + padding-bottom: 50px; + width: 100%; + transition: all 0.2s cubic-bezier(0, 0, 0.2, 1); + } + + /* responsive iframe with a 16:9 aspect ratio + thanks https://css-tricks.com/responsive-iframes/ + */ + &::after { + content: ''; + display: block; + padding-bottom: calc(100% / (16 / 9)); + } + + &:hover > .PlayerButton { + opacity: 1; + } + + & > iframe { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + } + + /* Play button */ + & > .PlayerButton { + background: none; + border: none; + background-image: url('/icons/youtube.svg'); + width: 68px; + height: 68px; + opacity: 0.8; + transition: all 0.2s cubic-bezier(0, 0, 0.2, 1); + } + + & > .PlayerButton, + & > .PlayerButton:before { + position: absolute; + top: 50%; + left: 50%; + transform: translate3d(-50%, -50%, 0); + } + + /* Post-click styles */ + &.lyt-activated { + cursor: unset; + } + &.lyt-activated::before, + &.lyt-activated > .PlayerButton { + opacity: 0; + pointer-events: none; + } + } + } + + .PartyInfo { + box-sizing: border-box; + display: flex; + flex-direction: row; + gap: $unit; + margin: 0 auto; + max-width: $unit * 94; + width: 100%; + + @include breakpoint(phone) { + flex-direction: column; + gap: $unit; + padding: 0 $unit; + } + + & > .Right { + display: flex; + gap: $unit-half; + } + + & > .Left { + flex-grow: 1; + + .Header { + align-items: center; + display: flex; + gap: $unit; + margin-bottom: $unit; + + h1 { + font-size: $font-xlarge; + font-weight: $normal; + text-align: left; + color: var(--text-primary); + + &.empty { + color: var(--text-secondary); + } + } + } + + .attribution { + align-items: center; + display: flex; + flex-direction: row; + + & > div { + align-items: center; + display: inline-flex; + font-size: $font-small; + height: 26px; + } + + time { + font-size: $font-small; + } + + a:visited:not(.fire):not(.water):not(.wind):not(.earth):not(.dark):not( + .light + ) { + color: var(--text-primary); + } + + a:hover:not(.fire):not(.water):not(.wind):not(.earth):not(.dark):not( + .light + ) { + color: $blue; + } + + & > *:not(:last-child):after { + content: ' · '; + margin: 0 calc($unit / 2); + } + } + } + + .user { + align-items: center; + display: inline-flex; + gap: calc($unit / 2); + margin-top: 1px; + + img, + .no-user { + $diameter: 24px; + + border-radius: calc($diameter / 2); + height: $diameter; + width: $diameter; + } + + img.gran { + background-color: #cee7fe; + } + + img.djeeta { + background-color: #ffe1fe; + } + + .no-user { + background: $grey-80; + } + } + } +} diff --git a/components/party/PartyHeader/index.tsx b/components/party/PartyHeader/index.tsx new file mode 100644 index 00000000..2e192d51 --- /dev/null +++ b/components/party/PartyHeader/index.tsx @@ -0,0 +1,405 @@ +import React, { useEffect, useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useSnapshot } from 'valtio' +import { useTranslation } from 'next-i18next' +import classNames from 'classnames' + +import Button from '~components/common/Button' +import Tooltip from '~components/common/Tooltip' +import Token from '~components/common/Token' + +import EditPartyModal from '~components/party/EditPartyModal' +import PartyDropdown from '~components/party/PartyDropdown' + +import { accountState } from '~utils/accountState' +import { appState, initialAppState } from '~utils/appState' +import { formatTimeAgo } from '~utils/timeAgo' + +import EditIcon from '~public/icons/Edit.svg' +import RemixIcon from '~public/icons/Remix.svg' +import SaveIcon from '~public/icons/Save.svg' + +import type { DetailsObject } from 'types' + +import './index.scss' +import api from '~utils/api' + +// Props +interface Props { + party?: Party + new: boolean + editable: boolean + deleteCallback: () => void + remixCallback: () => void + updateCallback: (details: DetailsObject) => void +} + +const PartyHeader = (props: Props) => { + const { party } = useSnapshot(appState) + + const { t } = useTranslation('common') + const router = useRouter() + const locale = router.locale || 'en' + + const { party: partySnapshot } = useSnapshot(appState) + + const [name, setName] = useState('') + + const [chargeAttack, setChargeAttack] = useState(true) + const [fullAuto, setFullAuto] = useState(false) + const [autoGuard, setAutoGuard] = useState(false) + + const [buttonCount, setButtonCount] = useState(undefined) + const [chainCount, setChainCount] = useState(undefined) + const [turnCount, setTurnCount] = useState(undefined) + const [clearTime, setClearTime] = useState(0) + + const classes = classNames({ + PartyDetails: true, + }) + + const userClass = classNames({ + user: true, + empty: !party.user, + }) + + const linkClass = classNames({ + wind: party && party.element == 1, + fire: party && party.element == 2, + water: party && party.element == 3, + earth: party && party.element == 4, + dark: party && party.element == 5, + light: party && party.element == 6, + }) + + useEffect(() => { + if (props.party) { + setName(props.party.name) + setAutoGuard(props.party.auto_guard) + setFullAuto(props.party.full_auto) + setChargeAttack(props.party.charge_attack) + setClearTime(props.party.clear_time) + if (props.party.turn_count) setTurnCount(props.party.turn_count) + if (props.party.button_count) setButtonCount(props.party.button_count) + if (props.party.chain_count) setChainCount(props.party.chain_count) + } + }, [props.party]) + + // Subscribe to router changes and reset state + // if the new route is a new team + useEffect(() => { + router.events.on('routeChangeStart', (url, { shallow }) => { + if (url === '/new' || url === '/') { + const party = initialAppState.party + + setName(party.name ? party.name : '') + setAutoGuard(party.autoGuard) + setFullAuto(party.fullAuto) + setChargeAttack(party.chargeAttack) + setClearTime(party.clearTime) + setTurnCount(party.turnCount) + setButtonCount(party.buttonCount) + setChainCount(party.chainCount) + } + }) + }, []) + + // Actions: Favorites + function toggleFavorite() { + if (appState.party.favorited) unsaveFavorite() + else saveFavorite() + } + + function saveFavorite() { + if (appState.party.id) + api.saveTeam({ id: appState.party.id }).then((response) => { + if (response.status == 201) appState.party.favorited = true + }) + else console.error('Failed to save team: No party ID') + } + + function unsaveFavorite() { + if (appState.party.id) + api.unsaveTeam({ id: appState.party.id }).then((response) => { + if (response.status == 200) appState.party.favorited = false + }) + else console.error('Failed to unsave team: No party ID') + } + + // Methods: Navigation + function goTo(shortcode?: string) { + if (shortcode) router.push(`/p/${shortcode}`) + } + + const userImage = (picture?: string, element?: string) => { + if (picture && element) + return ( + {picture} + ) + else + return ( + {t('no_user')} + ) + } + + const userBlock = (username?: string, picture?: string, element?: string) => { + return ( +
+ {userImage(picture, element)} + {username ? username : t('no_user')} +
+ ) + } + + 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 ( + + ) + } + + const linkedRaidBlock = (raid: Raid) => { + return ( + + ) + } + + // Render: Tokens + const chargeAttackToken = ( + + {`${t('party.details.labels.charge_attack')} ${ + chargeAttack ? 'On' : 'Off' + }`} + + ) + + const fullAutoToken = ( + + {`${t('party.details.labels.full_auto')} ${fullAuto ? 'On' : 'Off'}`} + + ) + + const autoGuardToken = ( + + {`${t('party.details.labels.auto_guard')} ${autoGuard ? 'On' : 'Off'}`} + + ) + + const turnCountToken = ( + + {t('party.details.turns.with_count', { + count: turnCount, + })} + + ) + + 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 {string} + } + } + + 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 {string} + } + + function renderTokens() { + return ( +
+ {chargeAttackToken} + {fullAutoToken} + {autoGuardToken} + {turnCount ? turnCountToken : ''} + {clearTime > 0 ? clearTimeToken() : ''} + {buttonChainToken()} +
+ ) + } + + // Render: Buttons + const saveButton = () => { + return ( + +
+ + ) +} + +export default PartyHeader diff --git a/components/party/PartySegmentedControl/index.scss b/components/party/PartySegmentedControl/index.scss index a6fd6c50..c58ebcc8 100644 --- a/components/party/PartySegmentedControl/index.scss +++ b/components/party/PartySegmentedControl/index.scss @@ -22,7 +22,12 @@ width: 100%; } + @include breakpoint(phone) { + padding: 0; + } + .SegmentedControl { + gap: $unit; flex-grow: 1; // prettier-ignore @@ -31,6 +36,7 @@ and (max-height: 920px) and (-webkit-min-device-pixel-ratio: 2) { flex-grow: 1; + gap: 0; width: 100%; display: grid; grid-template-columns: auto auto auto; diff --git a/components/party/PartySegmentedControl/index.tsx b/components/party/PartySegmentedControl/index.tsx index 1c807dfb..e45fb56e 100644 --- a/components/party/PartySegmentedControl/index.tsx +++ b/components/party/PartySegmentedControl/index.tsx @@ -1,22 +1,29 @@ import React from 'react' import { useSnapshot } from 'valtio' import { useTranslation } from 'next-i18next' +import classNames from 'classnames' import { appState } from '~utils/appState' +import { accountState } from '~utils/accountState' import SegmentedControl from '~components/common/SegmentedControl' -import Segment from '~components/common/Segment' -import ToggleSwitch from '~components/common/ToggleSwitch' +import RepSegment from '~components/reps/RepSegment' +import CharacterRep from '~components/reps/CharacterRep' +import WeaponRep from '~components/reps/WeaponRep' +import SummonRep from '~components/reps/SummonRep' import { GridType } from '~utils/enums' import './index.scss' -import classNames from 'classnames' + +// Fix for valtio readonly array +declare module 'valtio' { + function useSnapshot(p: T): T +} interface Props { selectedTab: GridType onClick: (event: React.ChangeEvent) => void - onCheckboxChange: (event: React.ChangeEvent) => void } const PartySegmentedControl = (props: Props) => { @@ -25,7 +32,7 @@ const PartySegmentedControl = (props: Props) => { const { party, grid } = useSnapshot(appState) - function getElement() { + const getElement = () => { let element: number = 0 if (party.element == 0 && grid.weapons.mainWeapon) element = grid.weapons.mainWeapon.element @@ -47,17 +54,56 @@ const PartySegmentedControl = (props: Props) => { } } - const extraToggle = ( -
- Extra - -
- ) + const characterSegment = () => { + return ( + + + + ) + } + + const weaponSegment = () => { + { + return ( + + + + ) + } + } + + const summonSegment = () => { + return ( + + + + ) + } return (
{ })} > - - {t('party.segmented_control.characters')} - - - - {t('party.segmented_control.weapons')} - - - - {t('party.segmented_control.summons')} - + {characterSegment()} + {weaponSegment()} + {summonSegment()} - - {(() => { - if (party.editable && props.selectedTab == GridType.Weapon) { - return extraToggle - } - })()}
) } diff --git a/components/raids/RaidCombobox/index.scss b/components/raids/RaidCombobox/index.scss new file mode 100644 index 00000000..f3c689f3 --- /dev/null +++ b/components/raids/RaidCombobox/index.scss @@ -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%; + } +} diff --git a/components/raids/RaidCombobox/index.tsx b/components/raids/RaidCombobox/index.tsx new file mode 100644 index 00000000..5cbe7c0d --- /dev/null +++ b/components/raids/RaidCombobox/index.tsx @@ -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) => 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.DESCENDING) + const [scrolled, setScrolled] = useState(false) + + // Data state + const [currentSection, setCurrentSection] = useState(1) + const [query, setQuery] = useState('') + const [sections, setSections] = useState() + const [currentRaid, setCurrentRaid] = useState() + const [tabIndex, setTabIndex] = useState(NUM_ELEMENTS + 1) + + // Data + const [farmingRaid, setFarmingRaid] = useState() + + // Refs + const listRef = createRef() + const inputRef = createRef() + const sortButtonRef = createRef() + + // ---------------------------------------------- + // 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 + ) => { + // 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) => { + 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 = ( +
+ {group.name[locale]} +
+
+ ) + + return ( + + {options} + + ) + } + + // Render the ungrouped raid group + function renderUngroupedRaids() { + let ungroupedRaids = farmingRaid ? [farmingRaid] : [] + + if (props.showAllRaidsOption) { + ungroupedRaids.push(allRaidsOption) + } + + const options = generateRaidItems(ungroupedRaids) + + return ( + + {options} + + ) + } + + // 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 ( + handleValueChange(raid)} + > + {raid.name[locale]} + + ) + } + + // Renders a SegmentedControl component for selecting raid sections. + function renderSegmentedControl() { + return ( + + setCurrentSection(2)} + > + {t('raids.sections.events')} + + setCurrentSection(1)} + > + {t('raids.sections.raids')} + + setCurrentSection(3)} + > + {t('raids.sections.solo')} + + + ) + } + + // Renders a Button for sorting raids and a Tooltip for explaining what it does. + function renderSortButton() { + return ( + +
- + diff --git a/components/search/SearchModal/index.scss b/components/search/SearchModal/index.scss index 3d3638f7..37c517f3 100644 --- a/components/search/SearchModal/index.scss +++ b/components/search/SearchModal/index.scss @@ -61,6 +61,11 @@ #Results { margin: 0; padding: 0 ($unit * 1.5); + padding-bottom: $unit * 1.5; + + // Infinite scroll + overflow-y: auto; + max-height: 500px; @include breakpoint(phone) { max-height: inherit; diff --git a/components/search/SearchModal/index.tsx b/components/search/SearchModal/index.tsx index a35184c2..09cca333 100644 --- a/components/search/SearchModal/index.tsx +++ b/components/search/SearchModal/index.tsx @@ -19,6 +19,7 @@ import CharacterResult from '~components/character/CharacterResult' import WeaponResult from '~components/weapon/WeaponResult' import SummonResult from '~components/summon/SummonResult' import JobSkillResult from '~components/job/JobSkillResult' +import GuidebookResult from '~components/extra/GuidebookResult' import type { DialogProps } from '@radix-ui/react-dialog' import type { SearchableObject, SearchableObjectArray } from '~types' @@ -31,7 +32,7 @@ interface Props extends DialogProps { placeholderText: string fromPosition: number job?: Job - object: 'weapons' | 'characters' | 'summons' | 'job_skills' + object: 'weapons' | 'characters' | 'summons' | 'job_skills' | 'guidebooks' } const SearchModal = (props: Props) => { @@ -184,7 +185,7 @@ const SearchModal = (props: Props) => { } else if (open && currentPage == 1) { fetchResults({ replace: true }) } - }, [currentPage]) + }, [open, currentPage]) useEffect(() => { // Filters changed @@ -219,6 +220,17 @@ const SearchModal = (props: Props) => { } }, [query]) + useEffect(() => { + if (open && props.object === 'guidebooks') { + setCurrentPage(1) + fetchResults({ replace: true }) + } + }, [query, open]) + + function incrementPage() { + setCurrentPage(currentPage + 1) + } + function renderResults() { let jsx @@ -235,12 +247,15 @@ const SearchModal = (props: Props) => { case 'job_skills': jsx = renderJobSkillSearchResults(results) break + case 'guidebooks': + jsx = renderGuidebookSearchResults(results) + break } return ( 0 ? results.length : 0} - next={() => setCurrentPage(currentPage + 1)} + next={incrementPage} hasMore={totalPages > currentPage} scrollableTarget="Results" loader={
Loading...
} @@ -334,6 +349,27 @@ const SearchModal = (props: Props) => { return jsx } + function renderGuidebookSearchResults(results: { [key: string]: any }) { + let jsx: React.ReactNode + + const castResults: Guidebook[] = results as Guidebook[] + if (castResults && Object.keys(castResults).length > 0) { + jsx = castResults.map((result: Guidebook) => { + return ( + { + storeRecentResult(result) + }} + /> + ) + }) + } + + return jsx + } + function openChange() { if (open) { setQuery('') @@ -365,6 +401,7 @@ const SearchModal = (props: Props) => { diff --git a/components/summon/ExtraSummons/index.scss b/components/summon/ExtraSummons/index.scss index 1d9bb8de..72e27b10 100644 --- a/components/summon/ExtraSummons/index.scss +++ b/components/summon/ExtraSummons/index.scss @@ -70,4 +70,8 @@ .SummonUnit .SummonImage .icon svg { fill: var(--subaura-orange-secondary); } + + .SummonUnit .QuickSummon { + display: none; + } } diff --git a/components/summon/SummonGrid/index.scss b/components/summon/SummonGrid/index.scss index 830cfee2..5f575ff9 100644 --- a/components/summon/SummonGrid/index.scss +++ b/components/summon/SummonGrid/index.scss @@ -1,6 +1,6 @@ #SummonGrid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)) 2fr; + grid-template-columns: 1.17fr 2fr 1.17fr; gap: $unit-3x; justify-content: center; margin: 0 auto; diff --git a/components/summon/SummonGrid/index.tsx b/components/summon/SummonGrid/index.tsx index 75f2b089..3a72028b 100644 --- a/components/summon/SummonGrid/index.tsx +++ b/components/summon/SummonGrid/index.tsx @@ -452,8 +452,8 @@ const SummonGrid = (props: Props) => {
{mainSummonElement} - {friendSummonElement} {summonGridElement} + {friendSummonElement}
{subAuraSummonElement} diff --git a/components/summon/SummonUnit/index.scss b/components/summon/SummonUnit/index.scss index 2124ed0b..ac8be29e 100644 --- a/components/summon/SummonUnit/index.scss +++ b/components/summon/SummonUnit/index.scss @@ -112,4 +112,51 @@ opacity: 0; } } + + &:hover .QuickSummon.Empty { + opacity: 1; + } + + &.main .QuickSummon { + $diameter: $unit-6x; + background-size: $diameter $diameter; + top: -2%; + right: 28%; + width: $diameter; + height: $diameter; + } + + &.friend .QuickSummon { + display: none; + } + + &.grid .QuickSummon { + $diameter: $unit-5x; + background-size: $diameter $diameter; + top: -5%; + right: 22%; + width: $diameter; + height: $diameter; + } + + .QuickSummon { + position: absolute; + background-image: url('/icons/quick_summon/filled.svg'); + z-index: 20; + transition: $duration-zoom opacity ease-in-out; + + &:hover { + background-image: url('/icons/quick_summon/empty.svg'); + cursor: pointer; + } + + &.Empty { + background-image: url('/icons/quick_summon/empty.svg'); + opacity: 0; + + &:hover { + background-image: url('/icons/quick_summon/filled.svg'); + } + } + } } diff --git a/components/summon/SummonUnit/index.tsx b/components/summon/SummonUnit/index.tsx index 2359cce3..35bbe18d 100644 --- a/components/summon/SummonUnit/index.tsx +++ b/components/summon/SummonUnit/index.tsx @@ -1,8 +1,12 @@ import React, { MouseEvent, useEffect, useState } from 'react' import { useRouter } from 'next/router' import { Trans, useTranslation } from 'next-i18next' +import { AxiosResponse } from 'axios' import classNames from 'classnames' +import api from '~utils/api' +import { appState } from '~utils/appState' + import Alert from '~components/common/Alert' import Button from '~components/common/Button' import { @@ -93,6 +97,10 @@ const SummonUnit = ({ setContextMenuOpen(!contextMenuOpen) } + function handleQuickSummonClick() { + if (gridSummon) updateQuickSummon(!gridSummon.quick_summon) + } + // Methods: Handle open change function handleContextMenuOpenChange(open: boolean) { if (!open) setContextMenuOpen(false) @@ -103,6 +111,38 @@ const SummonUnit = ({ } // 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) { if (gridSummon) updateUncap(gridSummon.id, position, uncap) } @@ -230,6 +270,17 @@ const SummonUnit = ({ } // Methods: Core element rendering + const quickSummon = () => { + if (gridSummon) { + const classes = classNames({ + QuickSummon: true, + Empty: !gridSummon.quick_summon, + }) + + return + } + } + const image = () => { let image = (
{contextMenu()} + {quickSummon()} {image()} {gridSummon ? ( 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 ( + + You remixed {{ title: partyName }} + + } + onOpenChange={handleOpenChange} + onCloseClick={handleCloseClick} + /> + ) +} + +export default RemixedToast diff --git a/components/about/UpdateToast/index.scss b/components/toasts/UpdateToast/index.scss similarity index 100% rename from components/about/UpdateToast/index.scss rename to components/toasts/UpdateToast/index.scss diff --git a/components/about/UpdateToast/index.tsx b/components/toasts/UpdateToast/index.tsx similarity index 100% rename from components/about/UpdateToast/index.tsx rename to components/toasts/UpdateToast/index.tsx diff --git a/components/toasts/UrlCopiedToast/index.tsx b/components/toasts/UrlCopiedToast/index.tsx new file mode 100644 index 00000000..60ffea89 --- /dev/null +++ b/components/toasts/UrlCopiedToast/index.tsx @@ -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 ( + + ) +} + +export default UrlCopiedToast diff --git a/components/weapon/ExtraWeapons/index.scss b/components/weapon/ExtraWeapons/index.scss deleted file mode 100644 index d4cc6d5b..00000000 --- a/components/weapon/ExtraWeapons/index.scss +++ /dev/null @@ -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); - } -} diff --git a/components/weapon/ExtraWeapons/index.tsx b/components/weapon/ExtraWeapons/index.tsx deleted file mode 100644 index 6c1ec552..00000000 --- a/components/weapon/ExtraWeapons/index.tsx +++ /dev/null @@ -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 - 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 ( -
- {t('extra_weapons')} -
    - {Array.from(Array(numWeapons)).map((x, i) => { - return ( -
  • - -
  • - ) - })} -
-
- ) -} - -export default ExtraWeapons diff --git a/components/weapon/WeaponGrid/index.scss b/components/weapon/WeaponGrid/index.scss index 7d04123d..92324d01 100644 --- a/components/weapon/WeaponGrid/index.scss +++ b/components/weapon/WeaponGrid/index.scss @@ -13,10 +13,7 @@ gap: $unit-3x; grid-template-columns: 1.278fr 3fr; justify-items: center; - grid-template-areas: - 'mainhand grid' - 'mainhand grid' - 'mainhand grid'; + grid-template-areas: 'mainhand grid'; max-width: $grid-width; @include breakpoint(tablet) { @@ -49,7 +46,7 @@ } } - li { - list-style: none; + li:not(.Empty) { + // aspect-ratio: 1 / 1.035; } } diff --git a/components/weapon/WeaponGrid/index.tsx b/components/weapon/WeaponGrid/index.tsx index 36ba8843..b27cb658 100644 --- a/components/weapon/WeaponGrid/index.tsx +++ b/components/weapon/WeaponGrid/index.tsx @@ -6,10 +6,13 @@ import { useTranslation } from 'next-i18next' import { AxiosError, AxiosResponse } from 'axios' import debounce from 'lodash.debounce' +import classNames from 'classnames' import Alert from '~components/common/Alert' 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 api from '~utils/api' @@ -24,8 +27,11 @@ interface Props { new: boolean editable: boolean weapons?: GridWeapon[] + guidebooks?: GuidebookList createParty: (details: DetailsObject) => Promise pushHistory?: (path: string) => void + updateExtra: (enabled: boolean) => void + updateGuidebook: (book: Guidebook | undefined, position: number) => void } 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) { if (data.hasOwnProperty('conflicts')) { 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 // Note: Saves, but debouncing is not working properly async function saveUncap(id: string, position: number, uncapLevel: number) { @@ -318,6 +335,12 @@ const WeaponGrid = (props: Props) => { setPreviousUncapValues(newPreviousValues) } + // Methods: Convenience + const displayExtraContainer = + props.editable || + appState.party.extra || + Object.values(appState.party.guidebooks).every((el) => el === undefined) + // Render: JSX components const mainhandElement = ( { ) const weaponGridElement = Array.from(Array(numWeapons)).map((x, i) => { + const itemClasses = classNames({ + Empty: appState.grid.weapons.allWeapons[i] === undefined, + }) + return ( -
  • +
  • { ) }) - const extraGridElement = ( - - ) + const extraElement = () => { + if (appState.party.raid && appState.party.raid.group.extra) { + return ( + + {appState.party.raid && appState.party.raid.group.extra && ( + + )} + {appState.party.raid && appState.party.raid.group.guidebooks && ( + + )} + + ) + } + } const conflictModal = () => { return incoming && conflicts ? ( @@ -409,9 +452,7 @@ const WeaponGrid = (props: Props) => {
      {weaponGridElement}
  • - {(() => { - return party.extra ? extraGridElement : '' - })()} + {displayExtraContainer ? extraElement() : ''}
    ) } diff --git a/components/weapon/WeaponHovercard/index.tsx b/components/weapon/WeaponHovercard/index.tsx index c0e3f48b..1e9132c1 100644 --- a/components/weapon/WeaponHovercard/index.tsx +++ b/components/weapon/WeaponHovercard/index.tsx @@ -12,7 +12,6 @@ import WeaponLabelIcon from '~components/weapon/WeaponLabelIcon' import UncapIndicator from '~components/uncap/UncapIndicator' import ax from '~data/ax' -import { weaponAwakening } from '~data/awakening' import './index.scss' @@ -146,11 +145,8 @@ const WeaponHovercard = (props: Props) => { const awakeningSection = () => { const gridAwakening = props.gridWeapon.awakening - const awakening = weaponAwakening.find( - (awakening) => awakening.id === gridAwakening?.type - ) - if (gridAwakening && awakening) { + if (gridAwakening) { return (
    @@ -158,11 +154,11 @@ const WeaponHovercard = (props: Props) => {
    {awakening.name[locale]} - {`${awakening.name[locale]}`}  + {`${gridAwakening.type.name[locale]}`}  {`Lv${gridAwakening.level}`}
    diff --git a/components/weapon/WeaponModal/index.tsx b/components/weapon/WeaponModal/index.tsx index 157529be..477b78e9 100644 --- a/components/weapon/WeaponModal/index.tsx +++ b/components/weapon/WeaponModal/index.tsx @@ -11,14 +11,15 @@ import { DialogTrigger, } from '~components/common/Dialog' import DialogContent from '~components/common/DialogContent' +import AwakeningSelectWithInput from '~components/mastery/AwakeningSelectWithInput' import AXSelect from '~components/mastery/AxSelect' -import AwakeningSelect from '~components/mastery/AwakeningSelect' import ElementToggle from '~components/ElementToggle' import WeaponKeySelect from '~components/weapon/WeaponKeySelect' import Button from '~components/common/Button' import api from '~utils/api' import { appState } from '~utils/appState' +import { NO_AWAKENING } from '~data/awakening' import CrossIcon from '~public/icons/Cross.svg' import './index.scss' @@ -33,7 +34,7 @@ interface GridWeaponObject { ax_modifier2?: number ax_strength1?: number ax_strength2?: number - awakening_type?: number + awakening_id?: string awakening_level?: Number } } @@ -70,7 +71,7 @@ const WeaponModal = ({ const [element, setElement] = useState(-1) - const [awakeningType, setAwakeningType] = useState(0) + const [awakening, setAwakening] = useState() const [awakeningLevel, setAwakeningLevel] = useState(1) const [primaryAxModifier, setPrimaryAxModifier] = useState(-1) @@ -136,9 +137,10 @@ const WeaponModal = ({ setFormValid(isValid) } - function receiveAwakeningValues(type: number, level: number) { - setAwakeningType(type) + function receiveAwakeningValues(id: string, level: number) { + setAwakening(gridWeapon.object.awakenings.find((a) => a.id === id)) setAwakeningLevel(level) + setFormValid(true) } function receiveElementValue(element: string) { @@ -167,8 +169,8 @@ const WeaponModal = ({ object.weapon.ax_strength2 = secondaryAxValue } - if (gridWeapon.object.awakening) { - object.weapon.awakening_type = awakeningType + if (gridWeapon.object.awakenings) { + object.weapon.awakening_id = awakening?.id object.weapon.awakening_level = awakeningLevel } @@ -313,10 +315,12 @@ const WeaponModal = ({ return (

    {t('modals.weapon.subtitles.awakening')}

    -