diff --git a/components/BottomHeader/index.tsx b/components/BottomHeader/index.tsx index 30972b90..dd7cff0e 100644 --- a/components/BottomHeader/index.tsx +++ b/components/BottomHeader/index.tsx @@ -44,7 +44,7 @@ const BottomHeader = () => { function deleteTeam(event: React.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('/') diff --git a/components/Button/index.scss b/components/Button/index.scss index 0b4b88d0..e2d7ce8d 100644 --- a/components/Button/index.scss +++ b/components/Button/index.scss @@ -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; } diff --git a/components/Button/index.tsx b/components/Button/index.tsx index 4db0290f..3fcd68f5 100644 --- a/components/Button/index.tsx +++ b/components/Button/index.tsx @@ -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 { icon = + } else if (this.props.icon === 'save') { + icon = + + } const classes = classNames({ @@ -70,12 +75,14 @@ class Button extends React.Component { '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 } } diff --git a/components/CharacterGrid/index.tsx b/components/CharacterGrid/index.tsx index f5429e01..ecfecafc 100644 --- a/components/CharacterGrid/index.tsx +++ b/components/CharacterGrid/index.tsx @@ -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) diff --git a/components/GridRep/index.scss b/components/GridRep/index.scss index bf9b81e9..d8850306 100644 --- a/components/GridRep/index.scss +++ b/components/GridRep/index.scss @@ -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; diff --git a/components/GridRep/index.tsx b/components/GridRep/index.tsx index a9281b65..9448bcde 100644 --- a/components/GridRep/index.tsx +++ b/components/GridRep/index.tsx @@ -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() const [weapons, setWeapons] = useState>({}) @@ -64,6 +74,10 @@ const GridRep = (props: Props) => { {weapons[position]?.name.en} : '' } + function sendSaveData() { + props.onSave(props.id, props.favorited) + } + const userImage = () => { if (props.user) return ( @@ -80,7 +94,7 @@ const GridRep = (props: Props) => { const details = (
-

{ (props.name) ? props.name : 'Untitled' }

+

{ (props.name) ? props.name : 'Untitled' }

{ (props.raid) ? props.raid.name.en : 'No raid set' }
@@ -90,8 +104,19 @@ const GridRep = (props: Props) => { const detailsWithUsername = (
-

{ (props.name) ? props.name : 'Untitled' }

-
{ (props.raid) ? props.raid.name.en : 'No raid set' }
+
+
+

{ (props.name) ? props.name : 'Untitled' }

+
{ (props.raid) ? props.raid.name.en : 'No raid set' }
+
+ { (!props.user || (account.user && account.user.id !== props.user.id)) ? +
{ userImage() } @@ -103,9 +128,9 @@ const GridRep = (props: Props) => { ) return ( -
+
{ (props.displayUser) ? detailsWithUsername : details} -
+
{generateMainhandImage()}
diff --git a/components/Header/index.scss b/components/Header/index.scss index d5f5afea..f4b2ff11 100644 --- a/components/Header/index.scss +++ b/components/Header/index.scss @@ -8,7 +8,7 @@ bottom: $unit * 2; } - #right { + #right > div { display: flex; gap: 8px; } diff --git a/components/HeaderMenu/index.tsx b/components/HeaderMenu/index.tsx index 389edfe0..00b87ae1 100644 --- a/components/HeaderMenu/index.tsx +++ b/components/HeaderMenu/index.tsx @@ -32,7 +32,7 @@ const HeaderMenu = (props: Props) => { {props.username}
  • - Saved + Saved
  • diff --git a/components/Party/index.tsx b/components/Party/index.tsx index d4b547c1..e9ee23e0 100644 --- a/components/Party/index.tsx +++ b/components/Party/index.tsx @@ -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 diff --git a/components/SummonGrid/index.tsx b/components/SummonGrid/index.tsx index 97404734..334ce9c4 100644 --- a/components/SummonGrid/index.tsx +++ b/components/SummonGrid/index.tsx @@ -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) diff --git a/components/SummonUnit/index.tsx b/components/SummonUnit/index.tsx index 1b196ffa..63b134d7 100644 --- a/components/SummonUnit/index.tsx +++ b/components/SummonUnit/index.tsx @@ -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' diff --git a/components/TopHeader/index.tsx b/components/TopHeader/index.tsx index 99bd9a54..b9b5897c 100644 --- a/components/TopHeader/index.tsx +++ b/components/TopHeader/index.tsx @@ -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 (
    - { (accountSnap.account.user) ? - : - + { (account.user) ? + : + }
    ) } + const saveButton = () => { + if (party.favorited) + return () + else + return () + } + const rightNav = () => { return (
    + { (router.route === '/p/[party]' && account.user && (!party.user || party.user.id !== account.user.id)) ? + saveButton() : '' + } { (router.route === '/p/[party]') ? : '' } diff --git a/components/WeaponGrid/index.tsx b/components/WeaponGrid/index.tsx index 103bf728..39dd358a 100644 --- a/components/WeaponGrid/index.tsx +++ b/components/WeaponGrid/index.tsx @@ -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) diff --git a/pages/saved.tsx b/pages/saved.tsx new file mode 100644 index 00000000..939c5d56 --- /dev/null +++ b/pages/saved.tsx @@ -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([]) + + 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 ( + + { + parties.map((party, i) => { + return + }) + } + + ) + } + + function renderNoGrids() { + return ( +
    +

    You haven't saved any teams yet

    +
    + ) + } + + return ( +
    + + { (parties.length > 0) ? renderGrids() : renderNoGrids() } +
    + ) +} + +export default SavedRoute \ No newline at end of file diff --git a/pages/teams.tsx b/pages/teams.tsx index b512aacc..c077f778 100644 --- a/pages/teams.tsx +++ b/pages/teams.tsx @@ -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 }) } @@ -90,7 +145,7 @@ const TeamsRoute: React.FC = () => { function renderNoGrids() { return (
    -

    No grids found

    +

    No teams found

    ) } diff --git a/types/Party.d.ts b/types/Party.d.ts index 94db577d..11c2158a 100644 --- a/types/Party.d.ts +++ b/types/Party.d.ts @@ -4,6 +4,7 @@ interface Party { raid: Raid shortcode: string extra: boolean + favorited: boolean characters: Array weapons: Array summons: Array diff --git a/utils/api.tsx b/utils/api.tsx index 683b2e71..61d49454 100644 --- a/utils/api.tsx +++ b/utils/api.tsx @@ -1,15 +1,16 @@ import axios, { Axios, AxiosRequestConfig, AxiosResponse } from "axios" +import { appState } from "./appState" interface Entity { name: string } type CollectionEndpoint = (params?: {}) => Promise> -type IdEndpoint = ({ id }: { id: string }) => Promise> -type IdWithObjectEndpoint = ({ id, object }: { id: string, object: string }) => Promise> +type IdEndpoint = ({ id, params }: { id: string, params?: {} }) => Promise> +type IdWithObjectEndpoint = ({ id, object, params }: { id: string, object: string, params?: {} }) => Promise> type PostEndpoint = (object: {}, headers?: {}) => Promise> type PutEndpoint = (id: string, object: {}, headers?: {}) => Promise> -type DestroyEndpoint = (id: string, headers?: {}) => Promise> +type DestroyEndpoint = ({ id, params }: { id: string, params?: {} }) => Promise> 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 \ No newline at end of file diff --git a/utils/appState.tsx b/utils/appState.tsx index e8059c4a..81d6db31 100644 --- a/utils/appState.tsx +++ b/utils/appState.tsx @@ -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: { diff --git a/utils/enums.tsx b/utils/enums.tsx index ca8289b1..caf17d02 100644 --- a/utils/enums.tsx +++ b/utils/enums.tsx @@ -1,5 +1,6 @@ export enum ButtonType { Base, + IconOnly, Destructive }