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
This commit is contained in:
Justin Edmund 2023-06-16 18:49:55 -07:00 committed by GitHub
parent f7c895d0ca
commit d765b00120
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 3317 additions and 794 deletions

View file

@ -296,25 +296,6 @@ const Header = () => {
}
// Rendering: Buttons
const saveButton = () => {
return (
<Tooltip content={t('tooltips.save')}>
<Button
leftAccessoryIcon={<SaveIcon />}
className={classNames({
Save: true,
Saved: partySnapshot.favorited,
})}
blended={true}
text={
partySnapshot.favorited ? t('buttons.saved') : t('buttons.save')
}
onClick={toggleFavorite}
/>
</Tooltip>
)
}
const newButton = () => {
return (
<Tooltip content={t('tooltips.new')}>
@ -329,20 +310,6 @@ const Header = () => {
)
}
const remixButton = () => {
return (
<Tooltip content={t('tooltips.remix')}>
<Button
leftAccessoryIcon={<RemixIcon />}
className="Remix"
blended={true}
text={t('buttons.remix')}
onClick={remixTeam}
/>
</Tooltip>
)
}
// Rendering: Toasts
const urlCopyToast = () => {
return (
@ -435,15 +402,6 @@ const Header = () => {
const right = () => {
return (
<section>
{router.route === '/p/[party]' &&
account.user &&
(!partySnapshot.user || partySnapshot.user.id !== account.user.id) &&
!appState.errorCode
? saveButton()
: ''}
{router.route === '/p/[party]' && !appState.errorCode
? remixButton()
: ''}
{newButton()}
<DropdownMenu
open={rightMenuOpen}

View file

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

View file

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

View file

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

View file

@ -166,6 +166,15 @@
}
}
&.Destructive {
background: $error;
color: white;
&:hover {
background: darken($error, 15);
}
}
.Accessory {
$dimension: $unit-2x;

View file

@ -59,8 +59,12 @@
width: 100%;
}
.Scrollable {
overflow-y: auto;
.Container {
overflow-y: hidden;
&.Scrollable {
overflow-y: auto;
}
}
.DialogHeader {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,95 @@
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next'
import Switch from '~components/common/Switch'
import GuidebookUnit from '../GuidebookUnit'
import classNames from 'classnames'
import type { SearchableObject } from '~types'
import './index.scss'
// Props
interface Props {
grid: GuidebookList
editable: boolean
offset: number
removeGuidebook: (position: number) => void
updateObject: (object: SearchableObject, position: number) => void
}
// Constants
const EXTRA_WEAPONS_COUNT = 3
const GuidebooksGrid = ({
grid,
editable,
removeGuidebook,
updateObject,
}: Props) => {
const { t } = useTranslation('common')
const [enabled, setEnabled] = useState(false)
const classes = classNames({
Guidebooks: true,
ContainerItem: true,
Disabled: !enabled,
})
useEffect(() => {
console.log('Grid updated')
if (hasGuidebooks()) setEnabled(true)
}, [grid])
function hasGuidebooks() {
return grid && (grid[0] || grid[1] || grid[2])
}
function onCheckedChange(checked: boolean) {
setEnabled(checked)
}
const enabledElement = (
<ul id="GuidebooksGrid">
{Array.from(Array(EXTRA_WEAPONS_COUNT)).map((x, i) => {
const itemClasses = classNames({
Empty: grid && grid[i] === undefined,
})
return (
<li className={itemClasses} key={`grid_unit_${i}`}>
<GuidebookUnit
editable={editable}
position={i + 1}
guidebook={grid[i + 1]}
removeGuidebook={removeGuidebook}
updateObject={updateObject}
/>
</li>
)
})}
</ul>
)
const guidebookElement = (
<div className={classes}>
<div className="Header">
<h3>{t('sephira_guidebooks')}</h3>
{editable ? (
<Switch
name="Guidebooks"
checked={enabled}
onCheckedChange={onCheckedChange}
/>
) : (
''
)}
</div>
{enabled ? enabledElement : ''}
</div>
)
return editable || (enabled && !editable) ? guidebookElement : <div />
}
export default GuidebooksGrid

View file

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

View file

@ -2,10 +2,13 @@ import React, { useEffect, useState } from 'react'
import { getCookie } from 'cookies-next'
import { useRouter } from 'next/router'
import { subscribe, useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import clonedeep from 'lodash.clonedeep'
import Alert from '~components/common/Alert'
import PartySegmentedControl from '~components/party/PartySegmentedControl'
import PartyDetails from '~components/party/PartyDetails'
import PartyHeader from '~components/party/PartyHeader'
import WeaponGrid from '~components/weapon/WeaponGrid'
import SummonGrid from '~components/summon/SummonGrid'
import CharacterGrid from '~components/character/CharacterGrid'
@ -39,11 +42,15 @@ const Party = (props: Props) => {
// Set up router
const router = useRouter()
// Localization
const { t } = useTranslation('common')
// Set up states
const { party } = useSnapshot(appState)
const [editable, setEditable] = useState(false)
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
const [refresh, setRefresh] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
// Retrieve cookies
const cookies = retrieveCookies()
@ -134,8 +141,11 @@ const Party = (props: Props) => {
if (details.turnCount) payload.turn_count = details.turnCount
if (details.extra) payload.extra = details.extra
if (details.job) payload.job_id = details.job.id
if (details.guidebook1_id) payload.guidebook1_id = details.guidebook1_id
if (details.guidebook2_id) payload.guidebook2_id = details.guidebook2_id
if (details.guidebook3_id) payload.guidebook3_id = details.guidebook3_id
if (Object.keys(payload).length > 1) return { party: payload }
if (Object.keys(payload).length >= 1) return { party: payload }
else return {}
}
@ -146,20 +156,74 @@ const Party = (props: Props) => {
return await api.endpoints.parties
.update(props.team.id, payload)
.then((response) => storeParty(response.data.party))
.catch((error) => {
const data = error.response.data
if (data.errors && Object.keys(data.errors).includes('guidebooks')) {
const message = t('errors.validation.guidebooks')
setErrorMessage(message)
}
})
}
}
function checkboxChanged(event: React.ChangeEvent<HTMLInputElement>) {
appState.party.extra = event.target.checked
function cancelAlert() {
setErrorMessage('')
}
function checkboxChanged(enabled: boolean) {
appState.party.extra = enabled
// Only save if this is a saved party
if (props.team && props.team.id) {
api.endpoints.parties.update(props.team.id, {
party: { extra: event.target.checked },
party: { extra: enabled },
})
}
}
function updateGuidebook(book: Guidebook | undefined, position: number) {
let id: string | undefined = ''
if (book) id = book.id
else if (!book) id = 'undefined'
else id = undefined
const details: DetailsObject = {
guidebook1_id: position === 1 ? id : undefined,
guidebook2_id: position === 2 ? id : undefined,
guidebook3_id: position === 3 ? id : undefined,
}
if (props.team && props.team.id) {
updateParty(details)
} else {
createParty(details)
}
}
// Remixing the party
function remixTeam() {
// setOriginalName(partySnapshot.name ? partySnapshot.name : t('no_title'))
if (props.team && props.team.shortcode) {
const body = getLocalId()
api
.remix({ shortcode: props.team.shortcode, body: body })
.then((response) => {
const remix = response.data.party
// Store the edit key in local storage
if (remix.edit_key) {
storeEditKey(remix.id, remix.edit_key)
setEditKey(remix.id, remix.user)
}
router.push(`/p/${remix.shortcode}`)
// setRemixToastOpen(true)
})
}
}
// Deleting the party
function deleteTeam() {
if (props.team && editable) {
@ -202,6 +266,7 @@ const Party = (props: Props) => {
appState.party.id = team.id
appState.party.shortcode = team.shortcode
appState.party.extra = team.extra
appState.party.guidebooks = team.guidebooks
appState.party.user = team.user
appState.party.favorited = team.favorited
appState.party.remix = team.remix
@ -297,12 +362,19 @@ const Party = (props: Props) => {
}
// Render: JSX components
const errorAlert = () => {
return (
<Alert
open={errorMessage.length > 0}
message={errorMessage}
cancelAction={cancelAlert}
cancelActionText={t('buttons.confirm')}
/>
)
}
const navigation = (
<PartySegmentedControl
selectedTab={currentTab}
onClick={segmentClicked}
onCheckboxChange={checkboxChanged}
/>
<PartySegmentedControl selectedTab={currentTab} onClick={segmentClicked} />
)
const weaponGrid = (
@ -310,8 +382,11 @@ const Party = (props: Props) => {
new={props.new || false}
editable={editable}
weapons={props.team?.weapons}
guidebooks={props.team?.guidebooks}
createParty={createParty}
pushHistory={props.pushHistory}
updateExtra={checkboxChanged}
updateGuidebook={updateGuidebook}
/>
)
@ -348,14 +423,26 @@ const Party = (props: Props) => {
return (
<React.Fragment>
{errorAlert()}
<PartyHeader
party={props.team}
new={props.new || false}
editable={party.editable}
deleteCallback={deleteTeam}
remixCallback={remixTeam}
updateCallback={updateDetails}
/>
{navigation}
<section id="Party">{currentGrid()}</section>
<PartyDetails
party={props.team}
new={props.new || false}
editable={party.editable}
updateCallback={updateDetails}
deleteCallback={deleteTeam}
/>
</React.Fragment>
)

View file

@ -1,7 +1,6 @@
import React, { useEffect, useState, ChangeEvent, KeyboardEvent } from 'react'
import Link from 'next/link'
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { subscribe, useSnapshot } from 'valtio'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import clonedeep from 'lodash.clonedeep'
@ -10,27 +9,20 @@ import LiteYouTubeEmbed from 'react-lite-youtube-embed'
import classNames from 'classnames'
import reactStringReplace from 'react-string-replace'
import Alert from '~components/common/Alert'
import Button from '~components/common/Button'
import CharLimitedFieldset from '~components/common/CharLimitedFieldset'
import DurationInput from '~components/common/DurationInput'
import GridRepCollection from '~components/GridRepCollection'
import GridRep from '~components/GridRep'
import Input from '~components/common/Input'
import RaidDropdown from '~components/RaidDropdown'
import Switch from '~components/common/Switch'
import Tooltip from '~components/common/Tooltip'
import TextFieldset from '~components/common/TextFieldset'
import Token from '~components/common/Token'
import api from '~utils/api'
import { accountState } from '~utils/accountState'
import { appState, initialAppState } from '~utils/appState'
import { formatTimeAgo } from '~utils/timeAgo'
import { youtube } from '~utils/youtube'
import CheckIcon from '~public/icons/Check.svg'
import CrossIcon from '~public/icons/Cross.svg'
import EllipsisIcon from '~public/icons/Ellipsis.svg'
import EditIcon from '~public/icons/Edit.svg'
import RemixIcon from '~public/icons/Remix.svg'
@ -44,38 +36,21 @@ interface Props {
new: boolean
editable: boolean
updateCallback: (details: DetailsObject) => void
deleteCallback: () => void
}
const PartyDetails = (props: Props) => {
const { party, raids } = useSnapshot(appState)
const { t } = useTranslation('common')
const router = useRouter()
const locale = router.locale || 'en'
const youtubeUrlRegex =
/(?:https:\/\/www\.youtube\.com\/watch\?v=|https:\/\/youtu\.be\/)([\w-]+)/g
const nameInput = React.createRef<HTMLInputElement>()
const descriptionInput = React.createRef<HTMLTextAreaElement>()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [alertOpen, setAlertOpen] = useState(false)
const [chargeAttack, setChargeAttack] = useState(true)
const [fullAuto, setFullAuto] = useState(false)
const [autoGuard, setAutoGuard] = useState(false)
const [buttonCount, setButtonCount] = useState<number | undefined>(undefined)
const [chainCount, setChainCount] = useState<number | undefined>(undefined)
const [turnCount, setTurnCount] = useState<number | undefined>(undefined)
const [clearTime, setClearTime] = useState(0)
const [remixes, setRemixes] = useState<Party[]>([])
const [raidSlug, setRaidSlug] = useState('')
const [embeddedDescription, setEmbeddedDescription] =
useState<React.ReactNode>()
@ -91,59 +66,11 @@ const PartyDetails = (props: Props) => {
Visible: open,
})
const userClass = classNames({
user: true,
empty: !party.user,
})
const linkClass = classNames({
wind: party && party.element == 1,
fire: party && party.element == 2,
water: party && party.element == 3,
earth: party && party.element == 4,
dark: party && party.element == 5,
light: party && party.element == 6,
})
const [errors, setErrors] = useState<{ [key: string]: string }>({
name: '',
description: '',
})
useEffect(() => {
if (props.party) {
setName(props.party.name)
setAutoGuard(props.party.auto_guard)
setFullAuto(props.party.full_auto)
setChargeAttack(props.party.charge_attack)
setClearTime(props.party.clear_time)
setRemixes(props.party.remixes)
if (props.party.turn_count) setTurnCount(props.party.turn_count)
if (props.party.button_count) setButtonCount(props.party.button_count)
if (props.party.chain_count) setChainCount(props.party.chain_count)
}
}, [props.party])
// Subscribe to router changes and reset state
// if the new route is a new team
useEffect(() => {
router.events.on('routeChangeStart', (url, { shallow }) => {
if (url === '/new' || url === '/') {
const party = initialAppState.party
setName(party.name ? party.name : '')
setAutoGuard(party.autoGuard)
setFullAuto(party.fullAuto)
setChargeAttack(party.chargeAttack)
setClearTime(party.clearTime)
setRemixes(party.remixes)
setTurnCount(party.turnCount)
setButtonCount(party.buttonCount)
setChainCount(party.chainCount)
}
})
}, [])
useEffect(() => {
// Extract the video IDs from the description
if (appState.party.description) {
@ -177,16 +104,6 @@ const PartyDetails = (props: Props) => {
}
}, [appState.party.description])
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
const { name, value } = event.target
setName(value)
let newErrors = errors
setErrors(newErrors)
}
function handleTextAreaChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
event.preventDefault()
@ -196,81 +113,6 @@ const PartyDetails = (props: Props) => {
setErrors(newErrors)
}
function handleChargeAttackChanged(checked: boolean) {
setChargeAttack(checked)
}
function handleFullAutoChanged(checked: boolean) {
setFullAuto(checked)
}
function handleAutoGuardChanged(checked: boolean) {
setAutoGuard(checked)
}
function handleClearTimeInput(value: number) {
if (!isNaN(value)) setClearTime(value)
}
function handleTurnCountInput(event: React.ChangeEvent<HTMLInputElement>) {
const value = parseInt(event.currentTarget.value)
if (!isNaN(value)) setTurnCount(value)
}
function handleButtonCountInput(event: ChangeEvent<HTMLInputElement>) {
const value = parseInt(event.currentTarget.value)
if (!isNaN(value)) setButtonCount(value)
}
function handleChainCountInput(event: ChangeEvent<HTMLInputElement>) {
const value = parseInt(event.currentTarget.value)
if (!isNaN(value)) setChainCount(value)
}
function handleInputKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
// Allow the key to be processed normally
return
}
// Get the current value
const input = event.currentTarget
let value = event.currentTarget.value
// Check if the key that was pressed is the backspace key
if (event.key === 'Backspace') {
// Remove the colon if the value is "12:"
if (value.length === 4) {
value = value.slice(0, -1)
}
// Allow the backspace key to be processed normally
input.value = value
return
}
// Check if the key that was pressed is the tab key
if (event.key === 'Tab') {
// Allow the tab key to be processed normally
return
}
// Get the character that was entered and check if it is numeric
const char = parseInt(event.key)
const isNumber = !isNaN(char)
// Check if the character should be accepted or rejected
const numberValue = parseInt(`${value}${char}`)
const minValue = parseInt(event.currentTarget.min)
const maxValue = parseInt(event.currentTarget.max)
if (!isNumber || numberValue < minValue || numberValue > maxValue) {
// Reject the character if it isn't a number,
// or if it exceeds the min and max values
event.preventDefault()
}
}
async function fetchYoutubeData(videoId: string) {
return await youtube
.getVideoById(videoId, { maxResults: 1 })
@ -289,30 +131,9 @@ const PartyDetails = (props: Props) => {
setOpen(!open)
}
function receiveRaid(slug?: string) {
if (slug) setRaidSlug(slug)
}
function switchValue(value: boolean) {
if (value) return 'on'
else return 'off'
}
function updateDetails(event: React.MouseEvent) {
const descriptionValue = descriptionInput.current?.value
const raid = raids.find((raid) => raid.slug === raidSlug)
const details: DetailsObject = {
fullAuto: fullAuto,
autoGuard: autoGuard,
chargeAttack: chargeAttack,
clearTime: clearTime,
buttonCount: buttonCount,
turnCount: turnCount,
chainCount: chainCount,
name: name,
description: descriptionValue,
raid: raid,
description: descriptionInput.current?.value,
}
props.updateCallback(details)
@ -323,15 +144,33 @@ const PartyDetails = (props: Props) => {
setAlertOpen(!alertOpen)
}
function deleteParty() {
props.deleteCallback()
}
// Methods: Navigation
function goTo(shortcode?: string) {
if (shortcode) router.push(`/p/${shortcode}`)
}
function extractYoutubeVideoIds(text: string) {
// Initialize an array to store the video IDs
const videoIds = []
// Use the regular expression to find all the Youtube URLs in the text
let match
while ((match = youtubeUrlRegex.exec(text)) !== null) {
// Extract the video ID from the URL
const videoId = match[1]
// Add the video ID to the array, along with the character position of the URL
videoIds.push({
id: videoId,
url: match[0],
position: match.index,
})
}
// Return the array of video IDs
return videoIds
}
// Methods: Favorites
function toggleFavorite(teamId: string, favorited: boolean) {
if (favorited) unsaveFavorite(teamId)
@ -370,103 +209,6 @@ const PartyDetails = (props: Props) => {
})
}
function extractYoutubeVideoIds(text: string) {
// Initialize an array to store the video IDs
const videoIds = []
// Use the regular expression to find all the Youtube URLs in the text
let match
while ((match = youtubeUrlRegex.exec(text)) !== null) {
// Extract the video ID from the URL
const videoId = match[1]
// Add the video ID to the array, along with the character position of the URL
videoIds.push({
id: videoId,
url: match[0],
position: match.index,
})
}
// Return the array of video IDs
return videoIds
}
const userImage = (picture?: string, element?: string) => {
if (picture && element)
return (
<img
alt={picture}
className={`profile ${element}`}
srcSet={`/profile/${picture}.png,
/profile/${picture}@2x.png 2x`}
src={`/profile/${picture}.png`}
/>
)
else
return (
<img
alt={t('no_user')}
className={`profile anonymous`}
srcSet={`/profile/npc.png,
/profile/npc@2x.png 2x`}
src={`/profile/npc.png`}
/>
)
}
const userBlock = (username?: string, picture?: string, element?: string) => {
return (
<div className={userClass}>
{userImage(picture, element)}
{username ? username : t('no_user')}
</div>
)
}
const renderUserBlock = () => {
let username, picture, element
if (accountState.account.authorized && props.new) {
username = accountState.account.user?.username
picture = accountState.account.user?.avatar.picture
element = accountState.account.user?.avatar.element
} else if (party.user && !props.new) {
username = party.user.username
picture = party.user.avatar.picture
element = party.user.avatar.element
}
if (username && picture && element) {
return linkedUserBlock(username, picture, element)
} else if (!props.new) {
return userBlock()
}
}
const linkedUserBlock = (
username?: string,
picture?: string,
element?: string
) => {
return (
<div>
<Link href={`/${username}`} passHref>
<a className={linkClass}>{userBlock(username, picture, element)}</a>
</Link>
</div>
)
}
const linkedRaidBlock = (raid: Raid) => {
return (
<div>
<Link href={`/teams?raid=${raid.slug}`} passHref>
<a className={`Raid ${linkClass}`}>{raid.name[locale]}</a>
</Link>
</div>
)
}
function renderRemixes() {
return remixes.map((party, i) => {
return (
@ -490,142 +232,9 @@ const PartyDetails = (props: Props) => {
})
}
const deleteAlert = () => {
if (party.editable) {
return (
<Alert
open={alertOpen}
primaryAction={deleteParty}
primaryActionText={t('modals.delete_team.buttons.confirm')}
cancelAction={() => setAlertOpen(false)}
cancelActionText={t('modals.delete_team.buttons.cancel')}
message={t('modals.delete_team.description')}
/>
)
}
}
const editable = () => {
return (
<section className={editableClasses}>
<CharLimitedFieldset
fieldName="name"
placeholder="Name your team"
value={props.party?.name}
limit={50}
onChange={handleInputChange}
error={errors.name}
ref={nameInput}
/>
<RaidDropdown
showAllRaidsOption={false}
currentRaid={props.party?.raid ? props.party?.raid.slug : undefined}
onChange={receiveRaid}
/>
<ul className="SwitchToggleGroup DetailToggleGroup">
<li className="Ougi ToggleSection">
<label htmlFor="ougi">
<span>{t('party.details.labels.charge_attack')}</span>
<div>
<Switch
name="charge_attack"
onCheckedChange={handleChargeAttackChanged}
value={switchValue(chargeAttack)}
checked={chargeAttack}
/>
</div>
</label>
</li>
<li className="FullAuto ToggleSection">
<label htmlFor="full_auto">
<span>{t('party.details.labels.full_auto')}</span>
<div>
<Switch
onCheckedChange={handleFullAutoChanged}
name="full_auto"
value={switchValue(fullAuto)}
checked={fullAuto}
/>
</div>
</label>
</li>
<li className="AutoGuard ToggleSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.auto_guard')}</span>
<div>
<Switch
onCheckedChange={handleAutoGuardChanged}
name="auto_guard"
value={switchValue(autoGuard)}
disabled={!fullAuto}
checked={autoGuard}
/>
</div>
</label>
</li>
</ul>
<ul className="InputToggleGroup DetailToggleGroup">
<li className="InputSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.button_chain')}</span>
<div className="Input Bound">
<Input
name="buttons"
type="number"
placeholder="0"
value={`${buttonCount}`}
min="0"
max="99"
onChange={handleButtonCountInput}
onKeyDown={handleInputKeyDown}
/>
<span>b</span>
<Input
name="chains"
type="number"
placeholder="0"
min="0"
max="99"
value={`${chainCount}`}
onChange={handleChainCountInput}
onKeyDown={handleInputKeyDown}
/>
<span>c</span>
</div>
</label>
</li>
<li className="InputSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.turn_count')}</span>
<Input
name="turn_count"
className="AlignRight Bound"
type="number"
step="1"
min="1"
max="999"
placeholder="0"
value={`${turnCount}`}
onChange={handleTurnCountInput}
onKeyDown={handleInputKeyDown}
/>
</label>
</li>
<li className="InputSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.clear_time')}</span>
<div>
<DurationInput
name="clear_time"
className="Bound"
placeholder="00:00"
value={clearTime}
onValueChange={(value: number) => handleClearTimeInput(value)}
/>
</div>
</label>
</li>
</ul>
<TextFieldset
fieldName="name"
placeholder={
@ -663,91 +272,9 @@ const PartyDetails = (props: Props) => {
)
}
const clearTimeString = () => {
const minutes = Math.floor(clearTime / 60)
const seconds = clearTime - minutes * 60
if (minutes > 0)
return `${minutes}${t('party.details.suffix.minutes')} ${seconds}${t(
'party.details.suffix.seconds'
)}`
else return `${seconds}${t('party.details.suffix.seconds')}`
}
const buttonChainToken = () => {
if (buttonCount || chainCount) {
let string = ''
if (buttonCount && buttonCount > 0) {
string += `${buttonCount}b`
}
if (!buttonCount && chainCount && chainCount > 0) {
string += `0${t('party.details.suffix.buttons')}${chainCount}${t(
'party.details.suffix.chains'
)}`
} else if (buttonCount && chainCount && chainCount > 0) {
string += `${chainCount}${t('party.details.suffix.chains')}`
} else if (buttonCount && !chainCount) {
string += `0${t('party.details.suffix.chains')}`
}
return <Token>{string}</Token>
}
}
const readOnly = () => {
return (
<section className={readOnlyClasses}>
<section className="Details">
<Token
className={classNames({
ChargeAttack: true,
On: chargeAttack,
Off: !chargeAttack,
})}
>
{`${t('party.details.labels.charge_attack')} ${
chargeAttack ? 'On' : 'Off'
}`}
</Token>
<Token
className={classNames({
FullAuto: true,
On: fullAuto,
Off: !fullAuto,
})}
>
{`${t('party.details.labels.full_auto')} ${
fullAuto ? 'On' : 'Off'
}`}
</Token>
<Token
className={classNames({
AutoGuard: true,
On: autoGuard,
Off: !autoGuard,
})}
>
{`${t('party.details.labels.auto_guard')} ${
autoGuard ? 'On' : 'Off'
}`}
</Token>
{turnCount ? (
<Token>
{t('party.details.turns.with_count', {
count: turnCount,
})}
</Token>
) : (
''
)}
{clearTime > 0 ? <Token>{clearTimeString()}</Token> : ''}
{buttonChainToken()}
</section>
<Linkify>{embeddedDescription}</Linkify>
</section>
)
@ -765,56 +292,8 @@ const PartyDetails = (props: Props) => {
return (
<>
<section className="DetailsWrapper">
<div className="PartyInfo">
<div className="Left">
<div className="Header">
<h1 className={name ? '' : 'empty'}>
{name ? name : t('no_title')}
</h1>
{party.remix && party.sourceParty ? (
<Tooltip content={t('tooltips.source')}>
<Button
className="IconButton Blended"
leftAccessoryIcon={<RemixIcon />}
text={t('tokens.remix')}
onClick={() => goTo(party.sourceParty?.shortcode)}
/>
</Tooltip>
) : (
''
)}
</div>
<div className="attribution">
{renderUserBlock()}
{party.raid ? linkedRaidBlock(party.raid) : ''}
{party.created_at != '' ? (
<time
className="last-updated"
dateTime={new Date(party.created_at).toString()}
>
{formatTimeAgo(new Date(party.created_at), locale)}
</time>
) : (
''
)}
</div>
</div>
{party.editable ? (
<div className="Right">
<Button
leftAccessoryIcon={<EditIcon />}
text={t('buttons.show_info')}
onClick={toggleDetails}
/>
</div>
) : (
''
)}
</div>
{readOnly()}
{editable()}
{deleteAlert()}
</section>
{remixes && remixes.length > 0 ? remixSection() : ''}
</>

View file

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

View file

@ -0,0 +1,394 @@
.DetailsWrapper {
display: flex;
flex-direction: column;
gap: $unit-2x;
margin: $unit-4x auto 0 auto;
max-width: $grid-width;
@include breakpoint(phone) {
.Button:not(.IconButton) {
justify-content: center;
width: 100%;
.Text {
width: auto;
}
}
}
.PartyDetails {
box-sizing: border-box;
display: none;
margin: 0 auto $unit-2x;
max-width: $unit * 94;
overflow: hidden;
width: 100%;
@include breakpoint(phone) {
padding: 0 $unit;
}
&.Visible {
// margin-bottom: $unit-12x;
}
&.Editable {
gap: $unit;
&.Visible {
display: grid;
}
fieldset {
display: block;
width: 100%;
textarea {
min-height: $unit * 22;
width: 100%;
}
}
.SelectTrigger {
padding: $unit-2x;
width: 100%;
}
.DetailToggleGroup {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: $unit;
@include breakpoint(phone) {
grid-template-columns: 1fr;
}
.ToggleSection,
.InputSection {
align-items: center;
display: flex;
background: var(--card-bg);
border-radius: $input-corner;
& > label {
align-items: center;
display: flex;
font-size: $font-regular;
gap: $unit;
grid-template-columns: 2fr 1fr;
justify-content: space-between;
width: 100%;
& > span {
flex-grow: 1;
}
}
}
.ToggleSection {
padding: ($unit * 1.5) $unit-2x;
}
.InputSection {
padding: $unit-half $unit-2x;
padding-right: $unit-half;
.Input {
border-radius: 7px;
}
div.Input {
align-items: center;
border: 2px solid transparent;
box-sizing: border-box;
display: flex;
padding: $unit;
&:has(> input:focus) {
border: 2px solid $blue;
outline: none;
}
& > input {
background: transparent;
border: none;
padding: $unit 0;
text-align: right;
width: 2rem;
&:focus {
outline: none;
border: none;
}
}
}
label {
display: flex;
justify-content: space-between;
span {
flex-grow: 1;
}
.Input {
border-radius: 7px;
max-width: 10rem;
}
div {
display: flex;
flex-direction: row;
gap: $unit-half;
justify-content: right;
}
}
}
}
.bottom {
display: flex;
flex-direction: row;
gap: $unit;
@include breakpoint(phone) {
flex-direction: column;
width: 100%;
}
.left {
flex-grow: 1;
}
.right {
display: flex;
flex-direction: row;
gap: $unit;
@include breakpoint(phone) {
.Button {
flex-grow: 1;
}
}
}
}
}
&.ReadOnly {
box-sizing: border-box;
line-height: 1.4;
white-space: pre-wrap;
&.Visible {
display: block;
}
a:hover {
text-decoration: underline;
}
p {
font-size: $font-regular;
line-height: $font-regular * 1.2;
white-space: pre-line;
}
.Tokens {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: $unit;
margin-bottom: $unit-2x;
}
.YoutubeWrapper {
background-color: var(--card-bg);
border-radius: $card-corner;
margin: $unit 0;
position: relative;
display: block;
contain: content;
background-position: center center;
background-size: cover;
cursor: pointer;
width: 60%;
height: 60%;
@include breakpoint(tablet) {
width: 100%;
height: 100%;
}
/* gradient */
&::before {
content: '';
display: block;
position: absolute;
top: 0;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADGCAYAAAAT+OqFAAAAdklEQVQoz42QQQ7AIAgEF/T/D+kbq/RWAlnQyyazA4aoAB4FsBSA/bFjuF1EOL7VbrIrBuusmrt4ZZORfb6ehbWdnRHEIiITaEUKa5EJqUakRSaEYBJSCY2dEstQY7AuxahwXFrvZmWl2rh4JZ07z9dLtesfNj5q0FU3A5ObbwAAAABJRU5ErkJggg==);
background-position: top;
background-repeat: repeat-x;
height: 60px;
padding-bottom: 50px;
width: 100%;
transition: all 0.2s cubic-bezier(0, 0, 0.2, 1);
}
/* responsive iframe with a 16:9 aspect ratio
thanks https://css-tricks.com/responsive-iframes/
*/
&::after {
content: '';
display: block;
padding-bottom: calc(100% / (16 / 9));
}
&:hover > .PlayerButton {
opacity: 1;
}
& > iframe {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
/* Play button */
& > .PlayerButton {
background: none;
border: none;
background-image: url('/icons/youtube.svg');
width: 68px;
height: 68px;
opacity: 0.8;
transition: all 0.2s cubic-bezier(0, 0, 0.2, 1);
}
& > .PlayerButton,
& > .PlayerButton:before {
position: absolute;
top: 50%;
left: 50%;
transform: translate3d(-50%, -50%, 0);
}
/* Post-click styles */
&.lyt-activated {
cursor: unset;
}
&.lyt-activated::before,
&.lyt-activated > .PlayerButton {
opacity: 0;
pointer-events: none;
}
}
}
}
.PartyInfo {
box-sizing: border-box;
display: flex;
flex-direction: row;
gap: $unit;
margin: 0 auto;
max-width: $unit * 94;
width: 100%;
@include breakpoint(phone) {
flex-direction: column;
gap: $unit;
padding: 0 $unit;
}
& > .Right {
display: flex;
gap: $unit-half;
}
& > .Left {
flex-grow: 1;
.Header {
align-items: center;
display: flex;
gap: $unit;
margin-bottom: $unit;
h1 {
font-size: $font-xlarge;
font-weight: $normal;
text-align: left;
color: var(--text-primary);
&.empty {
color: var(--text-secondary);
}
}
}
.attribution {
align-items: center;
display: flex;
flex-direction: row;
& > div {
align-items: center;
display: inline-flex;
font-size: $font-small;
height: 26px;
}
time {
font-size: $font-small;
}
a:visited:not(.fire):not(.water):not(.wind):not(.earth):not(.dark):not(
.light
) {
color: var(--text-primary);
}
a:hover:not(.fire):not(.water):not(.wind):not(.earth):not(.dark):not(
.light
) {
color: $blue;
}
& > *:not(:last-child):after {
content: ' · ';
margin: 0 calc($unit / 2);
}
}
}
.user {
align-items: center;
display: inline-flex;
gap: calc($unit / 2);
margin-top: 1px;
img,
.no-user {
$diameter: 24px;
border-radius: calc($diameter / 2);
height: $diameter;
width: $diameter;
}
img.gran {
background-color: #cee7fe;
}
img.djeeta {
background-color: #ffe1fe;
}
.no-user {
background: $grey-80;
}
}
}
}

View file

@ -0,0 +1,693 @@
import React, { useEffect, useState, ChangeEvent, KeyboardEvent } 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 CharLimitedFieldset from '~components/common/CharLimitedFieldset'
import DurationInput from '~components/common/DurationInput'
import Input from '~components/common/Input'
import RaidDropdown from '~components/RaidDropdown'
import Switch from '~components/common/Switch'
import Tooltip from '~components/common/Tooltip'
import Token from '~components/common/Token'
import PartyDropdown from '~components/party/PartyDropdown'
import { accountState } from '~utils/accountState'
import { appState, initialAppState } from '~utils/appState'
import { formatTimeAgo } from '~utils/timeAgo'
import CheckIcon from '~public/icons/Check.svg'
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, raids } = useSnapshot(appState)
const { t } = useTranslation('common')
const router = useRouter()
const locale = router.locale || 'en'
const { party: partySnapshot } = useSnapshot(appState)
const nameInput = React.createRef<HTMLInputElement>()
const descriptionInput = React.createRef<HTMLTextAreaElement>()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [alertOpen, setAlertOpen] = useState(false)
const [chargeAttack, setChargeAttack] = useState(true)
const [fullAuto, setFullAuto] = useState(false)
const [autoGuard, setAutoGuard] = useState(false)
const [buttonCount, setButtonCount] = useState<number | undefined>(undefined)
const [chainCount, setChainCount] = useState<number | undefined>(undefined)
const [turnCount, setTurnCount] = useState<number | undefined>(undefined)
const [clearTime, setClearTime] = useState(0)
const [raidSlug, setRaidSlug] = useState('')
const readOnlyClasses = classNames({
PartyDetails: true,
ReadOnly: true,
Visible: !open,
})
const editableClasses = classNames({
PartyDetails: true,
Editable: true,
Visible: open,
})
const userClass = classNames({
user: true,
empty: !party.user,
})
const linkClass = classNames({
wind: party && party.element == 1,
fire: party && party.element == 2,
water: party && party.element == 3,
earth: party && party.element == 4,
dark: party && party.element == 5,
light: party && party.element == 6,
})
const [errors, setErrors] = useState<{ [key: string]: string }>({
name: '',
description: '',
})
useEffect(() => {
if (props.party) {
setName(props.party.name)
setAutoGuard(props.party.auto_guard)
setFullAuto(props.party.full_auto)
setChargeAttack(props.party.charge_attack)
setClearTime(props.party.clear_time)
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)
}
})
}, [])
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
const { name, value } = event.target
setName(value)
let newErrors = errors
setErrors(newErrors)
}
function handleChargeAttackChanged(checked: boolean) {
setChargeAttack(checked)
}
function handleFullAutoChanged(checked: boolean) {
setFullAuto(checked)
}
function handleAutoGuardChanged(checked: boolean) {
setAutoGuard(checked)
}
function handleClearTimeInput(value: number) {
if (!isNaN(value)) setClearTime(value)
}
function handleTurnCountInput(event: React.ChangeEvent<HTMLInputElement>) {
const value = parseInt(event.currentTarget.value)
if (!isNaN(value)) setTurnCount(value)
}
function handleButtonCountInput(event: ChangeEvent<HTMLInputElement>) {
const value = parseInt(event.currentTarget.value)
if (!isNaN(value)) setButtonCount(value)
}
function handleChainCountInput(event: ChangeEvent<HTMLInputElement>) {
const value = parseInt(event.currentTarget.value)
if (!isNaN(value)) setChainCount(value)
}
function handleInputKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
// Allow the key to be processed normally
return
}
// Get the current value
const input = event.currentTarget
let value = event.currentTarget.value
// Check if the key that was pressed is the backspace key
if (event.key === 'Backspace') {
// Remove the colon if the value is "12:"
if (value.length === 4) {
value = value.slice(0, -1)
}
// Allow the backspace key to be processed normally
input.value = value
return
}
// Check if the key that was pressed is the tab key
if (event.key === 'Tab') {
// Allow the tab key to be processed normally
return
}
// Get the character that was entered and check if it is numeric
const char = parseInt(event.key)
const isNumber = !isNaN(char)
// Check if the character should be accepted or rejected
const numberValue = parseInt(`${value}${char}`)
const minValue = parseInt(event.currentTarget.min)
const maxValue = parseInt(event.currentTarget.max)
if (!isNumber || numberValue < minValue || numberValue > maxValue) {
// Reject the character if it isn't a number,
// or if it exceeds the min and max values
event.preventDefault()
}
}
function toggleDetails() {
// Enabling this code will make live updates not work,
// but I'm not sure why it's here, so we're not going to remove it.
// if (name !== party.name) {
// const resetName = party.name ? party.name : ''
// setName(resetName)
// if (nameInput.current) nameInput.current.value = resetName
// }
setOpen(!open)
}
function receiveRaid(slug?: string) {
if (slug) setRaidSlug(slug)
}
function switchValue(value: boolean) {
if (value) return 'on'
else return 'off'
}
// 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')
}
function updateDetails(event: React.MouseEvent) {
const descriptionValue = descriptionInput.current?.value
const raid = raids.find((raid) => raid.slug === raidSlug)
const details: DetailsObject = {
fullAuto: fullAuto,
autoGuard: autoGuard,
chargeAttack: chargeAttack,
clearTime: clearTime,
buttonCount: buttonCount,
turnCount: turnCount,
chainCount: chainCount,
name: name,
description: descriptionValue,
raid: raid,
}
props.updateCallback(details)
toggleDetails()
}
// Methods: Navigation
function goTo(shortcode?: string) {
if (shortcode) router.push(`/p/${shortcode}`)
}
const userImage = (picture?: string, element?: string) => {
if (picture && element)
return (
<img
alt={picture}
className={`profile ${element}`}
srcSet={`/profile/${picture}.png,
/profile/${picture}@2x.png 2x`}
src={`/profile/${picture}.png`}
/>
)
else
return (
<img
alt={t('no_user')}
className={`profile anonymous`}
srcSet={`/profile/npc.png,
/profile/npc@2x.png 2x`}
src={`/profile/npc.png`}
/>
)
}
const userBlock = (username?: string, picture?: string, element?: string) => {
return (
<div className={userClass}>
{userImage(picture, element)}
{username ? username : t('no_user')}
</div>
)
}
const renderUserBlock = () => {
let username, picture, element
if (accountState.account.authorized && props.new) {
username = accountState.account.user?.username
picture = accountState.account.user?.avatar.picture
element = accountState.account.user?.avatar.element
} else if (party.user && !props.new) {
username = party.user.username
picture = party.user.avatar.picture
element = party.user.avatar.element
}
if (username && picture && element) {
return linkedUserBlock(username, picture, element)
} else if (!props.new) {
return userBlock()
}
}
const linkedUserBlock = (
username?: string,
picture?: string,
element?: string
) => {
return (
<div>
<Link href={`/${username}`} passHref>
<a className={linkClass}>{userBlock(username, picture, element)}</a>
</Link>
</div>
)
}
const linkedRaidBlock = (raid: Raid) => {
return (
<div>
<Link href={`/teams?raid=${raid.slug}`} passHref>
<a className={`Raid ${linkClass}`}>{raid.name[locale]}</a>
</Link>
</div>
)
}
// Render: Tokens
const chargeAttackToken = (
<Token
className={classNames({
ChargeAttack: true,
On: chargeAttack,
Off: !chargeAttack,
})}
>
{`${t('party.details.labels.charge_attack')} ${
chargeAttack ? 'On' : 'Off'
}`}
</Token>
)
const fullAutoToken = (
<Token
className={classNames({
FullAuto: true,
On: fullAuto,
Off: !fullAuto,
})}
>
{`${t('party.details.labels.full_auto')} ${fullAuto ? 'On' : 'Off'}`}
</Token>
)
const autoGuardToken = (
<Token
className={classNames({
AutoGuard: true,
On: autoGuard,
Off: !autoGuard,
})}
>
{`${t('party.details.labels.auto_guard')} ${autoGuard ? 'On' : 'Off'}`}
</Token>
)
const turnCountToken = (
<Token>
{t('party.details.turns.with_count', {
count: turnCount,
})}
</Token>
)
const buttonChainToken = () => {
if (buttonCount || chainCount) {
let string = ''
if (buttonCount && buttonCount > 0) {
string += `${buttonCount}b`
}
if (!buttonCount && chainCount && chainCount > 0) {
string += `0${t('party.details.suffix.buttons')}${chainCount}${t(
'party.details.suffix.chains'
)}`
} else if (buttonCount && chainCount && chainCount > 0) {
string += `${chainCount}${t('party.details.suffix.chains')}`
} else if (buttonCount && !chainCount) {
string += `0${t('party.details.suffix.chains')}`
}
return <Token>{string}</Token>
}
}
const clearTimeToken = () => {
const minutes = Math.floor(clearTime / 60)
const seconds = clearTime - minutes * 60
let string = ''
if (minutes > 0)
string = `${minutes}${t('party.details.suffix.minutes')} ${seconds}${t(
'party.details.suffix.seconds'
)}`
else string = `${seconds}${t('party.details.suffix.seconds')}`
return <Token>{string}</Token>
}
function renderTokens() {
return (
<section className="Tokens">
{chargeAttackToken}
{fullAutoToken}
{autoGuardToken}
{turnCount ? turnCountToken : ''}
{clearTime > 0 ? clearTimeToken() : ''}
{buttonChainToken()}
</section>
)
}
// Render: Buttons
const saveButton = () => {
return (
<Tooltip content={t('tooltips.save')}>
<Button
leftAccessoryIcon={<SaveIcon />}
className={classNames({
Save: true,
Saved: partySnapshot.favorited,
})}
text={
appState.party.favorited ? t('buttons.saved') : t('buttons.save')
}
onClick={toggleFavorite}
/>
</Tooltip>
)
}
const remixButton = () => {
return (
<Tooltip content={t('tooltips.remix')}>
<Button
leftAccessoryIcon={<RemixIcon />}
className="Remix"
text={t('buttons.remix')}
onClick={props.remixCallback}
/>
</Tooltip>
)
}
const editable = () => {
return (
<section className={editableClasses}>
<CharLimitedFieldset
fieldName="name"
placeholder="Name your team"
value={props.party?.name}
limit={50}
onChange={handleInputChange}
error={errors.name}
ref={nameInput}
/>
<RaidDropdown
showAllRaidsOption={false}
currentRaid={props.party?.raid ? props.party?.raid.slug : undefined}
onChange={receiveRaid}
/>
<ul className="SwitchToggleGroup DetailToggleGroup">
<li className="Ougi ToggleSection">
<label htmlFor="ougi">
<span>{t('party.details.labels.charge_attack')}</span>
<div>
<Switch
name="charge_attack"
onCheckedChange={handleChargeAttackChanged}
value={switchValue(chargeAttack)}
checked={chargeAttack}
/>
</div>
</label>
</li>
<li className="FullAuto ToggleSection">
<label htmlFor="full_auto">
<span>{t('party.details.labels.full_auto')}</span>
<div>
<Switch
onCheckedChange={handleFullAutoChanged}
name="full_auto"
value={switchValue(fullAuto)}
checked={fullAuto}
/>
</div>
</label>
</li>
<li className="AutoGuard ToggleSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.auto_guard')}</span>
<div>
<Switch
onCheckedChange={handleAutoGuardChanged}
name="auto_guard"
value={switchValue(autoGuard)}
disabled={!fullAuto}
checked={autoGuard}
/>
</div>
</label>
</li>
</ul>
<ul className="InputToggleGroup DetailToggleGroup">
<li className="InputSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.button_chain')}</span>
<div className="Input Bound">
<Input
name="buttons"
type="number"
placeholder="0"
value={`${buttonCount}`}
min="0"
max="99"
onChange={handleButtonCountInput}
onKeyDown={handleInputKeyDown}
/>
<span>b</span>
<Input
name="chains"
type="number"
placeholder="0"
min="0"
max="99"
value={`${chainCount}`}
onChange={handleChainCountInput}
onKeyDown={handleInputKeyDown}
/>
<span>c</span>
</div>
</label>
</li>
<li className="InputSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.turn_count')}</span>
<Input
name="turn_count"
className="AlignRight Bound"
type="number"
step="1"
min="1"
max="999"
placeholder="0"
value={`${turnCount}`}
onChange={handleTurnCountInput}
onKeyDown={handleInputKeyDown}
/>
</label>
</li>
<li className="InputSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.clear_time')}</span>
<div>
<DurationInput
name="clear_time"
className="Bound"
placeholder="00:00"
value={clearTime}
onValueChange={(value: number) => handleClearTimeInput(value)}
/>
</div>
</label>
</li>
</ul>
<div className="bottom">
<div className="right">
<Button text={t('buttons.cancel')} onClick={toggleDetails} />
<Button
leftAccessoryIcon={<CheckIcon className="Check" />}
text={t('buttons.save_info')}
onClick={updateDetails}
/>
</div>
</div>
</section>
)
}
const readOnly = () => {
return <section className={readOnlyClasses}>{renderTokens()}</section>
}
return (
<>
<section className="DetailsWrapper">
<div className="PartyInfo">
<div className="Left">
<div className="Header">
<h1 className={name ? '' : 'empty'}>
{name ? name : t('no_title')}
</h1>
{party.remix && party.sourceParty ? (
<Tooltip content={t('tooltips.source')}>
<Button
className="IconButton Blended"
leftAccessoryIcon={<RemixIcon />}
text={t('tokens.remix')}
onClick={() => goTo(party.sourceParty?.shortcode)}
/>
</Tooltip>
) : (
''
)}
</div>
<div className="attribution">
{renderUserBlock()}
{party.raid ? linkedRaidBlock(party.raid) : ''}
{party.created_at != '' ? (
<time
className="last-updated"
dateTime={new Date(party.created_at).toString()}
>
{formatTimeAgo(new Date(party.created_at), locale)}
</time>
) : (
''
)}
</div>
</div>
{party.editable ? (
<div className="Right">
<Button
leftAccessoryIcon={<EditIcon />}
text={t('buttons.show_info')}
onClick={toggleDetails}
/>
<PartyDropdown
editable={props.editable}
deleteTeamCallback={props.deleteCallback}
remixTeamCallback={props.remixCallback}
/>
</div>
) : (
<div className="Right">
{saveButton()}
{remixButton()}
</div>
)}
</div>
{readOnly()}
{editable()}
</section>
</>
)
}
export default PartyHeader

View file

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

View file

@ -5,18 +5,20 @@ import { useTranslation } from 'next-i18next'
import { appState } from '~utils/appState'
import SegmentedControl from '~components/common/SegmentedControl'
import Segment from '~components/common/Segment'
import ToggleSwitch from '~components/common/ToggleSwitch'
import { GridType } from '~utils/enums'
import './index.scss'
import classNames from 'classnames'
import RepSegment from '~components/reps/RepSegment'
import CharacterRep from '~components/reps/CharacterRep'
import { accountState } from '~utils/accountState'
import WeaponRep from '~components/reps/WeaponRep'
import SummonRep from '~components/reps/SummonRep'
interface Props {
selectedTab: GridType
onClick: (event: React.ChangeEvent<HTMLInputElement>) => void
onCheckboxChange: (event: React.ChangeEvent<HTMLInputElement>) => void
}
const PartySegmentedControl = (props: Props) => {
@ -47,17 +49,56 @@ const PartySegmentedControl = (props: Props) => {
}
}
const extraToggle = (
<div className="ExtraSwitch">
<span className="Text">Extra</span>
<ToggleSwitch
name="ExtraSwitch"
editable={party.editable}
checked={party.extra}
onChange={props.onCheckboxChange}
/>
</div>
)
const characterSegment = () => {
return (
<RepSegment
controlGroup="grid"
inputName="characters"
name={t('party.segmented_control.characters')}
selected={props.selectedTab == GridType.Character}
onClick={props.onClick}
>
<CharacterRep
job={appState.party?.job}
element={appState.party?.element}
gender={
accountState.account.user ? accountState.account.user.gender : 0
}
grid={appState.grid.characters}
/>
</RepSegment>
)
}
const weaponSegment = () => {
{
return (
<RepSegment
controlGroup="grid"
inputName="weapons"
name="Weapons"
selected={props.selectedTab == GridType.Weapon}
onClick={props.onClick}
>
<WeaponRep grid={appState.grid.weapons} />
</RepSegment>
)
}
}
const summonSegment = () => {
return (
<RepSegment
controlGroup="grid"
inputName="summons"
name="Summons"
selected={props.selectedTab == GridType.Summon}
onClick={props.onClick}
>
<SummonRep grid={appState.grid.summons} />
</RepSegment>
)
}
return (
<div
@ -67,39 +108,10 @@ const PartySegmentedControl = (props: Props) => {
})}
>
<SegmentedControl elementClass={getElement()}>
<Segment
groupName="grid"
name="characters"
selected={props.selectedTab == GridType.Character}
onClick={props.onClick}
>
{t('party.segmented_control.characters')}
</Segment>
<Segment
groupName="grid"
name="weapons"
selected={props.selectedTab == GridType.Weapon}
onClick={props.onClick}
>
{t('party.segmented_control.weapons')}
</Segment>
<Segment
groupName="grid"
name="summons"
selected={props.selectedTab == GridType.Summon}
onClick={props.onClick}
>
{t('party.segmented_control.summons')}
</Segment>
{characterSegment()}
{weaponSegment()}
{summonSegment()}
</SegmentedControl>
{(() => {
if (party.editable && props.selectedTab == GridType.Weapon) {
return extraToggle
}
})()}
</div>
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,49 @@
import React from 'react'
import Toast from '~components/common/Toast'
import { Trans, useTranslation } from 'next-i18next'
import './index.scss'
interface Props {
partyName: string
open: boolean
onActionClick?: () => void
onOpenChange: (open: boolean) => void
onCloseClick: () => void
}
const RemixedToast = ({
partyName,
open,
onOpenChange,
onCloseClick,
}: Props) => {
const { t } = useTranslation('common')
// Methods: Event handlers
function handleOpenChange() {
onOpenChange(open)
}
function handleCloseClick() {
onCloseClick()
}
return (
<Toast
altText={t('toasts.remixed', { title: partyName })}
open={open}
duration={2400}
type="foreground"
content={
<Trans i18nKey="toasts.remixed">
You remixed <strong>{{ title: partyName }}</strong>
</Trans>
}
onOpenChange={handleOpenChange}
onCloseClick={handleCloseClick}
/>
)
}
export default RemixedToast

View file

@ -0,0 +1,39 @@
import React from 'react'
import Toast from '~components/common/Toast'
import './index.scss'
import { useTranslation } from 'next-i18next'
interface Props {
open: boolean
onActionClick?: () => void
onOpenChange: (open: boolean) => void
onCloseClick: () => void
}
const UrlCopiedToast = ({ open, onOpenChange, onCloseClick }: Props) => {
const { t } = useTranslation('common')
// Methods: Event handlers
function handleOpenChange() {
onOpenChange(open)
}
function handleCloseClick() {
onCloseClick()
}
return (
<Toast
altText={t('toasts.copied')}
open={open}
duration={2400}
type="foreground"
content={t('toasts.copied')}
onOpenChange={handleOpenChange}
onCloseClick={handleCloseClick}
/>
)
}
export default UrlCopiedToast

View file

@ -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);
}
}

View file

@ -1,48 +0,0 @@
import React from 'react'
import { useTranslation } from 'next-i18next'
import WeaponUnit from '~components/weapon/WeaponUnit'
import type { SearchableObject } from '~types'
import './index.scss'
// Props
interface Props {
grid: GridArray<GridWeapon>
editable: boolean
found?: boolean
offset: number
removeWeapon: (id: string) => void
updateObject: (object: SearchableObject, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
}
const ExtraWeapons = (props: Props) => {
const numWeapons: number = 3
const { t } = useTranslation('common')
return (
<div className="ExtraGrid Weapons">
<span>{t('extra_weapons')}</span>
<ul id="ExtraWeapons">
{Array.from(Array(numWeapons)).map((x, i) => {
return (
<li key={`grid_unit_${i}`}>
<WeaponUnit
editable={props.editable}
position={props.offset + i}
unitType={1}
gridWeapon={props.grid[props.offset + i]}
removeWeapon={props.removeWeapon}
updateObject={props.updateObject}
updateUncap={props.updateUncap}
/>
</li>
)
})}
</ul>
</div>
)
}
export default ExtraWeapons

View file

@ -49,7 +49,7 @@
}
}
li {
list-style: none;
li:not(.Empty) {
// aspect-ratio: 1 / 1.035;
}
}

View file

@ -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<Party>
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 = (
<WeaponUnit
@ -333,8 +356,12 @@ const WeaponGrid = (props: Props) => {
)
const weaponGridElement = Array.from(Array(numWeapons)).map((x, i) => {
const itemClasses = classNames({
Empty: appState.grid.weapons.allWeapons[i] === undefined,
})
return (
<li key={`grid_unit_${i}`}>
<li className={itemClasses} key={`grid_unit_${i}`}>
<WeaponUnit
gridWeapon={appState.grid.weapons.allWeapons[i]}
editable={props.editable}
@ -348,15 +375,26 @@ const WeaponGrid = (props: Props) => {
)
})
const extraGridElement = (
<ExtraWeapons
grid={appState.grid.weapons.allWeapons}
editable={props.editable}
offset={numWeapons}
removeWeapon={removeWeapon}
updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate}
/>
const extraElement = (
<ExtraContainer>
<ExtraWeaponsGrid
grid={appState.grid.weapons.allWeapons}
enabled={appState.party.extra}
editable={props.editable}
offset={numWeapons}
removeWeapon={removeWeapon}
updateExtra={props.updateExtra}
updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate}
/>
<GuidebooksGrid
grid={appState.party.guidebooks}
editable={props.editable}
offset={numWeapons}
removeGuidebook={removeGuidebook}
updateObject={receiveGuidebookFromSearch}
/>
</ExtraContainer>
)
const conflictModal = () => {
@ -409,9 +447,7 @@ const WeaponGrid = (props: Props) => {
<ul id="Weapons">{weaponGridElement}</ul>
</div>
{(() => {
return party.extra ? extraGridElement : ''
})()}
{displayExtraContainer ? extraElement : ''}
</div>
)
}

View file

@ -176,6 +176,8 @@
}
.WeaponName {
font-size: $font-name;
line-height: 1.2;
@include breakpoint(phone) {
font-size: $font-tiny;
}

View file

@ -0,0 +1,5 @@
<svg viewBox="0 0 14 19" xmlns="http://www.w3.org/2000/svg">
<circle cx="7" cy="2.5" r="2" />
<circle cx="7" cy="9.5" r="2" />
<circle cx="7" cy="16.5" r="2" />
</svg>

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 B

View file

@ -102,6 +102,9 @@
"description": "The page you're looking for couldn't be found",
"button": "Create a new party"
},
"validation": {
"guidebooks": "You cannot equip more than one of each Sephira Guidebook"
},
"unauthorized": "You don't have permission to perform that action"
},
"proficiencies": {
@ -232,6 +235,11 @@
"clear": "Clear filters"
}
},
"guidebooks": {
"buttons": {
"remove": "Remove guidebook"
}
},
"login": {
"title": "Log in",
"buttons": {
@ -248,6 +256,17 @@
"password": "Password"
}
},
"remix_team": {
"title": "Remix team",
"description": {
"creator": "You're already the creator of <strong>{{name}}</strong>. Are you sure you want to make a copy by remixing it?",
"viewer": "Remixing a team makes a copy of it in your account so you can make your own changes.\n\nWould you like to remix <strong>{{name}}</strong>?"
},
"buttons": {
"confirm": "Yes, remix team",
"cancel": "Nevermind"
}
},
"settings": {
"title": "Account Settings",
"labels": {
@ -383,7 +402,8 @@
"weapon": "Search for a weapon...",
"summon": "Search for a summon...",
"character": "Search for a character...",
"job_skill": "Search job skills..."
"job_skill": "Search job skills...",
"guidebook": "Search guidebooks..."
}
},
"teams": {
@ -448,6 +468,7 @@
"source": "Go to original team"
},
"extra_weapons": "Additional Weapons",
"sephira_guidebooks": "Sephira Guidebooks",
"equipped": "Equipped",
"coming_soon": "Coming Soon",
"new_party": "New party",

View file

@ -72,6 +72,9 @@
"description": "探しているページは見つかりませんでした",
"button": "新しい編成を作成"
},
"validation": {
"guidebooks": "セフィラ導本を複数個装備することはできません"
},
"unauthorized": "行ったアクションを実行する権限がありません"
},
"header": {
@ -232,6 +235,11 @@
"clear": "保存したフィルターをクリア"
}
},
"guidebooks": {
"buttons": {
"remove": "導本を削除する"
}
},
"login": {
"title": "ログイン",
"buttons": {
@ -248,6 +256,17 @@
"password": "パスワード"
}
},
"remix_team": {
"title": "編成をリミックス",
"description": {
"creator": "既に<strong>{{name}}</strong>の作家のため, 本当にリミックスでコピーを作成しますか?",
"viewer": "編成をリミックスすると変更をするために自アカウントにコピーを作成します。<strong>{{name}}</strong>をリミックスをしますか?"
},
"buttons": {
"confirm": "リミックス",
"cancel": "キャンセル"
}
},
"settings": {
"title": "アカウント設定",
"labels": {
@ -384,7 +403,8 @@
"weapon": "武器を検索...",
"summon": "召喚石を検索...",
"character": "キャラを検索...",
"job_skill": "ジョブのスキルを検索..."
"job_skill": "ジョブのスキルを検索...",
"guidebook": "導本を検索..."
}
},
"teams": {
@ -450,6 +470,7 @@
},
"equipped": "装備した",
"extra_weapons": "Additional Weapons",
"sephira_guidebooks": "セフィラ導本",
"coming_soon": "開発中",
"new_party": "新しい編成",
"no_accessory": "{{accessory}}は装備していません",

View file

@ -14,6 +14,7 @@ $tablet: 768px;
$phone: 375px;
$grid-width: 720px;
$rep-height: 111px;
// Legacy
$medium-screen: 768px;
@ -23,6 +24,7 @@ $unit: 8px;
$unit-fourth: calc($unit / 4);
$unit-half: calc($unit / 2);
$unit-three-fourth: calc($unit / 4) * 3;
$unit-2x: $unit * 2;
$unit-3x: $unit * 3;
$unit-4x: $unit * 4;
@ -97,7 +99,7 @@ $wind-text-00: #023e28;
$wind-text-10: #006a43;
$wind-text-20: #1dc688;
$wind-bg-00: #4cffbf55;
$wind-bg-10: #4cffbf;
$wind-bg-10: #00b551;
$wind-bg-20: #cdffed;
$fire-text-00: #3f0202;
@ -125,7 +127,7 @@ $light-text-00: #433d02;
$light-text-10: #716500;
$light-text-20: #c5b20c;
$light-bg-00: #ffed4c55;
$light-bg-10: #ffed4c;
$light-bg-10: #d9c50f;
$light-bg-20: #fffacd;
$dark-text-00: #260134;
@ -328,6 +330,7 @@ $bold: 600;
$font-tiny: 11px;
$font-small: 13px;
$font-button: 15px;
$font-name: 15px;
$font-regular: 16px;
$font-medium: 18px;
$font-large: 21px;
@ -351,6 +354,7 @@ $hover-shadow: rgba(0, 0, 0, 0.08) 0px 0px 14px;
$duration-modal-open: 0.48s;
$duration-color-fade: 0.24s;
$duration-zoom: 0.18s;
$duration-opacity-fade: 0.12s;
// Gradients
$hero--gradient--light: linear-gradient(

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

@ -0,0 +1,14 @@
interface Guidebook {
id: string
granblue_id: string
name: {
[key: string]: string
en: string
jp: string
}
description: {
[key: string]: string
en: string
jp: string
}
}

12
types/Party.d.ts vendored
View file

@ -1,4 +1,4 @@
type JobSkillObject = {
type JobSkillList = {
[key: number]: JobSkill | undefined
0: JobSkill | undefined
1: JobSkill | undefined
@ -6,6 +6,13 @@ type JobSkillObject = {
3: JobSkill | undefined
}
type GuidebookList = {
[key: number]: Guidebook | undefined
0: Guidebook | undefined
1: Guidebook | undefined
2: Guidebook | undefined
}
interface Party {
id: string
name: string
@ -22,10 +29,11 @@ interface Party {
job: Job
master_level?: number
ultimate_mastery?: number
job_skills: JobSkillObject
job_skills: JobSkillList
accessory: JobAccessory
shortcode: string
extra: boolean
guidebooks: GuidebookList
favorited: boolean
characters: Array<GridCharacter>
weapons: Array<GridWeapon>

10
types/index.d.ts vendored
View file

@ -1,4 +1,9 @@
export type SearchableObject = Character | Weapon | Summon | JobSkill
export type SearchableObject =
| Character
| Weapon
| Summon
| JobSkill
| Guidebook
export type SearchableObjectArray = (Character | Weapon | Summon | JobSkill)[]
export type JobSkillObject = {
[key: number]: JobSkill | undefined
@ -21,7 +26,7 @@ export type PaginationObject = {
}
export type DetailsObject = {
[key: string]: boolean | number | string | Raid | undefined
[key: string]: boolean | number | string | string[] | Raid | undefined
fullAuto?: boolean
autoGuard?: boolean
chargeAttack?: boolean
@ -34,6 +39,7 @@ export type DetailsObject = {
raid?: Raid
job?: Job
extra?: boolean
guidebooks?: string[]
}
export type ExtendedMastery = {

View file

@ -55,6 +55,7 @@ interface AppState {
turnCount?: number
chainCount?: number
extra: boolean
guidebooks: GuidebookList
user: User | undefined
favorited: boolean
remix: boolean
@ -116,6 +117,11 @@ export const initialAppState: AppState = {
chainCount: undefined,
element: 0,
extra: false,
guidebooks: {
0: undefined,
1: undefined,
2: undefined,
},
user: undefined,
favorited: false,
remix: false,