Add new fields to parties

I forgot to commit
This commit is contained in:
Justin Edmund 2023-01-03 18:06:27 -08:00
parent 9e2b7f2dd7
commit 54dd3feba7
26 changed files with 929 additions and 99 deletions

View file

@ -8,43 +8,6 @@
display: flex;
flex-direction: column;
gap: $unit * 2;
.Switch {
$height: 34px;
background: $grey-70;
border-radius: calc($height / 2);
border: none;
position: relative;
width: 58px;
height: $height;
&:focus {
box-shadow: 0 0 0 2px $grey-15;
}
&[data-state='checked'] {
background: $grey-15;
}
}
.Thumb {
background: $grey-100;
border-radius: 13px;
display: block;
height: 26px;
width: 26px;
transition: transform 100ms;
transform: translateX(-1px);
&:hover {
cursor: pointer;
}
&[data-state='checked'] {
background: $grey-100;
transform: translateX(21px);
}
}
}
.DialogDescription {

View file

@ -2,7 +2,7 @@ import React, { ForwardedRef, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import Input from '~components/Input'
import Input from '~components/LabelledInput'
import Select from '~components/Select'
import SelectItem from '~components/SelectItem'
@ -187,7 +187,7 @@ const AwakeningSelect = (props: Props) => {
max={maxValue}
step="1"
onChange={handleInputChange}
visible={`${awakeningType !== -1}`}
visible={awakeningType !== -1 ? true : false}
ref={awakeningLevelInput}
/>
</div>

View file

@ -17,10 +17,6 @@
// box-shadow: 0 2px rgba(255, 255, 255, 1);
}
.Input {
padding: $unit * 1.5 $unit-2x;
}
.Counter {
color: $grey-55;
font-weight: $bold;
@ -29,10 +25,13 @@
.Input {
background: transparent;
border: none;
border-radius: 0;
padding: $unit * 1.5 $unit-2x;
padding-left: calc($unit-2x - $offset);
&:focus {
border: none;
outline: none;
}
}

View file

View file

@ -0,0 +1,166 @@
import React, { useState, ChangeEvent, KeyboardEvent, useEffect } from 'react'
import classNames from 'classnames'
import Input from '~components/Input'
import './index.scss'
interface Props
extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
value: number
onValueChange: (value: number) => void
}
const DurationInput = React.forwardRef<HTMLInputElement, Props>(
function DurationInput(
{ className, placeholder, value, onValueChange },
forwardedRef
) {
const [duration, setDuration] = useState('')
useEffect(() => {
if (value > 0) setDuration(convertSecondsToString(value))
}, [value])
function convertStringToSeconds(string: string) {
const parts = string.split(':')
const minutes = parseInt(parts[0])
const seconds = parseInt(parts[1])
return minutes * 60 + seconds
}
function convertSecondsToString(value: number) {
const minutes = Math.floor(value / 60)
const seconds = value - minutes * 60
const paddedMinutes = padNumber(`${minutes}`, '0', 2)
return `${paddedMinutes}:${seconds}`
}
function padNumber(string: string, pad: string, length: number) {
return (new Array(length + 1).join(pad) + string).slice(-length)
}
function handleChange(event: ChangeEvent<HTMLInputElement>) {
const value = event.currentTarget.value
const durationInSeconds = convertStringToSeconds(value)
onValueChange(durationInSeconds)
}
function handleKeyDown(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
}
// Check if the key that was pressed is an arrow key
if (event.key === 'ArrowUp') {
// Step the value up by one
value = incrementTime(value)
} else if (event.key === 'ArrowDown') {
// Step the value down by one
value = decrementTime(value)
} else {
// Get the character that was entered
const char = parseInt(event.key)
// Check if the character is a number
const isNumber = !isNaN(char)
// Check if the character should be accepted or rejected
if (!isNumber || value.length >= 5) {
// Reject the character
event.preventDefault()
} else if (value.length === 2) {
// Insert a colon after the second digit
input.value = value + ':'
}
}
}
function incrementTime(time: string): string {
// Split the time into minutes and seconds
let [minutes, seconds] = time.split(':').map(Number)
// Increment the seconds
seconds += 1
// Check if the seconds have overflowed into the next minute
if (seconds >= 60) {
minutes += 1
seconds = 0
}
// Format the time as a string and return it
return `${minutes}:${seconds}`
}
function decrementTime(time: string): string {
// Split the time into minutes and seconds
let [minutes, seconds] = time.split(':').map(Number)
// Decrement the seconds
seconds -= 1
// Check if the seconds have underflowed into the previous minute
if (seconds < 0) {
minutes -= 1
seconds = 59
}
// Check if the minutes have underflowed into the previous hour
if (minutes < 0) {
minutes = 59
}
// Format the time as a string and return it
return `${minutes}:${seconds}`
}
return (
<Input
type="text"
className={classNames(
{
Duration: true,
AlignRight: true,
},
className
)}
value={duration}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
/>
)
}
)
export default DurationInput

View file

@ -1,19 +1,22 @@
.Label {
box-sizing: border-box;
display: grid;
width: 100%;
}
.Input {
-webkit-font-smoothing: antialiased;
background-color: var(--input-bg);
border: none;
border: 2px solid transparent;
border-radius: 6px;
box-sizing: border-box;
display: block;
padding: $unit-2x;
width: 100%;
&[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
}
&:focus {
border: 2px solid $blue;
outline: none;
}
&.Bound {
background-color: var(--input-bound-bg);
@ -22,6 +25,10 @@
}
}
&.AlignRight {
text-align: right;
}
&.Hidden {
display: none;
}

View file

@ -39,13 +39,7 @@ const Input = React.forwardRef<HTMLInputElement, Props>(function Input(
}
return (
<label
className={classNames({
Label: true,
Visible: props.visible,
})}
htmlFor={props.name}
>
<React.Fragment>
<input
{...inputProps}
autoComplete="off"
@ -55,11 +49,10 @@ const Input = React.forwardRef<HTMLInputElement, Props>(function Input(
onChange={handleChange}
formNoValidate
/>
{props.label}
{props.error && props.error.length > 0 && (
<p className="InputError">{props.error}</p>
)}
</label>
</React.Fragment>
)
})

View file

@ -0,0 +1,5 @@
.Label {
box-sizing: border-box;
display: grid;
width: 100%;
}

View file

@ -0,0 +1,68 @@
import React, { useEffect, useState } from 'react'
import classNames from 'classnames'
import './index.scss'
interface Props
extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
visible?: boolean
error?: string
label?: string
}
const defaultProps = {
visible: true,
}
const LabelledInput = React.forwardRef<HTMLInputElement, Props>(function Input(
props: Props,
forwardedRef
) {
// States
const [inputValue, setInputValue] = useState('')
// Classes
const classes = classNames({ Input: true }, props.className)
const { defaultValue, ...inputProps } = props
// Change value when prop updates
useEffect(() => {
if (props.value) setInputValue(`${props.value}`)
}, [props.value])
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setInputValue(event.target.value)
if (props.onChange) props.onChange(event)
}
return (
<label
className={classNames({
Label: true,
Visible: props.visible,
})}
htmlFor={props.name}
>
<input
{...inputProps}
autoComplete="off"
className={classes}
value={inputValue}
ref={forwardedRef}
onChange={handleChange}
formNoValidate
/>
{props.label}
{props.error && props.error.length > 0 && (
<p className="InputError">{props.error}</p>
)}
</label>
)
})
LabelledInput.defaultProps = defaultProps
export default LabelledInput

View file

@ -9,7 +9,7 @@ import setUserToken from '~utils/setUserToken'
import { accountState } from '~utils/accountState'
import Button from '~components/Button'
import Input from '~components/Input'
import Input from '~components/LabelledInput'
import {
Dialog,
DialogTrigger,

View file

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
import { getCookie } from 'cookies-next'
import clonedeep from 'lodash.clonedeep'
import PartySegmentedControl from '~components/PartySegmentedControl'
@ -13,6 +12,7 @@ import CharacterGrid from '~components/CharacterGrid'
import api from '~utils/api'
import { appState, initialAppState } from '~utils/appState'
import { GridType, TeamElement } from '~utils/enums'
import type { DetailsObject } from '~types'
import './index.scss'
@ -59,25 +59,42 @@ const Party = (props: Props) => {
}
}
function updateDetails(name?: string, description?: string, raid?: Raid) {
function updateDetails(details: DetailsObject) {
if (
appState.party.name !== name ||
appState.party.description !== description ||
appState.party.raid?.id !== raid?.id
appState.party.name !== details.name ||
appState.party.description !== details.description ||
appState.party.raid?.id !== details.raid?.id
) {
if (appState.party.id)
api.endpoints.parties
.update(appState.party.id, {
party: {
name: name,
description: description,
raid_id: raid?.id,
name: details.name,
description: details.description,
raid_id: details.raid?.id,
charge_attack: details.chargeAttack,
full_auto: details.fullAuto,
auto_guard: details.autoGuard,
clear_time: details.clearTime,
button_count: details.buttonCount,
chain_count: details.chainCount,
turn_count: details.turnCount,
},
})
.then(() => {
appState.party.name = name
appState.party.description = description
appState.party.raid = raid
appState.party.name = details.name
appState.party.description = details.description
appState.party.raid = details.raid
appState.party.chargeAttack = details.chargeAttack
appState.party.fullAuto = details.fullAuto
appState.party.autoGuard = details.autoGuard
appState.party.clearTime = details.clearTime
appState.party.buttonCount = details.buttonCount
appState.party.chainCount = details.chainCount
appState.party.turnCount = details.turnCount
appState.party.updated_at = party.updated_at
})
}

View file

@ -40,6 +40,94 @@
width: 100%;
}
.DetailToggleGroup {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: $unit;
.ToggleSection,
.InputSection {
align-items: center;
display: flex;
background: var(--card-bg);
border-radius: $card-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;
@ -75,6 +163,13 @@
white-space: pre-line;
}
.Details {
display: flex;
flex-direction: row;
gap: $unit-half;
margin-bottom: $unit-2x;
}
.YoutubeWrapper {
background-color: var(--card-bg);
border-radius: $card-corner;

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useState, ChangeEvent, KeyboardEvent } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
@ -8,32 +8,38 @@ import Linkify from 'react-linkify'
import LiteYouTubeEmbed from 'react-lite-youtube-embed'
import classNames from 'classnames'
import reactStringReplace from 'react-string-replace'
import sanitizeHtml from 'sanitize-html'
import * as AlertDialog from '@radix-ui/react-alert-dialog'
import Button from '~components/Button'
import CharLimitedFieldset from '~components/CharLimitedFieldset'
import Input from '~components/Input'
import DurationInput from '~components/DurationInput'
import Token from '~components/Token'
import RaidDropdown from '~components/RaidDropdown'
import TextFieldset from '~components/TextFieldset'
import Switch from '~components/Switch'
import { accountState } from '~utils/accountState'
import { appState } 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 EditIcon from '~public/icons/Edit.svg'
import type { DetailsObject } from 'types'
import './index.scss'
import { youtube } from '~utils/youtube'
// Props
interface Props {
party?: Party
new: boolean
editable: boolean
updateCallback: (name?: string, description?: string, raid?: Raid) => void
updateCallback: (details: DetailsObject) => void
deleteCallback: (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void
@ -53,6 +59,17 @@ const PartyDetails = (props: Props) => {
const descriptionInput = React.createRef<HTMLTextAreaElement>()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [chargeAttack, setChargeAttack] = useState(true)
const [fullAuto, setFullAuto] = useState(false)
const [autoGuard, setAutoGuard] = useState(false)
const [buttonCount, setButtonCount] = useState<number | undefined>(undefined)
const [chainCount, setChainCount] = useState<number | undefined>(undefined)
const [turnCount, setTurnCount] = useState<number | undefined>(undefined)
const [clearTime, setClearTime] = useState(0)
const [raidSlug, setRaidSlug] = useState('')
const [embeddedDescription, setEmbeddedDescription] =
useState<React.ReactNode>()
@ -88,23 +105,18 @@ const PartyDetails = (props: Props) => {
description: '',
})
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
const { name, value } = event.target
let newErrors = errors
setErrors(newErrors)
}
function handleTextAreaChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
event.preventDefault()
const { name, value } = event.target
let newErrors = errors
setErrors(newErrors)
}
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])
useEffect(() => {
// Extract the video IDs from the description
@ -139,6 +151,100 @@ 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()
const { name, value } = event.target
let newErrors = errors
setErrors(newErrors)
}
function handleChargeAttackChanged(checked: boolean) {
setChargeAttack(checked)
}
function handleFullAutoChanged(checked: boolean) {
setFullAuto(checked)
}
function handleAutoGuardChanged(checked: boolean) {
setAutoGuard(checked)
}
function handleClearTimeInput(value: number) {
if (!isNaN(value)) setClearTime(value)
}
function handleTurnCountInput(event: React.ChangeEvent<HTMLInputElement>) {
const value = parseInt(event.currentTarget.value)
if (!isNaN(value)) setTurnCount(value)
}
function handleButtonCountInput(event: ChangeEvent<HTMLInputElement>) {
const value = parseInt(event.currentTarget.value)
if (!isNaN(value)) setButtonCount(value)
}
function handleChainCountInput(event: ChangeEvent<HTMLInputElement>) {
const value = parseInt(event.currentTarget.value)
if (!isNaN(value)) setChainCount(value)
}
function handleInputKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
// Allow the key to be processed normally
return
}
// Get the current value
const input = event.currentTarget
let value = event.currentTarget.value
// Check if the key that was pressed is the backspace key
if (event.key === 'Backspace') {
// Remove the colon if the value is "12:"
if (value.length === 4) {
value = value.slice(0, -1)
}
// Allow the backspace key to be processed normally
input.value = value
return
}
// Check if the key that was pressed is the tab key
if (event.key === 'Tab') {
// Allow the tab key to be processed normally
return
}
// Get the character that was entered and check if it is numeric
const char = parseInt(event.key)
const isNumber = !isNaN(char)
// Check if the character should be accepted or rejected
const numberValue = parseInt(`${value}${char}`)
const minValue = parseInt(event.currentTarget.min)
const maxValue = parseInt(event.currentTarget.max)
if (!isNumber || numberValue < minValue || numberValue > maxValue) {
// Reject the character if it isn't a number,
// or if it exceeds the min and max values
event.preventDefault()
}
}
async function fetchYoutubeData(videoId: string) {
return await youtube
.getVideoById(videoId, { maxResults: 1 })
@ -146,6 +252,11 @@ const PartyDetails = (props: Props) => {
}
function toggleDetails() {
if (name !== party.name) {
const resetName = party.name ? party.name : 'Untitled'
setName(resetName)
if (nameInput.current) nameInput.current.value = resetName
}
setOpen(!open)
}
@ -153,12 +264,30 @@ const PartyDetails = (props: Props) => {
if (slug) setRaidSlug(slug)
}
function switchValue(value: boolean) {
if (value) return 'on'
else return 'off'
}
function updateDetails(event: React.MouseEvent) {
const nameValue = nameInput.current?.value
const descriptionValue = descriptionInput.current?.value
const raid = raids.find((raid) => raid.slug === raidSlug)
props.updateCallback(nameValue, descriptionValue, raid)
const details: DetailsObject = {
fullAuto: fullAuto,
autoGuard: autoGuard,
chargeAttack: chargeAttack,
clearTime: clearTime,
buttonCount: buttonCount,
turnCount: turnCount,
chainCount: chainCount,
name: nameValue,
description: descriptionValue,
raid: raid,
}
props.updateCallback(details)
toggleDetails()
}
@ -305,10 +434,114 @@ const PartyDetails = (props: Props) => {
currentRaid={props.party?.raid ? props.party?.raid.slug : undefined}
onChange={receiveRaid}
/>
<ul className="SwitchToggleGroup DetailToggleGroup">
<li className="Ougi ToggleSection">
<label htmlFor="ougi">
<span>{t('party.details.labels.charge_attack')}</span>
<div>
<Switch
name="charge_attack"
onCheckedChange={handleChargeAttackChanged}
value={switchValue(chargeAttack)}
checked={chargeAttack}
/>
</div>
</label>
</li>
<li className="FullAuto ToggleSection">
<label htmlFor="full_auto">
<span>{t('party.details.labels.full_auto')}</span>
<div>
<Switch
onCheckedChange={handleFullAutoChanged}
name="full_auto"
value={switchValue(fullAuto)}
checked={fullAuto}
/>
</div>
</label>
</li>
<li className="AutoGuard ToggleSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.auto_guard')}</span>
<div>
<Switch
onCheckedChange={handleAutoGuardChanged}
name="auto_guard"
value={switchValue(autoGuard)}
disabled={!fullAuto}
checked={autoGuard}
/>
</div>
</label>
</li>
</ul>
<ul className="InputToggleGroup DetailToggleGroup">
<li className="InputSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.button_chain')}</span>
<div className="Input Bound">
<Input
name="buttons"
type="number"
placeholder="0"
value={`${buttonCount}`}
min="0"
max="99"
onChange={handleButtonCountInput}
onKeyDown={handleInputKeyDown}
/>
<span>b</span>
<Input
name="chains"
type="number"
placeholder="0"
min="0"
max="99"
value={`${chainCount}`}
onChange={handleChainCountInput}
onKeyDown={handleInputKeyDown}
/>
<span>c</span>
</div>
</label>
</li>
<li className="InputSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.turn_count')}</span>
<Input
name="turn_count"
className="AlignRight Bound"
type="number"
step="1"
min="1"
max="999"
placeholder="0"
value={`${turnCount}`}
onChange={handleTurnCountInput}
onKeyDown={handleInputKeyDown}
/>
</label>
</li>
<li className="InputSection">
<label htmlFor="auto_guard">
<span>{t('party.details.labels.clear_time')}</span>
<div>
<DurationInput
name="clear_time"
className="Bound"
placeholder="00:00"
value={clearTime}
onValueChange={(value: number) => handleClearTimeInput(value)}
/>
</div>
</label>
</li>
</ul>
<TextFieldset
fieldName="name"
placeholder={
'Write your notes here\n\n\nWatch out for the 50% trigger!\nMake sure to click Fediels 1 first\nGood luck with RNG!'
'Write your notes here\n\n\nWatch out for the 50% trigger!\nMake sure to click Fediels 3 first\nGood luck with RNG!'
}
value={props.party?.description}
onChange={handleTextAreaChange}
@ -332,8 +565,63 @@ const PartyDetails = (props: Props) => {
</section>
)
const clearTimeString = () => {
const minutes = Math.floor(clearTime / 60)
const seconds = clearTime - minutes * 60
if (minutes > 0)
return `${minutes}${t('party.details.suffix.minutes')} ${seconds}${t(
'party.details.suffix.seconds'
)}`
else return `${seconds}${t('party.details.suffix.seconds')}`
}
const buttonChainToken = () => {
if (buttonCount || chainCount) {
let string = ''
if (buttonCount && buttonCount > 0) {
string += `${buttonCount}b`
}
if (!buttonCount && chainCount && chainCount > 0) {
string += `0${t('party.details.suffix.buttons')}${chainCount}${t(
'party.details.suffix.chains'
)}`
} else if (buttonCount && chainCount && chainCount > 0) {
string += `${chainCount}${t('party.details.suffix.chains')}`
} else if (buttonCount && !chainCount) {
string += `0${t('party.details.suffix.chains')}`
}
return <Token>{string}</Token>
}
}
const readOnly = (
<section className={readOnlyClasses}>
<section className="Details">
{
<Token>
{`${t('party.details.labels.charge_attack')} ${
chargeAttack ? 'On' : 'Off'
}`}
</Token>
}
{fullAuto ? <Token>{t('party.details.labels.full_auto')}</Token> : ''}
{autoGuard ? <Token>{t('party.details.labels.auto_guard')}</Token> : ''}
{turnCount ? (
<Token>
{t('party.details.turns.with_count', {
count: turnCount,
})}
</Token>
) : (
''
)}
{clearTime > 0 ? <Token>{clearTimeString()}</Token> : ''}
{buttonChainToken()}
</section>
<Linkify>{embeddedDescription}</Linkify>
</section>
)
@ -342,8 +630,8 @@ const PartyDetails = (props: Props) => {
<section className="DetailsWrapper">
<div className="PartyInfo">
<div className="Left">
<h1 className={!party.name ? 'empty' : ''}>
{party.name ? party.name : 'Untitled'}
<h1 className={name === '' ? 'empty' : ''}>
{name !== '' ? name : 'Untitled'}
</h1>
<div className="attribution">
{renderUserBlock()}

View file

@ -13,7 +13,7 @@ import {
DialogClose,
} from '~components/Dialog'
import Input from '~components/Input'
import Input from '~components/LabelledInput'
import CharacterSearchFilterBar from '~components/CharacterSearchFilterBar'
import WeaponSearchFilterBar from '~components/WeaponSearchFilterBar'
import SummonSearchFilterBar from '~components/SummonSearchFilterBar'

View file

@ -9,7 +9,7 @@ import setUserToken from '~utils/setUserToken'
import { accountState } from '~utils/accountState'
import Button from '~components/Button'
import Input from '~components/Input'
import Input from '~components/LabelledInput'
import {
Dialog,
DialogTrigger,

View file

@ -0,0 +1,49 @@
.Switch {
$height: 34px;
background: $grey-70;
border-radius: calc($height / 2);
border: none;
position: relative;
width: 58px;
height: $height;
&:focus {
box-shadow: 0 0 0 2px $grey-15;
}
&[data-state='checked'] {
background: $grey-15;
}
&:disabled {
box-shadow: none;
&:hover,
.SwitchThumb:hover {
cursor: not-allowed;
}
.SwitchThumb {
background: $grey-80;
}
}
}
.SwitchThumb {
background: $grey-100;
border-radius: 13px;
display: block;
height: 26px;
width: 26px;
transition: transform 100ms;
transform: translateX(-1px);
&:hover {
cursor: pointer;
}
&[data-state='checked'] {
background: $grey-100;
transform: translateX(21px);
}
}

View file

@ -0,0 +1,46 @@
import React from 'react'
import * as RadixSwitch from '@radix-ui/react-switch'
import classNames from 'classnames'
import './index.scss'
interface Props
extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
thumbClass?: string
onCheckedChange: (checked: boolean) => void
}
const Switch = (props: Props) => {
const {
checked,
className,
disabled,
name,
onCheckedChange,
required,
thumbClass,
value,
} = props
const mainClasses = classNames({ Switch: true }, className)
const thumbClasses = classNames({ SwitchThumb: true }, thumbClass)
return (
<RadixSwitch.Root
className={mainClasses}
checked={checked}
name={name}
disabled={disabled}
required={required}
value={value}
onCheckedChange={onCheckedChange}
>
<RadixSwitch.Thumb className={thumbClasses} />
</RadixSwitch.Root>
)
}
export default Switch

View file

@ -0,0 +1,10 @@
.Token {
background: var(--input-bg);
border-radius: 99px;
display: inline;
font-size: $font-tiny;
font-weight: $bold;
min-width: 3rem;
text-align: center;
padding: $unit-half $unit;
}

View file

@ -0,0 +1,20 @@
import classNames from 'classnames'
import React from 'react'
import './index.scss'
interface Props
extends React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> {}
const Token = React.forwardRef<HTMLDivElement, Props>(function Token(
{ children, className, ...props },
forwardedRef
) {
const classes = classNames({ Token: true }, className)
return <div className={classes}>{children}</div>
})
export default Token

27
package-lock.json generated
View file

@ -29,6 +29,7 @@
"next-i18next": "^10.5.0",
"next-themes": "^0.2.1",
"next-usequerystate": "^1.7.0",
"pluralize": "^8.0.0",
"react": "17.0.2",
"react-dom": "^17.0.2",
"react-i18next": "^11.15.5",
@ -46,6 +47,7 @@
"@types/lodash.clonedeep": "^4.5.6",
"@types/lodash.debounce": "^4.0.6",
"@types/node": "17.0.11",
"@types/pluralize": "^0.0.29",
"@types/react": "17.0.38",
"@types/react-dom": "^17.0.11",
"@types/react-infinite-scroller": "^1.2.2",
@ -2938,6 +2940,12 @@
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
},
"node_modules/@types/pluralize": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.29.tgz",
"integrity": "sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==",
"dev": true
},
"node_modules/@types/prop-types": {
"version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
@ -5965,6 +5973,14 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
"engines": {
"node": ">=4"
}
},
"node_modules/postcss": {
"version": "8.2.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.15.tgz",
@ -9201,6 +9217,12 @@
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
},
"@types/pluralize": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.29.tgz",
"integrity": "sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==",
"dev": true
},
"@types/prop-types": {
"version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
@ -11398,6 +11420,11 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
},
"pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="
},
"postcss": {
"version": "8.2.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.15.tgz",

View file

@ -34,6 +34,7 @@
"next-i18next": "^10.5.0",
"next-themes": "^0.2.1",
"next-usequerystate": "^1.7.0",
"pluralize": "^8.0.0",
"react": "17.0.2",
"react-dom": "^17.0.2",
"react-i18next": "^11.15.5",
@ -51,6 +52,7 @@
"@types/lodash.clonedeep": "^4.5.6",
"@types/lodash.debounce": "^4.0.6",
"@types/node": "17.0.11",
"@types/pluralize": "^0.0.29",
"@types/react": "17.0.38",
"@types/react-dom": "^17.0.11",
"@types/react-infinite-scroller": "^1.2.2",

View file

@ -258,6 +258,26 @@
"characters": "Characters",
"weapons": "Weapons",
"summons": "Summons"
},
"details": {
"labels": {
"charge_attack": "Charge Attack",
"full_auto": "Full Auto",
"auto_guard": "Auto Guard",
"turn_count": "Turn count",
"button_chain": "Buttons/Chains",
"clear_time": "Clear time"
},
"suffix": {
"buttons": "b",
"chains": "c",
"minutes": "m",
"seconds": "s"
},
"turns": {
"with_count_one": "{{count}} turn",
"with_count_other": "{{count}} turns"
}
}
},
"saved": {

View file

@ -259,6 +259,26 @@
"characters": "キャラ",
"weapons": "武器",
"summons": "召喚石"
},
"details": {
"labels": {
"charge_attack": "奥義",
"full_auto": "フルオート",
"auto_guard": "オートガード",
"turn_count": "経過ターン",
"button_chain": "ポチチェイン",
"clear_time": "討伐時間"
},
"suffix": {
"buttons": "ポチ",
"chains": "チェ",
"minutes": "分",
"seconds": "秒"
},
"turns": {
"with_count_one": "{{count}}ターン",
"with_count_other": "{{count}}ターン"
}
}
},
"saved": {

7
types/Party.d.ts vendored
View file

@ -11,6 +11,13 @@ interface Party {
name: string
description: string
raid: Raid
full_auto: boolean
auto_guard: boolean
charge_attack: boolean
clear_time: number
button_count?: number
turn_count?: number
chain_count?: number
job: Job
job_skills: JobSkillObject
shortcode: string

14
types/index.d.ts vendored
View file

@ -19,3 +19,17 @@ export type PaginationObject = {
totalPages: number
perPage: number
}
export type DetailsObject = {
[key: string]: boolean | number | string | Raid | undefined
fullAuto: boolean
autoGuard: boolean
chargeAttack: boolean
clearTime: number
buttonCount?: number
turnCount?: number
chainCount?: number
name?: string
description?: string
raid?: Raid
}

View file

@ -30,6 +30,13 @@ interface AppState {
jobSkills: JobSkillObject
raid: Raid | undefined
element: number
fullAuto: boolean
autoGuard: boolean
chargeAttack: boolean
clearTime: number
buttonCount?: number
turnCount?: number
chainCount?: number
extra: boolean
user: User | undefined
favorited: boolean
@ -76,6 +83,13 @@ export const initialAppState: AppState = {
3: undefined,
},
raid: undefined,
fullAuto: false,
autoGuard: false,
chargeAttack: true,
clearTime: 0,
buttonCount: undefined,
turnCount: undefined,
chainCount: undefined,
element: 0,
extra: false,
user: undefined,