From 54dd3feba7d4852ebf2ebcad82a40b1a258c93b7 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Tue, 3 Jan 2023 18:06:27 -0800 Subject: [PATCH] Add new fields to parties I forgot to commit --- components/AccountModal/index.scss | 37 --- components/AwakeningSelect/index.tsx | 4 +- components/CharLimitedFieldset/index.scss | 7 +- components/DurationInput/index.scss | 0 components/DurationInput/index.tsx | 166 +++++++++++ components/Input/index.scss | 21 +- components/Input/index.tsx | 11 +- components/LabelledInput/index.scss | 5 + components/LabelledInput/index.tsx | 68 +++++ components/LoginModal/index.tsx | 2 +- components/Party/index.tsx | 41 ++- components/PartyDetails/index.scss | 95 ++++++ components/PartyDetails/index.tsx | 338 ++++++++++++++++++++-- components/SearchModal/index.tsx | 2 +- components/SignupModal/index.tsx | 2 +- components/Switch/index.scss | 49 ++++ components/Switch/index.tsx | 46 +++ components/Token/index.scss | 10 + components/Token/index.tsx | 20 ++ package-lock.json | 27 ++ package.json | 2 + public/locales/en/common.json | 20 ++ public/locales/ja/common.json | 20 ++ types/Party.d.ts | 7 + types/index.d.ts | 14 + utils/appState.tsx | 14 + 26 files changed, 929 insertions(+), 99 deletions(-) create mode 100644 components/DurationInput/index.scss create mode 100644 components/DurationInput/index.tsx create mode 100644 components/LabelledInput/index.scss create mode 100644 components/LabelledInput/index.tsx create mode 100644 components/Switch/index.scss create mode 100644 components/Switch/index.tsx create mode 100644 components/Token/index.scss create mode 100644 components/Token/index.tsx diff --git a/components/AccountModal/index.scss b/components/AccountModal/index.scss index 781fb148..d77eed85 100644 --- a/components/AccountModal/index.scss +++ b/components/AccountModal/index.scss @@ -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 { diff --git a/components/AwakeningSelect/index.tsx b/components/AwakeningSelect/index.tsx index 349e7ae9..2debb197 100644 --- a/components/AwakeningSelect/index.tsx +++ b/components/AwakeningSelect/index.tsx @@ -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} /> diff --git a/components/CharLimitedFieldset/index.scss b/components/CharLimitedFieldset/index.scss index f465a160..446453f1 100644 --- a/components/CharLimitedFieldset/index.scss +++ b/components/CharLimitedFieldset/index.scss @@ -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; } } diff --git a/components/DurationInput/index.scss b/components/DurationInput/index.scss new file mode 100644 index 00000000..e69de29b diff --git a/components/DurationInput/index.tsx b/components/DurationInput/index.tsx new file mode 100644 index 00000000..ecccc902 --- /dev/null +++ b/components/DurationInput/index.tsx @@ -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 + > { + value: number + onValueChange: (value: number) => void +} + +const DurationInput = React.forwardRef( + 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) { + const value = event.currentTarget.value + const durationInSeconds = convertStringToSeconds(value) + onValueChange(durationInSeconds) + } + + function handleKeyDown(event: KeyboardEvent) { + 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 ( + + ) + } +) + +export default DurationInput diff --git a/components/Input/index.scss b/components/Input/index.scss index ce554942..f26aa1ef 100644 --- a/components/Input/index.scss +++ b/components/Input/index.scss @@ -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; } diff --git a/components/Input/index.tsx b/components/Input/index.tsx index edb6fdf9..11244306 100644 --- a/components/Input/index.tsx +++ b/components/Input/index.tsx @@ -39,13 +39,7 @@ const Input = React.forwardRef(function Input( } return ( - + ) }) diff --git a/components/LabelledInput/index.scss b/components/LabelledInput/index.scss new file mode 100644 index 00000000..1bb2975d --- /dev/null +++ b/components/LabelledInput/index.scss @@ -0,0 +1,5 @@ +.Label { + box-sizing: border-box; + display: grid; + width: 100%; +} diff --git a/components/LabelledInput/index.tsx b/components/LabelledInput/index.tsx new file mode 100644 index 00000000..19931805 --- /dev/null +++ b/components/LabelledInput/index.tsx @@ -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 + > { + visible?: boolean + error?: string + label?: string +} + +const defaultProps = { + visible: true, +} + +const LabelledInput = React.forwardRef(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) { + setInputValue(event.target.value) + if (props.onChange) props.onChange(event) + } + + return ( + + ) +}) + +LabelledInput.defaultProps = defaultProps + +export default LabelledInput diff --git a/components/LoginModal/index.tsx b/components/LoginModal/index.tsx index 02753844..5c4b454b 100644 --- a/components/LoginModal/index.tsx +++ b/components/LoginModal/index.tsx @@ -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, diff --git a/components/Party/index.tsx b/components/Party/index.tsx index 35c8499f..3cd98d4b 100644 --- a/components/Party/index.tsx +++ b/components/Party/index.tsx @@ -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 }) } diff --git a/components/PartyDetails/index.scss b/components/PartyDetails/index.scss index 2c570b1d..e9df8d65 100644 --- a/components/PartyDetails/index.scss +++ b/components/PartyDetails/index.scss @@ -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; diff --git a/components/PartyDetails/index.tsx b/components/PartyDetails/index.tsx index 2fd0e153..770ded74 100644 --- a/components/PartyDetails/index.tsx +++ b/components/PartyDetails/index.tsx @@ -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 ) => void @@ -53,6 +59,17 @@ const PartyDetails = (props: Props) => { const descriptionInput = React.createRef() 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(undefined) + const [chainCount, setChainCount] = useState(undefined) + const [turnCount, setTurnCount] = useState(undefined) + const [clearTime, setClearTime] = useState(0) + const [raidSlug, setRaidSlug] = useState('') const [embeddedDescription, setEmbeddedDescription] = useState() @@ -88,23 +105,18 @@ const PartyDetails = (props: Props) => { description: '', }) - function handleInputChange(event: React.ChangeEvent) { - event.preventDefault() - - const { name, value } = event.target - let newErrors = errors - - setErrors(newErrors) - } - - function handleTextAreaChange(event: React.ChangeEvent) { - 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) { + event.preventDefault() + + const { name, value } = event.target + setName(value) + + let newErrors = errors + setErrors(newErrors) + } + + function handleTextAreaChange(event: React.ChangeEvent) { + 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) { + const value = parseInt(event.currentTarget.value) + if (!isNaN(value)) setTurnCount(value) + } + + function handleButtonCountInput(event: ChangeEvent) { + const value = parseInt(event.currentTarget.value) + if (!isNaN(value)) setButtonCount(value) + } + + function handleChainCountInput(event: ChangeEvent) { + const value = parseInt(event.currentTarget.value) + if (!isNaN(value)) setChainCount(value) + } + + function handleInputKeyDown(event: KeyboardEvent) { + 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} /> +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
{ ) + const clearTimeString = () => { + const minutes = Math.floor(clearTime / 60) + const seconds = clearTime - minutes * 60 + + if (minutes > 0) + return `${minutes}${t('party.details.suffix.minutes')} ${seconds}${t( + 'party.details.suffix.seconds' + )}` + else return `${seconds}${t('party.details.suffix.seconds')}` + } + + const buttonChainToken = () => { + if (buttonCount || chainCount) { + let string = '' + + if (buttonCount && buttonCount > 0) { + string += `${buttonCount}b` + } + + if (!buttonCount && chainCount && chainCount > 0) { + string += `0${t('party.details.suffix.buttons')}${chainCount}${t( + 'party.details.suffix.chains' + )}` + } else if (buttonCount && chainCount && chainCount > 0) { + string += `${chainCount}${t('party.details.suffix.chains')}` + } else if (buttonCount && !chainCount) { + string += `0${t('party.details.suffix.chains')}` + } + + return {string} + } + } + const readOnly = (
+
+ { + + {`${t('party.details.labels.charge_attack')} ${ + chargeAttack ? 'On' : 'Off' + }`} + + } + {fullAuto ? {t('party.details.labels.full_auto')} : ''} + {autoGuard ? {t('party.details.labels.auto_guard')} : ''} + {turnCount ? ( + + {t('party.details.turns.with_count', { + count: turnCount, + })} + + ) : ( + '' + )} + {clearTime > 0 ? {clearTimeString()} : ''} + {buttonChainToken()} +
{embeddedDescription}
) @@ -342,8 +630,8 @@ const PartyDetails = (props: Props) => {
-

- {party.name ? party.name : 'Untitled'} +

+ {name !== '' ? name : 'Untitled'}

{renderUserBlock()} diff --git a/components/SearchModal/index.tsx b/components/SearchModal/index.tsx index 6353e51a..cf1a396a 100644 --- a/components/SearchModal/index.tsx +++ b/components/SearchModal/index.tsx @@ -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' diff --git a/components/SignupModal/index.tsx b/components/SignupModal/index.tsx index 505695fa..95ab5bd3 100644 --- a/components/SignupModal/index.tsx +++ b/components/SignupModal/index.tsx @@ -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, diff --git a/components/Switch/index.scss b/components/Switch/index.scss new file mode 100644 index 00000000..c5783d0d --- /dev/null +++ b/components/Switch/index.scss @@ -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); + } +} diff --git a/components/Switch/index.tsx b/components/Switch/index.tsx new file mode 100644 index 00000000..118ea01f --- /dev/null +++ b/components/Switch/index.tsx @@ -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 + > { + 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 ( + + + + ) +} + +export default Switch diff --git a/components/Token/index.scss b/components/Token/index.scss new file mode 100644 index 00000000..525b7597 --- /dev/null +++ b/components/Token/index.scss @@ -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; +} diff --git a/components/Token/index.tsx b/components/Token/index.tsx new file mode 100644 index 00000000..64509cb3 --- /dev/null +++ b/components/Token/index.tsx @@ -0,0 +1,20 @@ +import classNames from 'classnames' +import React from 'react' + +import './index.scss' + +interface Props + extends React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + > {} + +const Token = React.forwardRef(function Token( + { children, className, ...props }, + forwardedRef +) { + const classes = classNames({ Token: true }, className) + return
{children}
+}) + +export default Token diff --git a/package-lock.json b/package-lock.json index 3a064b2b..d0c42f68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 612211bb..77715f3f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 0ca0493a..57edabb9 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -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": { diff --git a/public/locales/ja/common.json b/public/locales/ja/common.json index bf7b0471..d714ca1f 100644 --- a/public/locales/ja/common.json +++ b/public/locales/ja/common.json @@ -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": { diff --git a/types/Party.d.ts b/types/Party.d.ts index c43a4d17..9b8d0978 100644 --- a/types/Party.d.ts +++ b/types/Party.d.ts @@ -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 diff --git a/types/index.d.ts b/types/index.d.ts index 3f8f6723..4541c991 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -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 +} diff --git a/utils/appState.tsx b/utils/appState.tsx index c898c0de..606e5b4e 100644 --- a/utils/appState.tsx +++ b/utils/appState.tsx @@ -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,