Add settings page, refactor session management #118

Merged
maxime.batista merged 6 commits from settings-reborn into master 1 year ago

@ -1,9 +1,28 @@
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 +32,9 @@ 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"))
const TOKEN_REFRESH_INTERVAL_MS = 60 * 1000
export default function App() {
function suspense(node: ReactNode) {
@ -23,57 +45,196 @@ export default function App() {
)
}
const fetcher = useMemo(() => new Fetcher(getStoredAuthentication()), [])
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],
)
useEffect(() => {
const interval = setInterval(() => {
fetcher.fetchAPIGet("auth/keep-alive")
}, TOKEN_REFRESH_INTERVAL_MS)
return () => clearInterval(interval)
}, [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(<NewTacticPage />)}
/>
<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 +243,20 @@ function AppLayout() {
</>
)
}
interface UserContext {
user: User | null
setUser: (user: User | null) => 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 | null) => 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,78 @@
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 = response.headers.get("Next-Authorization-Expiration-Date")!
if (nextToken && expirationDate) {
this.auth = { token: nextToken, expirationDate: new Date(expirationDate) }
}
return response
}
}

@ -95,6 +95,7 @@ import { LocalStorageTacticService } from "../service/LocalStorageTacticService.
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 +133,16 @@ 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 }) {
@ -269,6 +274,7 @@ function EditorPageWrapper({ service }: { service: TacticService }) {
stepsVersions.set(stepId, versions)
versions.insertAndCut(contentResult)
console.log(contentResult)
setStepContent(contentResult, false)
}

@ -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,17 @@ 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)
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,278 @@
import { FormEvent, useCallback, useEffect, useRef, useState } from "react"
import "../style/settings.css"
import { useAppFetcher, useUser } from "../App.tsx"
import { Fetcher } from "../app/Fetcher.ts"
export default function ProfileSettings() {
const fetcher = useAppFetcher()
const [user, setUser] = useUser()
const [errorMessages, setErrorMessages] = useState<string[]>([])
const [success, setSuccess] = useState(false)
const [name, setName] = useState(user!.name)
const [email, setEmail] = useState(user!.email)
const [password, setPassword] = useState<string>()
const [confirmPassword, setConfirmPassword] = useState<string>()
const passwordConfirmRef = useRef<HTMLInputElement>(null)
const formRef = useRef<HTMLFormElement>(null)
const submitForm = useCallback(
async (e: FormEvent) => {
e.preventDefault()
passwordConfirmRef.current!.checkValidity()
if (password !== confirmPassword) {
setErrorMessages(["Les mots de passe ne correspondent pas !"])
return
}
const req: AccountUpdateRequest = {
name: name,
email: email,
}
if (password && password.length !== 0) {
req.password = password
}
const errors = await updateAccount(fetcher, req)
if (errors.length !== 0) {
setErrorMessages(errors)
setSuccess(false)
return
}
setUser({ ...user!, email, name })
setSuccess(true)
formRef.current!.reset()
setErrorMessages([])
},
[confirmPassword, email, fetcher, name, password, setUser, user],
)
useEffect(() => {
passwordConfirmRef.current!.setCustomValidity(
password === confirmPassword ? "" : "Les mots de passe ne correspondent pas !"
)
}, [confirmPassword, password])
const [modalShow, setModalShow] = useState(false)
const width = 150
const profilePicture = user!.profilePicture
return (
<div id="settings-page">
<div id="settings-content">
{errorMessages.map((msg) => (
<div key={msg} className={"error-message"}>
{msg}
</div>
))}
{success && (
<p className={"success-message"}>
Modifications sauvegardées
</p>
)}
<div id="settings-inputs">
<div id="settings-profile-picture">
<div>
<div id="profile-picture">
<img
id="profile-picture-img"
src={profilePicture}
width={width}
height={width}
alt="profile-picture"
/>
</div>
<button
className="settings-button"
onClick={() => setModalShow(true)}>
Changer photo de profil
</button>
<ProfileImageInputPopup
show={modalShow}
onHide={() => setModalShow(false)}
/>
</div>
</div>
<div>
<form
ref={formRef}
id="credentials-form"
onSubmit={submitForm}>
<label htmlFor="name">Nom d'utilisateur</label>
<input
className="settings-input"
id="name"
name="name"
type="text"
autoComplete="username"
required
placeholder="Nom d'utilisateur"
value={name}
onChange={e => setName(e.target.value)}
/>
<label htmlFor="email">Adresse email</label>
<input
className="settings-input"
name="email"
id="email"
type="email"
placeholder={"Adresse email"}
autoComplete="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
/>
<label htmlFor="password">Mot de passe</label>
<input
className="settings-input"
name="password"
id={"password"}
type="password"
placeholder={"Mot de passe"}
autoComplete="new-password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<label htmlFor="confirmPassword">Confirmez le mot de passe</label>
<input
ref={passwordConfirmRef}
className="settings-input"
name="confirmPassword"
id="confirmPassword"
type="password"
autoComplete="new-password"
placeholder={"Confirmation du mot de passe"}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
<button
className="settings-button"
type="submit">
Mettre à jour
</button>
</form>
</div>
</div>
</div>
</div>
)
}
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()
const [link, setLink] = useState("")
useEffect(() => {
function onKeyUp(e: KeyboardEvent) {
if (e.key === "Escape") onHide()
}
window.addEventListener('keyup', onKeyUp)
return () => window.removeEventListener('keyup', onKeyUp)
}, [onHide])
const handleForm = useCallback(async (e: FormEvent) => {
e.preventDefault()
const url = urlRef.current!.value
const errors = await updateAccount(fetcher, {
profilePicture: url,
})
if (errors.length !== 0) {
setErrorMessages(errors)
return
}
setUser({ ...user!, profilePicture: url })
setErrorMessages([])
onHide()
}, [fetcher, onHide, setUser, user])
if (!show) return <></>
return (
<dialog id="profile-picture-popup">
<form id="profile-picture-popup-form" onSubmit={handleForm}>
<div id="profile-picture-popup-header">
<p id="profile-picture-popup-title">
Nouvelle photo de profil
</p>
</div>
{errorMessages?.map((msg) => (
<div key={msg} className="error-message">
{msg}
</div>
))}
<label id="profile-picture-popup-subtitle" htmlFor="profile-picture">
Saisissez le lien vers votre nouvelle photo de profil
</label>
<input
className={
`settings-input ` +
((errorMessages?.length ?? 0) === 0
? ""
: "invalid-input")
}
id="profile-picture"
ref={urlRef}
type="url"
autoComplete="url"
required
placeholder={"lien vers une image"}
value={link}
onChange={e => setLink(e.target.value)}
/>
<div id="profile-picture-popup-footer">
<button className={"settings-button"} onClick={onHide}>
Annuler
</button>
<input
type="submit"
className={"settings-button"}
value="Valider"
/>
</div>
</form>
</dialog>
)
}

@ -1,38 +1,21 @@
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)
}
// if the user is authenticated and the username is not already present in the session,
if (session.auth && !session.username) loadUsername()
}, [session])
const [user] = useUser()
const accountImage = user?.profilePicture ? (
<img
id="img-account"
src={user.profilePicture}
alt={`Photo de profil de ${user!.name}`}
/>
) : (
<AccountSvg id="img-account" />
)
return (
<div id="header">
@ -50,20 +33,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,25 @@
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,10 +35,13 @@ export class APITacticService implements TacticService {
parent: StepInfoNode,
content: StepContent,
): Promise<StepInfoNode | ServiceError> {
const response = await fetchAPI(`tactics/${this.tacticId}/steps`, {
parentId: parent.id,
content,
})
const response = await this.fetcher.fetchAPI(
`tactics/${this.tacticId}/steps`,
{
parentId: parent.id,
content,
},
)
if (response.status == 404) return ServiceError.NOT_FOUND
if (response.status == 401) return ServiceError.UNAUTHORIZED
@ -41,7 +50,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 +60,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 +73,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 +83,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

@ -23,6 +23,10 @@ export class LocalStorageTacticService implements TacticService {
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
JSON.stringify(<StepInfoNode>{ id: 1, children: [] }),
)
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + 1,
JSON.stringify(<StepContent>{components: []})
)
}
return new LocalStorageTacticService()

@ -20,13 +20,13 @@ body {
#body {
display: flex;
flex-direction: row;
margin: 0px;
margin: 0;
height: 100%;
background-color: var(--home-second-color);
}
.data {
border: 1.5px solid var(--main-contrast-color);
border: 2px solid var(--main-contrast-color);
background-color: var(--home-main-color);
border-radius: 0.75cap;
color: var(--main-contrast-color);

@ -0,0 +1,120 @@
.error-message {
color: red;
}
.success-message {
color: green;
}
#settings-page {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
#profile-picture {
display: flex;
justify-content: center;
}
#settings-content {
padding: 30px;
border: 1px solid gray;
border-radius: 15px;
background-color: #e1e1e1;
}
#settings-inputs {
display: flex;
gap: 20px;
}
.settings-button {
background-color: white;
border: solid 1px #0d6efd;
border-radius: 5px;
color: #0d6efd;
padding: 5px;
transition: background-color 250ms;
}
.settings-button:hover {
background-color: #0d6efd;
color: white;
cursor: pointer;
}
.settings-input {
border: solid 1px lightgray;
border-radius: 5px;
padding: 5px;
outline: #5f8fee;
}
.settings-input:focus {
box-shadow: #0056b3;
}
#credentials-form {
display: flex;
flex-direction: column;
}
#profile-picture-popup {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
z-index: 100000000000000000000000000000000;
width: 100%;
height: 100%;
background-color: rgba(84, 78, 78, 0.33);
}
#profile-picture-popup-form {
display: flex;
flex-direction: column;
row-gap: 20px;
background-color: white;
padding: 25px;
border-radius: 25px;
}
#profile-picture-popup-title {
font-size: 25px;
}
#profile-picture-popup-subtitle,
#profile-picture-popup-title {
margin: 0;
}
#profile-picture-popup-header {
border: 0 solid;
border-bottom-width: 1px;
}
#profile-picture-popup-footer {
display: flex;
width: 100%;
gap: 5px;
}
#profile-picture-img {
user-select: none;
-webkit-user-drag: none;
border-radius: 2000px;
}
.invalid-input {
border: 1px solid red;
}

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

Loading…
Cancel
Save