From 40280664b749a070242299061c8bda9a21867bc2 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 26 Mar 2026 17:34:08 +0000 Subject: [PATCH 01/36] moved errors to a global context, and abstracted some loader functionality. also added a proper loader as an upgrade to the old text based one --- frontend/public/pokeballLoader.svg | 11 +++ frontend/src/App.tsx | 69 ++++++++++--------- frontend/src/components/error/ErrorBanner.tsx | 36 ++++++++++ frontend/src/components/load/GlobalLoader.tsx | 34 +++++++++ .../src/components/load/LoadResourceIndex.tsx | 1 - frontend/src/components/load/ShowLoader.tsx | 22 +----- frontend/src/helpers/exports/exportError.ts | 16 +++++ frontend/src/pages/leagues/League.tsx | 8 +-- frontend/src/providers/ErrorProvider.tsx | 15 ++++ frontend/src/providers/LoadingProvider.tsx | 24 +------ frontend/src/redux/errorSlice.ts | 24 +++++++ frontend/src/redux/store.ts | 2 + 12 files changed, 182 insertions(+), 80 deletions(-) create mode 100644 frontend/public/pokeballLoader.svg create mode 100644 frontend/src/components/error/ErrorBanner.tsx create mode 100644 frontend/src/components/load/GlobalLoader.tsx create mode 100644 frontend/src/helpers/exports/exportError.ts create mode 100644 frontend/src/providers/ErrorProvider.tsx create mode 100644 frontend/src/redux/errorSlice.ts diff --git a/frontend/public/pokeballLoader.svg b/frontend/public/pokeballLoader.svg new file mode 100644 index 0000000..d24658f --- /dev/null +++ b/frontend/public/pokeballLoader.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 23c3b00..cade329 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,44 +15,47 @@ import Login from './pages/home/Login.tsx' import { AuthProvider } from './providers/AuthProvider.tsx' import { ProtectedRoutes } from './components/ProtectedRoutes.tsx' import { LoadingProvider } from './providers/LoadingProvider.tsx' +import { ErrorProvider } from './providers/ErrorProvider.tsx' function App() { return ( - -
- + + +
+ - - {/* unprotected routes */} - {/* login / signup */} - } /> - login page - {/* protected routes */} - }> - {/* home routes */} - } /> - home page - } /> - test page - {/* league routes */} - } /> - list of leagues - } /> - specific league - {/* trainer routes */} - } /> - list of trainers in league - } /> - specific trainer - {/* team routes */} - } /> - list of teams for a trainer - } /> - specific team - {/* doc routes */} - } /> - list of docs - } /> - hierarchy for model creation - } /> - leagues information - } /> - trainers information - - -
-
+ + {/* unprotected routes */} + {/* login / signup */} + } /> - login page + {/* protected routes */} + }> + {/* home routes */} + } /> - home page + } /> - test page + {/* league routes */} + } /> - list of leagues + } /> - specific league + {/* trainer routes */} + } /> - list of trainers in league + } /> - specific trainer + {/* team routes */} + } /> - list of teams for a trainer + } /> - specific team + {/* doc routes */} + } /> - list of docs + } /> - hierarchy for model creation + } /> - leagues information + } /> - trainers information + + +
+
+
) } diff --git a/frontend/src/components/error/ErrorBanner.tsx b/frontend/src/components/error/ErrorBanner.tsx new file mode 100644 index 0000000..46b4e67 --- /dev/null +++ b/frontend/src/components/error/ErrorBanner.tsx @@ -0,0 +1,36 @@ +import { clearError } from '../../helpers/exports/exportError.ts' + +export default function ErrorBanner({ message }: { message: string }) { + return ( +
+ {message} + +
+ ) +} diff --git a/frontend/src/components/load/GlobalLoader.tsx b/frontend/src/components/load/GlobalLoader.tsx new file mode 100644 index 0000000..7ef8292 --- /dev/null +++ b/frontend/src/components/load/GlobalLoader.tsx @@ -0,0 +1,34 @@ +export default function GlobalLoader() { + return ( +
+ Loading... + +
+ ) +} diff --git a/frontend/src/components/load/LoadResourceIndex.tsx b/frontend/src/components/load/LoadResourceIndex.tsx index 699e220..03c2cda 100644 --- a/frontend/src/components/load/LoadResourceIndex.tsx +++ b/frontend/src/components/load/LoadResourceIndex.tsx @@ -1,4 +1,3 @@ -import { useCallback } from 'react' import { startLoading, stopLoading } from '../../helpers/exports/exportLoading.ts' import fetchLeaguesIndex from '../../helpers/leagues/fetchLeaguesIndex.ts' import { Link } from 'react-router-dom' diff --git a/frontend/src/components/load/ShowLoader.tsx b/frontend/src/components/load/ShowLoader.tsx index 878d1be..6563037 100644 --- a/frontend/src/components/load/ShowLoader.tsx +++ b/frontend/src/components/load/ShowLoader.tsx @@ -1,28 +1,12 @@ import React from 'react' +import GlobalLoader from './GlobalLoader.tsx' // decides whether a loader should be shown export default function ShowLoader(isLoading: boolean, loader?: React.ReactNode) { // fallback to default global loader if (!loader) { - loader = ( -
-
Loading...
-
- ) + loader = } - if (isLoading) return
{loader}
+ if (isLoading) return <>{loader} return null // Return null when not loading, so parent can continue } diff --git a/frontend/src/helpers/exports/exportError.ts b/frontend/src/helpers/exports/exportError.ts new file mode 100644 index 0000000..74f8cb4 --- /dev/null +++ b/frontend/src/helpers/exports/exportError.ts @@ -0,0 +1,16 @@ +import { store } from '../../redux/store.ts' +import { setError, resetError } from '../../redux/errorSlice.ts' +import { stopLoading } from './exportLoading.ts' + +export function throwError(msg: string) { + store.dispatch(setError(msg)) + stopLoading() +} + +export function clearError() { + store.dispatch(resetError()) +} + +export function getError() { + return store.getState().error +} diff --git a/frontend/src/pages/leagues/League.tsx b/frontend/src/pages/leagues/League.tsx index 528a712..0627433 100644 --- a/frontend/src/pages/leagues/League.tsx +++ b/frontend/src/pages/leagues/League.tsx @@ -2,8 +2,8 @@ import { useParams } from 'react-router-dom' import Trainers from '../trainers/Trainers.tsx' import fetchLeaguesSpecific from '../../helpers/leagues/fetchLeaguesSpecific.ts' import { useCallback, useEffect, useState } from 'react' -import DisplayError from '../../components/DisplayError.tsx' import { startLoading, stopLoading } from '../../helpers/exports/exportLoading.ts' +import { throwError } from '../../helpers/exports/exportError.ts' /** * Individual league page. @@ -12,14 +12,13 @@ import { startLoading, stopLoading } from '../../helpers/exports/exportLoading.t */ export default function Leagues() { const { categoryId } = useParams() - const [error, setError] = useState(null) const [league, setLeague] = useState<{ id: number; name: string }>() const loadLeague = useCallback(async () => { startLoading() - if (!categoryId) return setError('No league id provided') + if (!categoryId) return throwError('No league id provided') const leagueJson = await fetchLeaguesSpecific(parseInt(categoryId)) - if (!Object.hasOwn(leagueJson, 'user_id')) return setError('No league found') + if (!Object.hasOwn(leagueJson, 'user_id')) return throwError('No league found') setLeague(leagueJson) console.table(leagueJson) stopLoading() @@ -32,7 +31,6 @@ export default function Leagues() { return (

League: '{league?.name}'

-
diff --git a/frontend/src/providers/ErrorProvider.tsx b/frontend/src/providers/ErrorProvider.tsx new file mode 100644 index 0000000..12c3803 --- /dev/null +++ b/frontend/src/providers/ErrorProvider.tsx @@ -0,0 +1,15 @@ +import { useSelector } from 'react-redux' +import { selectError } from '../redux/errorSlice.ts' +import ErrorBanner from '../components/error/ErrorBanner.tsx' +import React from 'react' + +export function ErrorProvider({ children }: { children: React.ReactNode }) { + const { error, errorMsg } = useSelector(selectError) + + return ( + <> + {error && } + {children} + + ) +} diff --git a/frontend/src/providers/LoadingProvider.tsx b/frontend/src/providers/LoadingProvider.tsx index c419b7e..c354e41 100644 --- a/frontend/src/providers/LoadingProvider.tsx +++ b/frontend/src/providers/LoadingProvider.tsx @@ -1,36 +1,16 @@ import { useSelector } from 'react-redux' import { loading } from '../redux/loadingSlice.ts' import ShowLoader from '../components/load/ShowLoader.tsx' +import GlobalLoader from '../components/load/GlobalLoader.tsx' import React from 'react' export function LoadingProvider({ children }: { children: React.ReactNode }) { const isLoading = useSelector(loading) - // overlay semi-opaque whitewash plus loader - const globalLoader = ( -
-
Loading...
{' '} - {/* todo: replace with a nicer loader */} -
- ) - // return loader with children under to maintain state return ( <> - {ShowLoader(isLoading, globalLoader)} + {ShowLoader(isLoading, )} {children} ) diff --git a/frontend/src/redux/errorSlice.ts b/frontend/src/redux/errorSlice.ts new file mode 100644 index 0000000..f0a36c7 --- /dev/null +++ b/frontend/src/redux/errorSlice.ts @@ -0,0 +1,24 @@ +import { createSlice } from '@reduxjs/toolkit' +import type { PayloadAction } from '@reduxjs/toolkit' +import type { RootState } from './store.ts' + +export const errorSlice = createSlice({ + name: 'error', + initialState: { error: false, errorMsg: '' }, + + reducers: { + setError(state, action: PayloadAction) { + state.error = true + state.errorMsg = action.payload ?? 'Something went wrong' + }, + resetError(state) { + state.error = false + state.errorMsg = '' + }, + }, +}) + +export const { setError, resetError } = errorSlice.actions +export const selectError = (state: RootState) => state.error +export const selectErrorMsg = (state: RootState) => state.error.errorMsg +export default errorSlice.reducer diff --git a/frontend/src/redux/store.ts b/frontend/src/redux/store.ts index f9bd0e9..73fb843 100644 --- a/frontend/src/redux/store.ts +++ b/frontend/src/redux/store.ts @@ -1,11 +1,13 @@ import { configureStore } from '@reduxjs/toolkit' import userSlice from './userSlice.ts' import loadingSlice from './loadingSlice.ts' +import errorReducer from './errorSlice.ts' export const store = configureStore({ reducer: { user: userSlice, loading: loadingSlice, + error: errorReducer, }, }) From 7e5700647167b8cb5b8779cf600781bb945ae165 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 26 Mar 2026 18:11:09 +0000 Subject: [PATCH 02/36] added delay to loader appearance so it doesnt flash on fast loads --- frontend/src/providers/LoadingProvider.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/providers/LoadingProvider.tsx b/frontend/src/providers/LoadingProvider.tsx index c354e41..7f7de50 100644 --- a/frontend/src/providers/LoadingProvider.tsx +++ b/frontend/src/providers/LoadingProvider.tsx @@ -2,15 +2,26 @@ import { useSelector } from 'react-redux' import { loading } from '../redux/loadingSlice.ts' import ShowLoader from '../components/load/ShowLoader.tsx' import GlobalLoader from '../components/load/GlobalLoader.tsx' +import { useEffect, useState } from 'react' import React from 'react' export function LoadingProvider({ children }: { children: React.ReactNode }) { const isLoading = useSelector(loading) + const [showLoader, setShowLoader] = useState(false) + + // delay loader appearance to avoid flash on fast loads // todo: tweak or even remove this delay if it feels unresponsive. we wont know until its in prod mode and debugging isn't slowing loading time + useEffect(() => { + if (isLoading) { + const timer = setTimeout(() => setShowLoader(true), 100) + return () => clearTimeout(timer) + } else { + setShowLoader(false) + } + }, [isLoading]) - // return loader with children under to maintain state return ( <> - {ShowLoader(isLoading, )} + {ShowLoader(showLoader, )} {children} ) From 125d2550e6635842b7e787270c2253093e91dad9 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 26 Mar 2026 20:48:11 +0000 Subject: [PATCH 03/36] Refactor loading logic and replace LoadResourceIndex with generic component. --- .../components/api/DisplayResourceGeneric.tsx | 56 +++++++++++++++ .../src/components/load/LoadResourceIndex.tsx | 27 ------- .../src/components/modals/CreationModal.tsx | 7 +- frontend/src/helpers/checkUniqueness.ts | 6 +- .../src/helpers/leagues/fetchLeaguesIndex.ts | 16 +++-- .../manageResource/createNewResource.ts | 7 +- frontend/src/hooks/load/useResource.ts | 23 ++++++ frontend/src/pages/leagues/Leagues.tsx | 72 +++++-------------- frontend/src/providers/LoadingProvider.tsx | 2 +- 9 files changed, 117 insertions(+), 99 deletions(-) create mode 100644 frontend/src/components/api/DisplayResourceGeneric.tsx delete mode 100644 frontend/src/components/load/LoadResourceIndex.tsx create mode 100644 frontend/src/hooks/load/useResource.ts diff --git a/frontend/src/components/api/DisplayResourceGeneric.tsx b/frontend/src/components/api/DisplayResourceGeneric.tsx new file mode 100644 index 0000000..ede0e5c --- /dev/null +++ b/frontend/src/components/api/DisplayResourceGeneric.tsx @@ -0,0 +1,56 @@ +import { useEffect } from 'react' +import { Link } from 'react-router-dom' +import { useResource } from '../../hooks/load/useResource.ts' +import { startLoading, stopLoading } from '../../helpers/exports/exportLoading.ts' + +type Item = { + id: number + name: string +} + +type Props = { + fetchFn: (page: number) => Promise + path: (id: number) => string + onAdd?: () => void + pageNum: number + setPageNum: (page: number) => void +} + +/** + * Generic resource list with pagination. + * Handles its own data fetching via useResource. + */ +export default function DisplayResourceGeneric({ + fetchFn, + path, + onAdd, + pageNum, + setPageNum, +}: Props) { + const { items, totalPages, load } = useResource(fetchFn, pageNum) + + useEffect(() => { + startLoading() + load() + .catch(err => console.error(err)) + .finally(() => stopLoading()) + }, [load]) + + return ( +
+ {items.map((item: Item) => ( + + ))} + {pageNum === totalPages && onAdd && } +
+ {pageNum > 1 && } + + Page {pageNum} of {totalPages} + + {pageNum < totalPages && } +
+
+ ) +} diff --git a/frontend/src/components/load/LoadResourceIndex.tsx b/frontend/src/components/load/LoadResourceIndex.tsx deleted file mode 100644 index 03c2cda..0000000 --- a/frontend/src/components/load/LoadResourceIndex.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { startLoading, stopLoading } from '../../helpers/exports/exportLoading.ts' -import fetchLeaguesIndex from '../../helpers/leagues/fetchLeaguesIndex.ts' -import { Link } from 'react-router-dom' - -/** - * Abstract component to load all resources for a given category todo: fully abstract and extend in LoadLeague.tsx - */ -export default async function LoadResourceIndex({ pageNum }: any) { - startLoading() - const response = await fetchLeaguesIndex(pageNum) - - // // get total pages from Yii2 response _meta - // if (response._meta) { - // setTotalPages(response._meta.pageCount) - // } - - const league = response.items.map((item: any) => { - return { - button: ( - - ), - } - }) - stopLoading() -} diff --git a/frontend/src/components/modals/CreationModal.tsx b/frontend/src/components/modals/CreationModal.tsx index 9e21aba..b497940 100644 --- a/frontend/src/components/modals/CreationModal.tsx +++ b/frontend/src/components/modals/CreationModal.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react' import { createPortal } from 'react-dom' import DisplayError from '../DisplayError.tsx' import type { ResourceCreation } from '../../helpers/manageResource/createNewResource.ts' +import { stopLoading } from '../../helpers/exports/exportLoading.ts' export interface FormField { name: string @@ -17,7 +18,7 @@ export interface CreationModalProps { title: string fields: FormField[] onSubmit: ({ data, onReload }: ResourceCreation) => Promise - onReload?: () => Promise + onReload?: () => Promise | void onCancel: () => void isVisible: boolean } @@ -71,12 +72,14 @@ export default function CreationModal({ if (field.unique && field.validateUnique) { if (!(await field.validateUnique(tableName, formData[field.name]))) { setError(`${field.placeholder} must be unique`) + stopLoading() return } } } - await onSubmit({ tableName, data: formData, onReload }) + await onSubmit({ tableName, data: formData }) + await onReload?.() pipeCancel() // close modal after successful submission (must be changed if onCancel is ever not just `closing window`) setFormData(setFormsBlank()) } diff --git a/frontend/src/helpers/checkUniqueness.ts b/frontend/src/helpers/checkUniqueness.ts index 25a83ad..f7a1896 100644 --- a/frontend/src/helpers/checkUniqueness.ts +++ b/frontend/src/helpers/checkUniqueness.ts @@ -1,10 +1,10 @@ // check if given name is unique to this user for specified table -import { startLoading, stopLoading } from './exports/exportLoading.ts' import { BASE_URL } from './exports/exportEnv.ts' +import { startLoading } from './exports/exportLoading.ts' export const isNameUnique = async (tableName: string, fieldName: string): Promise => { - startLoading() try { + startLoading() const response = await fetch( `${BASE_URL}/${tableName}/check-unique?name=${encodeURIComponent(fieldName)}`, { @@ -15,7 +15,5 @@ export const isNameUnique = async (tableName: string, fieldName: string): Promis } catch (err) { console.error('Error checking league name:', err) return false // default to not unique on error - } finally { - stopLoading() } } diff --git a/frontend/src/helpers/leagues/fetchLeaguesIndex.ts b/frontend/src/helpers/leagues/fetchLeaguesIndex.ts index edd563f..2f73839 100644 --- a/frontend/src/helpers/leagues/fetchLeaguesIndex.ts +++ b/frontend/src/helpers/leagues/fetchLeaguesIndex.ts @@ -1,9 +1,15 @@ import { BASE_URL } from '../exports/exportEnv.ts' +import { throwError } from '../exports/exportError.ts' export default async function fetchLeaguesIndex(pageNum: number) { - return await ( - await fetch(`${BASE_URL}/categories/index?page=${pageNum}`, { - credentials: 'include', - }) - ).json() + const response = await fetch(`${BASE_URL}/categories/index?page=${pageNum}`, { + credentials: 'include', + }).catch(err => { + throwError(`Failed to load leagues: ${err.message}`) + return null + }) + + if (!response) return null + + return await response.json() } diff --git a/frontend/src/helpers/manageResource/createNewResource.ts b/frontend/src/helpers/manageResource/createNewResource.ts index e1556bd..e6ee39e 100644 --- a/frontend/src/helpers/manageResource/createNewResource.ts +++ b/frontend/src/helpers/manageResource/createNewResource.ts @@ -1,10 +1,9 @@ -import { startLoading, stopLoading } from '../exports/exportLoading.ts' import { BASE_URL } from '../exports/exportEnv.ts' export interface ResourceCreation { tableName: string data: Record - onReload?: () => Promise + onReload?: () => Promise | void } /** @@ -16,8 +15,6 @@ export interface ResourceCreation { * @param reloadResource */ export const createNewResource = async ({ tableName, data, onReload }: ResourceCreation) => { - startLoading() - await fetch(`${BASE_URL}/${tableName}/create`, { method: 'POST', headers: { @@ -34,8 +31,6 @@ export const createNewResource = async ({ tableName, data, onReload }: ResourceC }) .catch(err => console.log(err)) - stopLoading() - // reload data if callback is provided if (onReload) { await onReload() diff --git a/frontend/src/hooks/load/useResource.ts b/frontend/src/hooks/load/useResource.ts new file mode 100644 index 0000000..fbb86ff --- /dev/null +++ b/frontend/src/hooks/load/useResource.ts @@ -0,0 +1,23 @@ +import { useCallback, useRef, useState } from 'react' + +export function useResource(fetchFn: (page: number) => Promise, pageNum: number) { + const [items, setItems] = useState([]) + const [totalPages, setTotalPages] = useState(0) + + const fetchRef = useRef(fetchFn) + + const load = useCallback(async () => { + const response = await fetchRef.current(pageNum) + + if (!response) return + + // get total pages from Yii2 response _meta + if (response._meta) { + setTotalPages(response._meta.pageCount) + } + + setItems(response.items) + }, [pageNum]) + + return { items, totalPages, load } +} diff --git a/frontend/src/pages/leagues/Leagues.tsx b/frontend/src/pages/leagues/Leagues.tsx index 4fb6e01..abec23b 100644 --- a/frontend/src/pages/leagues/Leagues.tsx +++ b/frontend/src/pages/leagues/Leagues.tsx @@ -1,12 +1,11 @@ -import { useCallback, useEffect, useState } from 'react' -import { Link } from 'react-router-dom' +import { useState } from 'react' import LeagueContextMenu from '../../components/leagues/LeagueContextMenu.tsx' import { createPortal } from 'react-dom' import LeagueCreationModal from '../../components/leagues/LeagueCreationModal.tsx' -import fetchLeaguesIndex from '../../helpers/leagues/fetchLeaguesIndex.ts' -import { startLoading, stopLoading } from '../../helpers/exports/exportLoading.ts' import { isNameUnique } from '../../helpers/checkUniqueness.ts' import { createNewResource } from '../../helpers/manageResource/createNewResource.ts' +import DisplayResourceGeneric from '../../components/api/DisplayResourceGeneric.tsx' +import fetchLeaguesIndex from '../../helpers/leagues/fetchLeaguesIndex.ts' /** * Home page for leagues. @@ -14,41 +13,15 @@ import { createNewResource } from '../../helpers/manageResource/createNewResourc * Displays a list of leagues the user has access to, and options to create, edit, etc. */ export default function Leagues() { - const [leagues, setLeague] = useState<{ button: any }[]>([]) const [isLeagueContextMenuVisible, setIsLeagueContextMenuVisible] = useState(false) const [isLeagueCreationMenuVisible, setIsLeagueCreationMenuVisible] = useState(false) const [leagueContextMenuPosition, setLeagueContextMenuPosition] = useState<{ x: number y: number - }>({ - x: 0, - y: 0, - }) - const portalRoot = document.getElementById('portal-root') + }>({ x: 0, y: 0 }) const [pageNum, setPageNum] = useState(1) - const [totalPages, setTotalPages] = useState(1) - - const loadLeagues = useCallback(async () => { - startLoading() - const leaguesJson = await fetchLeaguesIndex(pageNum) - - // get total pages from Yii2 response _meta - if (leaguesJson._meta) { - setTotalPages(leaguesJson._meta.pageCount) - } - - const league = leaguesJson.items.map((item: any) => { - return { - button: ( - - ), - } - }) - setLeague(league) - stopLoading() - }, [pageNum]) + const portalRoot = document.getElementById('portal-root') + const [reloadKey, setReloadKey] = useState(0) // right-click context menu for editing leagues const showLeagueContextMenu = (c: any) => { @@ -62,15 +35,9 @@ export default function Leagues() { setIsLeagueContextMenuVisible(false) } - useEffect(() => { - loadLeagues().catch(err => { - console.error(err) - // todo: display error message - }) - }, [loadLeagues]) - return (
+ {/*right-click menu for editing leagues - todo*/} {isLeagueContextMenuVisible && createPortal( @@ -81,10 +48,11 @@ export default function Leagues() { portalRoot ?? document.body )} + {/*popup when creating a new league*/} setReloadKey(k => k + 1)} onCancel={() => setIsLeagueCreationMenuVisible(false)} fields={[ { @@ -103,19 +71,15 @@ export default function Leagues() { ]} /> -
- {leagues.map(league => league.button)} {/* todo: replace with abstract component */} - {pageNum === totalPages && ( - - )} -
- {pageNum > 1 && } - - Page {pageNum} of {totalPages} - - {pageNum < totalPages && } -
-
+ {/*display all loaded leagues*/} + `/League/${id}`} + onAdd={() => setIsLeagueCreationMenuVisible(true)} + pageNum={pageNum} + setPageNum={setPageNum} + />
) } diff --git a/frontend/src/providers/LoadingProvider.tsx b/frontend/src/providers/LoadingProvider.tsx index 7f7de50..ae73e30 100644 --- a/frontend/src/providers/LoadingProvider.tsx +++ b/frontend/src/providers/LoadingProvider.tsx @@ -12,7 +12,7 @@ export function LoadingProvider({ children }: { children: React.ReactNode }) { // delay loader appearance to avoid flash on fast loads // todo: tweak or even remove this delay if it feels unresponsive. we wont know until its in prod mode and debugging isn't slowing loading time useEffect(() => { if (isLoading) { - const timer = setTimeout(() => setShowLoader(true), 100) + const timer = setTimeout(() => setShowLoader(true), 0) return () => clearTimeout(timer) } else { setShowLoader(false) From d6a5313c63f5cd5b7a5fde1e0b7d0e02562d63d8 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 26 Mar 2026 21:00:48 +0000 Subject: [PATCH 04/36] Refactor loading logic to improve state management in components. --- frontend/src/components/ProtectedRoutes.tsx | 18 ++++++++++----- .../components/api/DisplayResourceGeneric.tsx | 22 +++++++++++-------- .../src/components/modals/CreationModal.tsx | 2 -- frontend/src/helpers/checkUniqueness.ts | 6 +++-- frontend/src/hooks/load/useResource.ts | 12 ++++++++-- 5 files changed, 40 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/ProtectedRoutes.tsx b/frontend/src/components/ProtectedRoutes.tsx index 2337c58..921bb05 100644 --- a/frontend/src/components/ProtectedRoutes.tsx +++ b/frontend/src/components/ProtectedRoutes.tsx @@ -1,17 +1,25 @@ import { Outlet, Navigate } from 'react-router-dom' import { loggedIn } from '../redux/userSlice.ts' import { useSelector } from 'react-redux' -import ShowLoader from './load/ShowLoader.tsx' import { useAuth } from '../helpers/exports/exportAuth.ts' +import { startLoading, stopLoading } from '../helpers/exports/exportLoading.ts' +import { useEffect } from 'react' // wrapper for every route that requires authentication export function ProtectedRoutes() { const isLoggedIn = useSelector(loggedIn) - - // show loader if useAuth context shows a loading state const { loading } = useAuth() - const loaderElement = ShowLoader(loading,

auth loading

) - if (loaderElement) return loaderElement + + // use global loader for auth checks as it blocks the entire app + useEffect(() => { + if (loading) { + startLoading() + } else { + stopLoading() + } + }, [loading]) + + if (loading) return null if (!isLoggedIn) return ( diff --git a/frontend/src/components/api/DisplayResourceGeneric.tsx b/frontend/src/components/api/DisplayResourceGeneric.tsx index ede0e5c..e9985ea 100644 --- a/frontend/src/components/api/DisplayResourceGeneric.tsx +++ b/frontend/src/components/api/DisplayResourceGeneric.tsx @@ -1,7 +1,7 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { useResource } from '../../hooks/load/useResource.ts' -import { startLoading, stopLoading } from '../../helpers/exports/exportLoading.ts' +import ShowLoader from '../load/ShowLoader.tsx' type Item = { id: number @@ -28,21 +28,25 @@ export default function DisplayResourceGeneric({ setPageNum, }: Props) { const { items, totalPages, load } = useResource(fetchFn, pageNum) + const [isLoading, setIsLoading] = useState(true) useEffect(() => { - startLoading() + setIsLoading(true) load() .catch(err => console.error(err)) - .finally(() => stopLoading()) + .finally(() => setIsLoading(false)) }, [load]) return (
- {items.map((item: Item) => ( - - ))} + {/* show loading text while fetching, otherwise show items */} + {ShowLoader(isLoading,
Loading...
)} + {!isLoading && + items.map((item: Item) => ( + + ))} {pageNum === totalPages && onAdd && }
{pageNum > 1 && } diff --git a/frontend/src/components/modals/CreationModal.tsx b/frontend/src/components/modals/CreationModal.tsx index b497940..4517ce9 100644 --- a/frontend/src/components/modals/CreationModal.tsx +++ b/frontend/src/components/modals/CreationModal.tsx @@ -2,7 +2,6 @@ import React, { useState } from 'react' import { createPortal } from 'react-dom' import DisplayError from '../DisplayError.tsx' import type { ResourceCreation } from '../../helpers/manageResource/createNewResource.ts' -import { stopLoading } from '../../helpers/exports/exportLoading.ts' export interface FormField { name: string @@ -72,7 +71,6 @@ export default function CreationModal({ if (field.unique && field.validateUnique) { if (!(await field.validateUnique(tableName, formData[field.name]))) { setError(`${field.placeholder} must be unique`) - stopLoading() return } } diff --git a/frontend/src/helpers/checkUniqueness.ts b/frontend/src/helpers/checkUniqueness.ts index f7a1896..25a83ad 100644 --- a/frontend/src/helpers/checkUniqueness.ts +++ b/frontend/src/helpers/checkUniqueness.ts @@ -1,10 +1,10 @@ // check if given name is unique to this user for specified table +import { startLoading, stopLoading } from './exports/exportLoading.ts' import { BASE_URL } from './exports/exportEnv.ts' -import { startLoading } from './exports/exportLoading.ts' export const isNameUnique = async (tableName: string, fieldName: string): Promise => { + startLoading() try { - startLoading() const response = await fetch( `${BASE_URL}/${tableName}/check-unique?name=${encodeURIComponent(fieldName)}`, { @@ -15,5 +15,7 @@ export const isNameUnique = async (tableName: string, fieldName: string): Promis } catch (err) { console.error('Error checking league name:', err) return false // default to not unique on error + } finally { + stopLoading() } } diff --git a/frontend/src/hooks/load/useResource.ts b/frontend/src/hooks/load/useResource.ts index fbb86ff..52841f0 100644 --- a/frontend/src/hooks/load/useResource.ts +++ b/frontend/src/hooks/load/useResource.ts @@ -1,13 +1,20 @@ import { useCallback, useRef, useState } from 'react' +import { throwError } from '../../helpers/exports/exportError.ts' export function useResource(fetchFn: (page: number) => Promise, pageNum: number) { const [items, setItems] = useState([]) const [totalPages, setTotalPages] = useState(0) + const [ready, setReady] = useState(false) const fetchRef = useRef(fetchFn) const load = useCallback(async () => { - const response = await fetchRef.current(pageNum) + setReady(false) + + const response = await fetchRef.current(pageNum).catch(err => { + throwError(`Failed to load resources: ${err.message}`) + return null + }) if (!response) return @@ -17,7 +24,8 @@ export function useResource(fetchFn: (page: number) => Promise, pageNum: nu } setItems(response.items) + setReady(true) }, [pageNum]) - return { items, totalPages, load } + return { items, totalPages, load, ready } } From c8a882814dd95976d958d3729c07633624966fa9 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 26 Mar 2026 21:08:08 +0000 Subject: [PATCH 05/36] Refactor loading logic to add opacity control for better UX. --- frontend/src/components/load/GlobalLoader.tsx | 8 ++++++-- frontend/src/helpers/exports/exportLoading.ts | 4 ++-- frontend/src/pages/leagues/League.tsx | 2 +- frontend/src/providers/LoadingProvider.tsx | 5 +++-- frontend/src/redux/loadingSlice.ts | 8 ++++++-- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/load/GlobalLoader.tsx b/frontend/src/components/load/GlobalLoader.tsx index 7ef8292..6aaf3b6 100644 --- a/frontend/src/components/load/GlobalLoader.tsx +++ b/frontend/src/components/load/GlobalLoader.tsx @@ -1,4 +1,8 @@ -export default function GlobalLoader() { +type Props = { + opaque?: boolean +} + +export default function GlobalLoader({ opaque = false }: Props) { return (
() const loadLeague = useCallback(async () => { - startLoading() + startLoading(true) if (!categoryId) return throwError('No league id provided') const leagueJson = await fetchLeaguesSpecific(parseInt(categoryId)) if (!Object.hasOwn(leagueJson, 'user_id')) return throwError('No league found') diff --git a/frontend/src/providers/LoadingProvider.tsx b/frontend/src/providers/LoadingProvider.tsx index ae73e30..94bb838 100644 --- a/frontend/src/providers/LoadingProvider.tsx +++ b/frontend/src/providers/LoadingProvider.tsx @@ -1,5 +1,5 @@ import { useSelector } from 'react-redux' -import { loading } from '../redux/loadingSlice.ts' +import { loading, opaque } from '../redux/loadingSlice.ts' import ShowLoader from '../components/load/ShowLoader.tsx' import GlobalLoader from '../components/load/GlobalLoader.tsx' import { useEffect, useState } from 'react' @@ -7,6 +7,7 @@ import React from 'react' export function LoadingProvider({ children }: { children: React.ReactNode }) { const isLoading = useSelector(loading) + const isOpaque = useSelector(opaque) const [showLoader, setShowLoader] = useState(false) // delay loader appearance to avoid flash on fast loads // todo: tweak or even remove this delay if it feels unresponsive. we wont know until its in prod mode and debugging isn't slowing loading time @@ -21,7 +22,7 @@ export function LoadingProvider({ children }: { children: React.ReactNode }) { return ( <> - {ShowLoader(showLoader, )} + {ShowLoader(showLoader, )} {children} ) diff --git a/frontend/src/redux/loadingSlice.ts b/frontend/src/redux/loadingSlice.ts index b7aea4f..d6f8ebd 100644 --- a/frontend/src/redux/loadingSlice.ts +++ b/frontend/src/redux/loadingSlice.ts @@ -1,20 +1,24 @@ import { createSlice } from '@reduxjs/toolkit' +import type { PayloadAction } from '@reduxjs/toolkit' import type { RootState } from './store.ts' export const loadingSlice = createSlice({ name: 'loading', - initialState: { loading: false }, + initialState: { loading: false, opaque: false }, reducers: { - startLoad(state) { + startLoad(state, action: PayloadAction<{ opaque?: boolean }>) { state.loading = true + state.opaque = action.payload.opaque ?? false }, stopLoad(state) { state.loading = false + state.opaque = false }, }, }) export const { startLoad, stopLoad } = loadingSlice.actions export const loading = (state: RootState) => state.loading.loading +export const opaque = (state: RootState) => state.loading.opaque export default loadingSlice.reducer From a42fafe25aca6838b62eb48155b098d544ab4711 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 26 Mar 2026 23:13:20 +0000 Subject: [PATCH 06/36] improved resource display component re-mount logic to use a composite key that changes when pagination is iterated or an arbitrary trigger is fired --- frontend/src/hooks/load/useResource.ts | 5 +++-- frontend/src/pages/leagues/Leagues.tsx | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/hooks/load/useResource.ts b/frontend/src/hooks/load/useResource.ts index 52841f0..3173f93 100644 --- a/frontend/src/hooks/load/useResource.ts +++ b/frontend/src/hooks/load/useResource.ts @@ -7,11 +7,12 @@ export function useResource(fetchFn: (page: number) => Promise, pageNum: nu const [ready, setReady] = useState(false) const fetchRef = useRef(fetchFn) + const pageRef = useRef(pageNum) const load = useCallback(async () => { setReady(false) - const response = await fetchRef.current(pageNum).catch(err => { + const response = await fetchRef.current(pageRef.current).catch(err => { throwError(`Failed to load resources: ${err.message}`) return null }) @@ -25,7 +26,7 @@ export function useResource(fetchFn: (page: number) => Promise, pageNum: nu setItems(response.items) setReady(true) - }, [pageNum]) + }, []) return { items, totalPages, load, ready } } diff --git a/frontend/src/pages/leagues/Leagues.tsx b/frontend/src/pages/leagues/Leagues.tsx index abec23b..4664ccf 100644 --- a/frontend/src/pages/leagues/Leagues.tsx +++ b/frontend/src/pages/leagues/Leagues.tsx @@ -20,8 +20,8 @@ export default function Leagues() { y: number }>({ x: 0, y: 0 }) const [pageNum, setPageNum] = useState(1) + const [reloadKey, setReloadKey] = useState(1) const portalRoot = document.getElementById('portal-root') - const [reloadKey, setReloadKey] = useState(0) // right-click context menu for editing leagues const showLeagueContextMenu = (c: any) => { @@ -52,7 +52,7 @@ export default function Leagues() { setReloadKey(k => k + 1)} + onReload={() => setReloadKey(k => k * -1)} onCancel={() => setIsLeagueCreationMenuVisible(false)} fields={[ { @@ -73,7 +73,7 @@ export default function Leagues() { {/*display all loaded leagues*/} `/League/${id}`} onAdd={() => setIsLeagueCreationMenuVisible(true)} From a8887df17dd34f13a60f5453dfbe5a0b2d1a77c0 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 26 Mar 2026 23:31:39 +0000 Subject: [PATCH 07/36] Update pagination logic to use URL search parameters for state. --- .../components/api/DisplayResourceGeneric.tsx | 19 +++++++++---------- frontend/src/pages/leagues/League.tsx | 4 +++- frontend/src/pages/leagues/Leagues.tsx | 6 +++--- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/api/DisplayResourceGeneric.tsx b/frontend/src/components/api/DisplayResourceGeneric.tsx index e9985ea..69db1ee 100644 --- a/frontend/src/components/api/DisplayResourceGeneric.tsx +++ b/frontend/src/components/api/DisplayResourceGeneric.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Link } from 'react-router-dom' +import { Link, useSearchParams } from 'react-router-dom' import { useResource } from '../../hooks/load/useResource.ts' import ShowLoader from '../load/ShowLoader.tsx' @@ -12,21 +12,18 @@ type Props = { fetchFn: (page: number) => Promise path: (id: number) => string onAdd?: () => void - pageNum: number - setPageNum: (page: number) => void } /** * Generic resource list with pagination. * Handles its own data fetching via useResource. + * Page number is persisted in URL search params. */ -export default function DisplayResourceGeneric({ - fetchFn, - path, - onAdd, - pageNum, - setPageNum, -}: Props) { +export default function DisplayResourceGeneric({ fetchFn, path, onAdd }: Props) { + const [searchParams, setSearchParams] = useSearchParams() + const pageNum = parseInt(searchParams.get('page') ?? '1') + const setPageNum = (page: number) => setSearchParams({ page: String(page) }) + const { items, totalPages, load } = useResource(fetchFn, pageNum) const [isLoading, setIsLoading] = useState(true) @@ -49,11 +46,13 @@ export default function DisplayResourceGeneric({ ))} {pageNum === totalPages && onAdd && }
+ {pageNum > 1 && } {pageNum > 1 && } Page {pageNum} of {totalPages} {pageNum < totalPages && } + {pageNum < totalPages && }
) diff --git a/frontend/src/pages/leagues/League.tsx b/frontend/src/pages/leagues/League.tsx index af1fb2f..e927d76 100644 --- a/frontend/src/pages/leagues/League.tsx +++ b/frontend/src/pages/leagues/League.tsx @@ -1,4 +1,4 @@ -import { useParams } from 'react-router-dom' +import { useParams, useNavigate } from 'react-router-dom' import Trainers from '../trainers/Trainers.tsx' import fetchLeaguesSpecific from '../../helpers/leagues/fetchLeaguesSpecific.ts' import { useCallback, useEffect, useState } from 'react' @@ -12,6 +12,7 @@ import { throwError } from '../../helpers/exports/exportError.ts' */ export default function Leagues() { const { categoryId } = useParams() + const navigate = useNavigate() const [league, setLeague] = useState<{ id: number; name: string }>() const loadLeague = useCallback(async () => { @@ -30,6 +31,7 @@ export default function Leagues() { return (
+

League: '{league?.name}'

diff --git a/frontend/src/pages/leagues/Leagues.tsx b/frontend/src/pages/leagues/Leagues.tsx index 4664ccf..decf76c 100644 --- a/frontend/src/pages/leagues/Leagues.tsx +++ b/frontend/src/pages/leagues/Leagues.tsx @@ -6,6 +6,7 @@ import { isNameUnique } from '../../helpers/checkUniqueness.ts' import { createNewResource } from '../../helpers/manageResource/createNewResource.ts' import DisplayResourceGeneric from '../../components/api/DisplayResourceGeneric.tsx' import fetchLeaguesIndex from '../../helpers/leagues/fetchLeaguesIndex.ts' +import { useSearchParams } from 'react-router-dom' /** * Home page for leagues. @@ -19,7 +20,8 @@ export default function Leagues() { x: number y: number }>({ x: 0, y: 0 }) - const [pageNum, setPageNum] = useState(1) + const [searchParams] = useSearchParams() + const pageNum = parseInt(searchParams.get('page') ?? '1') const [reloadKey, setReloadKey] = useState(1) const portalRoot = document.getElementById('portal-root') @@ -77,8 +79,6 @@ export default function Leagues() { fetchFn={fetchLeaguesIndex} path={id => `/League/${id}`} onAdd={() => setIsLeagueCreationMenuVisible(true)} - pageNum={pageNum} - setPageNum={setPageNum} />
) From 640805f00c1a9633a827c8e90d8caebb310fbd36 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 26 Mar 2026 23:58:01 +0000 Subject: [PATCH 08/36] Add context menu support for league renaming in the leagues component. --- .../components/api/DisplayResourceGeneric.tsx | 16 +++-- .../context/ExtendableContextMenu.tsx | 72 +++++++++++++++++++ .../components/leagues/LeagueContextMenu.tsx | 57 ++++++--------- frontend/src/pages/leagues/Leagues.tsx | 22 ++++-- 4 files changed, 121 insertions(+), 46 deletions(-) create mode 100644 frontend/src/components/context/ExtendableContextMenu.tsx diff --git a/frontend/src/components/api/DisplayResourceGeneric.tsx b/frontend/src/components/api/DisplayResourceGeneric.tsx index 69db1ee..b1622ae 100644 --- a/frontend/src/components/api/DisplayResourceGeneric.tsx +++ b/frontend/src/components/api/DisplayResourceGeneric.tsx @@ -1,9 +1,9 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, type MouseEvent } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { useResource } from '../../hooks/load/useResource.ts' import ShowLoader from '../load/ShowLoader.tsx' -type Item = { +export type Item = { id: number name: string } @@ -12,6 +12,7 @@ type Props = { fetchFn: (page: number) => Promise path: (id: number) => string onAdd?: () => void + onContextMenu?: (e: MouseEvent, item: Item) => void } /** @@ -19,7 +20,7 @@ type Props = { * Handles its own data fetching via useResource. * Page number is persisted in URL search params. */ -export default function DisplayResourceGeneric({ fetchFn, path, onAdd }: Props) { +export default function DisplayResourceGeneric({ fetchFn, path, onAdd, onContextMenu }: Props) { const [searchParams, setSearchParams] = useSearchParams() const pageNum = parseInt(searchParams.get('page') ?? '1') const setPageNum = (page: number) => setSearchParams({ page: String(page) }) @@ -40,19 +41,22 @@ export default function DisplayResourceGeneric({ fetchFn, path, onAdd }: Props) {ShowLoader(isLoading,
Loading...
)} {!isLoading && items.map((item: Item) => ( - ))} {pageNum === totalPages && onAdd && }
- {pageNum > 1 && } + {pageNum > 1 && } {pageNum > 1 && } Page {pageNum} of {totalPages} {pageNum < totalPages && } - {pageNum < totalPages && } + {pageNum < totalPages && }
) diff --git a/frontend/src/components/context/ExtendableContextMenu.tsx b/frontend/src/components/context/ExtendableContextMenu.tsx new file mode 100644 index 0000000..24bd24a --- /dev/null +++ b/frontend/src/components/context/ExtendableContextMenu.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react' + +type ContextMenuField = + | { type: 'text'; label: string; onClick?: () => void } + | { type: 'input'; label: string; onSubmit: (value: string) => void; placeholder?: string } + +type Props = { + x: number + y: number + title: string + fields: ContextMenuField[] +} + +/** + * Generic context menu component. + * Supports text labels and input fields. + */ +export default function ContextMenu({ x, y, title, fields }: Props) { + return ( +
e.stopPropagation()} + style={{ + position: 'fixed', + top: y, + left: x, + background: 'white', + border: '1px solid black', + padding: '8px', + zIndex: 9999, + }} + > + {title} +
+ {fields.map((field, i) => { + if (field.type === 'text') { + return ( +
+ +
+ ) + } + + if (field.type === 'input') { + return ( + + ) + } + })} +
+
+ ) +} + +// isolated so each input field has its own state +function InputField({ field }: { field: Extract }) { + const [value, setValue] = useState('') + + return ( +
+ + setValue(e.target.value)} + /> + +
+ ) +} diff --git a/frontend/src/components/leagues/LeagueContextMenu.tsx b/frontend/src/components/leagues/LeagueContextMenu.tsx index 97b7010..e83eb8d 100644 --- a/frontend/src/components/leagues/LeagueContextMenu.tsx +++ b/frontend/src/components/leagues/LeagueContextMenu.tsx @@ -1,38 +1,25 @@ -/** - * Right-click context menu for leagues - * - * @param x horiz pos - * @param y vert pos - */ -export default function LeagueContextMenu({ x, y }: { x: number; y: number }) { +import ContextMenu from '../context/ExtendableContextMenu.tsx' + +type Props = { + x: number + y: number + onRename: (name: string) => void | Promise +} + +export default function LeagueContextMenu({ x, y, onRename }: Props) { return ( -
-
    -
  • -

    hard coded for now:

    -
  • -
  • - -
  • -
  • - -
  • -
-
+ ) } diff --git a/frontend/src/pages/leagues/Leagues.tsx b/frontend/src/pages/leagues/Leagues.tsx index decf76c..153387f 100644 --- a/frontend/src/pages/leagues/Leagues.tsx +++ b/frontend/src/pages/leagues/Leagues.tsx @@ -1,10 +1,10 @@ -import { useState } from 'react' +import { useState, type MouseEvent as ReactMouseEvent } from 'react' import LeagueContextMenu from '../../components/leagues/LeagueContextMenu.tsx' import { createPortal } from 'react-dom' import LeagueCreationModal from '../../components/leagues/LeagueCreationModal.tsx' import { isNameUnique } from '../../helpers/checkUniqueness.ts' import { createNewResource } from '../../helpers/manageResource/createNewResource.ts' -import DisplayResourceGeneric from '../../components/api/DisplayResourceGeneric.tsx' +import DisplayResourceGeneric, { type Item } from '../../components/api/DisplayResourceGeneric.tsx' import fetchLeaguesIndex from '../../helpers/leagues/fetchLeaguesIndex.ts' import { useSearchParams } from 'react-router-dom' @@ -16,6 +16,7 @@ import { useSearchParams } from 'react-router-dom' export default function Leagues() { const [isLeagueContextMenuVisible, setIsLeagueContextMenuVisible] = useState(false) const [isLeagueCreationMenuVisible, setIsLeagueCreationMenuVisible] = useState(false) + const [contextMenuItem, setContextMenuItem] = useState(null) const [leagueContextMenuPosition, setLeagueContextMenuPosition] = useState<{ x: number y: number @@ -26,10 +27,17 @@ export default function Leagues() { const portalRoot = document.getElementById('portal-root') // right-click context menu for editing leagues - const showLeagueContextMenu = (c: any) => { + const showLeagueContextMenu = (c: ReactMouseEvent, item: Item) => { c.preventDefault() setIsLeagueContextMenuVisible(true) setLeagueContextMenuPosition({ x: c.pageX, y: c.pageY }) + setContextMenuItem(item) + } + + // rename the league via api - todo: wire up to actual api call + const renameLeague = async (name: string) => { + console.log(`renaming league ${contextMenuItem?.id} to ${name}`) + setReloadKey(k => k * -1) } const hideLeagueContextMenu = (c: any) => { @@ -38,14 +46,17 @@ export default function Leagues() { } return ( -
+
{/*right-click menu for editing leagues - todo*/} - {isLeagueContextMenuVisible && createPortal( , portalRoot ?? document.body )} @@ -79,6 +90,7 @@ export default function Leagues() { fetchFn={fetchLeaguesIndex} path={id => `/League/${id}`} onAdd={() => setIsLeagueCreationMenuVisible(true)} + onContextMenu={(e, item) => showLeagueContextMenu(e, item)} />
) From 73847502575f06300d7284805838cb34de31f794 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Fri, 27 Mar 2026 00:49:50 +0000 Subject: [PATCH 09/36] improving validation and fields in league modal and context menu --- .../context/ExtendableContextMenu.tsx | 87 ++++++++++++++----- .../components/leagues/LeagueContextMenu.tsx | 3 + .../src/components/modals/CreationModal.tsx | 50 +++++++++-- .../helpers/leagues/fetchLeaguesSpecific.ts | 16 ++-- frontend/src/helpers/leagues/renameLeague.ts | 28 ++++++ .../{ => validation}/checkUniqueness.ts | 6 +- frontend/src/pages/leagues/League.tsx | 1 - frontend/src/pages/leagues/Leagues.tsx | 8 +- 8 files changed, 155 insertions(+), 44 deletions(-) create mode 100644 frontend/src/helpers/leagues/renameLeague.ts rename frontend/src/helpers/{ => validation}/checkUniqueness.ts (74%) diff --git a/frontend/src/components/context/ExtendableContextMenu.tsx b/frontend/src/components/context/ExtendableContextMenu.tsx index 24bd24a..b5d100e 100644 --- a/frontend/src/components/context/ExtendableContextMenu.tsx +++ b/frontend/src/components/context/ExtendableContextMenu.tsx @@ -2,7 +2,14 @@ import { useState } from 'react' type ContextMenuField = | { type: 'text'; label: string; onClick?: () => void } - | { type: 'input'; label: string; onSubmit: (value: string) => void; placeholder?: string } + | { + type: 'input' + label: string + onSubmit: (value: string) => void + placeholder?: string + validate?: (value: string) => Promise + validationError?: string + } type Props = { x: number @@ -14,8 +21,47 @@ type Props = { /** * Generic context menu component. * Supports text labels and input fields. + * Submit button is shared across all fields and sits at the bottom. */ export default function ContextMenu({ x, y, title, fields }: Props) { + const [values, setValues] = useState>({}) + const [validating, setValidating] = useState>({}) + const [valid, setValid] = useState>({}) + const [errors, setErrors] = useState>({}) + + const handleChange = async ( + i: number, + field: Extract, + val: string + ) => { + setValues(prev => ({ ...prev, [i]: val })) + setErrors(prev => ({ ...prev, [i]: '' })) + setValid(prev => ({ ...prev, [i]: null })) + + if (field.validate && val.length > 0) { + setValidating(prev => ({ ...prev, [i]: true })) + const isValid = await field.validate(val) + setValid(prev => ({ ...prev, [i]: isValid })) + if (!isValid) setErrors(prev => ({ ...prev, [i]: field.validationError ?? 'Invalid value' })) + setValidating(prev => ({ ...prev, [i]: false })) + } + } + + const handleSubmit = () => { + fields.forEach((field, i) => { + if (field.type === 'input' && values[i] !== undefined) { + field.onSubmit(values[i]) + } + }) + } + + const isAnyValidating = Object.values(validating).some(v => v) + const isAnyInvalid = Object.values(valid).some(v => v === false) + const isAnyPending = fields.some( + (field, i) => + field.type === 'input' && field.validate && values[i]?.length > 0 && valid[i] === null + ) + return (
e.stopPropagation()} @@ -42,31 +88,30 @@ export default function ContextMenu({ x, y, title, fields }: Props) { if (field.type === 'input') { return ( - +
+ + handleChange(i, field, e.target.value)} + /> + {errors[i] && {errors[i]}} +
) } })}
-
- ) -} - -// isolated so each input field has its own state -function InputField({ field }: { field: Extract }) { - const [value, setValue] = useState('') - return ( -
- - setValue(e.target.value)} - /> - + {/* shared submit button at the bottom */} + {fields.some(f => f.type === 'input') && ( + + )}
) } diff --git a/frontend/src/components/leagues/LeagueContextMenu.tsx b/frontend/src/components/leagues/LeagueContextMenu.tsx index e83eb8d..0c7dca8 100644 --- a/frontend/src/components/leagues/LeagueContextMenu.tsx +++ b/frontend/src/components/leagues/LeagueContextMenu.tsx @@ -1,4 +1,5 @@ import ContextMenu from '../context/ExtendableContextMenu.tsx' +import { isNameUnique } from '../../helpers/validation/checkUniqueness.ts' type Props = { x: number @@ -18,6 +19,8 @@ export default function LeagueContextMenu({ x, y, onRename }: Props) { label: 'Rename', placeholder: 'New name...', onSubmit: onRename, + validate: value => isNameUnique('categories', value), + validationError: 'Name already taken', }, ]} /> diff --git a/frontend/src/components/modals/CreationModal.tsx b/frontend/src/components/modals/CreationModal.tsx index 4517ce9..4d6e05d 100644 --- a/frontend/src/components/modals/CreationModal.tsx +++ b/frontend/src/components/modals/CreationModal.tsx @@ -49,33 +49,55 @@ export default function CreationModal({ }) return initial } + const [formData, setFormData] = useState>(setFormsBlank()) - const [error, setError] = useState('') + const [errors, setErrors] = useState>({}) + const [submitError, setSubmitError] = useState('') + const [isValidating, setIsValidating] = useState(false) const portalRoot = document.getElementById('portal-root') - const handleChange = (fieldName: string, value: string) => { + const handleChange = async (fieldName: string, value: string) => { setFormData(prev => ({ ...prev, [fieldName]: value })) + + const field = fields.find(f => f.name === fieldName) + if (!field) return + + // clear error on change + setErrors(prev => ({ ...prev, [fieldName]: '' })) + + // validate unique mid-typing + if (field.unique && field.validateUnique && value.length > 0) { + setIsValidating(true) + const unique = await field.validateUnique(tableName, value) + if (!unique) { + setErrors(prev => ({ ...prev, [fieldName]: `${field.placeholder} must be unique` })) + } + setIsValidating(false) + } } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() for (const field of fields) { - // Validate required fields + // validate required fields if (field.required && formData[field.name].trim() === '') { - setError(`${field.placeholder} cannot be empty`) + setSubmitError(`${field.placeholder} cannot be empty`) return } - // validate unique fields + // validate unique fields on submit as fallback if (field.unique && field.validateUnique) { if (!(await field.validateUnique(tableName, formData[field.name]))) { - setError(`${field.placeholder} must be unique`) + setErrors(prev => ({ ...prev, [field.name]: `${field.placeholder} must be unique` })) return } } } + // block submit if any field errors exist + if (Object.values(errors).some(e => e !== '')) return + await onSubmit({ tableName, data: formData }) await onReload?.() pipeCancel() // close modal after successful submission (must be changed if onCancel is ever not just `closing window`) @@ -85,7 +107,8 @@ export default function CreationModal({ // clear all fields then run onCancel callback function pipeCancel(): void { setFormData(setFormsBlank()) - setError('') + setErrors({}) + setSubmitError('') onCancel() } @@ -133,9 +156,18 @@ export default function CreationModal({ placeholder={field.placeholder} autoFocus={index === 0} /> + {/* per-field validation error */} + {errors[field.name] && ( + {errors[field.name]} + )}
))} - +
, portalRoot ?? document.body diff --git a/frontend/src/helpers/leagues/fetchLeaguesSpecific.ts b/frontend/src/helpers/leagues/fetchLeaguesSpecific.ts index 2e994aa..7adeed3 100644 --- a/frontend/src/helpers/leagues/fetchLeaguesSpecific.ts +++ b/frontend/src/helpers/leagues/fetchLeaguesSpecific.ts @@ -1,9 +1,15 @@ import { BASE_URL } from '../exports/exportEnv.ts' +import { throwError } from '../exports/exportError.ts' export default async function fetchLeaguesSpecific(id: number) { - return await ( - await fetch(`${BASE_URL}/categories/view?id=${id}`, { - credentials: 'include', - }) - ).json() + const response = await fetch(`${BASE_URL}/categories/view?id=${id}`, { + credentials: 'include', + }).catch(err => { + throwError(`Failed to load leagues: ${err.message}`) + return null + }) + + if (!response) return null + + return await response.json() } diff --git a/frontend/src/helpers/leagues/renameLeague.ts b/frontend/src/helpers/leagues/renameLeague.ts new file mode 100644 index 0000000..e764a0f --- /dev/null +++ b/frontend/src/helpers/leagues/renameLeague.ts @@ -0,0 +1,28 @@ +import { BASE_URL } from '../exports/exportEnv.ts' +import { throwError } from '../exports/exportError.ts' +import { isNameUnique } from '../validation/checkUniqueness.ts' + +export default async function renameLeague(id: number, name: string) { + // fallback validation before hitting the api + const unique = await isNameUnique('categories', name) + if (!unique) { + throwError('League name must be unique') + return null + } + + const response = await fetch(`${BASE_URL}/categories/update?id=${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ name }), + }).catch(err => { + throwError(`Failed to rename league: ${err.message}`) + return null + }) + + if (!response) return null + + return await response.json() +} diff --git a/frontend/src/helpers/checkUniqueness.ts b/frontend/src/helpers/validation/checkUniqueness.ts similarity index 74% rename from frontend/src/helpers/checkUniqueness.ts rename to frontend/src/helpers/validation/checkUniqueness.ts index 25a83ad..edf2671 100644 --- a/frontend/src/helpers/checkUniqueness.ts +++ b/frontend/src/helpers/validation/checkUniqueness.ts @@ -1,9 +1,7 @@ // check if given name is unique to this user for specified table -import { startLoading, stopLoading } from './exports/exportLoading.ts' -import { BASE_URL } from './exports/exportEnv.ts' +import { BASE_URL } from '../exports/exportEnv.ts' export const isNameUnique = async (tableName: string, fieldName: string): Promise => { - startLoading() try { const response = await fetch( `${BASE_URL}/${tableName}/check-unique?name=${encodeURIComponent(fieldName)}`, @@ -15,7 +13,5 @@ export const isNameUnique = async (tableName: string, fieldName: string): Promis } catch (err) { console.error('Error checking league name:', err) return false // default to not unique on error - } finally { - stopLoading() } } diff --git a/frontend/src/pages/leagues/League.tsx b/frontend/src/pages/leagues/League.tsx index e927d76..4e801c0 100644 --- a/frontend/src/pages/leagues/League.tsx +++ b/frontend/src/pages/leagues/League.tsx @@ -21,7 +21,6 @@ export default function Leagues() { const leagueJson = await fetchLeaguesSpecific(parseInt(categoryId)) if (!Object.hasOwn(leagueJson, 'user_id')) return throwError('No league found') setLeague(leagueJson) - console.table(leagueJson) stopLoading() }, [categoryId]) diff --git a/frontend/src/pages/leagues/Leagues.tsx b/frontend/src/pages/leagues/Leagues.tsx index 153387f..e7210cb 100644 --- a/frontend/src/pages/leagues/Leagues.tsx +++ b/frontend/src/pages/leagues/Leagues.tsx @@ -2,11 +2,12 @@ import { useState, type MouseEvent as ReactMouseEvent } from 'react' import LeagueContextMenu from '../../components/leagues/LeagueContextMenu.tsx' import { createPortal } from 'react-dom' import LeagueCreationModal from '../../components/leagues/LeagueCreationModal.tsx' -import { isNameUnique } from '../../helpers/checkUniqueness.ts' +import { isNameUnique } from '../../helpers/validation/checkUniqueness.ts' import { createNewResource } from '../../helpers/manageResource/createNewResource.ts' import DisplayResourceGeneric, { type Item } from '../../components/api/DisplayResourceGeneric.tsx' import fetchLeaguesIndex from '../../helpers/leagues/fetchLeaguesIndex.ts' import { useSearchParams } from 'react-router-dom' +import renameLeagueApi from '../../helpers/leagues/renameLeague.ts' /** * Home page for leagues. @@ -34,9 +35,10 @@ export default function Leagues() { setContextMenuItem(item) } - // rename the league via api - todo: wire up to actual api call + // rename the league via api const renameLeague = async (name: string) => { - console.log(`renaming league ${contextMenuItem?.id} to ${name}`) + if (!contextMenuItem) return + await renameLeagueApi(contextMenuItem.id, name) setReloadKey(k => k * -1) } From d04ea3160d6bd4be1e558048ae75f3429235f479 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Tue, 7 Apr 2026 23:26:13 +0100 Subject: [PATCH 10/36] Enhance loading state management to handle undefined actions gracefully. --- frontend/src/redux/loadingSlice.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/redux/loadingSlice.ts b/frontend/src/redux/loadingSlice.ts index d6f8ebd..17c35c5 100644 --- a/frontend/src/redux/loadingSlice.ts +++ b/frontend/src/redux/loadingSlice.ts @@ -7,9 +7,9 @@ export const loadingSlice = createSlice({ initialState: { loading: false, opaque: false }, reducers: { - startLoad(state, action: PayloadAction<{ opaque?: boolean }>) { + startLoad(state, action: PayloadAction<{ opaque?: boolean }> | undefined) { state.loading = true - state.opaque = action.payload.opaque ?? false + state.opaque = action?.payload?.opaque ?? false }, stopLoad(state) { state.loading = false From 6fe891980e9f7310c1ce0e565286b8176347b4d6 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 8 Apr 2026 01:39:20 +0100 Subject: [PATCH 11/36] added light / dark themes and a page at /test/theme to view themes --- frontend/src/App.tsx | 3 + frontend/src/pages/test/ThemeTest.tsx | 270 ++++++++++++++++++++++++++ frontend/src/styles/theme.css | 51 +++++ 3 files changed, 324 insertions(+) create mode 100644 frontend/src/pages/test/ThemeTest.tsx create mode 100644 frontend/src/styles/theme.css diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cade329..3759245 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import { AuthProvider } from './providers/AuthProvider.tsx' import { ProtectedRoutes } from './components/ProtectedRoutes.tsx' import { LoadingProvider } from './providers/LoadingProvider.tsx' import { ErrorProvider } from './providers/ErrorProvider.tsx' +import ThemeTest from './pages/test/ThemeTest.tsx' function App() { return ( @@ -32,6 +33,8 @@ function App() { {/* unprotected routes */} {/* login / signup */} } /> - login page + {/* DEV TESTING todo: remove these */} + } /> - theme test page {/* protected routes */} }> {/* home routes */} diff --git a/frontend/src/pages/test/ThemeTest.tsx b/frontend/src/pages/test/ThemeTest.tsx new file mode 100644 index 0000000..3a261bd --- /dev/null +++ b/frontend/src/pages/test/ThemeTest.tsx @@ -0,0 +1,270 @@ +import { useState } from 'react' + +/** + * Theme test page - visual reference for all CSS variables in theme.css + * Visit /Test to see this page + */ +export default function ThemeTest() { + const [dark, setDark] = useState(false) + + // inject theme override onto root + const theme = dark + ? { + '--color-bg': '#121212', + '--color-bg-secondary': '#1e1e1e', + '--color-bg-card': '#1e1e1e', + '--color-accent': '#e74c3c', + '--color-accent-hover': '#c0392b', + '--color-accent-muted': 'rgba(231,76,60,0.15)', + '--color-text': '#f0f0f0', + '--color-text-muted': '#999999', + '--color-text-inverse': '#ffffff', + '--color-border': '#2e2e2e', + '--color-border-strong': '#444444', + '--color-shadow': 'rgba(0,0,0,0.3)', + '--color-shadow-strong': 'rgba(0,0,0,0.6)', + '--color-error': '#e74c3c', + '--color-success': '#2ecc71', + '--color-disabled': '#555555', + } + : { + '--color-bg': '#ffffff', + '--color-bg-secondary': '#f2f2f2', + '--color-bg-card': '#f8f8f8', + '--color-accent': '#c0392b', + '--color-accent-hover': '#a93226', + '--color-accent-muted': 'rgba(192,57,43,0.15)', + '--color-text': '#1a1a1a', + '--color-text-muted': '#666666', + '--color-text-inverse': '#ffffff', + '--color-border': '#dddddd', + '--color-border-strong': '#bbbbbb', + '--color-shadow': 'rgba(0,0,0,0.08)', + '--color-shadow-strong': 'rgba(0,0,0,0.2)', + '--color-error': '#c0392b', + '--color-success': '#27ae60', + '--color-disabled': '#aaaaaa', + } + + return ( +
+ {/* ── Toggle ── */} +
+ +
+ + {/* ── League cards ── */} +

League cards

+
+ {['Kanto League', 'Johto League', 'Hoenn League'].map((name, i) => ( +
+ {/* pokeball */} + pokeball +
+
+ {name} +
+
+ {i + 3} trainers +
+
+
+ + Active + + + Page {i + 1} + +
+
+ ))} + + {/* add card */} +
+ + +
+
+ + {/* ── Trainer card ── */} +

Trainer card

+
+
+
+ A +
+
+
+ Ash Ketchum +
+
Pallet Town
+
+ pokeball +
+
+ {[ + ['Badges', '8'], + ['Wins', '42'], + ['Losses', '7'], + ].map(([label, val]) => ( +
+ {label} + {val} +
+ ))} +
+ +
+ + {/* ── Error / success states ── */} +

States

+
+
+ Something went wrong — please try again +
+
+ League created successfully +
+
+
+ ) +} diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css new file mode 100644 index 0000000..4a55cf6 --- /dev/null +++ b/frontend/src/styles/theme.css @@ -0,0 +1,51 @@ +/* ─── Dark Mode (default) ────────────────────────────────── */ +:root { + --color-bg: #121212; + --color-bg-secondary: #1e1e1e; + --color-bg-card: #1e1e1e; + --color-bg-overlay: rgba(0, 0, 0, 0.7); + + --color-accent: #e74c3c; + --color-accent-hover: #c0392b; + --color-accent-muted: rgba(231, 76, 60, 0.15); + + --color-text: #f0f0f0; + --color-text-muted: #999999; + --color-text-inverse: #ffffff; + + --color-border: #2e2e2e; + --color-border-strong:#444444; + + --color-shadow: rgba(0, 0, 0, 0.3); + --color-shadow-strong:rgba(0, 0, 0, 0.6); + + --color-error: #e74c3c; + --color-success: #2ecc71; + --color-disabled: #555555; +} + +/* ─── Light Mode (opt-in via .light class on ) ─────── */ +:root.light { + --color-bg: #ffffff; + --color-bg-secondary: #f8f8f8; + --color-bg-card: #f8f8f8; + --color-bg-overlay: rgba(0, 0, 0, 0.5); + + --color-accent: #c0392b; + --color-accent-hover: #a93226; + --color-accent-muted: rgba(192, 57, 43, 0.15); + + --color-text: #1a1a1a; + --color-text-muted: #666666; + --color-text-inverse: #ffffff; + + --color-border: #dddddd; + --color-border-strong:#bbbbbb; + + --color-shadow: rgba(0, 0, 0, 0.08); + --color-shadow-strong:rgba(0, 0, 0, 0.2); + + --color-error: #c0392b; + --color-success: #27ae60; + --color-disabled: #aaaaaa; +} From f5d0d6627171e4b0d16a714d185b7b5aceccdc12 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 8 Apr 2026 02:05:04 +0100 Subject: [PATCH 12/36] basic tailwind setup with custom themes --- frontend/package-lock.json | 665 +++++++++++++++++++++++++++++++++- frontend/package.json | 3 + frontend/postcss.config.js | 6 + frontend/src/Main.tsx | 8 + frontend/src/index.css | 71 +--- frontend/src/styles/theme.css | 10 + frontend/tailwind.config.ts | 39 ++ 7 files changed, 731 insertions(+), 71 deletions(-) create mode 100644 frontend/postcss.config.js create mode 100644 frontend/tailwind.config.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index da84532..7d9fba5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,16 +24,32 @@ "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react-swc": "^4.1.0", + "autoprefixer": "^10.4.27", "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "postcss": "^8.5.9", "prettier": "^3.6.2", + "tailwindcss": "^3.4.19", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "npm:rolldown-vite@7.1.14" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1639,6 +1655,34 @@ "node": ">=14" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1646,6 +1690,43 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -1678,6 +1759,32 @@ "dev": true, "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1702,6 +1809,40 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1711,6 +1852,37 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001786", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", + "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -1778,6 +1950,44 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1808,6 +2018,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1870,6 +2090,19 @@ "node": ">= 8" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -1945,6 +2178,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -1955,6 +2202,13 @@ "csstype": "^3.0.2" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.332", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.332.tgz", + "integrity": "sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ==", + "dev": true, + "license": "ISC" + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -1976,6 +2230,16 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2312,6 +2576,20 @@ "dev": true, "license": "ISC" }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2648,6 +2926,19 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -2735,6 +3026,16 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3078,6 +3379,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -3773,6 +4087,18 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3799,6 +4125,23 @@ "dev": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3808,6 +4151,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3979,10 +4332,30 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", "dev": true, "funding": [ { @@ -4008,6 +4381,140 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4238,6 +4745,29 @@ "react-dom": ">=16.6.0" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -4540,6 +5070,29 @@ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "license": "MIT" }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4565,6 +5118,67 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4659,6 +5273,13 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4812,6 +5433,37 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4845,6 +5497,13 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index d436787..bc0f654 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,11 +26,14 @@ "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react-swc": "^4.1.0", + "autoprefixer": "^10.4.27", "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "postcss": "^8.5.9", "prettier": "^3.6.2", + "tailwindcss": "^3.4.19", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "npm:rolldown-vite@7.1.14" diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/Main.tsx b/frontend/src/Main.tsx index 4a8cd6b..da25543 100644 --- a/frontend/src/Main.tsx +++ b/frontend/src/Main.tsx @@ -1,10 +1,18 @@ import React from 'react' +import './index.css' +import './styles/theme.css' import ReactDOM from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.tsx' import { store } from './redux/store.ts' import { Provider } from 'react-redux' +const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches +// only apply light if system explicitly prefers it +if (!prefersDark) { + document.documentElement.classList.add('light') +} + ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/index.css b/frontend/src/index.css index 08a3ac9..b5c61c9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,68 +1,3 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 4a55cf6..ad6ef98 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -1,3 +1,13 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + /* ─── Dark Mode (default) ────────────────────────────────── */ :root { --color-bg: #121212; diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..3e04556 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,39 @@ +import type { Config } from 'tailwindcss' + +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + darkMode: 'class', + corePlugins: { + preflight: false, + }, + theme: { + extend: { + colors: { + bg: { + DEFAULT: 'var(--color-bg)', + secondary: 'var(--color-bg-secondary)', + card: 'var(--color-bg-card)', + overlay: 'var(--color-bg-overlay)', + }, + accent: { + DEFAULT: 'var(--color-accent)', + hover: 'var(--color-accent-hover)', + muted: 'var(--color-accent-muted)', + }, + text: { + DEFAULT: 'var(--color-text)', + muted: 'var(--color-text-muted)', + inverse: 'var(--color-text-inverse)', + }, + border: { + DEFAULT: 'var(--color-border)', + strong: 'var(--color-border-strong)', + }, + error: 'var(--color-error)', + success: 'var(--color-success)', + disabled: 'var(--color-disabled)', + }, + }, + }, + plugins: [], +} satisfies Config From 4ae5607245a36d1b65230988dce919cf7cf3dd81 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 8 Apr 2026 02:05:16 +0100 Subject: [PATCH 13/36] refactored theme test page to use tailwind setup --- frontend/src/pages/test/ThemeTest.tsx | 232 ++++---------------------- 1 file changed, 37 insertions(+), 195 deletions(-) diff --git a/frontend/src/pages/test/ThemeTest.tsx b/frontend/src/pages/test/ThemeTest.tsx index 3a261bd..617130f 100644 --- a/frontend/src/pages/test/ThemeTest.tsx +++ b/frontend/src/pages/test/ThemeTest.tsx @@ -5,207 +5,76 @@ import { useState } from 'react' * Visit /Test to see this page */ export default function ThemeTest() { - const [dark, setDark] = useState(false) + const [dark, setDark] = useState(true) - // inject theme override onto root - const theme = dark - ? { - '--color-bg': '#121212', - '--color-bg-secondary': '#1e1e1e', - '--color-bg-card': '#1e1e1e', - '--color-accent': '#e74c3c', - '--color-accent-hover': '#c0392b', - '--color-accent-muted': 'rgba(231,76,60,0.15)', - '--color-text': '#f0f0f0', - '--color-text-muted': '#999999', - '--color-text-inverse': '#ffffff', - '--color-border': '#2e2e2e', - '--color-border-strong': '#444444', - '--color-shadow': 'rgba(0,0,0,0.3)', - '--color-shadow-strong': 'rgba(0,0,0,0.6)', - '--color-error': '#e74c3c', - '--color-success': '#2ecc71', - '--color-disabled': '#555555', - } - : { - '--color-bg': '#ffffff', - '--color-bg-secondary': '#f2f2f2', - '--color-bg-card': '#f8f8f8', - '--color-accent': '#c0392b', - '--color-accent-hover': '#a93226', - '--color-accent-muted': 'rgba(192,57,43,0.15)', - '--color-text': '#1a1a1a', - '--color-text-muted': '#666666', - '--color-text-inverse': '#ffffff', - '--color-border': '#dddddd', - '--color-border-strong': '#bbbbbb', - '--color-shadow': 'rgba(0,0,0,0.08)', - '--color-shadow-strong': 'rgba(0,0,0,0.2)', - '--color-error': '#c0392b', - '--color-success': '#27ae60', - '--color-disabled': '#aaaaaa', - } + // toggle dark/light class on + const toggleTheme = () => { + setDark(d => !d) + document.documentElement.classList.toggle('light') + } return ( -
+
{/* ── Toggle ── */} -
+
{/* ── League cards ── */} -

League cards

-
+

League cards

+
{['Kanto League', 'Johto League', 'Hoenn League'].map((name, i) => (
- {/* pokeball */} pokeball
-
- {name} -
-
- {i + 3} trainers -
+
{name}
+
{i + 3} trainers
-
- +
+ Active - - Page {i + 1} - + Page {i + 1}
))} {/* add card */} -
+
+
{/* ── Trainer card ── */} -

Trainer card

-
-
-
+

Trainer card

+
+
+
A
-
- Ash Ketchum -
-
Pallet Town
+
Ash Ketchum
+
Pallet Town
pokeball
-
+
{[ ['Badges', '8'], ['Wins', '42'], @@ -213,54 +82,27 @@ export default function ThemeTest() { ].map(([label, val]) => (
- {label} - {val} + {label} + {val}
))}
-
{/* ── Error / success states ── */} -

States

-
-
+

States

+
+
Something went wrong — please try again
League created successfully
From 3283dbbdc4d027325c84edc5fe262f6a6b8bbad4 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 8 Apr 2026 02:30:11 +0100 Subject: [PATCH 14/36] Add Pokeball component with light/dark theme support and SVG assets. --- .../{pokeballLoader.svg => pokeballDark.svg} | 0 frontend/public/pokeballLight.svg | 31 ++++++++++++++++++ frontend/src/components/Pokeball.tsx | 32 +++++++++++++++++++ 3 files changed, 63 insertions(+) rename frontend/public/{pokeballLoader.svg => pokeballDark.svg} (100%) create mode 100644 frontend/public/pokeballLight.svg create mode 100644 frontend/src/components/Pokeball.tsx diff --git a/frontend/public/pokeballLoader.svg b/frontend/public/pokeballDark.svg similarity index 100% rename from frontend/public/pokeballLoader.svg rename to frontend/public/pokeballDark.svg diff --git a/frontend/public/pokeballLight.svg b/frontend/public/pokeballLight.svg new file mode 100644 index 0000000..76cbef4 --- /dev/null +++ b/frontend/public/pokeballLight.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/frontend/src/components/Pokeball.tsx b/frontend/src/components/Pokeball.tsx new file mode 100644 index 0000000..c2a45d3 --- /dev/null +++ b/frontend/src/components/Pokeball.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react' + +type Props = { + className?: string + alt?: string +} + +/** + * Pokeball image that swaps between light/dark variants based on theme. + * Light variant is slightly scaled up to match dark variant's visual size. + */ +export default function Pokeball({ className, alt = 'pokeball' }: Props) { + const [isDark, setIsDark] = useState(!document.documentElement.classList.contains('light')) + + // watch for theme class changes on + useEffect(() => { + const observer = new MutationObserver(() => { + setIsDark(!document.documentElement.classList.contains('light')) + }) + observer.observe(document.documentElement, { attributeFilter: ['class'] }) + return () => observer.disconnect() + }, []) + + return ( + {alt} + ) +} From 3d70a8fa797c7fb5c5d4aba3ac225cae5400e930 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 8 Apr 2026 02:30:26 +0100 Subject: [PATCH 15/36] implemented new pokeball component --- frontend/src/components/load/GlobalLoader.tsx | 20 ++++--------------- frontend/src/pages/test/ThemeTest.tsx | 13 +++--------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/load/GlobalLoader.tsx b/frontend/src/components/load/GlobalLoader.tsx index 6aaf3b6..fb71710 100644 --- a/frontend/src/components/load/GlobalLoader.tsx +++ b/frontend/src/components/load/GlobalLoader.tsx @@ -1,3 +1,5 @@ +import Pokeball from '../Pokeball.tsx' + type Props = { opaque?: boolean } @@ -11,28 +13,14 @@ export default function GlobalLoader({ opaque = false }: Props) { left: 0, right: 0, bottom: 0, - backgroundColor: opaque ? '#fff' : 'rgba(0, 0, 0, 0.5)', + backgroundColor: opaque ? 'var(--color-bg)' : 'rgba(0, 0, 0, 0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, }} > - Loading... - +
) } diff --git a/frontend/src/pages/test/ThemeTest.tsx b/frontend/src/pages/test/ThemeTest.tsx index 617130f..c587a26 100644 --- a/frontend/src/pages/test/ThemeTest.tsx +++ b/frontend/src/pages/test/ThemeTest.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import Pokeball from '../../components/Pokeball.tsx' /** * Theme test page - visual reference for all CSS variables in theme.css @@ -33,11 +34,7 @@ export default function ThemeTest() { key={name} className="bg-bg-card border border-border rounded-lg p-5 min-w-[200px] flex flex-col gap-3 cursor-pointer" > - pokeball +
{name}
{i + 3} trainers
@@ -68,11 +65,7 @@ export default function ThemeTest() {
Ash Ketchum
Pallet Town
- pokeball +
{[ From dd7c0bd5bf7928d2f0d7fc66a3746f85b7d68fc1 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 8 Apr 2026 02:30:48 +0100 Subject: [PATCH 16/36] added link to /test/theme page on /test page --- frontend/src/pages/test/Test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/pages/test/Test.tsx b/frontend/src/pages/test/Test.tsx index cb8fc08..f95c2c8 100644 --- a/frontend/src/pages/test/Test.tsx +++ b/frontend/src/pages/test/Test.tsx @@ -1,8 +1,10 @@ import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import { BASE_URL } from '../../helpers/exports/exportEnv.ts' export default function Test() { const [health, setHealth] = useState('') + const navigate = useNavigate() async function healthCheck() { try { @@ -20,6 +22,8 @@ export default function Test() {
{health}
+ +
) } From 548b46b438a0a69b26d6a7e4a9261e5621c4dbb6 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 8 Apr 2026 23:34:01 +0100 Subject: [PATCH 17/36] added fixme.md as a todo list for fixes --- frontend/fixme.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 frontend/fixme.md diff --git a/frontend/fixme.md b/frontend/fixme.md new file mode 100644 index 0000000..7514b5f --- /dev/null +++ b/frontend/fixme.md @@ -0,0 +1,30 @@ +# Fix List (CodeRabbit Review) + +## Bugs — fix immediately + +- [ ] `League.tsx` — add null check before `Object.hasOwn(leagueJson, 'user_id')`, throws if fetch returns null +- [ ] `CreationModal.tsx` — race condition on `errors` state in `handleSubmit`, use local tracking variable instead of reading stale state +- [ ] `CreationModal.tsx` — `validateUnique` is passed `value` instead of `fieldName` as second argument +- [ ] `useResource.ts` — guard `response.items ?? []`, undefined will break rendering +- [ ] `Leagues.tsx` — rename reloads even if API call failed, check response before calling `setReloadKey` +- [ ] `DisplayResourceGeneric.tsx` — add button never shows when `totalPages === 0`, condition should be `totalPages === 0 || pageNum === totalPages` + +## Defensive — good practice + +- [ ] `fetchLeaguesIndex.ts` — add `response.ok` check, 4xx/5xx silently proceed to `.json()` +- [ ] `fetchLeaguesSpecific.ts` — same missing `response.ok` check +- [ ] `renameLeague.ts` — same missing `response.ok` check +- [ ] `errorSlice.ts` — replace `??` with `||` to catch empty string payloads +- [ ] `ProtectedRoutes.tsx` — add cleanup to `useEffect` so loader doesn't get stuck on unmount + +## Polish + +- [ ] `ExtendableContextMenu.tsx` — hardcoded `white`/`black` colours, swap for `var(--color-bg-card)` and `var(--color-border)` +- [ ] `ExtendableContextMenu.tsx` — tautological ternary `'Submit' : 'Submit'`, change to `'Validating...' : 'Submit'` +- [ ] `CreationModal.tsx` — tautological ternary `'Create' : 'Create'`, change to `'Validating...' : 'Create'` +- [ ] `ThemeTest.tsx` — initial dark state hardcoded to `true`, should read from DOM: `!document.documentElement.classList.contains('light')` + +## Skipped + +- `App.tsx` route comments — intentional, ignore +- `ErrorBanner.tsx` aria label — defer to accessibility pass From 813569d0e23012eadf853c1238097bc4a0a12422 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 8 Apr 2026 23:47:54 +0100 Subject: [PATCH 18/36] finally changed dash comments to real tsx comments --- frontend/src/App.tsx | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3759245..831c10a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,28 +32,28 @@ function App() { {/* unprotected routes */} {/* login / signup */} - } /> - login page + } />{/* login page */} {/* DEV TESTING todo: remove these */} - } /> - theme test page + } />{/* theme test page */} {/* protected routes */} }> {/* home routes */} - } /> - home page - } /> - test page + } />{/* home page */} + } />{/* test page */} {/* league routes */} - } /> - list of leagues - } /> - specific league + } />{/* list of leagues */} + } />{/* specific league */} {/* trainer routes */} - } /> - list of trainers in league - } /> - specific trainer + } />{/* list of trainers in league */} + } />{/* specific trainer */} {/* team routes */} - } /> - list of teams for a trainer - } /> - specific team + } />{/* list of teams for a trainer */} + } />{/* specific team */} {/* doc routes */} - } /> - list of docs - } /> - hierarchy for model creation - } /> - leagues information - } /> - trainers information + } />{/* list of docs */} + } />{/* hierarchy for model creation */} + } />{/* leagues information */} + } />{/* trainers information */}
From f36fa9973b2dc35438bbd7d760a2d65941952fce Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 8 Apr 2026 23:50:29 +0100 Subject: [PATCH 19/36] sanitised page num query param --- frontend/src/components/api/DisplayResourceGeneric.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/api/DisplayResourceGeneric.tsx b/frontend/src/components/api/DisplayResourceGeneric.tsx index b1622ae..49a8ddd 100644 --- a/frontend/src/components/api/DisplayResourceGeneric.tsx +++ b/frontend/src/components/api/DisplayResourceGeneric.tsx @@ -22,7 +22,7 @@ type Props = { */ export default function DisplayResourceGeneric({ fetchFn, path, onAdd, onContextMenu }: Props) { const [searchParams, setSearchParams] = useSearchParams() - const pageNum = parseInt(searchParams.get('page') ?? '1') + const pageNum = Math.max(1, parseInt(searchParams.get('page') ?? '1') || 1) const setPageNum = (page: number) => setSearchParams({ page: String(page) }) const { items, totalPages, load } = useResource(fetchFn, pageNum) From 33951bc313676f255333eb87f88446e67c24b5c4 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 8 Apr 2026 23:52:36 +0100 Subject: [PATCH 20/36] removed nested interactive elements in favour of just --- frontend/src/components/api/DisplayResourceGeneric.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/api/DisplayResourceGeneric.tsx b/frontend/src/components/api/DisplayResourceGeneric.tsx index 49a8ddd..02c30a1 100644 --- a/frontend/src/components/api/DisplayResourceGeneric.tsx +++ b/frontend/src/components/api/DisplayResourceGeneric.tsx @@ -41,12 +41,13 @@ export default function DisplayResourceGeneric({ fetchFn, path, onAdd, onContext {ShowLoader(isLoading,
Loading...
)} {!isLoading && items.map((item: Item) => ( - + {item.name} + ))} {pageNum === totalPages && onAdd && }
From 26744aa1354a403785139ca75a0b96c2b1ca3940 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 8 Apr 2026 23:55:52 +0100 Subject: [PATCH 21/36] Enhance form submission handling with loading state and async support. --- .../context/ExtendableContextMenu.tsx | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/context/ExtendableContextMenu.tsx b/frontend/src/components/context/ExtendableContextMenu.tsx index b5d100e..db65140 100644 --- a/frontend/src/components/context/ExtendableContextMenu.tsx +++ b/frontend/src/components/context/ExtendableContextMenu.tsx @@ -5,7 +5,7 @@ type ContextMenuField = | { type: 'input' label: string - onSubmit: (value: string) => void + onSubmit: (value: string) => void | Promise placeholder?: string validate?: (value: string) => Promise validationError?: string @@ -28,6 +28,7 @@ export default function ContextMenu({ x, y, title, fields }: Props) { const [validating, setValidating] = useState>({}) const [valid, setValid] = useState>({}) const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) const handleChange = async ( i: number, @@ -47,12 +48,19 @@ export default function ContextMenu({ x, y, title, fields }: Props) { } } - const handleSubmit = () => { - fields.forEach((field, i) => { - if (field.type === 'input' && values[i] !== undefined) { - field.onSubmit(values[i]) - } - }) + const handleSubmit = async () => { + setSubmitting(true) + try { + await Promise.all( + fields.map((field, i) => + field.type === 'input' && values[i] !== undefined + ? Promise.resolve(field.onSubmit(values[i])) + : Promise.resolve() + ) + ) + } finally { + setSubmitting(false) + } } const isAnyValidating = Object.values(validating).some(v => v) @@ -69,8 +77,8 @@ export default function ContextMenu({ x, y, title, fields }: Props) { position: 'fixed', top: y, left: x, - background: 'white', - border: '1px solid black', + background: 'var(--color-bg-card)', + border: '1px solid var(--color-border)', padding: '8px', zIndex: 9999, }} @@ -105,11 +113,11 @@ export default function ContextMenu({ x, y, title, fields }: Props) { {/* shared submit button at the bottom */} {fields.some(f => f.type === 'input') && ( )}
From 8618979e0d1abf16cb473e11aa07a584e78a852e Mon Sep 17 00:00:00 2001 From: Casper JB Date: Wed, 8 Apr 2026 23:58:14 +0100 Subject: [PATCH 22/36] added a div block to each link in DisplayResourceGeneric.tsx --- .../src/components/api/DisplayResourceGeneric.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/api/DisplayResourceGeneric.tsx b/frontend/src/components/api/DisplayResourceGeneric.tsx index 02c30a1..c113c81 100644 --- a/frontend/src/components/api/DisplayResourceGeneric.tsx +++ b/frontend/src/components/api/DisplayResourceGeneric.tsx @@ -41,13 +41,14 @@ export default function DisplayResourceGeneric({ fetchFn, path, onAdd, onContext {ShowLoader(isLoading,
Loading...
)} {!isLoading && items.map((item: Item) => ( - onContextMenu?.(e, item)} - > - {item.name} - +
+ onContextMenu?.(e, item)} + > + {item.name} + +
))} {pageNum === totalPages && onAdd && }
From ddfa1f9f5f7c1484b1a4b80aeecd8ecc8d1cc05a Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 9 Apr 2026 00:07:00 +0100 Subject: [PATCH 23/36] Refactor league loading logic to enhance error handling and clarity. --- frontend/src/pages/leagues/League.tsx | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/leagues/League.tsx b/frontend/src/pages/leagues/League.tsx index 4e801c0..7cfe53d 100644 --- a/frontend/src/pages/leagues/League.tsx +++ b/frontend/src/pages/leagues/League.tsx @@ -16,12 +16,25 @@ export default function Leagues() { const [league, setLeague] = useState<{ id: number; name: string }>() const loadLeague = useCallback(async () => { + if (!categoryId) { + throwError('No league id provided') + return + } + startLoading(true) - if (!categoryId) return throwError('No league id provided') - const leagueJson = await fetchLeaguesSpecific(parseInt(categoryId)) - if (!Object.hasOwn(leagueJson, 'user_id')) return throwError('No league found') - setLeague(leagueJson) - stopLoading() + + try { + const leagueJson = await fetchLeaguesSpecific(parseInt(categoryId)) + + if (!leagueJson || !Object.hasOwn(leagueJson, 'user_id')) { + throwError('No league found') + return + } + + setLeague(leagueJson) + } finally { + stopLoading() + } }, [categoryId]) useEffect(() => { From 37d95682fef55c4573b2fdc52db6904fb3a872fb Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 9 Apr 2026 00:09:25 +0100 Subject: [PATCH 24/36] Update dark mode state initialization based on current class list. --- frontend/src/pages/test/ThemeTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/test/ThemeTest.tsx b/frontend/src/pages/test/ThemeTest.tsx index c587a26..662eb32 100644 --- a/frontend/src/pages/test/ThemeTest.tsx +++ b/frontend/src/pages/test/ThemeTest.tsx @@ -6,7 +6,7 @@ import Pokeball from '../../components/Pokeball.tsx' * Visit /Test to see this page */ export default function ThemeTest() { - const [dark, setDark] = useState(true) + const [dark, setDark] = useState(!document.documentElement.classList.contains('light')) // toggle dark/light class on const toggleTheme = () => { From 435869507ba7c473f89c12075ccf1f5f7e43b706 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 9 Apr 2026 00:11:48 +0100 Subject: [PATCH 25/36] Update league renaming logic to conditionally reload on success. --- frontend/src/pages/leagues/Leagues.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/leagues/Leagues.tsx b/frontend/src/pages/leagues/Leagues.tsx index e7210cb..a3163f7 100644 --- a/frontend/src/pages/leagues/Leagues.tsx +++ b/frontend/src/pages/leagues/Leagues.tsx @@ -38,8 +38,8 @@ export default function Leagues() { // rename the league via api const renameLeague = async (name: string) => { if (!contextMenuItem) return - await renameLeagueApi(contextMenuItem.id, name) - setReloadKey(k => k * -1) + const result = await renameLeagueApi(contextMenuItem.id, name) + if (result) setReloadKey(k => k * -1) } const hideLeagueContextMenu = (c: any) => { From 388fae124d86241915fcf19f751f6ca38a3194e1 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 9 Apr 2026 00:13:40 +0100 Subject: [PATCH 26/36] Add useRef to track latest input values for async validation. --- .../context/ExtendableContextMenu.tsx | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/context/ExtendableContextMenu.tsx b/frontend/src/components/context/ExtendableContextMenu.tsx index db65140..faa1bd1 100644 --- a/frontend/src/components/context/ExtendableContextMenu.tsx +++ b/frontend/src/components/context/ExtendableContextMenu.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' type ContextMenuField = | { type: 'text'; label: string; onClick?: () => void } @@ -30,21 +30,39 @@ export default function ContextMenu({ x, y, title, fields }: Props) { const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) + // track latest value per field to discard stale async validation responses + const latestValues = useRef>({}) + const handleChange = async ( i: number, field: Extract, val: string ) => { + latestValues.current[i] = val setValues(prev => ({ ...prev, [i]: val })) setErrors(prev => ({ ...prev, [i]: '' })) setValid(prev => ({ ...prev, [i]: null })) if (field.validate && val.length > 0) { setValidating(prev => ({ ...prev, [i]: true })) - const isValid = await field.validate(val) - setValid(prev => ({ ...prev, [i]: isValid })) - if (!isValid) setErrors(prev => ({ ...prev, [i]: field.validationError ?? 'Invalid value' })) - setValidating(prev => ({ ...prev, [i]: false })) + try { + const isValid = await field.validate(val) + // discard if a newer value has been typed since this validation started + if (latestValues.current[i] !== val) return + setValid(prev => ({ ...prev, [i]: isValid })) + if (!isValid) + setErrors(prev => ({ ...prev, [i]: field.validationError ?? 'Invalid value' })) + } catch { + // treat validator errors as invalid to be safe + if (latestValues.current[i] === val) { + setValid(prev => ({ ...prev, [i]: false })) + setErrors(prev => ({ ...prev, [i]: 'Validation failed' })) + } + } finally { + if (latestValues.current[i] === val) { + setValidating(prev => ({ ...prev, [i]: false })) + } + } } } From 4d9ccb1e83cd40808e44d78fc769ef55e48695c0 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 9 Apr 2026 00:15:14 +0100 Subject: [PATCH 27/36] Enhance uniqueness check error logging for clarity and accuracy. --- frontend/src/helpers/validation/checkUniqueness.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/helpers/validation/checkUniqueness.ts b/frontend/src/helpers/validation/checkUniqueness.ts index edf2671..74ebe46 100644 --- a/frontend/src/helpers/validation/checkUniqueness.ts +++ b/frontend/src/helpers/validation/checkUniqueness.ts @@ -9,9 +9,12 @@ export const isNameUnique = async (tableName: string, fieldName: string): Promis credentials: 'include', } ) + + if (!response.ok) return false + return await response.json() } catch (err) { - console.error('Error checking league name:', err) + console.error('Error checking name uniqueness:', err) return false // default to not unique on error } } From 1e4d9106ca3c2af652f9efe14494628d374f95f8 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 9 Apr 2026 00:20:21 +0100 Subject: [PATCH 28/36] Refactor resource creation to improve error handling and loading states. --- .../manageResource/createNewResource.ts | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/frontend/src/helpers/manageResource/createNewResource.ts b/frontend/src/helpers/manageResource/createNewResource.ts index e6ee39e..fa5f59d 100644 --- a/frontend/src/helpers/manageResource/createNewResource.ts +++ b/frontend/src/helpers/manageResource/createNewResource.ts @@ -1,4 +1,6 @@ import { BASE_URL } from '../exports/exportEnv.ts' +import { throwError } from '../exports/exportError.ts' +import { startLoading, stopLoading } from '../exports/exportLoading.ts' export interface ResourceCreation { tableName: string @@ -9,30 +11,37 @@ export interface ResourceCreation { /** * Create a new resource, then reload the resource list and run any other callbacks. * - * takes a Record and callback parameters - * + * @param tableName * @param data - * @param reloadResource + * @param onReload */ export const createNewResource = async ({ tableName, data, onReload }: ResourceCreation) => { - await fetch(`${BASE_URL}/${tableName}/create`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: data.name, - }), - credentials: 'include', - }) - .then(res => res.json()) - .then(data => { - console.log(data) + startLoading() + + try { + const response = await fetch(`${BASE_URL}/${tableName}/create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: data.name, + }), + credentials: 'include', }) - .catch(err => console.log(err)) - // reload data if callback is provided - if (onReload) { - await onReload() + if (!response.ok) { + throwError(`Failed to create resource: ${response.status}`) + return + } + + // only reload on confirmed success + if (onReload) { + await onReload() + } + } catch (err: any) { + throwError(`Failed to create resource: ${err.message}`) + } finally { + stopLoading() } } From 48eb594d30e5fb2ed684335e545c3b250744cbb8 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 9 Apr 2026 00:21:31 +0100 Subject: [PATCH 29/36] Ensure dark mode detection is robust with environment checks. --- frontend/src/Main.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/Main.tsx b/frontend/src/Main.tsx index da25543..586b24f 100644 --- a/frontend/src/Main.tsx +++ b/frontend/src/Main.tsx @@ -7,9 +7,14 @@ import App from './App.tsx' import { store } from './redux/store.ts' import { Provider } from 'react-redux' -const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches +const prefersDark = + typeof window !== 'undefined' && + typeof document !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-color-scheme: dark)').matches + // only apply light if system explicitly prefers it -if (!prefersDark) { +if (typeof document !== 'undefined' && !prefersDark) { document.documentElement.classList.add('light') } From 9e2bd57cebc66278d60c988da3a60383eeb45036 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 9 Apr 2026 00:22:10 +0100 Subject: [PATCH 30/36] Conditionally render theme test route for development environment. --- frontend/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 831c10a..716a4e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -34,7 +34,7 @@ function App() { {/* login / signup */} } />{/* login page */} {/* DEV TESTING todo: remove these */} - } />{/* theme test page */} + {import.meta.env.DEV && } />}{/* theme test page */} {/* protected routes */} }> {/* home routes */} From 9d20ae33203a204db87a952d428f4512e1c87806 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 9 Apr 2026 00:23:06 +0100 Subject: [PATCH 31/36] Update GlobalLoader background color to use color-bg-overlay. --- frontend/src/components/load/GlobalLoader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/load/GlobalLoader.tsx b/frontend/src/components/load/GlobalLoader.tsx index fb71710..c3ec367 100644 --- a/frontend/src/components/load/GlobalLoader.tsx +++ b/frontend/src/components/load/GlobalLoader.tsx @@ -13,7 +13,7 @@ export default function GlobalLoader({ opaque = false }: Props) { left: 0, right: 0, bottom: 0, - backgroundColor: opaque ? 'var(--color-bg)' : 'rgba(0, 0, 0, 0.5)', + backgroundColor: opaque ? 'var(--color-bg)' : 'var(--color-bg-overlay)', display: 'flex', alignItems: 'center', justifyContent: 'center', From ac5fa3856e12000bcfed4e9a56d210b6dae1397d Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 9 Apr 2026 00:25:26 +0100 Subject: [PATCH 32/36] Improve accessibility by adding aria attributes and error IDs. --- .../context/ExtendableContextMenu.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/context/ExtendableContextMenu.tsx b/frontend/src/components/context/ExtendableContextMenu.tsx index faa1bd1..b74dfd2 100644 --- a/frontend/src/components/context/ExtendableContextMenu.tsx +++ b/frontend/src/components/context/ExtendableContextMenu.tsx @@ -113,15 +113,28 @@ export default function ContextMenu({ x, y, title, fields }: Props) { } if (field.type === 'input') { + const inputId = `context-menu-field-${i}-${field.label.replace(/\s+/g, '-').toLowerCase()}` + const errorId = `${inputId}-error` + return (
- + handleChange(i, field, e.target.value)} + aria-invalid={!!errors[i]} + aria-describedby={errors[i] ? errorId : undefined} /> - {errors[i] && {errors[i]}} + {errors[i] && ( + + {errors[i]} + + )}
) } From 2eba466c9cdc7e3e2dd2a84991103b997c15496b Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 9 Apr 2026 00:36:52 +0100 Subject: [PATCH 33/36] Add error handling and prevent multiple submissions in context menu. --- frontend/src/components/context/ExtendableContextMenu.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/context/ExtendableContextMenu.tsx b/frontend/src/components/context/ExtendableContextMenu.tsx index b74dfd2..befb095 100644 --- a/frontend/src/components/context/ExtendableContextMenu.tsx +++ b/frontend/src/components/context/ExtendableContextMenu.tsx @@ -67,6 +67,8 @@ export default function ContextMenu({ x, y, title, fields }: Props) { } const handleSubmit = async () => { + if (submitting) return + setSubmitting(true) try { await Promise.all( @@ -76,6 +78,8 @@ export default function ContextMenu({ x, y, title, fields }: Props) { : Promise.resolve() ) ) + } catch (error) { + console.error(error) } finally { setSubmitting(false) } @@ -130,7 +134,7 @@ export default function ContextMenu({ x, y, title, fields }: Props) { {errors[i] && ( {errors[i]} From b0ee2e79649333392f4e009404e8763c89c29846 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 9 Apr 2026 00:37:00 +0100 Subject: [PATCH 34/36] Refactor league ID handling to improve validation and error handling. --- frontend/src/pages/leagues/League.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/leagues/League.tsx b/frontend/src/pages/leagues/League.tsx index 7cfe53d..3e7fd57 100644 --- a/frontend/src/pages/leagues/League.tsx +++ b/frontend/src/pages/leagues/League.tsx @@ -21,10 +21,16 @@ export default function Leagues() { return } + const leagueId = Number(categoryId) + if (!Number.isInteger(leagueId) || leagueId <= 0 || String(leagueId) !== categoryId) { + throwError('Invalid league id provided') + return + } + startLoading(true) try { - const leagueJson = await fetchLeaguesSpecific(parseInt(categoryId)) + const leagueJson = await fetchLeaguesSpecific(leagueId) if (!leagueJson || !Object.hasOwn(leagueJson, 'user_id')) { throwError('No league found') From e503d00855621e8655940c56a3b7d8ddff112928 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 9 Apr 2026 00:38:37 +0100 Subject: [PATCH 35/36] Add validation reset for context menu submission error handling. --- frontend/src/components/context/ExtendableContextMenu.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/context/ExtendableContextMenu.tsx b/frontend/src/components/context/ExtendableContextMenu.tsx index befb095..1f3e8c6 100644 --- a/frontend/src/components/context/ExtendableContextMenu.tsx +++ b/frontend/src/components/context/ExtendableContextMenu.tsx @@ -63,6 +63,8 @@ export default function ContextMenu({ x, y, title, fields }: Props) { setValidating(prev => ({ ...prev, [i]: false })) } } + } else { + setValidating(prev => ({ ...prev, [i]: false })) } } From 1e808072c17e59a4a4fa340191da204122d05d49 Mon Sep 17 00:00:00 2001 From: Casper JB Date: Thu, 9 Apr 2026 00:39:38 +0100 Subject: [PATCH 36/36] Handle global error by throwing error message in league data fetch. --- frontend/src/pages/leagues/League.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/pages/leagues/League.tsx b/frontend/src/pages/leagues/League.tsx index 3e7fd57..943b85a 100644 --- a/frontend/src/pages/leagues/League.tsx +++ b/frontend/src/pages/leagues/League.tsx @@ -38,6 +38,9 @@ export default function Leagues() { } setLeague(leagueJson) + } catch (error) { + throwError(String(error)) + return } finally { stopLoading() }