Merge pull request #8 from jedmund/radix

Add valtio state management and Radix components
This commit is contained in:
Justin Edmund 2022-02-23 14:15:59 -08:00 committed by GitHub
commit c1879d2277
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1006 additions and 676 deletions

View file

@ -1,10 +1,21 @@
.AboutModal p {
font-size: $font-regular;
line-height: 1.24;
margin: 0;
margin-bottom: $unit * 2;
.About.Dialog {
width: $unit * 60;
&:last-of-type {
margin-bottom: 0;
section {
margin-bottom: $unit;
h2 {
margin-bottom: $unit * 3;
}
}
.DialogDescription {
font-size: $font-regular;
line-height: 1.24;
margin-bottom: $unit;
&:last-of-type {
margin-bottom: 0;
}
}
}

View file

@ -1,35 +1,55 @@
import React from 'react'
import { createPortal } from 'react-dom'
import api from '~utils/api'
import Modal from '~components/Modal'
import Overlay from '~components/Overlay'
import * as Dialog from '@radix-ui/react-dialog'
import CrossIcon from '~public/icons/Cross.svg'
import './index.scss'
interface Props {
close: () => void
}
const AboutModal = (props: Props) => {
const AboutModal = () => {
return (
createPortal(
<div>
<Modal
title="About"
styleName="AboutModal"
close={ () => {} }
>
<div>
<p>Siero is a tool to save and share parties for <a href="https://game.granbluefantasy.jp">Granblue Fantasy.</a></p>
<p>All you need to do to get started is start adding things. Siero will make a URL and you can share your party with the world.</p>
<p>If you want to save your parties for safe keeping or to edit them later, you can make a free account.</p>
<Dialog.Root>
<Dialog.Trigger asChild>
<li className="MenuItem">About</li>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content className="About Dialog" onOpenAutoFocus={ (event) => event.preventDefault() }>
<div className="DialogHeader">
<Dialog.Title className="DialogTitle">About</Dialog.Title>
<Dialog.Close className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</Dialog.Close>
</div>
</Modal>
<Overlay onClick={props.close} />
</div>,
document.body
)
<section>
<Dialog.Description className="DialogDescription">
Granblue.team is a tool to save and share team compositions for <a href="https://game.granbluefantasy.jp">Granblue Fantasy.</a>
</Dialog.Description>
<Dialog.Description className="DialogDescription">
Start adding things to a team and a URL will be created for you to share it wherever you like, no account needed.
</Dialog.Description>
<Dialog.Description className="DialogDescription">
You can make an account to save any teams you find for future reference, or to keep all of your teams together in one place.
</Dialog.Description>
</section>
<section>
<Dialog.Title className="DialogTitle">Credits</Dialog.Title>
<Dialog.Description className="DialogDescription">
Granblue.team was built by <a href="https://twitter.com/jedmund">@jedmund</a> with a lot of help from <a href="https://twitter.com/lalalalinna">@lalalalinna</a> and <a href="https://twitter.com/tarngerine">@tarngerine</a>.
</Dialog.Description>
</section>
<section>
<Dialog.Title className="DialogTitle">Open Source</Dialog.Title>
<Dialog.Description className="DialogDescription">
This app is open source. You can contribute on Github.
</Dialog.Description>
</section>
</Dialog.Content>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
)
}

View file

@ -0,0 +1,86 @@
import React, { MouseEventHandler, useContext, useEffect, useState } from 'react'
import { useCookies } from 'react-cookie'
import { useRouter } from 'next/router'
import AppContext from '~context/AppContext'
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import Header from '~components/Header'
import Button from '~components/Button'
import { ButtonType } from '~utils/enums'
import CrossIcon from '~public/icons/Cross.svg'
const BottomHeader = () => {
const { editable, setEditable, authenticated, setAuthenticated } = useContext(AppContext)
const [username, setUsername] = useState(undefined)
const [cookies, _, removeCookie] = useCookies(['user'])
const router = useRouter()
useEffect(() => {
if (cookies.user) {
setAuthenticated(true)
setUsername(cookies.user.username)
} else {
setAuthenticated(false)
}
}, [cookies, setUsername, setAuthenticated])
function deleteTeam(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
// TODO: Implement deleting teams
console.log("Deleting team...")
}
const leftNav = () => {
return (
<Button icon="edit" click={() => {}}>Add more info</Button>
)
}
const rightNav = () => {
if (editable && router.route === '/p/[party]') {
return (
<AlertDialog.Root>
<AlertDialog.Trigger className="Button destructive">
<span className='icon'>
<CrossIcon />
</span>
<span className="text">Delete team</span>
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay className="Overlay" />
<AlertDialog.Content className="Dialog">
<AlertDialog.Title className="DialogTitle">
Delete team
</AlertDialog.Title>
<AlertDialog.Description className="DialogDescription">
Are you sure you want to permanently delete this team?
</AlertDialog.Description>
<div className="actions">
<AlertDialog.Cancel className="Button modal">Nevermind</AlertDialog.Cancel>
<AlertDialog.Action className="Button modal destructive" onClick={(e) => deleteTeam(e)}>Yes, delete</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
} else {
return (<div />)
}
}
return (
<Header
position="bottom"
left={ leftNav() }
right={ rightNav() }
/>
)
}
export default BottomHeader

View file

@ -16,7 +16,33 @@
color: $grey-00;
.icon svg {
fill: $grey-50;
fill: $grey-00;
}
.icon.stroke svg {
fill: none;
stroke: $grey-00;
}
}
&.destructive:hover {
background: $error;
color: white;
.icon svg {
fill: white;
}
}
&.modal:hover {
background: $grey-90;
}
&.modal.destructive {
color: $error;
&:hover {
color: darken($error, 10)
}
}
@ -28,6 +54,11 @@
height: 12px;
width: 12px;
}
&.stroke svg {
fill: none;
stroke: $grey-50;
}
}
&.btn-blue {

View file

@ -1,15 +1,22 @@
import React from 'react'
import classNames from 'classnames'
import Link from 'next/link'
import AddIcon from '~public/icons/Add.svg'
import CrossIcon from '~public/icons/Cross.svg'
import EditIcon from '~public/icons/Edit.svg'
import LinkIcon from '~public/icons/Link.svg'
import MenuIcon from '~public/icons/Menu.svg'
import './index.scss'
import { ButtonType } from '~utils/enums'
interface Props {
color: string
disabled: boolean
type: string | null
icon: string | null
type: ButtonType
click: any
}
@ -19,9 +26,9 @@ interface State {
class Button extends React.Component<Props, State> {
static defaultProps: Props = {
color: 'grey',
disabled: false,
type: null,
icon: null,
type: ButtonType.Base,
click: () => {}
}
@ -34,17 +41,25 @@ class Button extends React.Component<Props, State> {
render() {
let icon
if (this.props.type === 'new') {
if (this.props.icon === 'new') {
icon = <span className='icon'>
<AddIcon />
</span>
} else if (this.props.type === 'menu') {
} else if (this.props.icon === 'menu') {
icon = <span className='icon'>
<MenuIcon />
</span>
} else if (this.props.type === 'link') {
} else if (this.props.icon === 'link') {
icon = <span className='icon stroke'>
<LinkIcon />
</span>
} else if (this.props.icon === 'cross') {
icon = <span className='icon'>
<img alt="" src="/icons/Link.svg" />
<CrossIcon />
</span>
} else if (this.props.icon === 'edit') {
icon = <span className='icon'>
<EditIcon />
</span>
}
@ -52,7 +67,7 @@ class Button extends React.Component<Props, State> {
Button: true,
'btn-pressed': this.state.isPressed,
'btn-disabled': this.props.disabled,
[`btn-${this.props.color}`]: true
'destructive': this.props.type == ButtonType.Destructive
})
return <button className={classes} disabled={this.props.disabled} onClick={this.props.click}>

View file

@ -1,18 +1,17 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useCookies } from 'react-cookie'
import { useModal as useModal } from '~utils/useModal'
import { useSnapshot } from 'valtio'
import { AxiosResponse } from 'axios'
import debounce from 'lodash.debounce'
import AppContext from '~context/AppContext'
import PartyContext from '~context/PartyContext'
import CharacterUnit from '~components/CharacterUnit'
import SearchModal from '~components/SearchModal'
import api from '~utils/api'
import state from '~utils/state'
import './index.scss'
// Props
@ -35,44 +34,28 @@ const CharacterGrid = (props: Props) => {
} : {}
// Set up state for view management
const { party, grid } = useSnapshot(state)
const [slug, setSlug] = useState()
const [found, setFound] = useState(false)
const [loading, setLoading] = useState(true)
const { id, setId } = useContext(PartyContext)
const { slug, setSlug } = useContext(PartyContext)
const { editable, setEditable } = useContext(AppContext)
// Set up states for Grid data
const [characters, setCharacters] = useState<GridArray<GridCharacter>>({})
// Set up states for Search
const { open, openModal, closeModal } = useModal()
const [itemPositionForSearch, setItemPositionForSearch] = useState(0)
// Create a temporary state to store previous character uncap values
const [previousUncapValues, setPreviousUncapValues] = useState<{[key: number]: number}>({})
// Create a state dictionary to store pure objects for Search
const [searchGrid, setSearchGrid] = useState<GridArray<Character>>({})
// Fetch data from the server
useEffect(() => {
const shortcode = (props.slug) ? props.slug : slug
if (shortcode) fetchGrid(shortcode)
else setEditable(true)
else state.party.editable = true
}, [slug, props.slug])
// Initialize an array of current uncap values for each characters
useEffect(() => {
let initialPreviousUncapValues: {[key: number]: number} = {}
Object.values(characters).map(o => initialPreviousUncapValues[o.position] = o.uncap_level)
Object.values(state.grid.characters).map(o => initialPreviousUncapValues[o.position] = o.uncap_level)
setPreviousUncapValues(initialPreviousUncapValues)
}, [characters])
// Update search grid whenever characters are updated
useEffect(() => {
let newSearchGrid = Object.values(characters).map((o) => o.character)
setSearchGrid(newSearchGrid)
}, [characters])
}, [state.grid.characters])
// Methods: Fetching an object from the server
async function fetchGrid(shortcode: string) {
@ -90,11 +73,12 @@ const CharacterGrid = (props: Props) => {
const loggedInUser = (cookies.user) ? cookies.user.user_id : ''
if (partyUser != undefined && loggedInUser != undefined && partyUser === loggedInUser) {
setEditable(true)
party.editable = true
}
// Store the important party and state-keeping values
setId(party.id)
state.party.id = party.id
setFound(true)
setLoading(false)
@ -114,60 +98,48 @@ const CharacterGrid = (props: Props) => {
}
function populateCharacters(list: [GridCharacter]) {
let characters: GridArray<GridCharacter> = {}
list.forEach((object: GridCharacter) => {
if (object.position != null)
characters[object.position] = object
state.grid.characters[object.position] = object
})
setCharacters(characters)
}
// Methods: Adding an object from search
function openSearchModal(position: number) {
setItemPositionForSearch(position)
openModal()
}
function receiveCharacterFromSearch(object: Character | Weapon | Summon, position: number) {
const character = object as Character
if (!id) {
if (!party.id) {
props.createParty()
.then(response => {
const party = response.data.party
setId(party.id)
state.party.id = party.id
setSlug(party.shortcode)
if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`)
saveCharacter(party.id, character, position)
.then(response => storeGridCharacter(response.data.grid_character))
.catch(error => console.error(error))
})
} else {
saveCharacter(id, character, position)
saveCharacter(party.id, character, position)
.then(response => storeGridCharacter(response.data.grid_character))
.catch(error => console.error(error))
}
}
async function saveCharacter(partyId: string, character: Character, position: number) {
async function saveCharacter(partyId: string, character: Character, position: number) {
return await api.endpoints.characters.create({
'character': {
'party_id': partyId,
'character_id': character.id,
'position': position,
'mainhand': (position == -1),
'uncap_level': characterUncapLevel(character)
}
}, headers)
}
function storeGridCharacter(gridCharacter: GridCharacter) {
// Store the grid unit at the correct position
let newCharacters = Object.assign({}, characters)
newCharacters[gridCharacter.position] = gridCharacter
setCharacters(newCharacters)
state.grid.characters[gridCharacter.position] = gridCharacter
}
// Methods: Helpers
@ -209,39 +181,37 @@ const CharacterGrid = (props: Props) => {
}
}
const initiateUncapUpdate = useCallback(
(id: string, position: number, uncapLevel: number) => {
memoizeAction(id, position, uncapLevel)
function initiateUncapUpdate(id: string, position: number, uncapLevel: number) {
memoizeAction(id, position, uncapLevel)
// Optimistically update UI
updateUncapLevel(position, uncapLevel)
}, [previousUncapValues, characters]
)
// Optimistically update UI
updateUncapLevel(position, uncapLevel)
}
const memoizeAction = useCallback(
(id: string, position: number, uncapLevel: number) => {
debouncedAction(id, position, uncapLevel)
}, [characters]
}, [props, previousUncapValues]
)
const debouncedAction = useMemo(() =>
debounce((id, position, number) => {
saveUncap(id, position, number)
}, 500), [characters, saveUncap]
}, 500), [props, saveUncap]
)
const updateUncapLevel = (position: number, uncapLevel: number) => {
let newCharacters = {...characters}
newCharacters[position].uncap_level = uncapLevel
setCharacters(newCharacters)
state.grid.characters[position].uncap_level = uncapLevel
}
function storePreviousUncapValue(position: number) {
// Save the current value in case of an unexpected result
let newPreviousValues = {...previousUncapValues}
newPreviousValues[position] = characters[position].uncap_level
setPreviousUncapValues(newPreviousValues)
if (grid.characters[position]) {
newPreviousValues[position] = grid.characters[position].uncap_level
setPreviousUncapValues(newPreviousValues)
}
}
// Render: JSX components
@ -252,26 +222,15 @@ const CharacterGrid = (props: Props) => {
return (
<li key={`grid_unit_${i}`} >
<CharacterUnit
gridCharacter={characters[i]}
editable={editable}
gridCharacter={grid.characters[i]}
editable={party.editable}
position={i}
onClick={() => { openSearchModal(i) }}
updateObject={receiveCharacterFromSearch}
updateUncap={initiateUncapUpdate}
/>
</li>
)
})}
{open ? (
<SearchModal
grid={searchGrid}
close={closeModal}
send={receiveCharacterFromSearch}
fromPosition={itemPositionForSearch}
object="characters"
placeholderText="Search for a character..."
/>
) : null}
</ul>
</div>
)

View file

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'
import classnames from 'classnames'
import SearchModal from '~components/SearchModal'
import UncapIndicator from '~components/UncapIndicator'
import PlusIcon from '~public/icons/Add.svg'
@ -10,7 +11,7 @@ interface Props {
gridCharacter: GridCharacter | undefined
position: number
editable: boolean
onClick: () => void
updateObject: (object: Character | Weapon | Summon, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
}
@ -24,7 +25,7 @@ const CharacterUnit = (props: Props) => {
})
const gridCharacter = props.gridCharacter
const character = gridCharacter?.character
const character = gridCharacter?.object
useEffect(() => {
generateImageUrl()
@ -34,7 +35,7 @@ const CharacterUnit = (props: Props) => {
let imgSrc = ""
if (props.gridCharacter) {
const character = props.gridCharacter.character!
const character = props.gridCharacter.object!
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-main/${character.granblue_id}_01.jpg`
}
@ -49,10 +50,17 @@ const CharacterUnit = (props: Props) => {
return (
<div>
<div className={classes}>
<div className="CharacterImage" onClick={ (props.editable) ? props.onClick : () => {} }>
<img alt={character?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' }
</div>
<SearchModal
placeholderText="Search for a character..."
fromPosition={props.position}
object="characters"
send={props.updateObject}>
<div className="CharacterImage">
<img alt={character?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' }
</div>
</SearchModal>
{ (gridCharacter && character) ?
<UncapIndicator
type="character"

View file

@ -2,14 +2,6 @@ import React from 'react'
import SummonUnit from '~components/SummonUnit'
import './index.scss'
// GridType
export enum GridType {
Class,
Character,
Weapon,
Summon
}
// Props
interface Props {
grid: GridArray<GridSummon>
@ -17,7 +9,7 @@ interface Props {
exists: boolean
found?: boolean
offset: number
onClick: (position: number) => void
updateObject: (object: Character | Weapon | Summon, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
}
@ -37,7 +29,7 @@ const ExtraSummons = (props: Props) => {
position={props.offset + i}
unitType={1}
gridSummon={props.grid[props.offset + i]}
onClick={() => { props.onClick(props.offset + i) }}
updateObject={props.updateObject}
updateUncap={props.updateUncap}
/>
</li>

View file

@ -3,21 +3,13 @@ import WeaponUnit from '~components/WeaponUnit'
import './index.scss'
// GridType
export enum GridType {
Class,
Character,
Weapon,
Summon
}
// Props
interface Props {
grid: GridArray<GridWeapon>
editable: boolean
found?: boolean
offset: number
onClick: (position: number) => void
updateObject: (object: Character | Weapon | Summon, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
}
@ -37,7 +29,7 @@ const ExtraWeapons = (props: Props) => {
position={props.offset + i}
unitType={1}
gridWeapon={props.grid[props.offset + i]}
onClick={() => { props.onClick(props.offset + i)}}
updateObject={props.updateObject}
updateUncap={props.updateUncap}
/>
</li>

View file

@ -19,9 +19,9 @@ const GridRep = (props: Props) => {
for (const [key, value] of Object.entries(props.grid)) {
if (value.position == -1)
setMainhand(value.weapon)
setMainhand(value.object)
else if (!value.mainhand && value.position != null)
newWeapons[value.position] = value.weapon
newWeapons[value.position] = value.object
}
setWeapons(newWeapons)

View file

@ -1,8 +1,13 @@
#Header {
.Header {
display: flex;
height: 34px;
width: 100%;
&.bottom {
position: sticky;
bottom: $unit * 2;
}
#right {
display: flex;
gap: 8px;

View file

@ -1,82 +1,19 @@
import React, { useContext, useEffect, useState } from 'react'
import { useCookies } from 'react-cookie'
import { useRouter } from 'next/router'
import AppContext from '~context/AppContext'
import Button from '~components/Button'
import HeaderMenu from '~components/HeaderMenu'
import React from 'react'
import './index.scss'
interface Props {}
interface Props {
position: 'top' | 'bottom'
left: JSX.Element,
right: JSX.Element
}
const Header = (props: Props) => {
const { editable, setEditable, authenticated, setAuthenticated } = useContext(AppContext)
const [username, setUsername] = useState(undefined)
const [cookies, _, removeCookie] = useCookies(['user'])
const router = useRouter()
useEffect(() => {
if (cookies.user) {
setAuthenticated(true)
setUsername(cookies.user.username)
console.log(`Logged in as user "${cookies.user.username}"`)
} else {
setAuthenticated(false)
console.log('You are currently not logged in.')
}
}, [cookies, setUsername, setAuthenticated])
function copyToClipboard() {
const el = document.createElement('input')
el.value = window.location.href
el.id = 'url-input'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
el.remove()
}
function newParty() {
router.push('/')
}
function logout() {
removeCookie('user')
setAuthenticated(false)
if (editable) setEditable(false)
// How can we log out without navigating to root
router.push('/')
return false
}
const Header = (props: Props) => {
return (
<nav id="Header">
<div id="left">
<div className="dropdown">
<Button type="menu">Menu</Button>
{ (username) ?
<HeaderMenu authenticated={authenticated} username={username} logout={logout} /> :
<HeaderMenu authenticated={authenticated} />
}
</div>
</div>
<nav className={`Header ${props.position}`}>
<div id="left">{ props.left }</div>
<div className="push" />
<div id="right">
{ (editable && router.route === '/p/[slug]') ?
<Button color="red" type="link" click={() => {}}>Delete</Button> : ''
}
{ (router.route === '/p/[slug]') ?
<Button type="link" click={copyToClipboard}>Copy link</Button> : ''
}
<Button type="new" click={newParty}>New</Button>
</div>
<div id="right">{ props.right }</div>
</nav>
)
}

View file

@ -38,7 +38,7 @@ const HeaderMenu = (props: Props) => {
</div>
<div className="MenuGroup">
<li className="MenuItem">
<Link href='/teans'>Teams</Link>
<Link href='/teams'>Teams</Link>
</li>
<li className="MenuItem">
@ -46,10 +46,7 @@ const HeaderMenu = (props: Props) => {
</li>
</div>
<div className="MenuGroup">
<li className="MenuItem" onClick={openAboutModal}>About</li>
{aboutOpen ? (
<AboutModal close={closeAboutModal} />
) : null}
<AboutModal />
<li className="MenuItem">Settings</li>
<li className="MenuItem" onClick={props.logout}>Logout</li>
</div>
@ -62,14 +59,11 @@ const HeaderMenu = (props: Props) => {
return (
<ul className="Menu unauth">
<div className="MenuGroup">
<li className="MenuItem" onClick={openAboutModal}>About</li>
{aboutOpen ? (
<AboutModal close={closeAboutModal} />
) : null}
<AboutModal />
</div>
<div className="MenuGroup">
<li className="MenuItem">
<Link href='/teans'>Teams</Link>
<Link href='/teams'>Teams</Link>
</li>
<li className="MenuItem">

View file

@ -1,5 +1,6 @@
import type { ReactElement } from 'react'
import Header from '~components/Header'
import TopHeader from '~components/TopHeader'
import BottomHeader from '~components/BottomHeader'
interface Props {
children: ReactElement
@ -8,8 +9,9 @@ interface Props {
const Layout = ({children}: Props) => {
return (
<>
<Header />
<TopHeader />
<main>{children}</main>
<BottomHeader />
</>
)
}

View file

@ -22,9 +22,9 @@
overflow-y: auto;
padding: $unit * 3;
position: relative;
z-index: 10;
z-index: 21;
#ModalTop {
#ModalHeader {
display: flex;
flex-direction: row;
align-items: center;

View file

@ -1,12 +1,12 @@
.Overlay {
background: black;
position: absolute;
opacity: 0.28;
// .Overlay {
// background: black;
// position: absolute;
// opacity: 0.28;
height: 100%;
width: 100%;
// height: 100%;
// width: 100%;
top: 0;
left: 0;
z-index: 2;
}
// top: 0;
// left: 0;
// }

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'
import { useSnapshot } from 'valtio'
import { useCookies } from 'react-cookie'
import PartyContext from '~context/PartyContext'
import PartySegmentedControl from '~components/PartySegmentedControl'
import WeaponGrid from '~components/WeaponGrid'
@ -9,18 +9,11 @@ import SummonGrid from '~components/SummonGrid'
import CharacterGrid from '~components/CharacterGrid'
import api from '~utils/api'
import { TeamElement } from '~utils/enums'
import state from '~utils/state'
import { GridType, TeamElement } from '~utils/enums'
import './index.scss'
// GridType
enum GridType {
Class,
Character,
Weapon,
Summon
}
// Props
interface Props {
slug?: string
@ -37,12 +30,8 @@ const Party = (props: Props) => {
} : {}
// Set up states
const { party } = useSnapshot(state)
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
const [id, setId] = useState('')
const [slug, setSlug] = useState('')
const [element, setElement] = useState<TeamElement>(TeamElement.Any)
const [editable, setEditable] = useState(false)
const [hasExtra, setHasExtra] = useState(false)
// Methods: Creating a new party
async function createParty(extra: boolean = false) {
@ -58,10 +47,12 @@ const Party = (props: Props) => {
// Methods: Updating the party's extra flag
function checkboxChanged(event: React.ChangeEvent<HTMLInputElement>) {
setHasExtra(event.target.checked)
api.endpoints.parties.update(id, {
'party': { 'is_extra': event.target.checked }
}, headers)
if (party.id) {
state.party.extra = event.target.checked
api.endpoints.parties.update(party.id, {
'party': { 'is_extra': event.target.checked }
}, headers)
}
}
// Methods: Navigating with segmented control
@ -130,10 +121,8 @@ const Party = (props: Props) => {
return (
<div>
<PartyContext.Provider value={{ id, setId, slug, setSlug, element, setElement, editable, setEditable, hasExtra, setHasExtra }}>
{ navigation }
{ currentGrid() }
</PartyContext.Provider>
{ navigation }
{ currentGrid() }
</div>
)
}

View file

@ -1,19 +1,14 @@
import React, { useContext } from 'react'
import './index.scss'
import PartyContext from '~context/PartyContext'
import state from '~utils/state'
import SegmentedControl from '~components/SegmentedControl'
import Segment from '~components/Segment'
import ToggleSwitch from '~components/ToggleSwitch'
// GridType
export enum GridType {
Class,
Character,
Weapon,
Summon
}
import { GridType } from '~utils/enums'
import { useSnapshot } from 'valtio'
interface Props {
selectedTab: GridType
@ -22,10 +17,10 @@ interface Props {
}
const PartySegmentedControl = (props: Props) => {
const { editable, element, hasExtra } = useContext(PartyContext)
const { party } = useSnapshot(state)
function getElement() {
switch(element) {
switch(party.element) {
case 1: return "wind"; break
case 2: return "fire"; break
case 3: return "water"; break
@ -40,8 +35,8 @@ const PartySegmentedControl = (props: Props) => {
Extra
<ToggleSwitch
name="ExtraSwitch"
editable={editable}
checked={hasExtra}
editable={party.editable}
checked={party.extra}
onChange={props.onCheckboxChange}
/>
</div>
@ -80,7 +75,7 @@ const PartySegmentedControl = (props: Props) => {
{
(() => {
if (editable && props.selectedTab == GridType.Weapon) {
if (party.editable && props.selectedTab == GridType.Weapon) {
return extraToggle
}
})()

View file

@ -1,11 +1,11 @@
.ModalContainer .Modal.SearchModal {
.Modal.Search {
display: flex;
flex-direction: column;
min-height: 420px;
min-width: 600px;
padding: 0;
#ModalTop {
#ModalHeader {
background: $grey-90;
gap: $unit;
margin: 0;
@ -13,12 +13,28 @@
position: sticky;
top: 0;
button {
background: transparent;
border: none;
height: 42px;
padding: 0;
svg {
height: 24px;
width: 24px;
vertical-align: middle;
}
}
label {
width: 100%;
.Input {
border: 1px solid $grey-70;
border-radius: $unit / 2;
box-sizing: border-box;
padding: 12px 8px;
font-size: $font-regular;
padding: $unit * 1.5;
text-align: left;
width: 100%;
}
@ -26,13 +42,13 @@
}
}
.SearchModal #results_container {
.Search.Modal #results_container {
margin: 0;
max-height: 330px;
padding: 0 12px 12px 12px;
}
.SearchModal #NoResults {
.Search.Modal #NoResults {
display: flex;
flex-direction: column;
align-items: center;
@ -40,7 +56,7 @@
flex-grow: 1;
}
.SearchModal #NoResults h2 {
.Search.Modal #NoResults h2 {
color: #ccc;
font-size: $font-large;
font-weight: 500;

View file

@ -1,10 +1,11 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { useSnapshot } from 'valtio'
import { createPortal } from 'react-dom'
import state from '~utils/state'
import api from '~utils/api'
import Modal from '~components/Modal'
import Overlay from '~components/Overlay'
import * as Dialog from '@radix-ui/react-dialog'
import CharacterResult from '~components/CharacterResult'
import WeaponResult from '~components/WeaponResult'
import SummonResult from '~components/SummonResult'
@ -13,138 +14,143 @@ import './index.scss'
import PlusIcon from '~public/icons/Add.svg'
interface Props {
close: () => void
send: (object: Character | Weapon | Summon, position: number) => any
grid: GridArray<Character|Weapon|Summon>
placeholderText: string
fromPosition: number
object: 'weapons' | 'characters' | 'summons'
object: 'weapons' | 'characters' | 'summons',
children: React.ReactNode
}
interface State {
query: string,
results: { [key: string]: any }
loading: boolean
message: string
totalResults: number
}
const SearchModal = (props: Props) => {
let { grid } = useSnapshot(state)
class SearchModal extends React.Component<Props, State> {
searchInput: React.RefObject<HTMLInputElement>
let searchInput = React.createRef<HTMLInputElement>()
constructor(props: Props) {
super(props)
this.state = {
query: '',
results: {},
loading: false,
message: '',
totalResults: 0
}
this.searchInput = React.createRef<HTMLInputElement>()
}
const [objects, setObjects] = useState<{[id: number]: GridCharacter | GridWeapon | GridSummon}>()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState({})
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [totalResults, setTotalResults] = useState(0)
componentDidMount() {
if (this.searchInput.current) {
this.searchInput.current.focus()
}
}
useEffect(() => {
setObjects(grid[props.object])
}, [grid, props.object])
filterExclusions = (o: Character | Weapon | Summon) => {
if (this.props.grid[this.props.fromPosition] &&
o.granblue_id == this.props.grid[this.props.fromPosition].granblue_id) {
return null
} else return o
}
useEffect(() => {
if (searchInput.current)
searchInput.current.focus()
}, [searchInput])
fetchResults = (query: string) => {
const excludes = Object.values(this.props.grid).filter(this.filterExclusions).map((o) => { return o.name.en }).join(',')
function inputChanged(event: React.ChangeEvent<HTMLInputElement>) {
const text = event.target.value
if (text.length) {
setQuery(text)
setLoading(true)
setMessage('')
api.search(this.props.object, query, excludes)
.then((response) => {
const data = response.data
const totalResults = data.length
this.setState({
results: data,
totalResults: totalResults,
loading: false
})
}, (error) => {
this.setState({
loading: false,
message: error
})
})
}
inputChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
const query = event.target.value
if (query.length) {
this.setState({ query, loading: true, message: '' }, () => {
this.fetchResults(query)
})
if (text.length > 2)
fetchResults()
} else {
this.setState({ query, results: {}, message: '' })
setQuery('')
setResults({})
setMessage('')
}
}
function fetchResults() {
// Exclude objects in grid from search results
// unless the object is in the position that the user clicked
// so that users can replace object versions with
// compatible other objects.
const excludes = (objects) ? Object.values(objects)
.filter(filterExclusions)
.map((o) => { return (o.object) ? o.object.name.en : undefined }).join(',') : ''
sendData = (result: Character | Weapon | Summon) => {
this.props.send(result, this.props.fromPosition)
this.props.close()
api.search(props.object, query, excludes)
.then(response => {
setResults(response.data)
setTotalResults(response.data.length)
setLoading(false)
})
.catch(error => {
setMessage(error)
setLoading(false)
})
}
renderSearchResults = () => {
const { results } = this.state
switch(this.props.object) {
function filterExclusions(gridObject: GridCharacter | GridWeapon | GridSummon) {
if (objects && gridObject.object &&
gridObject.object.granblue_id === objects[props.fromPosition]?.object.granblue_id)
return null
else return gridObject
}
function sendData(result: Character | Weapon | Summon) {
props.send(result, props.fromPosition)
setOpen(false)
}
function renderResults() {
switch(props.object) {
case 'weapons':
return this.renderWeaponSearchResults(results)
return renderWeaponSearchResults(results)
break
case 'summons':
return this.renderSummonSearchResults(results)
return renderSummonSearchResults(results)
break
case 'characters':
return this.renderCharacterSearchResults(results)
return renderCharacterSearchResults(results)
break
}
}
renderWeaponSearchResults = (results: { [key: string]: any }) => {
return (
<ul id="results_container">
{ results.map( (result: Weapon) => {
return <WeaponResult key={result.id} data={result} onClick={() => { this.sendData(result) }} />
})}
</ul>
)
function renderWeaponSearchResults(results: { [key: string]: any }) {
const elements = results.map((result: Weapon) => {
return <WeaponResult
key={result.id}
data={result}
onClick={() => { sendData(result) }}
/>
})
return (<ul id="results_container">{elements}</ul>)
}
renderSummonSearchResults = (results: { [key: string]: any }) => {
return (
<ul id="results_container">
{ results.map( (result: Summon) => {
return <SummonResult key={result.id} data={result} onClick={() => { this.sendData(result) }} />
})}
</ul>
)
function renderSummonSearchResults(results: { [key: string]: any }) {
const elements = results.map((result: Summon) => {
return <SummonResult
key={result.id}
data={result}
onClick={() => { sendData(result) }}
/>
})
return (<ul id="results_container">{elements}</ul>)
}
renderCharacterSearchResults = (results: { [key: string]: any }) => {
return (
<ul id="results_container">
{ results.map( (result: Character) => {
return <CharacterResult key={result.id} data={result} onClick={() => { this.sendData(result) }} />
})}
</ul>
)
function renderCharacterSearchResults(results: { [key: string]: any }) {
const elements = results.map((result: Character) => {
return <CharacterResult
key={result.id}
data={result}
onClick={() => { sendData(result) }}
/>
})
return (<ul id="results_container">{elements}</ul>)
}
renderEmptyState = () => {
function renderEmptyState() {
let string = ''
if (this.state.query === '') {
string = `No ${this.props.object}`
if (query === '') {
string = `No ${props.object}`
} else if (query.length < 3) {
string = `Type at least 3 characters`
} else {
string = `No results found for '${this.state.query}'`
string = `No results found for '${query}'`
}
return (
@ -153,48 +159,46 @@ class SearchModal extends React.Component<Props, State> {
</div>
)
}
render() {
const { query, loading } = this.state
let content: JSX.Element
if (Object.entries(this.state.results).length == 0) {
content = this.renderEmptyState()
} else {
content = this.renderSearchResults()
}
return (
createPortal(
<div>
<div className="ModalContainer">
<div className="Modal SearchModal" key="search_modal">
<div id="ModalTop">
<label className="search_label" htmlFor="search_input">
<input
autoComplete="off"
type="text"
name="query"
className="Input"
id="search_input"
ref={this.searchInput}
value={query}
placeholder={this.props.placeholderText}
onChange={this.inputChanged}
/>
</label>
<PlusIcon onClick={this.props.close} />
</div>
{content}
</div>
</div>
<Overlay onClick={this.props.close} />
</div>,
document.body
)
)
function resetAndClose() {
setQuery('')
setResults({})
setOpen(true)
}
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
{props.children}
</Dialog.Trigger>
<Dialog.Portal>
<div className="ModalContainer">
<Dialog.Content className="Search Modal">
<div id="ModalHeader">
<label className="search_label" htmlFor="search_input">
<input
autoComplete="off"
type="text"
name="query"
className="Input"
id="search_input"
ref={searchInput}
value={query}
placeholder={props.placeholderText}
onChange={inputChanged}
/>
</label>
<Dialog.Close onClick={resetAndClose}>
<PlusIcon />
</Dialog.Close>
</div>
{ ((Object.entries(results).length == 0) ? renderEmptyState() : renderResults()) }
</Dialog.Content>
</div>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
)
}
export default SearchModal

View file

@ -1,19 +1,17 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useCookies } from 'react-cookie'
import { useModal as useModal } from '~utils/useModal'
import { useSnapshot } from 'valtio'
import { AxiosResponse } from 'axios'
import debounce from 'lodash.debounce'
import AppContext from '~context/AppContext'
import PartyContext from '~context/PartyContext'
import SearchModal from '~components/SearchModal'
import SummonUnit from '~components/SummonUnit'
import ExtraSummons from '~components/ExtraSummons'
import api from '~utils/api'
import state from '~utils/state'
import './index.scss'
// Props
@ -36,55 +34,37 @@ const SummonGrid = (props: Props) => {
} : {}
// Set up state for view management
const { party, grid } = useSnapshot(state)
const [slug, setSlug] = useState()
const [found, setFound] = useState(false)
const [loading, setLoading] = useState(true)
const { id, setId } = useContext(PartyContext)
const { slug, setSlug } = useContext(PartyContext)
const { editable, setEditable } = useContext(AppContext)
// Set up states for Grid data
const [summons, setSummons] = useState<GridArray<GridSummon>>({})
const [mainSummon, setMainSummon] = useState<GridSummon>()
const [friendSummon, setFriendSummon] = useState<GridSummon>()
// Set up states for Search
const { open, openModal, closeModal } = useModal()
const [itemPositionForSearch, setItemPositionForSearch] = useState(0)
// Create a temporary state to store previous weapon uncap value
const [previousUncapValues, setPreviousUncapValues] = useState<{[key: number]: number}>({})
// Create a state dictionary to store pure objects for Search
const [searchGrid, setSearchGrid] = useState<GridArray<Summon>>({})
// Fetch data from the server
useEffect(() => {
const shortcode = (props.slug) ? props.slug : slug
if (shortcode) fetchGrid(shortcode)
else setEditable(true)
else state.party.editable = true
}, [slug, props.slug])
// Initialize an array of current uncap values for each summon
useEffect(() => {
let initialPreviousUncapValues: {[key: number]: number} = {}
if (mainSummon) initialPreviousUncapValues[-1] = mainSummon.uncap_level
if (friendSummon) initialPreviousUncapValues[6] = friendSummon.uncap_level
Object.values(summons).map(o => initialPreviousUncapValues[o.position] = o.uncap_level)
if (state.grid.summons.mainSummon)
initialPreviousUncapValues[-1] = state.grid.summons.mainSummon.uncap_level
if (state.grid.summons.friendSummon)
initialPreviousUncapValues[6] = state.grid.summons.friendSummon.uncap_level
Object.values(state.grid.summons.allSummons).map(o => initialPreviousUncapValues[o.position] = o.uncap_level)
setPreviousUncapValues(initialPreviousUncapValues)
}, [summons, mainSummon, friendSummon])
}, [state.grid.summons.mainSummon, state.grid.summons.friendSummon, state.grid.summons.allSummons])
// Update search grid whenever any summon is updated
useEffect(() => {
let newSearchGrid = Object.values(summons).map((o) => o.summon)
if (mainSummon)
newSearchGrid.unshift(mainSummon.summon)
if (friendSummon)
newSearchGrid.unshift(friendSummon.summon)
setSearchGrid(newSearchGrid)
}, [summons, mainSummon, friendSummon])
// Methods: Fetching an object from the server
async function fetchGrid(shortcode: string) {
@ -102,11 +82,12 @@ const SummonGrid = (props: Props) => {
const loggedInUser = (cookies.user) ? cookies.user.user_id : ''
if (partyUser != undefined && loggedInUser != undefined && partyUser === loggedInUser) {
setEditable(true)
state.party.editable = true
}
// Store the important party and state-keeping values
setId(party.id)
state.party.id = party.id
setFound(true)
setLoading(false)
@ -126,42 +107,34 @@ const SummonGrid = (props: Props) => {
}
function populateSummons(list: [GridSummon]) {
let summons: GridArray<GridSummon> = {}
list.forEach((object: GridSummon) => {
if (object.main)
setMainSummon(object)
else if (object.friend)
setFriendSummon(object)
else if (!object.main && !object.friend && object.position != null)
summons[object.position] = object
list.forEach((gridObject: GridSummon) => {
if (gridObject.main)
state.grid.summons.mainSummon = gridObject
else if (gridObject.friend)
state.grid.summons.friendSummon = gridObject
else if (!gridObject.main && !gridObject.friend && gridObject.position != null)
state.grid.summons.allSummons[gridObject.position] = gridObject
})
setSummons(summons)
}
// Methods: Adding an object from search
function openSearchModal(position: number) {
setItemPositionForSearch(position)
openModal()
}
function receiveSummonFromSearch(object: Character | Weapon | Summon, position: number) {
const summon = object as Summon
if (!id) {
if (!party.id) {
props.createParty()
.then(response => {
const party = response.data.party
setId(party.id)
state.party.id = party.id
setSlug(party.shortcode)
if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`)
saveSummon(party.id, summon, position)
.then(response => storeGridSummon(response.data.grid_summon))
})
} else {
saveSummon(id, summon, position)
saveSummon(party.id, summon, position)
.then(response => storeGridSummon(response.data.grid_summon))
}
}
@ -184,16 +157,12 @@ const SummonGrid = (props: Props) => {
}
function storeGridSummon(gridSummon: GridSummon) {
if (gridSummon.position == -1) {
setMainSummon(gridSummon)
} else if (gridSummon.position == 6) {
setFriendSummon(gridSummon)
} else {
// Store the grid unit at the correct position
let newSummons = Object.assign({}, summons)
newSummons[gridSummon.position] = gridSummon
setSummons(newSummons)
}
if (gridSummon.position == -1)
state.grid.summons.mainSummon = gridSummon
else if (gridSummon.position == 6)
state.grid.summons.friendSummon = gridSummon
else
state.grid.summons.allSummons[gridSummon.position] = gridSummon
}
// Methods: Updating uncap level
@ -218,40 +187,41 @@ const SummonGrid = (props: Props) => {
}
}
const initiateUncapUpdate = useCallback(
(id: string, position: number, uncapLevel: number) => {
memoizeAction(id, position, uncapLevel)
function initiateUncapUpdate(id: string, position: number, uncapLevel: number) {
memoizeAction(id, position, uncapLevel)
// Optimistically update UI
updateUncapLevel(position, uncapLevel)
}, [previousUncapValues, summons]
)
// Optimistically update UI
updateUncapLevel(position, uncapLevel)
}
const memoizeAction = useCallback(
(id: string, position: number, uncapLevel: number) => {
debouncedAction(id, position, uncapLevel)
}, [summons, mainSummon, friendSummon]
}, [props, previousUncapValues]
)
const debouncedAction = useMemo(() =>
debounce((id, position, number) => {
saveUncap(id, position, number)
}, 500), [summons, mainSummon, friendSummon, saveUncap]
}, 500), [props, saveUncap]
)
const updateUncapLevel = (position: number, uncapLevel: number) => {
let newSummons = Object.assign({}, summons)
newSummons[position].uncap_level = uncapLevel
setSummons(newSummons)
if (state.grid.summons.mainSummon && position == -1)
state.grid.summons.mainSummon.uncap_level = uncapLevel
else if (state.grid.summons.friendSummon && position == 6)
state.grid.summons.friendSummon.uncap_level = uncapLevel
else
state.grid.summons.allSummons[position].uncap_level = uncapLevel
}
function storePreviousUncapValue(position: number) {
// Save the current value in case of an unexpected result
let newPreviousValues = {...previousUncapValues}
if (mainSummon && position == -1) newPreviousValues[position] = mainSummon.uncap_level
else if (friendSummon && position == 6) newPreviousValues[position] = friendSummon.uncap_level
else newPreviousValues[position] = summons[position].uncap_level
if (state.grid.summons.mainSummon && position == -1) newPreviousValues[position] = state.grid.summons.mainSummon.uncap_level
else if (state.grid.summons.friendSummon && position == 6) newPreviousValues[position] = state.grid.summons.friendSummon.uncap_level
else newPreviousValues[position] = state.grid.summons.allSummons[position].uncap_level
setPreviousUncapValues(newPreviousValues)
}
@ -261,12 +231,12 @@ const SummonGrid = (props: Props) => {
<div className="LabeledUnit">
<div className="Label">Main Summon</div>
<SummonUnit
gridSummon={mainSummon}
editable={editable}
gridSummon={grid.summons.mainSummon}
editable={party.editable}
key="grid_main_summon"
position={-1}
unitType={0}
onClick={() => { openSearchModal(-1) }}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
/>
</div>
@ -276,12 +246,12 @@ const SummonGrid = (props: Props) => {
<div className="LabeledUnit">
<div className="Label">Friend Summon</div>
<SummonUnit
gridSummon={friendSummon}
editable={editable}
gridSummon={grid.summons.friendSummon}
editable={party.editable}
key="grid_friend_summon"
position={6}
unitType={2}
onClick={() => { openSearchModal(6) }}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
/>
</div>
@ -293,11 +263,11 @@ const SummonGrid = (props: Props) => {
{Array.from(Array(numSummons)).map((x, i) => {
return (<li key={`grid_unit_${i}`} >
<SummonUnit
gridSummon={summons[i]}
editable={editable}
gridSummon={grid.summons.allSummons[i]}
editable={party.editable}
position={i}
unitType={1}
onClick={() => { openSearchModal(i) }}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
/>
</li>)
@ -307,11 +277,11 @@ const SummonGrid = (props: Props) => {
)
const subAuraSummonElement = (
<ExtraSummons
grid={summons}
editable={editable}
grid={grid.summons.allSummons}
editable={party.editable}
exists={false}
offset={numSummons}
onClick={openSearchModal}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
/>
)
@ -324,17 +294,6 @@ const SummonGrid = (props: Props) => {
</div>
{ subAuraSummonElement }
{open ? (
<SearchModal
grid={searchGrid}
close={closeModal}
send={receiveSummonFromSearch}
fromPosition={itemPositionForSearch}
object="summons"
placeholderText="Search for a summon..."
/>
) : null}
</div>
)
}

View file

@ -12,7 +12,7 @@
background: #e9e9e9;
border-radius: 6px;
display: inline-block;
height: 72px;
height: auto;
width: 120px;
}

View file

@ -1,18 +1,19 @@
import React, { useEffect, useState } from 'react'
import classnames from 'classnames'
import SearchModal from '~components/SearchModal'
import UncapIndicator from '~components/UncapIndicator'
import PlusIcon from '~public/icons/Add.svg'
import './index.scss'
interface Props {
onClick: () => void
updateUncap: (id: string, position: number, uncap: number) => void
gridSummon: GridSummon | undefined
unitType: 0 | 1 | 2
position: number
editable: boolean
unitType: 0 | 1 | 2
updateObject: (object: Character | Weapon | Summon, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
}
const SummonUnit = (props: Props) => {
@ -28,7 +29,7 @@ const SummonUnit = (props: Props) => {
})
const gridSummon = props.gridSummon
const summon = gridSummon?.summon
const summon = gridSummon?.object
useEffect(() => {
generateImageUrl()
@ -37,7 +38,7 @@ const SummonUnit = (props: Props) => {
function generateImageUrl() {
let imgSrc = ""
if (props.gridSummon) {
const summon = props.gridSummon.summon!
const summon = props.gridSummon.object!
// Generate the correct source for the summon
if (props.unitType == 0 || props.unitType == 2)
@ -57,19 +58,27 @@ const SummonUnit = (props: Props) => {
return (
<div>
<div className={classes}>
<div className="SummonImage" onClick={ (props.editable) ? props.onClick : () => {} }>
<img alt={summon?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' }
</div>
<SearchModal
placeholderText="Search for a summon..."
fromPosition={props.position}
object="summons"
send={props.updateObject}>
<div className="SummonImage">
<img alt={summon?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' }
</div>
</SearchModal>
{ (gridSummon) ?
<UncapIndicator
type="summon"
ulb={summon?.uncap.ulb || false}
flb={summon?.uncap.flb || false}
uncapLevel={gridSummon?.uncap_level}
updateUncap={passUncapData}
special={false}
/> : '' }
<UncapIndicator
type="summon"
ulb={gridSummon.object.uncap.ulb || false}
flb={gridSummon.object.uncap.flb || false}
uncapLevel={gridSummon.uncap_level}
updateUncap={passUncapData}
special={false}
/> : ''
}
<h3 className="SummonName">{summon?.name.en}</h3>
</div>
</div>

View file

View file

@ -0,0 +1,89 @@
import React, { useContext, useEffect, useState } from 'react'
import { useCookies } from 'react-cookie'
import { useRouter } from 'next/router'
import AppContext from '~context/AppContext'
import Header from '~components/Header'
import Button from '~components/Button'
import HeaderMenu from '~components/HeaderMenu'
const TopHeader = () => {
const { editable, setEditable, authenticated, setAuthenticated } = useContext(AppContext)
const [username, setUsername] = useState(undefined)
const [cookies, _, removeCookie] = useCookies(['user'])
const router = useRouter()
useEffect(() => {
if (cookies.user) {
setAuthenticated(true)
setUsername(cookies.user.username)
console.log(`Logged in as user "${cookies.user.username}"`)
} else {
setAuthenticated(false)
console.log('You are currently not logged in.')
}
}, [cookies, setUsername, setAuthenticated])
function copyToClipboard() {
const el = document.createElement('input')
el.value = window.location.href
el.id = 'url-input'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
el.remove()
}
function newParty() {
router.push('/')
}
function logout() {
removeCookie('user')
setAuthenticated(false)
if (editable) setEditable(false)
// TODO: How can we log out without navigating to root
router.push('/')
return false
}
const leftNav = () => {
return (
<div className="dropdown">
<Button icon="menu">Menu</Button>
{ (username) ?
<HeaderMenu authenticated={authenticated} username={username} logout={logout} /> :
<HeaderMenu authenticated={authenticated} />
}
</div>
)
}
const rightNav = () => {
return (
<div>
{ (router.route === '/p/[party]') ?
<Button icon="link" click={copyToClipboard}>Copy link</Button> : ''
}
<Button icon="new" click={newParty}>New</Button>
</div>
)
}
return (
<Header
position="top"
left={ leftNav() }
right={ rightNav() }
/>
)
}
export default TopHeader

View file

@ -1,19 +1,17 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useCookies } from 'react-cookie'
import { useModal as useModal } from '~utils/useModal'
import { useSnapshot } from 'valtio'
import { AxiosResponse } from 'axios'
import debounce from 'lodash.debounce'
import AppContext from '~context/AppContext'
import PartyContext from '~context/PartyContext'
import SearchModal from '~components/SearchModal'
import WeaponUnit from '~components/WeaponUnit'
import ExtraWeapons from '~components/ExtraWeapons'
import api from '~utils/api'
import state from '~utils/state'
import './index.scss'
// Props
@ -36,58 +34,33 @@ const WeaponGrid = (props: Props) => {
} : {}
// Set up state for view management
const { party, grid } = useSnapshot(state)
const [slug, setSlug] = useState()
const [found, setFound] = useState(false)
const [loading, setLoading] = useState(true)
// Set up the party context
const { setEditable: setAppEditable } = useContext(AppContext)
const { id, setId } = useContext(PartyContext)
const { slug, setSlug } = useContext(PartyContext)
const { editable, setEditable } = useContext(PartyContext)
const { hasExtra, setHasExtra } = useContext(PartyContext)
const { setElement } = useContext(PartyContext)
// Set up states for Grid data
const [weapons, setWeapons] = useState<GridArray<GridWeapon>>({})
const [mainWeapon, setMainWeapon] = useState<GridWeapon>()
// Set up states for Search
const { open, openModal, closeModal } = useModal()
const [itemPositionForSearch, setItemPositionForSearch] = useState(0)
// Create a temporary state to store previous weapon uncap values
const [previousUncapValues, setPreviousUncapValues] = useState<{[key: number]: number}>({})
// Create a state dictionary to store pure objects for Search
const [searchGrid, setSearchGrid] = useState<GridArray<Weapon>>({})
// Fetch data from the server
useEffect(() => {
const shortcode = (props.slug) ? props.slug : slug
if (shortcode) fetchGrid(shortcode)
else {
setEditable(true)
setAppEditable(true)
}
else state.party.editable = true
}, [slug, props.slug])
// Initialize an array of current uncap values for each weapon
useEffect(() => {
let initialPreviousUncapValues: {[key: number]: number} = {}
if (mainWeapon) initialPreviousUncapValues[-1] = mainWeapon.uncap_level
Object.values(weapons).map(o => initialPreviousUncapValues[o.position] = o.uncap_level)
if (state.grid.weapons.mainWeapon)
initialPreviousUncapValues[-1] = state.grid.weapons.mainWeapon.uncap_level
Object.values(state.grid.weapons.allWeapons).map(o => initialPreviousUncapValues[o.position] = o.uncap_level)
setPreviousUncapValues(initialPreviousUncapValues)
}, [mainWeapon, weapons])
// Update search grid whenever weapons or the mainhand are updated
useEffect(() => {
let newSearchGrid = Object.values(weapons).map((o) => o.weapon)
if (mainWeapon)
newSearchGrid.unshift(mainWeapon.weapon)
setSearchGrid(newSearchGrid)
}, [weapons, mainWeapon])
}, [state.grid.weapons.mainWeapon, state.grid.weapons.allWeapons])
// Methods: Fetching an object from the server
async function fetchGrid(shortcode: string) {
@ -105,13 +78,13 @@ const WeaponGrid = (props: Props) => {
const loggedInUser = (cookies.user) ? cookies.user.user_id : ''
if (partyUser != undefined && loggedInUser != undefined && partyUser === loggedInUser) {
setEditable(true)
setAppEditable(true)
state.party.editable = true
}
// Store the important party and state-keeping values
setId(party.id)
setHasExtra(party.is_extra)
state.party.id = party.id
state.party.extra = party.is_extra
setFound(true)
setLoading(false)
@ -131,43 +104,36 @@ const WeaponGrid = (props: Props) => {
}
function populateWeapons(list: [GridWeapon]) {
let weapons: GridArray<GridWeapon> = {}
list.forEach((object: GridWeapon) => {
if (object.mainhand) {
setMainWeapon(object)
setElement(object.weapon.element)
} else if (!object.mainhand && object.position != null) {
weapons[object.position] = object
list.forEach((gridObject: GridWeapon) => {
if (gridObject.mainhand) {
state.grid.weapons.mainWeapon = gridObject
state.party.element = gridObject.object.element
} else if (!gridObject.mainhand && gridObject.position != null) {
state.grid.weapons.allWeapons[gridObject.position] = gridObject
}
})
setWeapons(weapons)
}
// Methods: Adding an object from search
function openSearchModal(position: number) {
setItemPositionForSearch(position)
openModal()
}
function receiveWeaponFromSearch(object: Character | Weapon | Summon, position: number) {
const weapon = object as Weapon
setElement(weapon.element)
if (position == 1)
state.party.element = weapon.element
if (!id) {
props.createParty(hasExtra)
if (!party.id) {
props.createParty(party.extra)
.then(response => {
const party = response.data.party
setId(party.id)
state.party.id = party.id
setSlug(party.shortcode)
if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`)
saveWeapon(party.id, weapon, position)
.then(response => storeGridWeapon(response.data.grid_weapon))
})
} else {
saveWeapon(id, weapon, position)
saveWeapon(party.id, weapon, position)
.then(response => storeGridWeapon(response.data.grid_weapon))
}
}
@ -190,12 +156,11 @@ const WeaponGrid = (props: Props) => {
function storeGridWeapon(gridWeapon: GridWeapon) {
if (gridWeapon.position == -1) {
setMainWeapon(gridWeapon)
state.grid.weapons.mainWeapon = gridWeapon
state.party.element = gridWeapon.object.element
} else {
// Store the grid unit at the correct position
let newWeapons = Object.assign({}, weapons)
newWeapons[gridWeapon.position] = gridWeapon
setWeapons(newWeapons)
state.grid.weapons.allWeapons[gridWeapon.position] = gridWeapon
}
}
@ -241,32 +206,29 @@ const WeaponGrid = (props: Props) => {
)
const updateUncapLevel = (position: number, uncapLevel: number) => {
if (mainWeapon && position == -1) {
mainWeapon.uncap_level = uncapLevel
setMainWeapon(mainWeapon)
} else {
let newWeapons = Object.assign({}, weapons)
newWeapons[position].uncap_level = uncapLevel
setWeapons(newWeapons)
}
if (state.grid.weapons.mainWeapon && position == -1)
state.grid.weapons.mainWeapon.uncap_level = uncapLevel
else
state.grid.weapons.allWeapons[position].uncap_level = uncapLevel
}
function storePreviousUncapValue(position: number) {
// Save the current value in case of an unexpected result
let newPreviousValues = {...previousUncapValues}
newPreviousValues[position] = (mainWeapon && position == -1) ? mainWeapon.uncap_level : weapons[position].uncap_level
newPreviousValues[position] = (state.grid.weapons.mainWeapon && position == -1) ?
state.grid.weapons.mainWeapon.uncap_level : state.grid.weapons.allWeapons[position].uncap_level
setPreviousUncapValues(newPreviousValues)
}
// Render: JSX components
const mainhandElement = (
<WeaponUnit
gridWeapon={mainWeapon}
editable={editable}
gridWeapon={grid.weapons.mainWeapon}
editable={party.editable}
key="grid_mainhand"
position={-1}
unitType={0}
onClick={() => { openSearchModal(-1) }}
updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate}
/>
)
@ -276,11 +238,11 @@ const WeaponGrid = (props: Props) => {
return (
<li key={`grid_unit_${i}`} >
<WeaponUnit
gridWeapon={weapons[i]}
editable={editable}
gridWeapon={grid.weapons.allWeapons[i]}
editable={party.editable}
position={i}
unitType={1}
onClick={() => { openSearchModal(i) }}
updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate}
/>
</li>
@ -290,10 +252,10 @@ const WeaponGrid = (props: Props) => {
const extraGridElement = (
<ExtraWeapons
grid={weapons}
editable={editable}
grid={state.grid.weapons.allWeapons}
editable={party.editable}
offset={numWeapons}
onClick={openSearchModal}
updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate}
/>
)
@ -305,18 +267,7 @@ const WeaponGrid = (props: Props) => {
<ul className="grid_weapons">{ weaponGridElement }</ul>
</div>
{ (() => { return (hasExtra) ? extraGridElement : '' })() }
{open ? (
<SearchModal
grid={searchGrid}
close={closeModal}
send={receiveWeaponFromSearch}
fromPosition={itemPositionForSearch}
object="weapons"
placeholderText="Search for a weapon..."
/>
) : null}
{ (() => { return (party.extra) ? extraGridElement : '' })() }
</div>
)
}

View file

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'
import classnames from 'classnames'
import SearchModal from '~components/SearchModal'
import UncapIndicator from '~components/UncapIndicator'
import PlusIcon from '~public/icons/Add.svg'
@ -11,7 +12,7 @@ interface Props {
unitType: 0 | 1
position: number
editable: boolean
onClick: () => void
updateObject: (object: Character | Weapon | Summon, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
}
@ -27,7 +28,7 @@ const WeaponUnit = (props: Props) => {
})
const gridWeapon = props.gridWeapon
const weapon = gridWeapon?.weapon
const weapon = gridWeapon?.object
useEffect(() => {
generateImageUrl()
@ -36,7 +37,7 @@ const WeaponUnit = (props: Props) => {
function generateImageUrl() {
let imgSrc = ""
if (props.gridWeapon) {
const weapon = props.gridWeapon.weapon!
const weapon = props.gridWeapon.object!
if (props.unitType == 0)
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${weapon.granblue_id}.jpg`
@ -55,15 +56,22 @@ const WeaponUnit = (props: Props) => {
return (
<div>
<div className={classes}>
<div className="WeaponImage" onClick={ (props.editable) ? props.onClick : () => {} }>
<img alt={weapon?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' }
</div>
<SearchModal
placeholderText="Search for a weapon..."
fromPosition={props.position}
object="weapons"
send={props.updateObject}>
<div className="WeaponImage">
<img alt={weapon?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' }
</div>
</SearchModal>
{ (gridWeapon) ?
<UncapIndicator
type="weapon"
ulb={gridWeapon.weapon.uncap.ulb || false}
flb={gridWeapon.weapon.uncap.flb || false}
ulb={gridWeapon.object.uncap.ulb || false}
flb={gridWeapon.object.uncap.flb || false}
uncapLevel={gridWeapon.uncap_level}
updateUncap={passUncapData}
special={false}

98
package-lock.json generated
View file

@ -6,6 +6,7 @@
"": {
"name": "grid-web",
"dependencies": {
"@radix-ui/react-alert-dialog": "^0.1.5",
"@radix-ui/react-dialog": "^0.1.5",
"@radix-ui/react-dropdown-menu": "^0.1.4",
"@radix-ui/react-hover-card": "^0.1.3",
@ -22,7 +23,8 @@
"react-cookie": "^4.1.1",
"react-dom": "^17.0.2",
"react-i18next": "^11.15.3",
"sass": "^1.49.0"
"sass": "^1.49.0",
"valtio": "^1.3.0"
},
"devDependencies": {
"@types/lodash.debounce": "^4.0.6",
@ -31,6 +33,7 @@
"@types/react-dom": "^17.0.11",
"eslint": "8.7.0",
"eslint-config-next": "12.0.8",
"eslint-plugin-valtio": "^0.4.1",
"typescript": "4.5.5"
}
},
@ -2284,6 +2287,24 @@
"@babel/runtime": "^7.13.10"
}
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-0.1.5.tgz",
"integrity": "sha512-Lq9h3GSvw752e7dFll3UWvm4uWiTlYAXLFX6wr/VQPRoa7XaQO8/1NBu4ikLHAecGEd/uDGZLY3aP7ovGPQYtg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "0.1.0",
"@radix-ui/react-compose-refs": "0.1.0",
"@radix-ui/react-context": "0.1.1",
"@radix-ui/react-dialog": "0.1.5",
"@radix-ui/react-primitive": "0.1.3",
"@radix-ui/react-slot": "0.1.2"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": "^16.8 || ^17.0"
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.3.tgz",
@ -4293,6 +4314,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/eslint-plugin-valtio": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-valtio/-/eslint-plugin-valtio-0.4.1.tgz",
"integrity": "sha512-mORVREchU66YRWa0svret65i9U6gSliNThPH2GJEJlNHE/J1sYdcEcuobKAAMKlz5WpflC38nslkRxBKpiU/rA==",
"dev": true
},
"node_modules/eslint-scope": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.0.tgz",
@ -5762,6 +5789,11 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
},
"node_modules/proxy-compare": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.0.2.tgz",
"integrity": "sha512-3qUXJBariEj3eO90M3Rgqq3+/P5Efl0t/dl9g/1uVzIQmO3M+ql4hvNH3mYdu8H+1zcKv07YvL55tsY74jmH1A=="
},
"node_modules/punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
@ -6667,6 +6699,37 @@
"integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
"dev": true
},
"node_modules/valtio": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-1.3.0.tgz",
"integrity": "sha512-wsE6EDIkt+CNZPNHOxNVzoi026Fyt6ZRT750etZCAvrndcdT3N7Z+SSV4kJQdCwl5gNxsnU4BhP1wFS7cu21oA==",
"dependencies": {
"proxy-compare": "2.0.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@babel/helper-module-imports": ">=7.12",
"@babel/types": ">=7.13",
"babel-plugin-macros": ">=3.0",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@babel/helper-module-imports": {
"optional": true
},
"@babel/types": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@ -8284,6 +8347,20 @@
"@babel/runtime": "^7.13.10"
}
},
"@radix-ui/react-alert-dialog": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-0.1.5.tgz",
"integrity": "sha512-Lq9h3GSvw752e7dFll3UWvm4uWiTlYAXLFX6wr/VQPRoa7XaQO8/1NBu4ikLHAecGEd/uDGZLY3aP7ovGPQYtg==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "0.1.0",
"@radix-ui/react-compose-refs": "0.1.0",
"@radix-ui/react-context": "0.1.1",
"@radix-ui/react-dialog": "0.1.5",
"@radix-ui/react-primitive": "0.1.3",
"@radix-ui/react-slot": "0.1.2"
}
},
"@radix-ui/react-arrow": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.3.tgz",
@ -9813,6 +9890,12 @@
"dev": true,
"requires": {}
},
"eslint-plugin-valtio": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-valtio/-/eslint-plugin-valtio-0.4.1.tgz",
"integrity": "sha512-mORVREchU66YRWa0svret65i9U6gSliNThPH2GJEJlNHE/J1sYdcEcuobKAAMKlz5WpflC38nslkRxBKpiU/rA==",
"dev": true
},
"eslint-scope": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.0.tgz",
@ -10820,6 +10903,11 @@
}
}
},
"proxy-compare": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.0.2.tgz",
"integrity": "sha512-3qUXJBariEj3eO90M3Rgqq3+/P5Efl0t/dl9g/1uVzIQmO3M+ql4hvNH3mYdu8H+1zcKv07YvL55tsY74jmH1A=="
},
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
@ -11429,6 +11517,14 @@
"integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
"dev": true
},
"valtio": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-1.3.0.tgz",
"integrity": "sha512-wsE6EDIkt+CNZPNHOxNVzoi026Fyt6ZRT750etZCAvrndcdT3N7Z+SSV4kJQdCwl5gNxsnU4BhP1wFS7cu21oA==",
"requires": {
"proxy-compare": "2.0.2"
}
},
"void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",

View file

@ -11,6 +11,7 @@
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^0.1.5",
"@radix-ui/react-dialog": "^0.1.5",
"@radix-ui/react-dropdown-menu": "^0.1.4",
"@radix-ui/react-hover-card": "^0.1.3",
@ -27,7 +28,8 @@
"react-cookie": "^4.1.1",
"react-dom": "^17.0.2",
"react-i18next": "^11.15.3",
"sass": "^1.49.0"
"sass": "^1.49.0",
"valtio": "^1.3.0"
},
"devDependencies": {
"@types/lodash.debounce": "^4.0.6",
@ -36,6 +38,7 @@
"@types/react-dom": "^17.0.11",
"eslint": "8.7.0",
"eslint-config-next": "12.0.8",
"eslint-plugin-valtio": "^0.4.1",
"typescript": "4.5.5"
}
}

View file

@ -2,6 +2,7 @@ import React, { useContext, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import Party from '~components/Party'
import * as AlertDialog from '@radix-ui/react-alert-dialog'
const PartyRoute: React.FC = () => {
const router = useRouter()

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

@ -0,0 +1,3 @@
<svg viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
<path d="M10.9425 11.8186C11.1844 12.0605 11.5766 12.0605 11.8186 11.8186C12.0605 11.5766 12.0605 11.1844 11.8186 10.9425L7.8761 7L11.8186 3.05755C12.0605 2.81562 12.0605 2.42338 11.8186 2.18145C11.5766 1.93952 11.1844 1.93952 10.9425 2.18145L7 6.1239L3.05755 2.18145C2.81562 1.93952 2.42338 1.93952 2.18145 2.18145C1.93952 2.42338 1.93952 2.81562 2.18145 3.05755L6.1239 7L2.18145 10.9425C1.93952 11.1844 1.93952 11.5766 2.18145 11.8186C2.42338 12.0605 2.81562 12.0605 3.05755 11.8186L7 7.8761L10.9425 11.8186Z" />
</svg>

After

Width:  |  Height:  |  Size: 583 B

View file

@ -3,25 +3,35 @@
html {
background: $background-color;
font-size: 62.5%;
padding: $unit * 2;
}
body {
-webkit-font-smoothing: antialiased;
font-family: system-ui, -apple-system, Helvetica Neue, Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-family: system-ui, -apple-system, 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 1.4rem;
height: 100vh;
padding: $unit * 2 !important;
&.no-scroll {
overflow: hidden;
}
}
#__next {
height: 100%;
}
main {
min-height: 90%;
}
a {
text-decoration: none;
}
button, input {
font-family: system-ui, -apple-system, Helvetica Neue, Helvetica, Arial, sans-serif;
font-family: system-ui, -apple-system, 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
h1, h2, h3, p {
@ -48,3 +58,80 @@ h1 {
}
.Overlay {
background: rgba(0, 0, 0, 0.6);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
}
.Dialog {
animation: 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal none running openModal;
background: white;
border-radius: $unit;
display: flex;
flex-direction: column;
gap: $unit * 3;
height: auto;
min-width: $unit * 48;
min-height: $unit * 12;
padding: $unit * 3;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 21;
.DialogHeader {
display: flex;
}
.DialogClose {
background: transparent;
height: 21px;
width: 21px;
&:hover {
cursor: pointer;
svg {
fill: $grey-00;
}
}
svg {
fill: $grey-10;
}
}
.DialogTitle {
font-size: $font-large;
flex-grow: 1;
}
.DialogDescription {
flex-grow: 1;
}
.actions {
display: flex;
justify-content: flex-end;
width: 100%;
}
}
@keyframes openModal {
0% {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
100% {
// opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}

View file

@ -1,4 +1,6 @@
interface Character {
type: 'character'
id: string
granblue_id: string
element: number

View file

@ -1,6 +1,6 @@
interface GridCharacter {
interface GridCharacter {
id: string
position: number
character: Character
object: Character
uncap_level: number
}

View file

@ -1,8 +1,8 @@
interface GridSummon {
interface GridSummon {
id: string
main: boolean
friend: boolean
position: number
summon: Summon
object: Summon
uncap_level: number
}

View file

@ -2,6 +2,6 @@ interface GridWeapon {
id: string
mainhand: boolean
position: number
weapon: Weapon
object: Weapon
uncap_level: number
}

2
types/Summon.d.ts vendored
View file

@ -1,4 +1,6 @@
interface Summon {
type: 'summon'
id: string
granblue_id: number
element: number

2
types/Weapon.d.ts vendored
View file

@ -1,4 +1,6 @@
interface Weapon {
type: 'weapon'
id: string
granblue_id: number
element: number

View file

@ -1,3 +1,8 @@
export enum ButtonType {
Base,
Destructive
}
export enum GridType {
Class,
Character,

57
utils/state.tsx Normal file
View file

@ -0,0 +1,57 @@
import { proxy } from "valtio";
interface State {
app: {
authenticated: boolean
},
party: {
id: string | undefined,
editable: boolean,
element: number,
extra: boolean
},
grid: {
weapons: {
mainWeapon: GridWeapon | undefined,
allWeapons: GridArray<GridWeapon>
},
summons: {
mainSummon: GridSummon | undefined,
friendSummon: GridSummon | undefined,
allSummons: GridArray<GridSummon>
},
characters: GridArray<GridCharacter>
},
search: {
sourceItem: GridCharacter | GridWeapon | GridSummon | undefined
}
}
const state: State = {
app: {
authenticated: false
},
party: {
id: undefined,
editable: false,
element: 0,
extra: false
},
grid: {
weapons: {
mainWeapon: undefined,
allWeapons: {}
},
summons: {
mainSummon: undefined,
friendSummon: undefined,
allSummons: {}
},
characters: {}
},
search: {
sourceItem: undefined
}
}
export default proxy(state)