diff --git a/components/BottomHeader/index.tsx b/components/BottomHeader/index.tsx index ef9b4272..31f6cab4 100644 --- a/components/BottomHeader/index.tsx +++ b/components/BottomHeader/index.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router' import { useCookies } from 'react-cookie' import { useSnapshot } from 'valtio' import clonedeep from 'lodash.clonedeep' +import * as Scroll from 'react-scroll' import * as AlertDialog from '@radix-ui/react-alert-dialog' @@ -21,6 +22,7 @@ const BottomHeader = () => { const app = useSnapshot(appState) const router = useRouter() + const scroll = Scroll.animateScroll; // Cookies const [cookies] = useCookies(['user']) @@ -30,6 +32,15 @@ const BottomHeader = () => { } } : {} + function toggleDetails() { + appState.party.detailsVisible = !appState.party.detailsVisible + + if (appState.party.detailsVisible) + scroll.scrollToBottom() + else + scroll.scrollToTop() + } + function deleteTeam(event: React.MouseEvent) { if (appState.party.editable && appState.party.id) { api.endpoints.parties.destroy(appState.party.id, headers) @@ -53,9 +64,11 @@ const BottomHeader = () => { } const leftNav = () => { - return ( - - ) + if (app.party.detailsVisible) { + return () + } else { + return () + } } const rightNav = () => { diff --git a/components/Button/index.scss b/components/Button/index.scss index 666d1800..0b4b88d0 100644 --- a/components/Button/index.scss +++ b/components/Button/index.scss @@ -61,6 +61,10 @@ } } + &.Active { + background: white; + } + &.btn-blue { background: $blue; color: #8b8b8b; diff --git a/components/Button/index.tsx b/components/Button/index.tsx index d355730f..4db0290f 100644 --- a/components/Button/index.tsx +++ b/components/Button/index.tsx @@ -14,6 +14,7 @@ import './index.scss' import { ButtonType } from '~utils/enums' interface Props { + active: boolean disabled: boolean icon: string | null type: ButtonType @@ -26,6 +27,7 @@ interface State { class Button extends React.Component { static defaultProps: Props = { + active: false, disabled: false, icon: null, type: ButtonType.Base, @@ -65,6 +67,7 @@ class Button extends React.Component { const classes = classNames({ Button: true, + 'Active': this.props.active, 'btn-pressed': this.state.isPressed, 'btn-disabled': this.props.disabled, 'destructive': this.props.type == ButtonType.Destructive diff --git a/components/CharLimitedFieldset/index.scss b/components/CharLimitedFieldset/index.scss new file mode 100644 index 00000000..5755fd4a --- /dev/null +++ b/components/CharLimitedFieldset/index.scss @@ -0,0 +1,29 @@ +.Limited { + background: white; + border-radius: 6px; + border: 2px solid transparent; + box-sizing: border-box; + display: flex; + gap: $unit; + padding-right: $unit * 2; + + &:focus-within { + border: 2px solid #275DC5; + box-shadow: 0 2px rgba(255, 255, 255, 1); + } + + .Counter { + color: $grey-50; + font-weight: $bold; + line-height: 42px; + } + + .Input { + background: transparent; + border-radius: 0; + + &:focus { + outline: none; + } + } +} \ No newline at end of file diff --git a/components/CharLimitedFieldset/index.tsx b/components/CharLimitedFieldset/index.tsx new file mode 100644 index 00000000..aefd8ab6 --- /dev/null +++ b/components/CharLimitedFieldset/index.tsx @@ -0,0 +1,54 @@ +import React, { useEffect, useState } from 'react' +import './index.scss' + +interface Props { + fieldName: string + placeholder: string + value?: string + limit: number + error: string + onBlur?: (event: React.ChangeEvent) => void + onChange?: (event: React.ChangeEvent) => void +} + +const CharLimitedFieldset = React.forwardRef(function fieldSet(props, ref) { + const fieldType = (['password', 'confirm_password'].includes(props.fieldName)) ? 'password' : 'text' + + const [currentCount, setCurrentCount] = useState(0) + + useEffect(() => { + setCurrentCount((props.value) ? props.limit - props.value.length : props.limit) + }, [props.limit, props.value]) + + function onChange(event: React.ChangeEvent) { + setCurrentCount(props.limit - event.currentTarget.value.length) + if (props.onChange) props.onChange(event) + } + + return ( +
+
+ + {currentCount} +
+ { + props.error.length > 0 && +

{props.error}

+ } +
+ ) +}) + +export default CharLimitedFieldset \ No newline at end of file diff --git a/components/Fieldset/index.tsx b/components/Fieldset/index.tsx index 5b22247d..b8a98e39 100644 --- a/components/Fieldset/index.tsx +++ b/components/Fieldset/index.tsx @@ -4,6 +4,7 @@ import './index.scss' interface Props { fieldName: string placeholder: string + value?: string error: string onBlur?: (event: React.ChangeEvent) => void onChange?: (event: React.ChangeEvent) => void @@ -17,9 +18,10 @@ const Fieldset = React.forwardRef(function fieldSet(pro { const { party } = useSnapshot(appState) const [currentTab, setCurrentTab] = useState(GridType.Weapon) + // Fetch data from the server + useEffect(() => { + const shortcode = (props.slug) ? props.slug : undefined + + if (shortcode) + fetchDetails(shortcode) + else + appState.party.editable = true + }, [props.slug]) + // Methods: Creating a new party async function createParty(extra: boolean = false) { let body = { party: { ...(cookies.user) && { user_id: cookies.user.user_id }, - is_extra: extra + extra: extra } } return await api.endpoints.parties.create(body, headers) } - // Methods: Updating the party's extra flag + // Methods: Updating the party's details function checkboxChanged(event: React.ChangeEvent) { appState.party.extra = event.target.checked if (party.id) { api.endpoints.parties.update(party.id, { - 'party': { 'is_extra': event.target.checked } + 'party': { 'extra': event.target.checked } }, headers) } } + function updateDetails(name?: string, description?: string, raid?: Raid) { + if (appState.party.name !== name || + appState.party.description !== description || + appState.party.raid?.id !== raid?.id) { + if (appState.party.id) + api.endpoints.parties.update(appState.party.id, { + 'party': { + 'name': name, + 'description': description, + 'raid_id': raid?.id + } + }, headers) + .then(() => { + appState.party.name = name + appState.party.description = description + appState.party.raid = raid + }) + } + + } + // Methods: Navigating with segmented control function segmentClicked(event: React.ChangeEvent) { switch(event.target.value) { @@ -76,6 +108,33 @@ const Party = (props: Props) => { } } + // Methods: Fetch party details + function fetchDetails(shortcode: string) { + return api.endpoints.parties.getOne({ id: shortcode }) + .then(response => processResult(response)) + .catch(error => processError(error)) + } + + function processResult(response: AxiosResponse) { + appState.party.id = response.data.party.id + + // Store the party's user-generated details + appState.party.name = response.data.party.name + appState.party.description = response.data.party.description + appState.party.raid = response.data.party.raid + } + + function processError(error: any) { + if (error.response != null) { + if (error.response.status == 404) { + // setFound(false) + // setLoading(false) + } + } else { + console.error(error) + } + } + // Render: JSX components const navigation = ( { return (
{ navigation } - { currentGrid() } +
+ { currentGrid() } +
+ { }
) } diff --git a/components/PartyDetails/index.scss b/components/PartyDetails/index.scss new file mode 100644 index 00000000..abc7bed8 --- /dev/null +++ b/components/PartyDetails/index.scss @@ -0,0 +1,87 @@ +.Details { + display: none; // This breaks transition, find a workaround + opacity: 0; + margin: 0 auto; + max-width: $unit * 95; + position: relative; + + &.Editable { + top: $unit; + height: 0; + z-index: 2; + transition: opacity 0.2s ease-in-out, + top 0.2s ease-in-out; + + + &.Visible { + display: block; + height: auto; + margin-bottom: 40vh; + opacity: 1; + top: 0; + } + + fieldset { + display: block; + width: 100%; + + textarea { + min-height: $unit * 20; + width: 100%; + } + } + + select { + appearance: none; + background-image: url('/icons/Arrow.svg'); + background-repeat: no-repeat; + background-position-y: center; + background-position-x: 98%; + background-size: $unit * 1.5; + border: none; + border-radius: 6px; + color: $grey-00; + margin-bottom: $unit; + font-size: $font-regular; + padding: 0 ($unit * 2); + height: $unit * 6; + width: 100%; + + &:hover { + cursor: pointer; + } + } + } + + &.ReadOnly { + top: $unit * -1; + transition: opacity 0.2s ease-in-out, + top 0.2s ease-in-out; + + &.Visible { + display: block; + height: auto; + opacity: 1; + top: 0; + } + + h1 { + font-size: $font-xlarge; + font-weight: $normal; + text-align: left; + margin-bottom: $unit; + } + + .Raid { + color: $grey-50; + font-size: $font-regular; + font-weight: $medium; + margin-bottom: $unit * 2; + } + + p { + font-size: $font-regular; + line-height: $font-regular * 1.2; + } + } +} \ No newline at end of file diff --git a/components/PartyDetails/index.tsx b/components/PartyDetails/index.tsx new file mode 100644 index 00000000..bd92744c --- /dev/null +++ b/components/PartyDetails/index.tsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react' +import { useSnapshot } from 'valtio' +import classNames from 'classnames' + +import CharLimitedFieldset from '~components/CharLimitedFieldset' +import TextFieldset from '~components/TextFieldset' + +import { appState } from '~utils/appState' + +import './index.scss' +import RaidDropdown from '~components/RaidDropdown' + +// Props +interface Props { + editable: boolean + updateCallback: (name?: string, description?: string, raid?: Raid) => void +} + +const PartyDetails = (props: Props) => { + const appSnapshot = useSnapshot(appState) + + const nameInput = React.createRef() + const descriptionInput = React.createRef() + const raidSelect = React.createRef() + + const readOnlyClasses = classNames({ + 'Details': true, + 'ReadOnly': true, + 'Visible': !appSnapshot.party.detailsVisible + }) + + const editableClasses = classNames({ + 'Details': true, + 'Editable': true, + 'Visible': appSnapshot.party.detailsVisible + }) + + const [errors, setErrors] = useState<{ [key: string]: string }>({ + name: '', + 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) + } + + function updateDetails(event: React.ChangeEvent) { + const nameValue = nameInput.current?.value + const descriptionValue = descriptionInput.current?.value + const raid = appSnapshot.raids.find(raid => raid.id == raidSelect.current?.value) + + props.updateCallback(nameValue, descriptionValue, raid) + } + + const editable = ( +
+ + + +
+ ) + + const readOnly = ( +
+

{ (appSnapshot.party.name) ? appSnapshot.party.name : 'No title' }

+ { (appSnapshot.party.raid) ?
{appSnapshot.party.raid.name.en}
: '' } +

{ (appSnapshot.party.description) ? appSnapshot.party.description : '' }

+
+ ) + + return ( +
+ {readOnly} + {editable} +
+ ) +} + +export default PartyDetails diff --git a/components/PartySegmentedControl/index.scss b/components/PartySegmentedControl/index.scss index 96b9f789..a161a3ca 100644 --- a/components/PartySegmentedControl/index.scss +++ b/components/PartySegmentedControl/index.scss @@ -3,6 +3,7 @@ gap: 58px; justify-content: center; margin: 0 auto; + margin-bottom: $unit * 3; max-width: 760px; position: relative; } diff --git a/components/RaidDropdown/index.scss b/components/RaidDropdown/index.scss new file mode 100644 index 00000000..e69de29b diff --git a/components/RaidDropdown/index.tsx b/components/RaidDropdown/index.tsx new file mode 100644 index 00000000..61a1372d --- /dev/null +++ b/components/RaidDropdown/index.tsx @@ -0,0 +1,93 @@ +import React, { useEffect, useState } from 'react' +import { useCookies } from 'react-cookie' + +import { appState } from '~utils/appState' +import api from '~utils/api' + +import './index.scss' + +// Props +interface Props { + selected?: string + onBlur: (event: React.ChangeEvent) => void +} + +const RaidDropdown = React.forwardRef(function fieldSet(props, ref) { + const [cookies, _] = useCookies(['user']) + const headers = (cookies.user != null) ? { + headers: { 'Authorization': `Bearer ${cookies.user.access_token}` } + } : {} + + const [raids, setRaids] = useState() + const [flatRaids, setFlatRaids] = useState() + + const raidGroups = [ + 'Assorted', + 'Omega', + 'T1 Summons', + 'T2 Summons', + 'Primarchs', + 'Nightmare', + 'Omega (Impossible)', + 'Omega II', + 'Tier 1 Summons (Impossible)', + 'Tier 3 Summons', + 'Ennead', + 'Malice', + '6-Star Raids', + 'Six-Dragons', + 'Nightmare (Impossible)', + 'Astral', + 'Super Ultimate' + ] + + useEffect(() => { + fetchRaids() + }, []) + + function fetchRaids() { + api.endpoints.raids.getAll(headers) + .then((response) => { + const raids = response.data.map((r: any) => r.raid) + + appState.raids = raids + organizeRaids(raids) + }) + } + + function organizeRaids(raids: Raid[]) { + const numGroups = Math.max.apply(Math, raids.map(raid => raid.group)) + let groupedRaids = [] + + for (let i = 0; i <= numGroups; i++) { + groupedRaids[i] = raids.filter(raid => raid.group == i) + } + + setRaids(groupedRaids) + } + + function raidGroup(index: number) { + const options = raids && raids.length > 0 && raids[index].length > 0 && + raids[index].sort((a, b) => a.element - b.element).map((item, i) => { + return ( + + ) + }) + + return ( + + {options} + + ) + } + + return ( + + ) +}) + +export default RaidDropdown diff --git a/components/SegmentedControl/index.scss b/components/SegmentedControl/index.scss index 7051099a..2ae75feb 100644 --- a/components/SegmentedControl/index.scss +++ b/components/SegmentedControl/index.scss @@ -1,7 +1,6 @@ .SegmentedControlWrapper { display: flex; justify-content: center; - margin-bottom: $unit * 3; } .SegmentedControl { diff --git a/components/SignupModal/index.tsx b/components/SignupModal/index.tsx index 137217a8..ee6e15b4 100644 --- a/components/SignupModal/index.tsx +++ b/components/SignupModal/index.tsx @@ -57,7 +57,7 @@ const SignupModal = (props: Props) => { setErrors(newErrors) }, (error) => { - console.log(error) + console.error(error) }) } } @@ -88,7 +88,7 @@ const SignupModal = (props: Props) => { props.close() }, (error) => { - console.log(error) + console.error(error) }) } } diff --git a/components/TextFieldset/index.scss b/components/TextFieldset/index.scss new file mode 100644 index 00000000..f84fe540 --- /dev/null +++ b/components/TextFieldset/index.scss @@ -0,0 +1,5 @@ +.Fieldset textarea { + color: $grey-00; + font-family: system-ui, -apple-system, 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 21px; +} \ No newline at end of file diff --git a/components/TextFieldset/index.tsx b/components/TextFieldset/index.tsx new file mode 100644 index 00000000..63b80c61 --- /dev/null +++ b/components/TextFieldset/index.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import './index.scss' + +interface Props { + fieldName: string + placeholder: string + value?: string + error: string + onBlur?: (event: React.ChangeEvent) => void + onChange?: (event: React.ChangeEvent) => void +} + +const TextFieldset = React.forwardRef(function fieldSet(props, ref) { + return ( +
+