Merge pull request #29 from jedmund/details-v2

Party Details redesign
This commit is contained in:
Justin Edmund 2022-03-14 16:48:30 -07:00 committed by GitHub
commit f5dd8372e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 372 additions and 164 deletions

View file

@ -1,121 +0,0 @@
import React from 'react'
import { useRouter } from 'next/router'
import { useCookies } from 'react-cookie'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import clonedeep from 'lodash.clonedeep'
import * as Scroll from 'react-scroll'
import * as AlertDialog from '@radix-ui/react-alert-dialog'
import Header from '~components/Header'
import Button from '~components/Button'
import api from '~utils/api'
import { appState, initialAppState } from '~utils/appState'
import CrossIcon from '~public/icons/Cross.svg'
const BottomHeader = () => {
const { t } = useTranslation('common')
const app = useSnapshot(appState)
const router = useRouter()
const scroll = Scroll.animateScroll;
// Cookies
const [cookies] = useCookies(['account'])
const headers = (cookies.account != null) ? {
headers: {
'Authorization': `Bearer ${cookies.account.access_token}`
}
} : {}
function toggleDetails() {
appState.party.detailsVisible = !appState.party.detailsVisible
if (appState.party.detailsVisible)
scroll.scrollToBottom()
else
scroll.scrollToTop()
}
function deleteTeam(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
if (appState.party.editable && appState.party.id) {
api.endpoints.parties.destroy({ id: appState.party.id, params: headers })
.then(() => {
// Push to route
router.push('/')
// Clean state
const resetState = clonedeep(initialAppState)
Object.keys(resetState).forEach((key) => {
appState[key] = resetState[key]
})
// Set party to be editable
appState.party.editable = true
})
.catch((error) => {
console.error(error)
})
}
}
const leftNav = () => {
if (router.pathname === '/p/[party]' || router.pathname === '/new') {
if (app.party.detailsVisible) {
return (<Button icon="edit" active={true} onClick={toggleDetails}>{t('buttons.hide_info')}</Button>)
} else {
return (<Button icon="edit" onClick={toggleDetails}>{t('buttons.show_info')}</Button>)
}
} else {
return (<div />)
}
}
const rightNav = () => {
if (app.party.editable && router.route === '/p/[party]') {
return (
<AlertDialog.Root>
<AlertDialog.Trigger className="Button destructive">
<span className='icon'>
<CrossIcon />
</span>
<span className="text">{t('buttons.delete')}</span>
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay className="Overlay" />
<AlertDialog.Content className="Dialog">
<AlertDialog.Title className="DialogTitle">
{t('modals.delete_team.title')}
</AlertDialog.Title>
<AlertDialog.Description className="DialogDescription">
{t('modals.delete_team.description')}
</AlertDialog.Description>
<div className="actions">
<AlertDialog.Cancel className="Button modal">{t('modals.delete_team.buttons.cancel')}</AlertDialog.Cancel>
<AlertDialog.Action className="Button modal destructive" onClick={(e) => deleteTeam(e)}>{t('modals.delete_team.buttons.confirm')}</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

@ -82,6 +82,12 @@
width: 12px;
}
&.check svg {
margin-top: 1px;
height: 14px;
width: auto;
}
&.stroke svg {
fill: none;
stroke: $grey-50;

View file

@ -4,6 +4,7 @@ import classNames from 'classnames'
import Link from 'next/link'
import AddIcon from '~public/icons/Add.svg'
import CheckIcon from '~public/icons/LargeCheck.svg'
import CrossIcon from '~public/icons/Cross.svg'
import EditIcon from '~public/icons/Edit.svg'
import LinkIcon from '~public/icons/Link.svg'
@ -65,6 +66,12 @@ const Button = (props: Props) => {
</span>
)
const checkIcon = (
<span className='icon check'>
<CheckIcon />
</span>
)
const crossIcon = (
<span className='icon'>
<CrossIcon />
@ -96,6 +103,7 @@ const Button = (props: Props) => {
case 'new': icon = addIcon; break
case 'menu': icon = menuIcon; break
case 'link': icon = linkIcon; break
case 'check': icon = checkIcon; break
case 'cross': icon = crossIcon; break
case 'edit': icon = editIcon; break
case 'save': icon = saveIcon; break

View file

@ -86,7 +86,9 @@ const CharacterGrid = (props: Props) => {
appState.party.id = party.id
appState.party.user = party.user
appState.party.favorited = party.favorited
appState.party.created_at = party.created_at
appState.party.updated_at = party.updated_at
setFound(true)
setLoading(false)

View file

@ -2,7 +2,9 @@
display: flex;
flex-direction: column;
gap: calc($unit / 2);
min-height: 320px;
max-width: 200px;
margin-bottom: $unit * 4;
&.editable .CharacterImage:hover {
border: $hover-stroke;

View file

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

View file

@ -1,6 +1,3 @@
#Party {
margin-bottom: $unit * 4;
}
#Party .Extra {
color: #888;
display: flex;

View file

@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
import { useCookies } from 'react-cookie'
import clonedeep from 'lodash.clonedeep'
@ -32,6 +33,9 @@ const Party = (props: Props) => {
} : {}
}, [cookies.account])
// Set up router
const router = useRouter()
// Set up states
const { party } = useSnapshot(appState)
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
@ -81,10 +85,34 @@ const Party = (props: Props) => {
appState.party.name = name
appState.party.description = description
appState.party.raid = raid
appState.party.updated_at = party.updated_at
})
}
}
// Deleting the party
function deleteTeam(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
if (appState.party.editable && appState.party.id) {
api.endpoints.parties.destroy({ id: appState.party.id, params: headers })
.then(() => {
// Push to route
router.push('/')
// Clean state
const resetState = clonedeep(initialAppState)
Object.keys(resetState).forEach((key) => {
appState[key] = resetState[key]
})
// Set party to be editable
appState.party.editable = true
})
.catch((error) => {
console.error(error)
})
}
}
// Methods: Navigating with segmented control
function segmentClicked(event: React.ChangeEvent<HTMLInputElement>) {
switch(event.target.value) {
@ -110,6 +138,8 @@ const Party = (props: Props) => {
appState.party.id = response.data.party.id
appState.party.user = response.data.party.user
appState.party.favorited = response.data.party.favorited
appState.party.created_at = response.data.party.created_at
appState.party.updated_at = response.data.party.updated_at
// Store the party's user-generated details
appState.party.name = response.data.party.name
@ -194,6 +224,7 @@ const Party = (props: Props) => {
{ <PartyDetails
editable={party.editable}
updateCallback={updateDetails}
deleteCallback={deleteTeam}
/>}
</div>
)

View file

@ -31,6 +31,22 @@
width: 100%;
}
}
.bottom {
display: flex;
flex-direction: row;
gap: $unit;
.left {
flex-grow: 1;
}
.right {
display: flex;
flex-direction: row;
gap: $unit;
}
}
}
&.ReadOnly {
@ -45,26 +61,8 @@
top: 0;
}
h1 {
font-size: $font-xlarge;
font-weight: $normal;
text-align: left;
margin-bottom: $unit;
}
a {
color: $blue;
&:hover {
text-decoration: underline;
}
}
.Raid {
color: $grey-50;
font-size: $font-regular;
font-weight: $medium;
margin-bottom: $unit * 2;
a:hover {
text-decoration: underline;
}
p {
@ -72,5 +70,84 @@
line-height: $font-regular * 1.2;
white-space: pre-line;
}
h1 {
font-size: $font-xlarge;
font-weight: $normal;
text-align: left;
margin-bottom: $unit;
}
.info {
align-items: center;
display: flex;
flex-direction: row;
gap: $unit;
margin-bottom: $unit * 2;
.left {
flex-grow: 1;
}
}
.attribution {
align-items: center;
display: flex;
flex-direction: row;
& > div {
align-items: center;
display: inline-flex;
font-size: $font-small;
height: 26px;
}
time {
font-size: $font-small;
}
& > *:not(:last-child):after {
content: " · ";
margin: 0 calc($unit / 2);
}
}
.user {
align-items: center;
display: inline-flex;
gap: calc($unit / 2);
margin-top: 1px;
img, .no-user {
$diameter: 24px;
border-radius: calc($diameter / 2);
height: $diameter;
width: $diameter;
}
img.gran {
background-color: #CEE7FE;
}
img.djeeta {
background-color: #FFE1FE;
}
.no-user {
background: $grey-80;
}
}
}
}
.EmptyDetails {
display: none;
justify-content: center;
margin-bottom: $unit * 10;
&.Visible {
display: flex;
}
}

View file

@ -1,27 +1,53 @@
import React, { useState } from 'react'
import Head from 'next/head'
import Router, { useRouter } from 'next/router'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import Linkify from 'react-linkify'
import classNames from 'classnames'
import * as AlertDialog from '@radix-ui/react-alert-dialog'
import CrossIcon from '~public/icons/Cross.svg'
import Button from '~components/Button'
import CharLimitedFieldset from '~components/CharLimitedFieldset'
import RaidDropdown from '~components/RaidDropdown'
import TextFieldset from '~components/TextFieldset'
import { accountState } from '~utils/accountState'
import { appState } from '~utils/appState'
import './index.scss'
import Link from 'next/link'
import { formatTimeAgo } from '~utils/timeAgo'
const emptyRaid: Raid = {
id: '',
name: {
en: '',
ja: ''
},
slug: '',
level: 0,
group: 0,
element: 0
}
// Props
interface Props {
editable: boolean
updateCallback: (name?: string, description?: string, raid?: Raid) => void
deleteCallback: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
}
const PartyDetails = (props: Props) => {
const { party, raids } = useSnapshot(appState)
const { account } = useSnapshot(accountState)
const { t } = useTranslation('common')
const router = useRouter()
const locale = router.locale || 'en'
const nameInput = React.createRef<HTMLInputElement>()
const descriptionInput = React.createRef<HTMLTextAreaElement>()
@ -39,6 +65,25 @@ const PartyDetails = (props: Props) => {
'Visible': party.detailsVisible
})
const emptyClasses = classNames({
'EmptyDetails': true,
'Visible': !party.detailsVisible
})
const userClass = classNames({
'user': true,
'empty': !party.user
})
const linkClass = classNames({
'wind': party && party.element == 1,
'fire': party && party.element == 2,
'water': party && party.element == 3,
'earth': party && party.element == 4,
'dark': party && party.element == 5,
'light': party && party.element == 6
})
const [errors, setErrors] = useState<{ [key: string]: string }>({
name: '',
description: ''
@ -62,12 +107,100 @@ const PartyDetails = (props: Props) => {
setErrors(newErrors)
}
function updateDetails(event: React.ChangeEvent) {
function toggleDetails() {
appState.party.detailsVisible = !appState.party.detailsVisible
// if (appState.party.detailsVisible)
// scroll.scrollToBottom()
// else
// scroll.scrollToTop()
}
function updateDetails(event: React.MouseEvent) {
const nameValue = nameInput.current?.value
const descriptionValue = descriptionInput.current?.value
const raid = raids.find(raid => raid.slug === raidSelect.current?.value)
props.updateCallback(nameValue, descriptionValue, raid)
toggleDetails()
}
const userImage = () => {
if (party.user)
return (
<img
alt={party.user.picture.picture}
className={`profile ${party.user.picture.element}`}
srcSet={`/profile/${party.user.picture.picture}.png,
/profile/${party.user.picture.picture}@2x.png 2x`}
src={`/profile/${party.user.picture.picture}.png`}
/>
)
else
return (<div className="no-user" />)
}
const userBlock = () => {
return (
<div className={userClass}>
{ userImage() }
{ (party.user) ? party.user.username : t('no_user') }
</div>
)
}
const linkedUserBlock = (user: User) => {
return (
<div>
<Link href={`/${user.username}`} passHref>
<a className={linkClass}>{userBlock()}</a>
</Link>
</div>
)
}
const linkedRaidBlock = (raid: Raid) => {
return (
<div>
<Link href={`/teams?raid=${raid.slug}`} passHref>
<a className={`Raid ${linkClass}`}>
{raid.name[locale]}
</a>
</Link>
</div>
)
}
const deleteButton = () => {
if (party.editable) {
return (
<AlertDialog.Root>
<AlertDialog.Trigger className="Button destructive">
<span className='icon'>
<CrossIcon />
</span>
<span className="text">{t('buttons.delete')}</span>
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay className="Overlay" />
<AlertDialog.Content className="Dialog">
<AlertDialog.Title className="DialogTitle">
{t('modals.delete_team.title')}
</AlertDialog.Title>
<AlertDialog.Description className="DialogDescription">
{t('modals.delete_team.description')}
</AlertDialog.Description>
<div className="actions">
<AlertDialog.Cancel className="Button modal">{t('modals.delete_team.buttons.cancel')}</AlertDialog.Cancel>
<AlertDialog.Action className="Button modal destructive" onClick={(e) => props.deleteCallback(e)}>{t('modals.delete_team.buttons.confirm')}</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
} else {
return ('')
}
}
const editable = (
@ -77,7 +210,6 @@ const PartyDetails = (props: Props) => {
placeholder="Name your team"
value={party.name}
limit={50}
onBlur={updateDetails}
onChange={handleInputChange}
error={errors.name}
ref={nameInput}
@ -85,29 +217,72 @@ const PartyDetails = (props: Props) => {
<RaidDropdown
showAllRaidsOption={false}
currentRaid={party.raid?.slug || ''}
onBlur={updateDetails}
ref={raidSelect}
/>
<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!"}
value={party.description}
onBlur={updateDetails}
onChange={handleTextAreaChange}
error={errors.description}
ref={descriptionInput}
/>
<div className="bottom">
<div className="left">
{ (router.pathname !== '/new') ? deleteButton() : '' }
</div>
<div className="right">
<Button
active={true}
onClick={toggleDetails}>
{t('buttons.cancel')}
</Button>
<Button
active={true}
icon="check"
onClick={updateDetails}>
{t('buttons.save_info')}
</Button>
</div>
</div>
</section>
)
const readOnly = (
<section className={readOnlyClasses}>
{ (party.name) ? <h1>{party.name}</h1> : '' }
{ (party.raid) ? <div className="Raid">{party.raid.name.en}</div> : '' }
<div className="info">
<div className="left">
{ (party.name) ? <h1>{party.name}</h1> : '' }
<div className="attribution">
{ (party.user) ? linkedUserBlock(party.user) : userBlock() }
{ (party.raid) ? linkedRaidBlock(party.raid) : '' }
{ (party.created_at != undefined)
? <time
className="last-updated"
dateTime={new Date(party.created_at).toString()}>
{formatTimeAgo(new Date(party.created_at), locale)}
</time>
: '' }
</div>
</div>
<div className="right">
{ (party.editable)
? <Button active={true} icon="edit" onClick={toggleDetails}>{t('buttons.show_info')}</Button>
: <div /> }
</div>
</div>
{ (party.description) ? <p><Linkify>{party.description}</Linkify></p> : '' }
</section>
)
const emptyDetails = (
<div className={emptyClasses}>
<Button active={true} icon="edit" onClick={toggleDetails}>{t('buttons.show_info')}</Button>
</div>
)
const generateTitle = () => {
let title = ''
@ -138,7 +313,7 @@ const PartyDetails = (props: Props) => {
<meta name="twitter:title" content={generateTitle()} />
<meta name="twitter:description" content={ (party.description) ? party.description : '' } />
</Head>
{readOnly}
{ (editable && (party.name || party.description || party.raid)) ? readOnly : emptyDetails}
{editable}
</div>
)

View file

@ -99,7 +99,9 @@ const SummonGrid = (props: Props) => {
appState.party.id = party.id
appState.party.user = party.user
appState.party.favorited = party.favorited
appState.party.created_at = party.created_at
appState.party.updated_at = party.updated_at
setFound(true)
setLoading(false)

View file

@ -93,6 +93,8 @@ const WeaponGrid = (props: Props) => {
appState.party.extra = party.extra
appState.party.user = party.user
appState.party.favorited = party.favorited
appState.party.created_at = party.created_at
appState.party.updated_at = party.updated_at
setFound(true)
setLoading(false)

View file

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.8858 5.35355C12.0811 5.15829 12.0811 4.84171 11.8858 4.64645C11.6906 4.45118 11.374 4.45118 11.1787 4.64645L5.08066 10.7445L2.85355 8.51741C2.65829 8.32215 2.34171 8.32215 2.14645 8.51741C1.95118 8.71268 1.95118 9.02926 2.14645 9.22452L4.72709 11.8052C4.82473 11.9028 4.9527 11.9516 5.08066 11.9516C5.1119 11.9516 5.14314 11.9487 5.17394 11.9429C5.2693 11.9249 5.36042 11.879 5.43422 11.8052L11.8858 5.35355Z" />
</svg>

After

Width:  |  Height:  |  Size: 569 B

View file

@ -9,10 +9,12 @@
}
},
"buttons": {
"cancel": "Cancel",
"copy": "Copy link",
"delete": "Delete team",
"show_info": "Edit info",
"hide_info": "Hide info",
"save_info": "Save info",
"menu": "Menu",
"new": "New",
"wiki": "View more on gbf.wiki"

View file

@ -9,9 +9,11 @@
}
},
"buttons": {
"cancel": "キャンセルs",
"copy": "リンクをコピー",
"delete": "編成を削除",
"show_info": "詳細を編集",
"save_info": "詳細を保存",
"hide_info": "詳細を非表示",
"menu": "メニュー",
"new": "作成",

View file

@ -28,6 +28,30 @@ main {
a {
text-decoration: none;
&.wind {
color: $wind-text-dark;
}
&.fire {
color: $fire-text-dark;
}
&.water {
color: $water-text-dark;
}
&.earth {
color: $earth-text-dark;
}
&.dark {
color: $dark-text-dark;
}
&.light {
color: $light-text-dark;
}
}
button, input {

View file

@ -1,6 +0,0 @@
interface WeaponGridProps {
onReceiveData: (Weapon, number) => void
weapon: Weapon | undefined
position: number
editable: boolean
}

View file

@ -13,7 +13,9 @@ interface AppState {
element: number,
extra: boolean,
user: User | undefined,
favorited: boolean
favorited: boolean,
created_at: string
updated_at: string
},
grid: {
weapons: {
@ -48,7 +50,9 @@ export const initialAppState: AppState = {
element: 0,
extra: false,
user: undefined,
favorited: false
favorited: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
},
grid: {
weapons: {