add settings page, refactor session handling

pull/118/head
maxime 1 year ago
parent 62be8f2a0b
commit 6738ddcb67

@ -5,9 +5,21 @@
<link rel="icon" type="image/svg+xml" href="/src/assets/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IQBall</title>
</head>
<body>
<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/react/umd/react.production.min.js" crossorigin></script>
<script
src="https://cdn.jsdelivr.net/npm/react-dom/umd/react-dom.production.min.js"
crossorigin></script>
<script
src="https://cdn.jsdelivr.net/npm/react-bootstrap@next/dist/react-bootstrap.min.js"
crossorigin></script>
<script>var Alert = ReactBootstrap.Alert;</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

@ -9,8 +9,10 @@
"@testing-library/user-event": "^13.5.0",
"@types/react": "^18.2.31",
"@types/react-dom": "^18.2.14",
"bootstrap": "^5.3.3",
"eslint-plugin-react-refresh": "^0.4.5",
"react": "^18.2.0",
"react-bootstrap": "^2.10.2",
"react-dom": "^18.2.0",
"react-draggable": "^4.4.6",
"react-router-dom": "^6.22.0",

@ -1,9 +1,11 @@
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom"
import { BrowserRouter, Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom"
import { Header } from "./pages/template/Header.tsx"
import "./style/app.css"
import { lazy, ReactNode, Suspense } from "react"
import { createContext, lazy, ReactNode, Suspense, useCallback, useContext, useEffect, useMemo, useState } from "react"
import { BASE } from "./Constants.ts"
import { Authentication, Fetcher } from "./app/Fetcher.ts"
import { User } from "./model/User.ts"
const HomePage = lazy(() => import("./pages/HomePage.tsx"))
const LoginPage = lazy(() => import("./pages/LoginPage.tsx"))
@ -13,6 +15,7 @@ const CreateTeamPage = lazy(() => import("./pages/CreateTeamPage.tsx"))
const TeamPanelPage = lazy(() => import("./pages/TeamPanel.tsx"))
const NewTacticPage = lazy(() => import("./pages/NewTacticPage.tsx"))
const Editor = lazy(() => import("./pages/Editor.tsx"))
const Settings = lazy(() => import("./pages/Settings.tsx"))
export default function App() {
function suspense(node: ReactNode) {
@ -23,57 +26,185 @@ export default function App() {
)
}
const storedAuth = useMemo(() => getStoredAuthentication(), [])
const fetcher = useMemo(() => new Fetcher(storedAuth), [storedAuth])
const [user, setUser] = useState<User | null>(null)
const handleAuthSuccess = useCallback(async (auth: Authentication) => {
fetcher.updateAuthentication(auth)
const user = await fetchUser(fetcher)
setUser(user)
storeAuthentication(auth)
}, [fetcher])
return (
<div id="app">
<BrowserRouter basename={BASE}>
<Outlet />
<Routes>
<Route path={"/login"} element={suspense(<LoginPage />)} />
<Route
path={"/register"}
element={suspense(<RegisterPage />)}
/>
<Route path={"/"} element={suspense(<AppLayout />)}>
<Route path={"/"} element={suspense(<HomePage />)} />
<Route
path={"/home"}
element={suspense(<HomePage />)}
/>
<Route
path={"/team/new"}
element={suspense(<CreateTeamPage />)}
/>
<Route
path={"/team/:teamId"}
element={suspense(<TeamPanelPage />)}
/>
<Route
path={"/tactic/new"}
element={suspense(<NewTacticPage />)}
/>
<Route
path={"/tactic/:tacticId/edit"}
element={suspense(<Editor guestMode={false} />)}
/>
<Route
path={"/tactic/edit-guest"}
element={suspense(<Editor guestMode={true} />)}
/>
<Route
path={"*"}
element={suspense(<NotFoundPage />)}
/>
</Route>
</Routes>
</BrowserRouter>
<FetcherContext.Provider value={fetcher}>
<SignedInUserContext.Provider value={{
user,
setUser,
}}>
<BrowserRouter basename={BASE}>
<Outlet />
<Routes>
<Route
path={"/login"}
element={suspense(<LoginPage onSuccess={handleAuthSuccess} />)}
/>
<Route
path={"/register"}
element={suspense(<RegisterPage onSuccess={handleAuthSuccess} />)}
/>
<Route path={"/"} element={suspense(<AppLayout />)}>
<Route path={"/"} element={
suspense(
<LoggedInPage>
<HomePage />
</LoggedInPage>,
)
}
/>
<Route
path={"/home"}
element={
suspense(
<LoggedInPage>
<HomePage />
</LoggedInPage>,
)
}
/>
<Route
path={"/settings"}
element={
suspense(
<LoggedInPage>
<Settings />
</LoggedInPage>,
)
}
/>
<Route
path={"/team/new"}
element={
suspense(<CreateTeamPage />)
}
/>
<Route
path={"/team/:teamId"}
element={
suspense(
<LoggedInPage>
<TeamPanelPage />
</LoggedInPage>,
)
}
/>
<Route
path={"/tactic/new"}
element={
suspense(
<LoggedInPage>
<NewTacticPage />
</LoggedInPage>,
)
}
/>
<Route
path={"/tactic/:tacticId/edit"}
element={
suspense(
<LoggedInPage>
<Editor guestMode={false} />
</LoggedInPage>,
)
}
/>
<Route
path={"/tactic/edit-guest"}
element={suspense(<Editor guestMode={true} />)}
/>
<Route
path={"*"}
element={suspense(<NotFoundPage />)}
/>
</Route>
</Routes>
</BrowserRouter>
</SignedInUserContext.Provider>
</FetcherContext.Provider>
</div>
)
}
async function fetchUser(fetcher: Fetcher): Promise<User> {
const response = await fetcher.fetchAPIGet("user")
if (!response.ok) {
throw Error("Could not retrieve user information : " + await response.text())
}
return await response.json()
}
const STORAGE_AUTH_KEY = "token"
function getStoredAuthentication(): Authentication {
const storedUser = localStorage.getItem(STORAGE_AUTH_KEY)
return storedUser == null ? null : JSON.parse(storedUser)
}
function storeAuthentication(auth: Authentication) {
localStorage.setItem(STORAGE_AUTH_KEY, JSON.stringify(auth))
}
interface LoggedInPageProps {
children: ReactNode
}
enum UserFetchingState {
FETCHING,
FETCHED,
ERROR
}
function LoggedInPage({ children }: LoggedInPageProps) {
const [user, setUser] = useUser()
const fetcher = useAppFetcher()
const [userFetchingState, setUserFetchingState] = useState(user === null ? UserFetchingState.FETCHING : UserFetchingState.FETCHED)
const location = useLocation()
useEffect(() => {
async function initUser() {
try {
const user = await fetchUser(fetcher)
setUser(user)
setUserFetchingState(UserFetchingState.FETCHED)
} catch (e) {
setUserFetchingState(UserFetchingState.ERROR)
}
}
if (userFetchingState === UserFetchingState.FETCHING)
initUser()
}, [fetcher, setUser, userFetchingState])
switch (userFetchingState) {
case UserFetchingState.ERROR:
return <Navigate to={"/login"} replace state={{ from: location.pathname }} />
case UserFetchingState.FETCHED:
return children
case UserFetchingState.FETCHING:
return <p>Fetching user...</p>
}
}
function AppLayout() {
return (
<>
@ -82,3 +213,22 @@ function AppLayout() {
</>
)
}
interface UserContext {
user: User | null
setUser: (user: User) => void
}
const SignedInUserContext = createContext<UserContext | null>(null)
const FetcherContext = createContext(new Fetcher())
export function useAppFetcher() {
return useContext(FetcherContext)
}
export function useUser(): [User | null, (user: User) => void] {
const { user, setUser } = useContext(SignedInUserContext)!
return [user, setUser]
}

@ -1,68 +0,0 @@
import { API } from "./Constants"
import { getSession, saveSession, Session } from "./api/session.ts"
export async function fetchAPI(
url: string,
payload: unknown,
method = "POST",
): Promise<Response> {
const session = getSession()
const token = session?.auth?.token
const headers: HeadersInit = {
Accept: "application/json",
"Content-Type": "application/json",
}
if (token) {
headers.Authorization = token
}
const response = await fetch(`${API}/${url}`, {
method,
headers,
body: JSON.stringify(payload),
})
return await handleResponse(session, response)
}
export async function fetchAPIGet(url: string): Promise<Response> {
const session = getSession()
const token = session?.auth?.token
const headers: HeadersInit = {
Accept: "application/json",
"Content-Type": "application/json",
}
if (token) {
headers.Authorization = token
}
const response = await fetch(`${API}/${url}`, {
method: "GET",
headers,
})
return await handleResponse(session, response)
}
async function handleResponse(
session: Session,
response: Response,
): Promise<Response> {
// if we provided a token but still unauthorized, the token has expired
if (!response.ok) {
return response
}
const nextToken = response.headers.get("Next-Authorization")!
const expirationDate = new Date(
response.headers.get("Next-Authorization-Expiration-Date")!,
)
if (nextToken && expirationDate)
saveSession({ ...session, auth: { token: nextToken, expirationDate } })
return response
}

@ -1,23 +0,0 @@
export interface Session {
auth?: Authentication
urlTarget?: string
username?: string
}
export interface Authentication {
token: string
expirationDate: Date
}
const SESSION_KEY = "session"
// export const SessionContext = createContext<Session>(getSession())
export function saveSession(session: Session) {
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
}
export function getSession(): Session {
const json = localStorage.getItem(SESSION_KEY)
return json ? JSON.parse(json) : {}
}

@ -0,0 +1,87 @@
import { API } from "../Constants.ts"
export interface Authentication {
token: string
expirationDate: Date
}
export class Fetcher {
private auth?: Authentication
public constructor(auth?: Authentication) {
this.auth = auth
}
async fetchAPI(
url: string,
payload: unknown,
method = "POST",
): Promise<Response> {
const token = this.auth?.token
const headers: HeadersInit = {
Accept: "application/json",
"Content-Type": "application/json",
}
if (token) {
headers.Authorization = token
}
const response = await fetch(`${API}/${url}`, {
method,
headers,
body: JSON.stringify(payload),
})
return await this.handleResponse(response)
}
async fetchAPIGet(url: string): Promise<Response> {
const token = this.auth?.token
const headers: HeadersInit = {
Accept: "application/json",
"Content-Type": "application/json",
}
if (token) {
headers.Authorization = token
}
const response = await fetch(`${API}/${url}`, {
method: "GET",
headers,
})
return await this.handleResponse(response)
}
updateAuthentication(auth: Authentication) {
this.auth = auth
}
async handleResponse(
response: Response,
): Promise<Response> {
// if we provided a token but still unauthorized, the token has expired
if (!response.ok) {
return response
}
const nextToken = response.headers.get("Next-Authorization")!
const expirationDate = new Date(
response.headers.get("Next-Authorization-Expiration-Date")!,
)
if (nextToken && expirationDate) {
this.auth = { token: nextToken, expirationDate }
}
return response
}
}

@ -1,13 +1,4 @@
import {
CSSProperties,
RefObject,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { CSSProperties, RefObject, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"
import "../style/editor.css"
import TitleInput from "../components/TitleInput"
import PlainCourt from "../assets/court/full_court.svg?react"
@ -18,18 +9,9 @@ import { BallPiece } from "../components/editor/BallPiece"
import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece"
import {
ComponentId,
CourtType,
StepContent,
StepInfoNode,
TacticComponent,
} from "../model/tactic/Tactic"
import SavingState, {
SaveState,
SaveStates,
} from "../components/editor/SavingState"
import { ComponentId, CourtType, StepContent, StepInfoNode, TacticComponent } from "../model/tactic/Tactic"
import SavingState, { SaveState, SaveStates } from "../components/editor/SavingState"
import { BALL_TYPE } from "../model/tactic/CourtObjects"
import { CourtAction } from "../components/editor/CourtAction"
@ -52,19 +34,10 @@ import {
updateComponent,
} from "../editor/TacticContentDomains"
import {
BallState,
Player,
PlayerInfo,
PlayerLike,
PlayerTeam,
} from "../model/tactic/Player"
import { BallState, Player, PlayerInfo, PlayerLike, PlayerTeam } from "../model/tactic/Player"
import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems"
import {
CourtPlayer,
EditableCourtPlayer,
} from "../components/editor/CourtPlayer.tsx"
import { CourtPlayer, EditableCourtPlayer } from "../components/editor/CourtPlayer.tsx"
import {
createAction,
getActionKind,
@ -76,25 +49,17 @@ import ArrowAction from "../components/actions/ArrowAction"
import { middlePos, Pos, ratioWithinBase } from "../geo/Pos"
import { Action, ActionKind } from "../model/tactic/Action"
import BallAction from "../components/actions/BallAction"
import {
computePhantomPositioning,
getOrigin,
removePlayer,
} from "../editor/PlayerDomains"
import { computePhantomPositioning, getOrigin, removePlayer } from "../editor/PlayerDomains"
import { CourtBall } from "../components/editor/CourtBall"
import StepsTree from "../components/editor/StepsTree"
import {
addStepNode,
getParent,
getStepNode,
removeStepNode,
} from "../editor/StepsDomain"
import { addStepNode, getParent, getStepNode, removeStepNode } from "../editor/StepsDomain"
import SplitLayout from "../components/SplitLayout.tsx"
import { ServiceError, TacticService } from "../service/TacticService.ts"
import { LocalStorageTacticService } from "../service/LocalStorageTacticService.ts"
import { APITacticService } from "../service/APITacticService.ts"
import { useParams } from "react-router-dom"
import { ContentVersions } from "../editor/ContentVersions.ts"
import { useAppFetcher } from "../App.tsx"
const ERROR_STYLE: CSSProperties = {
borderColor: "red",
@ -132,12 +97,12 @@ interface EditorService {
function EditorPortal({ guestMode }: EditorPageProps) {
const { tacticId: idStr } = useParams()
const fetcher = useAppFetcher()
if (guestMode || !idStr) {
return <EditorPageWrapper service={LocalStorageTacticService.init()} />
}
return <EditorPageWrapper service={new APITacticService(parseInt(idStr))} />
return <EditorPageWrapper service={new APITacticService(fetcher, parseInt(idStr))} />
}
function EditorPageWrapper({ service }: { service: TacticService }) {
@ -245,7 +210,7 @@ function EditorPageWrapper({ service }: { service: TacticService }) {
if (typeof contextResult === "string") {
setPanicMessage(
"There has been an error retrieving the editor initial context : " +
contextResult,
contextResult,
)
return
}
@ -260,7 +225,7 @@ function EditorPageWrapper({ service }: { service: TacticService }) {
if (typeof contentResult === "string") {
setPanicMessage(
"There has been an error retrieving the tactic's root step content : " +
contentResult,
contentResult,
)
return
}
@ -523,15 +488,15 @@ function EditorPage({
/>
),
!isFrozen &&
(info.ballState === BallState.HOLDS_ORIGIN ||
info.ballState === BallState.PASSED_ORIGIN) && (
<BallAction
key={2}
onDrop={(ballBounds) => {
doMoveBall(ballBounds, player)
}}
/>
),
(info.ballState === BallState.HOLDS_ORIGIN ||
info.ballState === BallState.PASSED_ORIGIN) && (
<BallAction
key={2}
onDrop={(ballBounds) => {
doMoveBall(ballBounds, player)
}}
/>
),
]
},
[
@ -845,12 +810,12 @@ interface EditorStepsTreeProps {
}
function EditorStepsTree({
selectedStepId,
root,
onAddChildren,
onRemoveNode,
onStepSelected,
}: EditorStepsTreeProps) {
selectedStepId,
root,
onAddChildren,
onRemoveNode,
onStepSelected,
}: EditorStepsTreeProps) {
return (
<div id="steps-div">
<StepsTree
@ -875,12 +840,12 @@ interface PlayerRackProps {
}
function PlayerRack({
id,
objects,
setObjects,
courtRef,
setComponents,
}: PlayerRackProps) {
id,
objects,
setObjects,
courtRef,
setComponents,
}: PlayerRackProps) {
const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(),
[courtRef],
@ -934,15 +899,15 @@ interface CourtPlayerArrowActionProps {
}
function CourtPlayerArrowAction({
playerInfo,
player,
isInvalid,
playerInfo,
player,
isInvalid,
content,
setContent,
setPreviewAction,
courtRef,
}: CourtPlayerArrowActionProps) {
content,
setContent,
setPreviewAction,
courtRef,
}: CourtPlayerArrowActionProps) {
const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(),
[courtRef],

@ -1,9 +1,8 @@
import "../style/home/home.css"
import { getSession } from "../api/session.ts"
import { useNavigate } from "react-router-dom"
import { useEffect, useState } from "react"
import { User } from "../model/User.ts"
import { fetchAPIGet } from "../Fetcher.ts"
import { useAppFetcher } from "../App.tsx"
interface Tactic {
id: number
@ -27,17 +26,11 @@ export default function HomePage() {
})
const navigate = useNavigate()
const fetcher = useAppFetcher()
useEffect(() => {
const session = getSession()
if (!session.auth) {
navigate("/login")
return
}
async function getUser() {
const response = await fetchAPIGet("user-data")
async function initUserData() {
const response = await fetcher.fetchAPIGet("user-data")
if (response.status == 401) {
navigate("/login")
return // if unauthorized
@ -45,8 +38,8 @@ export default function HomePage() {
setInfo(await response.json())
}
getUser()
}, [navigate])
initUserData()
}, [fetcher, navigate])
tactics!.sort((a, b) => b.creationDate - a.creationDate)

@ -1,14 +1,20 @@
import { FormEvent, useState } from "react"
import { fetchAPI } from "../Fetcher.ts"
import { Failure } from "../api/failure.ts"
import { getSession, saveSession } from "../api/session.ts"
import { Failure } from "../app/Failure.ts"
import "../style/form.css"
import { Link, useNavigate } from "react-router-dom"
import { Link, useLocation, useNavigate } from "react-router-dom"
import { useAppFetcher } from "../App.tsx"
import { Authentication } from "../app/Fetcher.ts"
export default function LoginApp() {
export interface LoginAppProps {
onSuccess: (auth: Authentication) => void
}
export default function LoginApp({onSuccess}: LoginAppProps) {
const [errors, setErrors] = useState<Failure[]>([])
const fetcher = useAppFetcher()
const navigate = useNavigate()
const location = useLocation()
async function handleSubmit(e: FormEvent) {
e.preventDefault()
@ -17,21 +23,18 @@ export default function LoginApp() {
new FormData(e.target as HTMLFormElement),
)
const response = await fetchAPI(
const response = await fetcher.fetchAPI(
"auth/token",
{ email, password },
"POST",
)
if (response.ok) {
const session = getSession()
const { token, expirationDate } = await response.json()
saveSession({
...session,
auth: { token, expirationDate: new Date(expirationDate) },
urlTarget: undefined,
})
navigate(session.urlTarget ?? "/")
const auth = { token, expirationDate: new Date(expirationDate) }
onSuccess(auth)
console.log(location)
navigate(location.state?.from || "/")
return
}

@ -5,9 +5,8 @@ import plainCourt from "../assets/court/full_court.svg"
import halfCourt from "../assets/court/half_court.svg"
import { CourtType } from "../model/tactic/Tactic.ts"
import { useCallback } from "react"
import { fetchAPI } from "../Fetcher.ts"
import { getSession, saveSession } from "../api/session.ts"
import { useLocation, useNavigate } from "react-router-dom"
import { useAppFetcher, useUser } from "../App.tsx"
import { useNavigate } from "react-router-dom"
export const DEFAULT_TACTIC_NAME = "Nouvelle tactique"
@ -45,18 +44,19 @@ function CourtKindButton({
courtType: CourtType
}) {
const navigate = useNavigate()
const location = useLocation()
const fetcher = useAppFetcher()
const [user] = useUser()
return (
<div
className="court-kind-button"
onClick={useCallback(async () => {
// if user is not authenticated
if (!getSession().auth) {
if (!user) {
navigate(`/tactic/edit-guest`)
}
const response = await fetchAPI(
const response = await fetcher.fetchAPI(
"tactics",
{
name: DEFAULT_TACTIC_NAME,
@ -66,10 +66,6 @@ function CourtKindButton({
)
if (response.status === 401) {
saveSession({
...getSession(),
urlTarget: location.pathname,
})
// if unauthorized
navigate("/login")
return
@ -77,7 +73,7 @@ function CourtKindButton({
const { id } = await response.json()
navigate(`/tactic/${id}/edit`)
}, [courtType, location.pathname, navigate])}>
}, [courtType, fetcher, navigate, user])}>
<div className="court-kind-button-top">
<div className="court-kind-button-image-div">
<img

@ -1,20 +1,26 @@
import { FormEvent, useRef, useState } from "react"
import "../style/form.css"
import { Failure } from "../api/failure.ts"
import { fetchAPI } from "../Fetcher.ts"
import { getSession, saveSession } from "../api/session.ts"
import { Link, useNavigate } from "react-router-dom"
import { Failure } from "../app/Failure.ts"
import { Link, useLocation, useNavigate } from "react-router-dom"
import { useAppFetcher } from "../App.tsx"
import { Authentication } from "../app/Fetcher.ts"
export default function RegisterPage() {
export interface RegisterPageProps {
onSuccess: (auth: Authentication) => void
}
export default function RegisterPage({ onSuccess }: RegisterPageProps) {
const usernameField = useRef<HTMLInputElement>(null)
const passwordField = useRef<HTMLInputElement>(null)
const confirmpasswordField = useRef<HTMLInputElement>(null)
const emailField = useRef<HTMLInputElement>(null)
const [errors, setErrors] = useState<Failure[]>([])
const navigate = useNavigate()
const location = useLocation()
const fetcher = useAppFetcher()
async function handleSubmit(e: FormEvent) {
e.preventDefault()
@ -36,7 +42,7 @@ export default function RegisterPage() {
return
}
const response = await fetchAPI("auth/register", {
const response = await fetcher.fetchAPI("auth/register", {
username,
password,
email,
@ -44,13 +50,10 @@ export default function RegisterPage() {
if (response.ok) {
const { token, expirationDate } = await response.json()
const session = getSession()
saveSession({
...session,
auth: { token, expirationDate: new Date(expirationDate) },
urlTarget: undefined,
})
navigate(session.urlTarget ?? "/")
const auth = { token, expirationDate: new Date(expirationDate) }
navigate(location.state?.from || "/")
onSuccess(auth)
return
}

@ -0,0 +1,222 @@
import "bootstrap/dist/css/bootstrap.min.css"
import Button from "react-bootstrap/Button"
import Form from "react-bootstrap/Form"
import Image from "react-bootstrap/Image"
import Container from "react-bootstrap/Container"
import Row from "react-bootstrap/Row"
import Col from "react-bootstrap/Col"
import Modal from "react-bootstrap/Modal"
import { Stack } from "react-bootstrap"
import { useRef, useState } from "react"
import "../style/settings.css"
import { useAppFetcher, useUser } from "../App.tsx"
import { Fetcher } from "../app/Fetcher.ts"
export default function Settings() {
return (
<div id="settings-page">
<div id="content">
<AccountSettings />
</div>
</div>
)
}
function AccountSettings() {
return (
<div id="account">
<ProfilSettings/>
</div>
)
}
function ProfilSettings() {
const nameRef = useRef<HTMLInputElement>(null)
const emailRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null)
const confirmPasswordRef = useRef<HTMLInputElement>(null)
const fetcher = useAppFetcher()
const [user, setUser] = useUser()
const [errorMessages, setErrorMessages] = useState<string[]>([])
const [modalShow, setModalShow] = useState(false)
const width = 150
console.log(user)
const profilePicture = user!.profilePicture
return (
<Container>
{errorMessages.map(msg => (<div key={msg} className={"error-message"}>{msg}</div>))}
<Row>
<Col>
<Stack>
<div id="profile-picture">
<Image src={profilePicture} width={width} height={width} roundedCircle />
</div>
<Button variant="outline-primary" onClick={() => setModalShow(true)}>
Changer photo de profil
</Button>
<ProfileImageInputPopup
show={modalShow}
onHide={() => setModalShow(false)}
/>
</Stack>
</Col>
<Col>
<Form>
<Form.Group className="mb-3" controlId="formUsername">
<Form.Label className="content">Nom d'utilisateur</Form.Label>
<Form.Control ref={nameRef} size="sm" defaultValue={user!.name} />
</Form.Group>
<Form.Group className="mb-3" controlId="formEmail">
<Form.Label className="content">Adresse mail</Form.Label>
<Form.Control ref={emailRef} size="sm" defaultValue={user!.email} type="email"
placeholder="Addresse email" />
</Form.Group>
<Form.Group className="mb-3" controlId="formPassword">
<Form.Label className="content">Mot de passe</Form.Label>
<Form.Control ref={passwordRef}
size="sm"
type="password"
placeholder="Mot de passe" />
</Form.Group>
<Form.Group className="mb-3" controlId="formConfirmPassword">
<Form.Label className="content">Confirmez le mot de passe</Form.Label>
<Form.Control ref={confirmPasswordRef}
size="sm"
type="password"
placeholder="Confirmation du mot de passe" />
</Form.Group>
<Button variant="outline-primary" type="button"
onClick={async () => {
const name = nameRef.current!.value
const email = emailRef.current!.value
const password = passwordRef.current?.value
const confirmPassword = confirmPasswordRef.current?.value
console.log(password, confirmPassword, name, email)
if (password !== confirmPassword) {
setErrorMessages(["Les mots de passe ne correspondent pas !"])
return
}
const req: AccountUpdateRequest = {
name,
email,
}
if (password && password.length !== 0) {
req.password = password
}
const errors = await updateAccount(fetcher, req)
if (errors.length !== 0) {
setErrorMessages(errors)
return
}
setUser({...user!, email, name})
}}>Mettre
à jour</Button>
</Form>
</Col>
</Row>
</Container>
)
}
interface AccountUpdateRequest {
name?: string
email?: string
profilePicture?: string
password?: string
}
async function updateAccount(fetcher: Fetcher, req: AccountUpdateRequest): Promise<string[]> {
const response = await fetcher.fetchAPI("user", req, "PUT")
if (response.ok)
return []
const body = await response.json()
return Object.entries(body)
.flatMap(([kind, messages]) => (messages as string[]).map(msg => `${kind}: ${msg}`))
}
interface ProfileImageInputPopupProps {
show: boolean
onHide: () => void
}
function ProfileImageInputPopup({ show, onHide }: ProfileImageInputPopupProps) {
const urlRef = useRef<HTMLInputElement>(null)
const [errorMessages, setErrorMessages] = useState<string[]>()
const fetcher = useAppFetcher()
const [user, setUser] = useUser()
return (
<Modal
show={show}
onHide={onHide}
size="lg"
aria-labelledby="title-modal"
centered
>
<Modal.Header>
<Modal.Title id="title-modal">
Nouvelle photo de profil
</Modal.Title>
</Modal.Header>
<Modal.Body>
{errorMessages?.map(msg => <div key={msg} className="error-message">{msg}</div>)}
<Form.Label>Saisissez le lien vers votre nouvelle photo de profil</Form.Label>
<Form.Control isInvalid={errorMessages?.length !== 0} ref={urlRef} type="input"
placeholder={"lien vers une image"} />
</Modal.Body>
<Modal.Footer>
<Button onClick={onHide}>Annuler</Button>
<Button onClick={async () => {
const url = urlRef.current!.value
const exists = await imageExists(url)
setErrorMessages(["Le lien ne renvoie vers aucune image !"])
if (!exists) {
return
}
const errors = await updateAccount(fetcher, { profilePicture: url })
if (errors.length !== 0) {
setErrorMessages(errors)
return
}
setUser({...user!, profilePicture: url})
onHide()
}
}>Valider</Button>
</Modal.Footer>
</Modal>
)
}
async function imageExists(imageLink: string) {
try {
const response = await fetch(imageLink)
console.log(response)
if (response.ok) {
const contentType = response.headers.get("content-type")
return contentType?.startsWith("image/") ?? false
}
return false
} catch (error) {
console.error(error)
return false
}
}

@ -1,38 +1,15 @@
import AccountSvg from "../../assets/account.svg?react"
import "../../style/template/header.css"
import { useEffect, useState } from "react"
import { fetchAPIGet } from "../../Fetcher.ts"
import { getSession, saveSession } from "../../api/session.ts"
import { useLocation, useNavigate } from "react-router-dom"
import { useUser } from "../../App.tsx"
import { useNavigate } from "react-router-dom"
export function Header() {
const session = getSession()
const [username, setUsername] = useState<string | null>(
session.username ?? null,
)
const navigate = useNavigate()
const location = useLocation()
useEffect(() => {
async function loadUsername() {
const response = await fetchAPIGet("user")
if (response.status === 401) {
//if unauthorized, the header will display a login button instead,
//based on the nullity of username
return
}
//TODO check if the response is ok and handle errors
const { name: username } = await response.json()
saveSession({ ...session, username })
setUsername(username)
}
const [user, ] = useUser()
// if the user is authenticated and the username is not already present in the session,
if (session.auth && !session.username) loadUsername()
}, [session])
const accountImage = user?.profilePicture
? <img id="img-account" src={user.profilePicture} alt={"profile-picture"} />
: <AccountSvg id="img-account" />
return (
<div id="header">
@ -50,20 +27,15 @@ export function Header() {
className="clickable"
id="clickable-header-right"
onClick={() => {
if (username) {
if (user) {
navigate("/settings")
return
}
saveSession({
...session,
urlTarget: location.pathname,
})
navigate("/login")
}}>
{/* <AccountSvg id="img-account" /> */}
<AccountSvg id="img-account" />
<p id="username">{username ?? "Log In"}</p>
{accountImage}
<p id="username">{user?.name ?? "Log In"}</p>
</div>
</div>
</div>

@ -1,19 +1,21 @@
import { TacticService, ServiceError, TacticContext } from "./TacticService.ts"
import { StepContent, StepInfoNode } from "../model/tactic/Tactic.ts"
import { fetchAPI, fetchAPIGet } from "../Fetcher.ts"
import { Fetcher } from "../app/Fetcher.ts"
export class APITacticService implements TacticService {
private readonly tacticId: number
private readonly fetcher: Fetcher
constructor(tacticId: number) {
constructor(fetcher: Fetcher, tacticId: number) {
this.tacticId = tacticId
this.fetcher = fetcher
}
async getContext(): Promise<TacticContext | ServiceError> {
const infoResponsePromise = fetchAPIGet(`tactics/${this.tacticId}`)
const infoResponsePromise = this.fetcher.fetchAPIGet(`tactics/${this.tacticId}`)
const infoResponse = await infoResponsePromise
const treeResponsePromise = fetchAPIGet(`tactics/${this.tacticId}/tree`)
const treeResponsePromise = this.fetcher.fetchAPIGet(`tactics/${this.tacticId}/tree`)
const treeResponse = await treeResponsePromise
if (infoResponse.status == 401 || treeResponse.status == 401) {
@ -29,7 +31,7 @@ export class APITacticService implements TacticService {
parent: StepInfoNode,
content: StepContent,
): Promise<StepInfoNode | ServiceError> {
const response = await fetchAPI(`tactics/${this.tacticId}/steps`, {
const response = await this.fetcher.fetchAPI(`tactics/${this.tacticId}/steps`, {
parentId: parent.id,
content,
})
@ -41,7 +43,7 @@ export class APITacticService implements TacticService {
}
async removeStep(id: number): Promise<void | ServiceError> {
const response = await fetchAPI(
const response = await this.fetcher.fetchAPI(
`tactics/${this.tacticId}/steps/${id}`,
{},
"DELETE",
@ -51,7 +53,7 @@ export class APITacticService implements TacticService {
}
async setName(name: string): Promise<void | ServiceError> {
const response = await fetchAPI(
const response = await this.fetcher.fetchAPI(
`tactics/${this.tacticId}/name`,
{ name },
"PUT",
@ -64,7 +66,7 @@ export class APITacticService implements TacticService {
step: number,
content: StepContent,
): Promise<void | ServiceError> {
const response = await fetchAPI(
const response = await this.fetcher.fetchAPI(
`tactics/${this.tacticId}/steps/${step}`,
{ content },
"PUT",
@ -74,7 +76,7 @@ export class APITacticService implements TacticService {
}
async getContent(step: number): Promise<StepContent | ServiceError> {
const response = await fetchAPIGet(
const response = await this.fetcher.fetchAPIGet(
`tactics/${this.tacticId}/steps/${step}`,
)
if (response.status == 404) return ServiceError.NOT_FOUND

@ -0,0 +1,16 @@
.error-message {
color: red
}
#settings-page {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
#profile-picture {
display: flex;
justify-content: center;
}

@ -16,6 +16,9 @@
#img-account {
cursor: pointer;
margin-right: 5px;
width: 50px;
height: 50px;
border-radius: 20%;
}
#header-left,
@ -29,14 +32,20 @@
flex-direction: column;
justify-content: center;
align-items: end;
margin-right: 30px;
color: white;
margin-right: 5px;
}
#clickable-header-right:hover #username {
color: var(--accent-color);
}
#username {
text-align: center;
vertical-align: center;
margin: 0;
}
#clickable-header-right {
border-radius: 1cap;

Loading…
Cancel
Save