Merge pull request #15 from jedmund/saving

Implement the ability to save your favorite grids
This commit is contained in:
Justin Edmund 2022-02-28 01:04:18 -08:00 committed by GitHub
commit 65d46f8f96
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 408 additions and 30 deletions

View file

@ -44,7 +44,7 @@ const BottomHeader = () => {
function deleteTeam(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
if (appState.party.editable && appState.party.id) {
api.endpoints.parties.destroy(appState.party.id, headers)
api.endpoints.parties.destroy({ id: appState.party.id, params: headers })
.then(() => {
// Push to route
router.push('/')

View file

@ -34,6 +34,33 @@
}
}
&.save:hover {
color: #FF4D4D;
.icon svg {
fill: #FF4D4D;
stroke: #FF4D4D;
}
}
&.save.Active {
color: #FF4D4D;
.icon svg {
fill: #FF4D4D;
stroke: #FF4D4D;
}
&:hover {
color: darken(#FF4D4D, 30);
.icon svg {
fill: darken(#FF4D4D, 30);
stroke: darken(#FF4D4D, 30);
}
}
}
&.modal:hover {
background: $grey-90;
}

View file

@ -8,6 +8,7 @@ import CrossIcon from '~public/icons/Cross.svg'
import EditIcon from '~public/icons/Edit.svg'
import LinkIcon from '~public/icons/Link.svg'
import MenuIcon from '~public/icons/Menu.svg'
import SaveIcon from '~public/icons/Save.svg'
import './index.scss'
@ -63,6 +64,10 @@ class Button extends React.Component<Props, State> {
icon = <span className='icon'>
<EditIcon />
</span>
} else if (this.props.icon === 'save') {
icon = <span className='icon stroke'>
<SaveIcon />
</span>
}
const classes = classNames({
@ -70,12 +75,14 @@ class Button extends React.Component<Props, State> {
'Active': this.props.active,
'btn-pressed': this.state.isPressed,
'btn-disabled': this.props.disabled,
'save': this.props.icon === 'save',
'destructive': this.props.type == ButtonType.Destructive
})
return <button className={classes} disabled={this.props.disabled} onClick={this.props.click}>
{icon}
<span className='text'>{this.props.children}</span>
{ (this.props.type != ButtonType.IconOnly) ?
<span className='text'>{this.props.children}</span> : '' }
</button>
}
}

View file

@ -58,7 +58,7 @@ const CharacterGrid = (props: Props) => {
// Methods: Fetching an object from the server
async function fetchGrid(shortcode: string) {
return api.endpoints.parties.getOneWithObject({ id: shortcode, object: 'characters' })
return api.endpoints.parties.getOneWithObject({ id: shortcode, object: 'characters', params: headers })
.then(response => processResult(response))
.catch(error => processError(error))
}
@ -78,6 +78,8 @@ const CharacterGrid = (props: Props) => {
// Store the important party and state-keeping values
appState.party.id = party.id
appState.party.user = party.user
appState.party.favorited = party.favorited
setFound(true)
setLoading(false)

View file

@ -7,7 +7,10 @@
&:hover {
background: white;
cursor: pointer;
h2, .Grid {
cursor: pointer;
}
.Grid .weapon {
box-shadow: inset 0 0 0 1px $grey-80;
@ -68,6 +71,30 @@
}
}
.top {
display: flex;
flex-direction: row;
gap: $unit / 2;
align-items: center;
.info {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: $unit / 2;
}
button svg {
width: 14px;
height: 14px;
}
button:hover,
button.Active {
background: $grey-90;
}
}
.bottom {
display: flex;
flex-direction: row;

View file

@ -1,25 +1,35 @@
import React, { useEffect, useState } from 'react'
import { useSnapshot } from 'valtio'
import classNames from 'classnames'
import { accountState } from '~utils/accountState'
import { formatTimeAgo } from '~utils/timeAgo'
import Button from '~components/Button'
import { ButtonType } from '~utils/enums'
import './index.scss'
interface Props {
shortcode: string
id: string
name: string
raid: Raid
grid: GridWeapon[]
user?: User
favorited: boolean
createdAt: Date
displayUser?: boolean | false
onClick: (shortcode: string) => void
onSave: (partyId: string, favorited: boolean) => void
}
const GridRep = (props: Props) => {
const numWeapons: number = 9
const { account } = useSnapshot(accountState)
const [mainhand, setMainhand] = useState<Weapon>()
const [weapons, setWeapons] = useState<GridArray<Weapon>>({})
@ -64,6 +74,10 @@ const GridRep = (props: Props) => {
<img alt={weapons[position]?.name.en} src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapons[position]?.granblue_id}.jpg`} /> : ''
}
function sendSaveData() {
props.onSave(props.id, props.favorited)
}
const userImage = () => {
if (props.user)
return (
@ -80,7 +94,7 @@ const GridRep = (props: Props) => {
const details = (
<div className="Details">
<h2 className={titleClass}>{ (props.name) ? props.name : 'Untitled' }</h2>
<h2 className={titleClass} onClick={navigate}>{ (props.name) ? props.name : 'Untitled' }</h2>
<div className="bottom">
<div className={raidClass}>{ (props.raid) ? props.raid.name.en : 'No raid set' }</div>
<time className="last-updated" dateTime={props.createdAt.toISOString()}>{formatTimeAgo(props.createdAt, 'en-us')}</time>
@ -90,8 +104,19 @@ const GridRep = (props: Props) => {
const detailsWithUsername = (
<div className="Details">
<h2 className={titleClass}>{ (props.name) ? props.name : 'Untitled' }</h2>
<div className={raidClass}>{ (props.raid) ? props.raid.name.en : 'No raid set' }</div>
<div className="top">
<div className="info">
<h2 className={titleClass} onClick={navigate}>{ (props.name) ? props.name : 'Untitled' }</h2>
<div className={raidClass}>{ (props.raid) ? props.raid.name.en : 'No raid set' }</div>
</div>
{ (!props.user || (account.user && account.user.id !== props.user.id)) ?
<Button
active={props.favorited}
icon="save"
type={ButtonType.IconOnly}
click={sendSaveData}
/> : ''}
</div>
<div className="bottom">
<div className={userClass}>
{ userImage() }
@ -103,9 +128,9 @@ const GridRep = (props: Props) => {
)
return (
<div className="GridRep" onClick={navigate}>
<div className="GridRep">
{ (props.displayUser) ? detailsWithUsername : details}
<div className="Grid">
<div className="Grid" onClick={navigate}>
<div className="weapon grid_mainhand">
{generateMainhandImage()}
</div>

View file

@ -8,7 +8,7 @@
bottom: $unit * 2;
}
#right {
#right > div {
display: flex;
gap: 8px;
}

View file

@ -32,7 +32,7 @@ const HeaderMenu = (props: Props) => {
<Link href={`/${props.username}` || ''}>{props.username}</Link>
</li>
<li className="MenuItem">
<Link href={`/${props.username}/saved` || ''}>Saved</Link>
<Link href={`/saved` || ''}>Saved</Link>
</li>
</div>
<div className="MenuGroup">

View file

@ -117,13 +117,15 @@ const Party = (props: Props) => {
// Methods: Fetch party details
function fetchDetails(shortcode: string) {
return api.endpoints.parties.getOne({ id: shortcode })
return api.endpoints.parties.getOne({ id: shortcode, params: headers })
.then(response => processResult(response))
.catch(error => processError(error))
}
function processResult(response: AxiosResponse) {
appState.party.id = response.data.party.id
appState.party.user = response.data.party.user
appState.party.favorited = response.data.party.favorited
// Store the party's user-generated details
appState.party.name = response.data.party.name

View file

@ -68,7 +68,7 @@ const SummonGrid = (props: Props) => {
// Methods: Fetching an object from the server
async function fetchGrid(shortcode: string) {
return api.endpoints.parties.getOneWithObject({ id: shortcode, object: 'summons' })
return api.endpoints.parties.getOneWithObject({ id: shortcode, object: 'summons', params: headers })
.then(response => processResult(response))
.catch(error => processError(error))
}
@ -88,6 +88,8 @@ const SummonGrid = (props: Props) => {
// Store the important party and state-keeping values
appState.party.id = party.id
appState.party.user = party.user
appState.party.favorited = party.favorited
setFound(true)
setLoading(false)

View file

@ -44,8 +44,7 @@ const SummonUnit = (props: Props) => {
'2040094000', '2040100000', '2040080000', '2040098000',
'2040090000', '2040084000', '2040003000', '2040056000'
]
console.log(`${summon.granblue_id} ${summon.name.en} ${props.gridSummon.uncap_level} ${upgradedSummons.indexOf(summon.granblue_id.toString())}`)
let suffix = ''
if (upgradedSummons.indexOf(summon.granblue_id.toString()) != -1 && props.gridSummon.uncap_level == 5)
suffix = '_02'

View file

@ -4,6 +4,7 @@ import { useRouter } from 'next/router'
import clonedeep from 'lodash.clonedeep'
import { useSnapshot } from 'valtio'
import api from '~utils/api'
import { accountState } from '~utils/accountState'
import { appState, initialAppState } from '~utils/appState'
@ -12,9 +13,14 @@ import Button from '~components/Button'
import HeaderMenu from '~components/HeaderMenu'
const TopHeader = () => {
// Cookies
const [cookies, _, removeCookie] = useCookies(['user'])
const headers = (cookies.user != null) ? {
'Authorization': `Bearer ${cookies.user.access_token}`
} : {}
const accountSnap = useSnapshot(accountState)
const { account } = useSnapshot(accountState)
const { party } = useSnapshot(appState)
const router = useRouter()
function copyToClipboard() {
@ -53,21 +59,60 @@ const TopHeader = () => {
return false
}
function toggleFavorite() {
if (party.favorited)
unsaveFavorite()
else
saveFavorite()
}
function saveFavorite() {
if (party.id)
api.saveTeam({ id: party.id, params: headers })
.then((response) => {
if (response.status == 201)
appState.party.favorited = true
})
else
console.error("Failed to save team: No party ID")
}
function unsaveFavorite() {
if (party.id)
api.unsaveTeam({ id: party.id, params: headers })
.then((response) => {
if (response.status == 200)
appState.party.favorited = false
})
else
console.error("Failed to unsave team: No party ID")
}
const leftNav = () => {
return (
<div className="dropdown">
<Button icon="menu">Menu</Button>
{ (accountSnap.account.user) ?
<HeaderMenu authenticated={accountSnap.account.authorized} username={accountSnap.account.user.username} logout={logout} /> :
<HeaderMenu authenticated={accountSnap.account.authorized} />
{ (account.user) ?
<HeaderMenu authenticated={account.authorized} username={account.user.username} logout={logout} /> :
<HeaderMenu authenticated={account.authorized} />
}
</div>
)
}
const saveButton = () => {
if (party.favorited)
return (<Button icon="save" active={true} click={toggleFavorite}>Saved</Button>)
else
return (<Button icon="save" click={toggleFavorite}>Save</Button>)
}
const rightNav = () => {
return (
<div>
{ (router.route === '/p/[party]' && account.user && (!party.user || party.user.id !== account.user.id)) ?
saveButton() : ''
}
{ (router.route === '/p/[party]') ?
<Button icon="link" click={copyToClipboard}>Copy link</Button> : ''
}

View file

@ -64,7 +64,7 @@ const WeaponGrid = (props: Props) => {
// Methods: Fetching an object from the server
async function fetchGrid(shortcode: string) {
return api.endpoints.parties.getOneWithObject({ id: shortcode, object: 'weapons' })
return api.endpoints.parties.getOneWithObject({ id: shortcode, object: 'weapons', params: headers })
.then(response => processResult(response))
.catch(error => processError(error))
}
@ -85,7 +85,8 @@ const WeaponGrid = (props: Props) => {
// Store the important party and state-keeping values
appState.party.id = party.id
appState.party.extra = party.extra
appState.party.user = party.user
appState.party.favorited = party.favorited
setFound(true)
setLoading(false)

161
pages/saved.tsx Normal file
View file

@ -0,0 +1,161 @@
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useCookies } from 'react-cookie'
import clonedeep from 'lodash.clonedeep'
import api from '~utils/api'
import GridRep from '~components/GridRep'
import GridRepCollection from '~components/GridRepCollection'
import FilterBar from '~components/FilterBar'
const SavedRoute: React.FC = () => {
const router = useRouter()
// Cookies
const [cookies, _] = useCookies(['user'])
const headers = (cookies.user != null) ? {
'Authorization': `Bearer ${cookies.user.access_token}`
} : {}
const [found, setFound] = useState(false)
const [loading, setLoading] = useState(true)
const [scrolled, setScrolled] = useState(false)
const [parties, setParties] = useState<Party[]>([])
useEffect(() => {
console.log(`Fetching favorite teams...`)
fetchTeams()
}, [])
useEffect(() => {
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll);
}, [])
async function fetchTeams(element?: number, raid?: string, recency?: number) {
const params = {
params: {
element: (element && element >= 0) ? element : undefined,
raid: (raid && raid != '0') ? raid : undefined,
recency: (recency && recency > 0) ? recency : undefined
},
headers: {
'Authorization': `Bearer ${cookies.user.access_token}`
}
}
api.savedTeams(params)
.then(response => {
const parties: Party[] = response.data
setParties(parties.map((p: any) => p.party).sort((a, b) => (a.created_at > b.created_at) ? -1 : 1))
})
.then(() => {
setFound(true)
setLoading(false)
})
.catch(error => {
if (error.response != null) {
if (error.response.status == 404) {
setFound(false)
}
} else {
console.error(error)
}
})
}
function toggleFavorite(teamId: string, favorited: boolean) {
if (favorited)
unsaveFavorite(teamId)
else
saveFavorite(teamId)
}
function saveFavorite(teamId: string) {
api.saveTeam({ id: teamId, params: headers })
.then((response) => {
if (response.status == 201) {
const index = parties.findIndex(p => p.id === teamId)
const party = parties[index]
party.favorited = true
let clonedParties = clonedeep(parties)
clonedParties[index] = party
setParties(clonedParties)
}
})
}
function unsaveFavorite(teamId: string) {
api.unsaveTeam({ id: teamId, params: headers })
.then((response) => {
if (response.status == 200) {
const index = parties.findIndex(p => p.id === teamId)
const party = parties[index]
party.favorited = false
let clonedParties = clonedeep(parties)
clonedParties.splice(index, 1)
setParties(clonedParties)
}
})
}
function handleScroll() {
if (window.pageYOffset > 90)
setScrolled(true)
else
setScrolled(false)
}
function goTo(shortcode: string) {
router.push(`/p/${shortcode}`)
}
function renderGrids() {
return (
<GridRepCollection>
{
parties.map((party, i) => {
return <GridRep
id={party.id}
shortcode={party.shortcode}
name={party.name}
createdAt={new Date(party.created_at)}
raid={party.raid}
grid={party.weapons}
user={party.user}
favorited={party.favorited}
key={`party-${i}`}
displayUser={true}
onClick={goTo}
onSave={toggleFavorite}
/>
})
}
</GridRepCollection>
)
}
function renderNoGrids() {
return (
<div id="NotFound">
<h2>You haven't saved any teams yet</h2>
</div>
)
}
return (
<div id="Teams">
<FilterBar onFilter={fetchTeams} name="Your saved teams" scrolled={scrolled} />
{ (parties.length > 0) ? renderGrids() : renderNoGrids() }
</div>
)
}
export default SavedRoute

View file

@ -1,5 +1,7 @@
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useCookies } from 'react-cookie'
import clonedeep from 'lodash.clonedeep'
import api from '~utils/api'
@ -10,6 +12,12 @@ import FilterBar from '~components/FilterBar'
const TeamsRoute: React.FC = () => {
const router = useRouter()
// Cookies
const [cookies, _] = useCookies(['user'])
const headers = (cookies.user != null) ? {
'Authorization': `Bearer ${cookies.user.access_token}`
} : {}
const [found, setFound] = useState(false)
const [loading, setLoading] = useState(true)
const [scrolled, setScrolled] = useState(false)
@ -31,6 +39,9 @@ const TeamsRoute: React.FC = () => {
element: (element && element >= 0) ? element : undefined,
raid: (raid && raid != '0') ? raid : undefined,
recency: (recency && recency > 0) ? recency : undefined
},
headers: {
'Authorization': `Bearer ${cookies.user.access_token}`
}
}
@ -54,6 +65,47 @@ const TeamsRoute: React.FC = () => {
})
}
function toggleFavorite(teamId: string, favorited: boolean) {
if (favorited)
unsaveFavorite(teamId)
else
saveFavorite(teamId)
}
function saveFavorite(teamId: string) {
api.saveTeam({ id: teamId, params: headers })
.then((response) => {
if (response.status == 201) {
const index = parties.findIndex(p => p.id === teamId)
const party = parties[index]
party.favorited = true
let clonedParties = clonedeep(parties)
clonedParties[index] = party
setParties(clonedParties)
}
})
}
function unsaveFavorite(teamId: string) {
api.unsaveTeam({ id: teamId, params: headers })
.then((response) => {
if (response.status == 200) {
const index = parties.findIndex(p => p.id === teamId)
const party = parties[index]
party.favorited = false
let clonedParties = clonedeep(parties)
clonedParties[index] = party
setParties(clonedParties)
}
})
}
function handleScroll() {
if (window.pageYOffset > 90)
setScrolled(true)
@ -71,15 +123,18 @@ const TeamsRoute: React.FC = () => {
{
parties.map((party, i) => {
return <GridRep
id={party.id}
shortcode={party.shortcode}
name={party.name}
createdAt={new Date(party.created_at)}
raid={party.raid}
grid={party.weapons}
user={party.user}
favorited={party.favorited}
key={`party-${i}`}
displayUser={true}
onClick={goTo}
onSave={toggleFavorite}
/>
})
}
@ -90,7 +145,7 @@ const TeamsRoute: React.FC = () => {
function renderNoGrids() {
return (
<div id="NotFound">
<h2>No grids found</h2>
<h2>No teams found</h2>
</div>
)
}

1
types/Party.d.ts vendored
View file

@ -4,6 +4,7 @@ interface Party {
raid: Raid
shortcode: string
extra: boolean
favorited: boolean
characters: Array<GridCharacter>
weapons: Array<GridWeapon>
summons: Array<GridSummon>

View file

@ -1,15 +1,16 @@
import axios, { Axios, AxiosRequestConfig, AxiosResponse } from "axios"
import { appState } from "./appState"
interface Entity {
name: string
}
type CollectionEndpoint = (params?: {}) => Promise<AxiosResponse<any>>
type IdEndpoint = ({ id }: { id: string }) => Promise<AxiosResponse<any>>
type IdWithObjectEndpoint = ({ id, object }: { id: string, object: string }) => Promise<AxiosResponse<any>>
type IdEndpoint = ({ id, params }: { id: string, params?: {} }) => Promise<AxiosResponse<any>>
type IdWithObjectEndpoint = ({ id, object, params }: { id: string, object: string, params?: {} }) => Promise<AxiosResponse<any>>
type PostEndpoint = (object: {}, headers?: {}) => Promise<AxiosResponse<any>>
type PutEndpoint = (id: string, object: {}, headers?: {}) => Promise<AxiosResponse<any>>
type DestroyEndpoint = (id: string, headers?: {}) => Promise<AxiosResponse<any>>
type DestroyEndpoint = ({ id, params }: { id: string, params?: {} }) => Promise<AxiosResponse<any>>
interface EndpointMap {
getAll: CollectionEndpoint
@ -42,11 +43,11 @@ class Api {
return {
getAll: (params?: {}) => axios.get(resourceUrl, params),
getOne: ({ id }: { id: string }) => axios.get(`${resourceUrl}/${id}/`),
getOneWithObject: ({ id, object }: { id: string, object: string }) => axios.get(`${resourceUrl}/${id}/${object}`),
getOne: ({ id, params }: { id: string, params?: {} }) => axios.get(`${resourceUrl}/${id}/`, params),
getOneWithObject: ({ id, object, params }: { id: string, object: string, params?: {} }) => axios.get(`${resourceUrl}/${id}/${object}`, params),
create: (object: {}, headers?: {}) => axios.post(resourceUrl, object, headers),
update: (id: string, object: {}, headers?: {}) => axios.put(`${resourceUrl}/${id}`, object, headers),
destroy: (id: string, headers?: {}) => axios.delete(`${resourceUrl}/${id}`, headers)
destroy: ({ id, params }: { id: string, params?: {} }) => axios.delete(`${resourceUrl}/${id}`, params)
} as EndpointMap
}
@ -70,6 +71,23 @@ class Api {
})
}
savedTeams(params: {}) {
const resourceUrl = `${this.url}/parties/favorites`
return axios.get(resourceUrl, params)
}
saveTeam({ id, params }: { id: string, params?: {} }) {
const body = { favorite: { party_id: id } }
const resourceUrl = `${this.url}/favorites`
return axios.post(resourceUrl, body, { headers: params })
}
unsaveTeam({ id, params }: { id: string, params?: {} }) {
const body = { favorite: { party_id: id } }
const resourceUrl = `${this.url}/favorites`
return axios.delete(resourceUrl, { data: body, headers: params })
}
updateUncap(resource: 'character'|'weapon'|'summon', id: string, value: number) {
const pluralized = resource + 's'
const resourceUrl = `${this.url}/${pluralized}/update_uncap`
@ -89,5 +107,6 @@ api.createEntity( { name: 'characters' })
api.createEntity( { name: 'weapons' })
api.createEntity( { name: 'summons' })
api.createEntity( { name: 'raids' })
api.createEntity( { name: 'favorites' })
export default api

View file

@ -11,7 +11,9 @@ interface AppState {
description: string | undefined,
raid: Raid | undefined,
element: number,
extra: boolean
extra: boolean,
user: User | undefined,
favorited: boolean
},
grid: {
weapons: {
@ -40,7 +42,9 @@ export const initialAppState: AppState = {
description: undefined,
raid: undefined,
element: 0,
extra: false
extra: false,
user: undefined,
favorited: false
},
grid: {
weapons: {

View file

@ -1,5 +1,6 @@
export enum ButtonType {
Base,
IconOnly,
Destructive
}