diff --git a/backend/src/api.rs b/backend/src/api.rs index fc30e6d..f0529a1 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -31,6 +31,8 @@ struct ApiResponse { #[serde(skip_serializing_if = "Option::is_none")] gamenights: Option>, #[serde(skip_serializing_if = "Option::is_none")] + gamenight: Option, + #[serde(skip_serializing_if = "Option::is_none")] games: Option>, } @@ -43,6 +45,7 @@ impl ApiResponse { message: None, user: None, gamenights: None, + gamenight: None, games: None, }; @@ -52,6 +55,7 @@ impl ApiResponse { message: Some(Cow::Owned(message)), user: None, gamenights: None, + gamenight: None, games: None, } } @@ -65,16 +69,29 @@ impl ApiResponse { jwt: jwt, }), gamenights: None, + gamenight: None, games: None, } } - fn gamenight_response(gamenights: Vec) -> Self { + fn gamenights_response(gamenights: Vec) -> Self { Self { result: Self::SUCCES_RESULT, message: None, user: None, gamenights: Some(gamenights), + gamenight: None, + games: None, + } + } + + fn gamenight_response(gamenight: GamenightOutput) -> Self { + Self { + result: Self::SUCCES_RESULT, + message: None, + user: None, + gamenights: None, + gamenight: Some(gamenight), games: None, } } @@ -85,6 +102,7 @@ impl ApiResponse { message: None, user: None, gamenights: None, + gamenight: None, games: Some(games), } } @@ -140,6 +158,63 @@ pub struct GamenightOutput { participants: Vec, } +#[derive(Debug, Serialize, Deserialize)] +pub struct GamenightUpdate { + action: String +} + +#[patch("/gamenights/", format = "application/json", data = "")] +pub async fn patch_gamenight(conn: DbConn, user: User, gamenight_id: String, patch_json: Json) -> ApiResponseVariant { + let uuid = Uuid::parse_str(&gamenight_id).unwrap(); + let patch = patch_json.into_inner(); + match patch.action.as_str() { + "RemoveParticipant" => { + let entry = GamenightParticipantsEntry { + gamenight_id: uuid, + user_id: user.id + }; + match remove_participant(&conn, entry).await { + Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)), + Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))) + } + } + "AddParticipant" => { + let entry = GamenightParticipantsEntry { + gamenight_id: uuid, + user_id: user.id + }; + match add_participant(&conn, entry).await { + Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)), + Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))) + } + } + _ => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)) + } +} + +#[get("/gamenights/")] +pub async fn gamenight(conn: DbConn, _user: User, gamenight_id: String) -> ApiResponseVariant { + let uuid = Uuid::parse_str(&gamenight_id).unwrap(); + let gamenight = match get_gamenight(&conn, uuid).await { + Ok(result) => result, + Err(err) => return ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))), + }; + let games = match get_games_of_gamenight(&conn, uuid).await { + Ok(result) => result, + Err(err) => return ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))), + }; + let participants = match load_participants(&conn, uuid).await { + Ok(result) => result, + Err(err) => return ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))), + }; + let gamenight_output = GamenightOutput { + gamenight: gamenight, + game_list: games, + participants: participants + }; + return ApiResponseVariant::Value(json!(ApiResponse::gamenight_response(gamenight_output))) +} + #[get("/gamenights")] pub async fn gamenights(conn: DbConn, _user: User) -> ApiResponseVariant { let gamenights = match get_all_gamenights(&conn).await { @@ -164,7 +239,7 @@ pub async fn gamenights(conn: DbConn, _user: User) -> ApiResponseVariant { .collect(); match game_results { - Ok(result) => ApiResponseVariant::Value(json!(ApiResponse::gamenight_response(result))), + Ok(result) => ApiResponseVariant::Value(json!(ApiResponse::gamenights_response(result))), Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))), } } @@ -220,7 +295,7 @@ pub async fn gamenights_post_json( gamenight_id: gamenight_id, user_id: user.id, }; - match insert_participant(&conn, participant).await { + match add_participant(&conn, participant).await { Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)), Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))), } @@ -375,7 +450,7 @@ pub async fn post_participants( _user: User, entry_json: Json, ) -> ApiResponseVariant { - match insert_participant(&conn, entry_json.into_inner()).await { + match add_participant(&conn, entry_json.into_inner()).await { Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)), Err(error) => ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string()))), } diff --git a/backend/src/main.rs b/backend/src/main.rs index 2e1c2b0..26c47dc 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -50,6 +50,8 @@ async fn rocket() -> _ { .mount( "/api", routes![ + api::gamenight, + api::patch_gamenight, api::gamenights, api::gamenights_unauthorized, api::gamenights_post_json, diff --git a/backend/src/schema/gamenight.rs b/backend/src/schema/gamenight.rs index 3cc1c0a..fc7a4c6 100644 --- a/backend/src/schema/gamenight.rs +++ b/backend/src/schema/gamenight.rs @@ -110,9 +110,9 @@ pub async fn insert_gamenight( .await?) } -pub async fn get_gamenight(conn: &DbConn, game_id: Uuid) -> Result { +pub async fn get_gamenight(conn: &DbConn, gamenight_id: Uuid) -> Result { Ok(conn - .run(move |c| gamenight::table.find(game_id).first(c)) + .run(move |c| gamenight::table.find(gamenight_id).first(c)) .await?) } @@ -190,7 +190,7 @@ pub async fn load_participants( .await?) } -pub async fn insert_participant( +pub async fn add_participant( conn: &DbConn, participant: GamenightParticipantsEntry, ) -> Result { @@ -198,6 +198,7 @@ pub async fn insert_participant( .run(move |c| { diesel::insert_into(gamenight_participants::table) .values(&participant) + .on_conflict_do_nothing() .execute(c) }) .await?) diff --git a/frontend/src/App.js b/frontend/src/App.js index dbccafd..f48c64e 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -5,6 +5,8 @@ import Login from './components/Login'; import Gamenights from './components/Gamenights'; import Gamenight from './components/Gamenight'; +import { get_gamenights, get_games, unpack_api_result, login } from './api/Api'; + const localStorageUserKey = 'user'; function App() { @@ -13,26 +15,16 @@ function App() { const [gamenights, setGamenights] = useState([]); const [flashData, setFlashData] = useState({}); const [games, setGames] = useState([]); - const [activeGamenight, setActiveGamenight] = useState(null); + const [activeGamenightId, setActiveGamenightId] = useState(null); + + const POST_HEADER = {'Content-Type': 'application/json'}; + const AUTH_HEADER = {'Authorization': `Bearer ${user?.jwt}`}; const handleLogin = (input) => { - const requestOptions = { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input) - }; - fetch('api/login', requestOptions) - .then(response => response.json()) - .then(data => { - if(data.result === "Ok") { - setUser(data.user); - localStorage.setItem(localStorageUserKey, JSON.stringify(data.user)); - } else { - setFlashData({ - type: "Error", - message: data.message - }); - } + unpack_api_result(login(input), setFlashData) + .then(result => { + setUser(result.user); + localStorage.setItem(localStorageUserKey, JSON.stringify(result.user)); }); }; @@ -50,49 +42,21 @@ function App() { }; const dismissActiveGamenight = () => { - setActiveGamenight(null); + setActiveGamenightId(null); }; useEffect(() => { if (user !== null) { - const requestOptions = { - method: 'GET', - headers: { 'Authorization': `Bearer ${user.jwt}` }, - }; - fetch('api/gamenights', requestOptions) - .then(response => response.json()) - .then(data => { - if(data.result === "Ok") { - setGamenights(data.gamenights) - } else { - setFlashData({ - type: "Error", - message: data.message - }); - } - }); + unpack_api_result(get_gamenights(user.jwt), setFlashData) + .then(result => setGamenights(result.gamenights)); } }, [user]) useEffect(() => { if (user !== null) { - const requestOptions = { - method: 'GET', - headers: { 'Authorization': `Bearer ${user.jwt}` }, - }; - fetch('api/games', requestOptions) - .then(response => response.json()) - .then(data => { - if(data.result === "Ok") { - setGames(data.games) - } else { - setFlashData({ - type: "Error", - message: data.message - }); - } - }); - } + unpack_api_result(get_games(user.jwt), setFlashData) + .then(result => setGames(result.games)); + } }, [user]) useEffect(() => { @@ -108,7 +72,7 @@ function App() { ); } else { let mainview; - if(activeGamenight === null) { + if(activeGamenightId === null) { mainview = ( setActiveGamenight(g)}/> + onSelectGamenight={(g) => setActiveGamenightId(g.id)}/> ) } else { mainview = ( ) } diff --git a/frontend/src/api/Api.js b/frontend/src/api/Api.js new file mode 100644 index 0000000..3630501 --- /dev/null +++ b/frontend/src/api/Api.js @@ -0,0 +1,90 @@ + +import fetchResource from './FetchResource' + +export function unpack_api_result(promise, onError) { + promise.then(result => { + if(result.result !== 'Ok') { + onError({ + type: 'Error', + message: result.message + }); + } + }) + .catch(error => { + onError({ + type: 'Error', + message: `${error.status} ${error.message}` + }); + }); + return promise; +} + +export function get_gamenights(token) { + return fetchResource('api/gamenights', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + }, + }); +} + +export function get_gamenight(gamenight_id, token) { + return fetchResource(`api/gamenights/${gamenight_id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + }, + }); +} + +export function post_gamenight(input, token) { + return fetchResource('api/gamenights', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(input) + }); +} + +export function patch_gamenight(gamenight_id, input, token) { + return fetchResource(`api/gamenights/${gamenight_id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(input) + }) +} + +export function delete_gamenight(input, token) { + return fetchResource('api/gamenights', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(input) + }); +} + +export function get_games(token) { + return fetchResource('api/games', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); +} + +export function login(body) { + return fetchResource('api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); +} \ No newline at end of file diff --git a/frontend/src/api/FetchResource.js b/frontend/src/api/FetchResource.js new file mode 100644 index 0000000..0be490e --- /dev/null +++ b/frontend/src/api/FetchResource.js @@ -0,0 +1,73 @@ +const API_URL = ''; + +function ApiError(message, data, status) { + let response = null; + let isObject = false; + + try { + response = JSON.parse(data); + isObject = true; + } catch (e) { + response = data; + } + + this.response = response; + this.message = message; + this.status = status; + this.toString = function () { + return `${ this.message }\nResponse:\n${ isObject ? JSON.stringify(this.response, null, 2) : this.response }`; + }; +} + +const fetchResource = (path, userOptions = {}) => { + const defaultOptions = {}; + const defaultHeaders = {}; + + const options = { + ...defaultOptions, + ...userOptions, + headers: { + ...defaultHeaders, + ...userOptions.headers, + }, + }; + + const url = `${ API_URL }/${ path }`; + const isFile = options.body instanceof File; + + if (options.body && typeof options.body === 'object' && !isFile) { + options.body = JSON.stringify(options.body); + } + + let response = null; + + return fetch(url, options) + .then(responseObject => { + response = responseObject; + + if (response.status === 401) { + } + + if (response.status < 200 || response.status >= 300) { + return response.text(); + } + + return response.json(); + }) + .then(parsedResponse => { + if (response.status < 200 || response.status >= 300) { + throw parsedResponse; + } + + return parsedResponse; + }) + .catch(error => { + if (response) { + throw new ApiError(`Request failed with status ${ response.status }.`, error, response.status); + } else { + throw new ApiError(error.toString(), null, 'REQUEST_FAILED'); + } + }); +}; + +export default fetchResource; \ No newline at end of file diff --git a/frontend/src/components/AddGameNight.jsx b/frontend/src/components/AddGameNight.jsx index dc2a746..83b34a5 100644 --- a/frontend/src/components/AddGameNight.jsx +++ b/frontend/src/components/AddGameNight.jsx @@ -9,6 +9,7 @@ import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import GameAdder from './GameAdder'; +import { post_gamenight, unpack_api_result} from '../api/Api'; function AddGameNight(props) { const [expanded, setExpanded] = useState(false); @@ -44,32 +45,14 @@ function AddGameNight(props) { game_list: gameList, } - const requestOptions = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${props.user.jwt}` - }, - body: JSON.stringify(input) - }; - - fetch('api/gamenights', requestOptions) - .then(response => response.json()) - .then(data => { - if(data.result !== "Ok") { - props.setFlash({ - type: "Error", - message: data.message - }); - } else { - setExpanded(false); - setGameName(""); - setDate(null); - } + unpack_api_result(post_gamenight(input, props.user.jwt), props.setFlash) + .then(result => { + setExpanded(false); + setGameName(""); + setDate(null); }) .then(() => props.refetchGamenights()) } - }; if(expanded) { diff --git a/frontend/src/components/Gamenight.jsx b/frontend/src/components/Gamenight.jsx index e6446b9..624196e 100644 --- a/frontend/src/components/Gamenight.jsx +++ b/frontend/src/components/Gamenight.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemText from '@mui/material/ListItemText'; @@ -6,15 +6,26 @@ import ListSubheader from '@mui/material/ListSubheader'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; import moment from 'moment'; - +import {unpack_api_result, get_gamenight, patch_gamenight} from '../api/Api'; function Gamenight(props) { const [dense, setDense] = useState(true); + const [gamenight, setGamenight] = useState(null); - let games = props.gamenight.game_list.map(g => + const fetchGamenight = () => { + if (props.user !== null) { + unpack_api_result(get_gamenight(props.gamenightId, props.user.jwt), props.setFlash) + .then(result => setGamenight(result.gamenight)); + } + } + + useEffect(fetchGamenight, []); + + let games = gamenight?.game_list.map(g => ( + const participants = gamenight?.participants.map(p => ( ) - ) + ); + + const Join = () => { + const input = { + action: 'AddParticipant' + }; + + unpack_api_result(patch_gamenight(gamenight.id, input, props.user.jwt), props.setFlash) + .then(() => fetchGamenight()); + }; + + const Leave = () => { + const input = { + action: 'RemoveParticipant', + }; + + unpack_api_result(patch_gamenight(gamenight.id, input, props.user.jwt), props.setFlash) + .then(() => fetchGamenight()); + }; + + let join_or_leave_button; + if(gamenight?.participants.find(p => p.id === props.user.id) === undefined) { + join_or_leave_button = ( + + ) + } else { + join_or_leave_button = ( + + ) + } return (
@@ -43,10 +93,10 @@ function Gamenight(props) { - {props.gamenight.name} + {gamenight?.name} - When: {moment(props.gamenight.datetime).format('LL HH:mm')} + When: {moment(gamenight?.datetime).format('LL HH:mm')} {participants} + {join_or_leave_button}
) } diff --git a/frontend/src/components/Gamenights.jsx b/frontend/src/components/Gamenights.jsx index f034cfa..a86903c 100644 --- a/frontend/src/components/Gamenights.jsx +++ b/frontend/src/components/Gamenights.jsx @@ -9,35 +9,15 @@ import GamesIcon from '@mui/icons-material/Games'; import DeleteIcon from '@mui/icons-material/Delete'; import AddGameNight from './AddGameNight'; +import {delete_gamenight, unpack_api_result} from '../api/Api'; function Gamenights(props) { const [dense, setDense] = React.useState(false); - const DeleteGamenight = (gameId) => { + const DeleteGamenight = (game_id) => { if (props.user !== null) { - let input = { - game_id: gameId, - } - - const requestOptions = { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${props.user.jwt}` - }, - body: JSON.stringify(input) - }; - - fetch('api/gamenights', requestOptions) - .then(response => response.json()) - .then(data => { - if(data.result !== "Ok") { - props.setFlash({ - type: "Error", - message: data.message - }); - } - }) + const input = { game_id: game_id }; + unpack_api_result(delete_gamenight(input, props.user.jwt), props.setFlash) .then(() => props.refetchGamenights()); } }