diff --git a/FU.API/FU.API/Controllers/PostsController.cs b/FU.API/FU.API/Controllers/PostsController.cs index bdfbc4bd..46dbcb47 100644 --- a/FU.API/FU.API/Controllers/PostsController.cs +++ b/FU.API/FU.API/Controllers/PostsController.cs @@ -46,7 +46,7 @@ public async Task UpdatePost([FromRoute] int postId, [FromBody] P } var post = dto.ToModel(); - post.Creator = user; + post.CreatorId = user.UserId; post.Id = postId; post = await _postService.UpdatePost(post); diff --git a/FU.SPA/favicon.svg b/FU.SPA/favicon.svg new file mode 100644 index 00000000..9dbfdd77 --- /dev/null +++ b/FU.SPA/favicon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/FU.SPA/index.html b/FU.SPA/index.html index dd5c9577..12f6a2c8 100644 --- a/FU.SPA/index.html +++ b/FU.SPA/index.html @@ -2,7 +2,7 @@ - + Forces Unite diff --git a/FU.SPA/src/App.jsx b/FU.SPA/src/App.jsx index f70c8020..f891a053 100644 --- a/FU.SPA/src/App.jsx +++ b/FU.SPA/src/App.jsx @@ -15,6 +15,9 @@ import { Route, Routes } from 'react-router-dom'; import { ProtectedRoute } from './components/ProtectedRoute'; import UserProvider from './context/userProvider'; import './App.css'; +import ProfileSettings from './components/pages/ProfileSettings'; +import AccountSettings from './components/pages/AccountSettings'; +import EditPost from './components/pages/EditPost'; function App() { return ( @@ -43,11 +46,30 @@ function App() { } /> + + + + + } + /> + + + + } + /> } /> } /> } /> + } /> } /> + } /> diff --git a/FU.SPA/src/components/Chat.jsx b/FU.SPA/src/components/Chat.jsx index cb7a3680..e30ffb1f 100644 --- a/FU.SPA/src/components/Chat.jsx +++ b/FU.SPA/src/components/Chat.jsx @@ -15,6 +15,7 @@ import { getChat, getMessages, saveMessage } from '../services/chatService'; import './Chat.css'; import ChatMessage from './ChatMessage'; import UserContext from '../context/userContext'; +import config from '../config'; export default function Chat({ chatId }) { const [chat, setChat] = useState(null); @@ -33,7 +34,7 @@ export default function Chat({ chatId }) { const chat = await getChat(chatId); setChat(chat); // See #281: We need to wait for the signalR connection to be started before joining the chat - await new Promise((resolve) => setTimeout(resolve, 80)); + await new Promise((resolve) => setTimeout(resolve, config.WAIT_TIME)); await joinChatGroup(chatId); const messages = await getMessages(chatId, 1, limit); setMessages(messages); diff --git a/FU.SPA/src/components/Edit.jsx b/FU.SPA/src/components/Edit.jsx new file mode 100644 index 00000000..3c4d48d4 --- /dev/null +++ b/FU.SPA/src/components/Edit.jsx @@ -0,0 +1,302 @@ +import { + Button, + TextField, + Box, + Container, + Typography, + Grid, + Checkbox, + Autocomplete, + createFilterOptions, +} from '@mui/material'; +import { useContext, useEffect, useState } from 'react'; +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@mui/icons-material/CheckBox'; +import PostService from '../services/postService'; +import TagService from '../services/tagService'; +import GameService from '../services/gameService'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import dayjs from 'dayjs'; +import { useNavigate } from 'react-router-dom'; +import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; +import UserContext from '../context/userContext'; + +export default function Edit({ postId }) { + const [game, setGame] = useState(); + const [title, setTitle] = useState(''); + const [startTime, setStartTime] = useState(dayjs()); + const [endTime, setEndTime] = useState(dayjs().add(30, 'minute')); + const [description, setDescription] = useState(''); + const [tags, setTags] = useState([]); + const navigate = useNavigate(); + + const { user } = useContext(UserContext); + + useEffect(() => { + const init = async () => { + try { + const postDetails = await PostService.getPostDetails(postId); + if (user.id !== postDetails.creator.id) { + alert('You are not authorized to edit this post'); + navigate(`/discover`); + } + } catch (e) { + console.log(e); + } + }; + + init(); + }); + + const handleSubmit = async (e) => { + // change to get post state, autofill fields based on info + e.preventDefault(); + + let tagIds = []; + + for (const tag of tags) { + const newTag = await TagService.findOrCreateTagByName(tag.name); + tagIds.push(newTag.id); + } + + try { + var findGame = await GameService.findOrCreateGameByTitle(game.name); + } catch (e) { + alert(e); + console.log(e); + } + + const updatedPost = { + title: title, + description: description, + startTime: startTime !== null ? startTime.toISOString() : null, + endTime: endTime !== null ? endTime.toISOString() : null, + tagIds: tagIds, + gameId: findGame.id, + }; + + try { + const newPost = await PostService.updatePost(updatedPost, postId); + console.log(newPost); + alert('Post updated successfully!'); + navigate(`/posts/${postId}`); + } catch (e) { + window.alert(e); + console.log(e); + } + }; + + return ( + + + + Edit Post + + { + if (e.key === 'Enter') e.preventDefault(); + }} + sx={{ + display: 'flex', + flexDirection: 'column', + mt: 3, + gap: 2, + }} + > + setTitle(e.target.value)} + /> + + + +
+ + setStartTime(newValue)} + /> + setEndTime(newValue)} + /> + + + + + {' '} + Description + + + setDescription(e.target.value)} + multiline + > + + +
+
+
+ ); +} + +const checkboxIconBlank = ; +const checkboxIconChecked = ; +const filter = createFilterOptions(); + +const GameSelector = ({ onChange }) => { + const [gammeOptions, setGameOptions] = useState([]); + const [value, setValue] = useState(''); + + useEffect(() => { + GameService.searchGames('').then((games) => setGameOptions(games)); + }, []); + + const onInputChange = (event, newValue) => { + console.log('newValue'); + console.log(newValue); + + setValue(newValue); + onChange(newValue); + }; + + const onFilterOptions = (options, params) => { + const filtered = filter(options, params); + + const { inputValue } = params; + // Suggest the creation of a new value + const isExisting = options.some((option) => inputValue === option.name); + if (inputValue !== '' && !isExisting) { + filtered.push({ + // inputValue, + id: null, + name: inputValue, + }); + } + + return filtered; + }; + + return ( + (o ? o.name : '')} + isOptionEqualToValue={(option, value) => option.name === value.name} + renderOption={(props, option) =>
  • {option.name}
  • } + renderInput={(params) => ( + + )} + /> + ); +}; + +const TagsSelector = ({ onChange }) => { + const [tagOptions, setTagOptions] = useState([]); + const [value, setValue] = useState([]); + + useEffect(() => { + TagService.searchTags('').then((tags) => setTagOptions(tags)); + }, []); + + const onInputChange = (event, newValues) => { + for (const newValue of newValues) { + if (newValue.id === null) { + // if not in options add to options + if (!tagOptions.some((o) => o.name === newValue.name)) { + const newOptions = tagOptions.concat([newValue]); + setTagOptions(newOptions); + } + } + } + + setValue(newValues); + onChange(newValues); + }; + + const onFilterOptions = (options, params) => { + const filtered = filter(options, params); + + const { inputValue } = params; + // Suggest the creation of a new value + const isExisting = options.some((option) => inputValue === option.name); + if (inputValue !== '' && !isExisting) { + filtered.push({ + // inputValue, + id: null, + name: inputValue, + }); + } + + return filtered; + }; + + return ( + o.name} + isOptionEqualToValue={(option, value) => option.name === value.name} + renderOption={(props, option, { selected }) => ( +
  • + + {option.name} +
  • + )} + renderInput={(params) => ( + + )} + /> + ); +}; diff --git a/FU.SPA/src/components/Navbar.jsx b/FU.SPA/src/components/Navbar.jsx index fa40b305..0c2f3cf4 100644 --- a/FU.SPA/src/components/Navbar.jsx +++ b/FU.SPA/src/components/Navbar.jsx @@ -49,10 +49,14 @@ export default function Navbar() { sx={{ width: 33, height: 33, bgcolor: stringToColor(user.username) }} /> ); + // For some reason, can't user user.Id inside of link itself so this is + // an easy workaround + const navUserId = user.id; return ( <>
  • - {pfpComponent} + {/* This link redirects to a user's profile page */} + {pfpComponent}
  • @@ -95,6 +99,7 @@ export default function Navbar() { ); } + function stringToColor(string) { let hash = 0; let i; diff --git a/FU.SPA/src/components/ProtectedRoute.jsx b/FU.SPA/src/components/ProtectedRoute.jsx index a359f106..4bf12d33 100644 --- a/FU.SPA/src/components/ProtectedRoute.jsx +++ b/FU.SPA/src/components/ProtectedRoute.jsx @@ -1,6 +1,7 @@ import { useContext, useEffect, useState } from 'react'; import { Navigate } from 'react-router-dom'; import UserContext from '../context/userContext'; +import config from '../config'; export const ProtectedRoute = ({ children }) => { const { user } = useContext(UserContext); @@ -12,7 +13,7 @@ export const ProtectedRoute = ({ children }) => { useEffect(() => { const delay = async () => { // See #281: We need to wait for the user to be set before rendering the children - await new Promise((resolve) => setTimeout(resolve, 80)); + await new Promise((resolve) => setTimeout(resolve, config.WAIT_TIME)); setIsLoading(false); }; diff --git a/FU.SPA/src/components/pages/AccountSettings.jsx b/FU.SPA/src/components/pages/AccountSettings.jsx new file mode 100644 index 00000000..c1fa3c13 --- /dev/null +++ b/FU.SPA/src/components/pages/AccountSettings.jsx @@ -0,0 +1,129 @@ +import { Container, Box, Typography, Button, TextField } from '@mui/material'; +import { useState } from 'react'; +import UserService from '../../services/userService'; +import { useNavigate } from 'react-router'; +import UserContext from '../../context/userContext'; +import { useContext } from 'react'; + +export default function AccountSettings() { + const { logout } = useContext(UserContext); + const [username, setUsername] = useState(''); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + + // Error checking common cases + if (newPassword !== confirmPassword) { + alert('Passwords do not match'); + return; + } else if (newPassword !== '' && oldPassword === '') { + alert('Old password must be supplied when updating password'); + return; + } + + try { + // Make request and attempt to fetch API + const data = { + username: username !== '' ? username : null, + oldPassword: oldPassword !== '' ? oldPassword : null, + newPassword: newPassword !== '' ? newPassword : null, + }; + + await UserService.updateAccountInfo(data); + let alertMessage = 'Info updated successfully!'; + if (data.newPassword !== null) { + alertMessage += '\nYou will be logged out.'; + } + alert(alertMessage); + if (data.newPassword !== null) { + localStorage.clear(); + logout(); + navigate('/signin'); + } + // Not the best way to do this but it'll do for now + //const idJson = await UserService.getUserIdJson(); + //navigate('/profile/' + idJson.userId); + } catch (e) { + alert(e); + console.log(e); + } + }; + + // Display component + return ( + + + + Account Settings + + { + if (e.key === 'Enter') e.preventDefault(); + }} + sx={{ + display: 'flex', + flexDirection: 'column', + mt: 1, + gap: 2, + }} + > + setUsername(e.target.value)} + /> + setOldPassword(e.target.value)} + /> + setNewPassword(e.target.value)} + /> + setConfirmPassword(e.target.value)} + /> + + + + + ); +} diff --git a/FU.SPA/src/components/pages/EditPost.jsx b/FU.SPA/src/components/pages/EditPost.jsx new file mode 100644 index 00000000..dac5031d --- /dev/null +++ b/FU.SPA/src/components/pages/EditPost.jsx @@ -0,0 +1,7 @@ +import Edit from '../Edit.jsx'; +import { useParams } from 'react-router'; + +export default function EditPost() { + const { postId } = useParams(); + return ; +} diff --git a/FU.SPA/src/components/pages/PostPage.jsx b/FU.SPA/src/components/pages/PostPage.jsx index 0714658b..e7d7b2fc 100644 --- a/FU.SPA/src/components/pages/PostPage.jsx +++ b/FU.SPA/src/components/pages/PostPage.jsx @@ -88,6 +88,20 @@ const PostPage = () => { ); }; + const renderEditButton = () => { + if (user == null || user.id !== post.creator.id) return; + + return ( + + ); + }; + const ConfirmLeaveDialog = () => { const handleClose = () => { setLeaveDialogOpen(false); @@ -133,6 +147,7 @@ const PostPage = () => { {renderLeaveButton()} {renderChat()} + {renderEditButton()} ); diff --git a/FU.SPA/src/components/pages/ProfileSettings.jsx b/FU.SPA/src/components/pages/ProfileSettings.jsx new file mode 100644 index 00000000..2353ac34 --- /dev/null +++ b/FU.SPA/src/components/pages/ProfileSettings.jsx @@ -0,0 +1,128 @@ +import { Container, Box, Typography, Button, TextField } from '@mui/material'; +import { useState } from 'react'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import dayjs from 'dayjs'; +import UserService from '../../services/userService'; +// import { TagsSelector, GamesSelector } from "../Selectors"; +import { DatePicker } from '@mui/x-date-pickers'; +import { useNavigate } from 'react-router'; + +export default function ProfileSettings() { + const [bio, setBio] = useState(''); + const [dateOfBirth, setDateOfBirth] = useState(dayjs()); + const [pfpUrl, setPfpUrl] = useState(''); + // const [favoriteGames, setFavoriteGames] = useState([]); + // const [favoriteTags, setFavoriteTags] = useState([]); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + + try { + const idJson = await UserService.getUserIdJson(); + + // console.log(favoriteGames); + // console.log(favoriteTags); + + // Form request payload + const data = { + id: idJson.userId, + pfpUrl: pfpUrl !== '' ? pfpUrl : null, + bio: bio !== '' ? bio : null, + // if the date of birth is the same as today, ignore and set as null + // if not same day, update + dob: + dateOfBirth.toISOString().substring(0, 10) !== + dayjs().toISOString().substring(0, 10) + ? dateOfBirth.toISOString().substring(0, 10) + : null, + //favoriteGames: favoriteGames, + //favoriteTags: favoriteTags + }; + + const response = await UserService.updateUserProfile(data); + console.log(response); + alert('Info updated successfully!'); + + // Redirect to user profile + navigate('/profile/' + idJson.userId); + } catch (e) { + alert(e); + console.log(e); + } + }; + + // Display component + return ( + + + + Profile Settings + + { + if (e.key === 'Enter') e.preventDefault(); + }} + sx={{ + display: 'flex', + flexDirection: 'column', + mt: 1, + gap: 2, + }} + > + setBio(e.target.value)} + /> +
    + + setDateOfBirth(newValue)} + /> + + setPfpUrl(e.target.value)} + /> + {/* TODO(epadams) make this work once the backend gets fixed + setFavoriteGames(e.target.value)} + /> + setFavoriteTags(e.target.value)} + /> + */} + +
    +
    +
    + ); +} diff --git a/FU.SPA/src/components/pages/UserProfile.jsx b/FU.SPA/src/components/pages/UserProfile.jsx index 289e6893..26f35291 100644 --- a/FU.SPA/src/components/pages/UserProfile.jsx +++ b/FU.SPA/src/components/pages/UserProfile.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useContext, useCallback } from 'react'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import UserContext from '../../context/userContext'; import UserService from '../../services/userService'; import NoPage from './NoPage'; @@ -9,6 +9,7 @@ import Chat from '../Chat'; import ChatLocked from '../ChatLocked'; import RelationService from '../../services/relationService'; import Button from '@mui/material/Button'; +import { Box, ButtonGroup } from '@mui/material'; const UserProfile = () => { const { userId } = useParams(); @@ -135,8 +136,7 @@ const UserProfile = () => { const renderChat = () => { if (isOwnProfile) { - // Maybe instead we can render profile/account settings - return; + return ; } if (user) { return ; @@ -242,4 +242,24 @@ const SocialRelationActionButton = ({ requesteeId }) => { ); }; +const UserSettings = () => { + const navigate = useNavigate(); + return ( + + + + + + + ); +}; + export default UserProfile; diff --git a/FU.SPA/src/config.js b/FU.SPA/src/config.js index e9f7b984..e010dc8c 100644 --- a/FU.SPA/src/config.js +++ b/FU.SPA/src/config.js @@ -7,6 +7,10 @@ const config = { window.location.hostname === 'jolly-glacier-0ae92c40f.4.azurestaticapps.net' ? 'https://fuapi.azurewebsites.net/chathub' : import.meta.env.VITE_API_URL.replace(/\/api$/, '') + '/chathub', + WAIT_TIME: + import.meta.env.VITE_WAIT_TIME !== undefined + ? import.meta.env.VITE_WAIT_TIME + : 80, }; export default config; diff --git a/FU.SPA/src/helpers/requestBuilder.js b/FU.SPA/src/helpers/requestBuilder.js index 94dea42f..d2bf1ccd 100644 --- a/FU.SPA/src/helpers/requestBuilder.js +++ b/FU.SPA/src/helpers/requestBuilder.js @@ -46,5 +46,8 @@ const buildUserQueryString = (query) => { return queryString; }; -const RequestBuilder = { buildPostQueryString, buildUserQueryString }; +const RequestBuilder = { + buildPostQueryString, + buildUserQueryString, +}; export default RequestBuilder; diff --git a/FU.SPA/src/services/postService.js b/FU.SPA/src/services/postService.js index c6fc0fdf..1e4319fd 100644 --- a/FU.SPA/src/services/postService.js +++ b/FU.SPA/src/services/postService.js @@ -23,6 +23,28 @@ const createPost = async (params) => { return jsonResponse; }; +// Update post information +const updatePost = async (params, postId) => { + const response = await fetch(`${API_BASE_URL}/Posts/${postId}`, { + method: 'PUT', + headers: { + 'content-type': 'application/json', + ...AuthService.getAuthHeader(), + }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + throw new Error('Error in updating post'); + } + const jsonResponse = await response.json(); + + console.log(jsonResponse); + + return jsonResponse; +}; + +// Get details of post in JSON format const getPostDetails = async (postId) => { const response = await fetch(`${API_BASE_URL}/posts/${postId}`, { method: 'GET', @@ -42,6 +64,7 @@ const getPostDetails = async (postId) => { return jsonResponse; }; +// Get all users of a post in JSON format const getPostUsers = async (postId) => { const response = await fetch(`${API_BASE_URL}/posts/${postId}/users`, { method: 'GET', @@ -53,6 +76,7 @@ const getPostUsers = async (postId) => { return jsonResponse; }; +// Request to join a post as current user const joinPost = async (postId) => { await fetch(`${API_BASE_URL}/Posts/${postId}/users/current`, { method: 'POST', @@ -62,6 +86,7 @@ const joinPost = async (postId) => { }); }; +// Request to leave a post as current user const leavePost = async (postId) => { await fetch(`${API_BASE_URL}/Posts/${postId}/users/current`, { method: 'DELETE', @@ -73,6 +98,7 @@ const leavePost = async (postId) => { const PostService = { createPost, + updatePost, getPostDetails, joinPost, leavePost, diff --git a/FU.SPA/src/services/userService.js b/FU.SPA/src/services/userService.js index 188b31a0..7b9dfb48 100644 --- a/FU.SPA/src/services/userService.js +++ b/FU.SPA/src/services/userService.js @@ -56,10 +56,63 @@ const getUserprofile = async (userString) => { return await response.json(); }; +// Currently this function returns the username and id +const getUserIdJson = async () => { + // Call API endpoint + const response = await fetch(`${API_BASE_URL}/Accounts`, { + headers: { ...AuthService.getAuthHeader() }, + method: 'GET', + }); + + if (!response.ok) { + throw new Error('Error in retrieving ID'); + } + + return await response.json(); +}; + +// Updates Profile Information +const updateUserProfile = async (data) => { + // Call API endpoint + const response = await fetch(`${API_BASE_URL}/Users/current`, { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + ...AuthService.getAuthHeader(), + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error('Error in updating information'); + } + + return response.json(); +}; + +// Updates Account Information +const updateAccountInfo = async (data) => { + const response = await fetch(`${API_BASE_URL}/Accounts`, { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + ...AuthService.getAuthHeader(), + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error('Error in updating account information'); + } +}; + const UserService = { getConnectedPosts, getConnectedGroups, getConnectedPlayers, getUserprofile, + getUserIdJson, + updateUserProfile, + updateAccountInfo, }; export default UserService;