From 55e6650e0e57baf0d8b9ea58e3ac6e78b81d1cda Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Thu, 5 Feb 2026 18:36:19 +0100 Subject: [PATCH] Add admin panel with CRUD endpoints and management UI Add admin API endpoints for games, routes, pokemon, and route encounters with full CRUD operations including bulk import. Build admin frontend with game/route/pokemon management pages, navigation, and data tables. Co-Authored-By: Claude Opus 4.5 --- .beans/nuzlocke-tracker-hy41--admin-panel.md | 5 +- backend/src/app/api/games.py | 169 ++++++++++++- backend/src/app/api/pokemon.py | 227 +++++++++++++++++- backend/src/app/schemas/__init__.py | 30 ++- backend/src/app/schemas/game.py | 40 +++ backend/src/app/schemas/pokemon.py | 47 ++++ frontend/src/App.tsx | 11 +- frontend/src/api/admin.ts | 71 ++++++ frontend/src/api/client.ts | 6 + frontend/src/components/Layout.tsx | 6 + frontend/src/components/admin/AdminLayout.tsx | 39 +++ frontend/src/components/admin/AdminTable.tsx | 78 ++++++ .../src/components/admin/BulkImportModal.tsx | 106 ++++++++ .../components/admin/DeleteConfirmModal.tsx | 48 ++++ frontend/src/components/admin/FormModal.tsx | 49 ++++ .../src/components/admin/GameFormModal.tsx | 119 +++++++++ .../src/components/admin/PokemonFormModal.tsx | 83 +++++++ .../admin/RouteEncounterFormModal.tsx | 134 +++++++++++ .../src/components/admin/RouteFormModal.tsx | 52 ++++ frontend/src/components/admin/index.ts | 9 + frontend/src/hooks/useAdmin.ts | 164 +++++++++++++ frontend/src/pages/admin/AdminGameDetail.tsx | 171 +++++++++++++ frontend/src/pages/admin/AdminGames.tsx | 110 +++++++++ frontend/src/pages/admin/AdminPokemon.tsx | 149 ++++++++++++ frontend/src/pages/admin/AdminRouteDetail.tsx | 155 ++++++++++++ frontend/src/pages/admin/index.ts | 4 + frontend/src/types/admin.ts | 67 ++++++ frontend/src/types/index.ts | 1 + 28 files changed, 2140 insertions(+), 10 deletions(-) create mode 100644 frontend/src/api/admin.ts create mode 100644 frontend/src/components/admin/AdminLayout.tsx create mode 100644 frontend/src/components/admin/AdminTable.tsx create mode 100644 frontend/src/components/admin/BulkImportModal.tsx create mode 100644 frontend/src/components/admin/DeleteConfirmModal.tsx create mode 100644 frontend/src/components/admin/FormModal.tsx create mode 100644 frontend/src/components/admin/GameFormModal.tsx create mode 100644 frontend/src/components/admin/PokemonFormModal.tsx create mode 100644 frontend/src/components/admin/RouteEncounterFormModal.tsx create mode 100644 frontend/src/components/admin/RouteFormModal.tsx create mode 100644 frontend/src/components/admin/index.ts create mode 100644 frontend/src/hooks/useAdmin.ts create mode 100644 frontend/src/pages/admin/AdminGameDetail.tsx create mode 100644 frontend/src/pages/admin/AdminGames.tsx create mode 100644 frontend/src/pages/admin/AdminPokemon.tsx create mode 100644 frontend/src/pages/admin/AdminRouteDetail.tsx create mode 100644 frontend/src/pages/admin/index.ts create mode 100644 frontend/src/types/admin.ts diff --git a/.beans/nuzlocke-tracker-hy41--admin-panel.md b/.beans/nuzlocke-tracker-hy41--admin-panel.md index 009038e..8e143ed 100644 --- a/.beans/nuzlocke-tracker-hy41--admin-panel.md +++ b/.beans/nuzlocke-tracker-hy41--admin-panel.md @@ -1,10 +1,11 @@ --- # nuzlocke-tracker-hy41 title: Admin Panel -status: todo +status: in-progress type: task +priority: normal created_at: 2026-02-04T15:47:05Z -updated_at: 2026-02-04T15:47:05Z +updated_at: 2026-02-05T17:25:58Z parent: nuzlocke-tracker-f5ob --- diff --git a/backend/src/app/api/games.py b/backend/src/app/api/games.py index c40eb99..0949f30 100644 --- a/backend/src/app/api/games.py +++ b/backend/src/app/api/games.py @@ -6,7 +6,16 @@ from sqlalchemy.orm import selectinload from app.core.database import get_session from app.models.game import Game from app.models.route import Route -from app.schemas.game import GameDetailResponse, GameResponse, RouteResponse +from app.schemas.game import ( + GameCreate, + GameDetailResponse, + GameResponse, + GameUpdate, + RouteCreate, + RouteReorderRequest, + RouteResponse, + RouteUpdate, +) router = APIRouter() @@ -48,3 +57,161 @@ async def list_game_routes( .order_by(Route.order) ) return result.scalars().all() + + +# --- Admin endpoints --- + + +@router.post("", response_model=GameResponse, status_code=201) +async def create_game( + data: GameCreate, session: AsyncSession = Depends(get_session) +): + existing = await session.execute( + select(Game).where(Game.slug == data.slug) + ) + if existing.scalar_one_or_none() is not None: + raise HTTPException(status_code=409, detail="Game with this slug already exists") + + game = Game(**data.model_dump()) + session.add(game) + await session.commit() + await session.refresh(game) + return game + + +@router.put("/{game_id}", response_model=GameResponse) +async def update_game( + game_id: int, data: GameUpdate, session: AsyncSession = Depends(get_session) +): + game = await session.get(Game, game_id) + if game is None: + raise HTTPException(status_code=404, detail="Game not found") + + update_data = data.model_dump(exclude_unset=True) + if "slug" in update_data: + existing = await session.execute( + select(Game).where(Game.slug == update_data["slug"], Game.id != game_id) + ) + if existing.scalar_one_or_none() is not None: + raise HTTPException(status_code=409, detail="Game with this slug already exists") + + for field, value in update_data.items(): + setattr(game, field, value) + + await session.commit() + await session.refresh(game) + return game + + +@router.delete("/{game_id}", status_code=204) +async def delete_game( + game_id: int, session: AsyncSession = Depends(get_session) +): + result = await session.execute( + select(Game).where(Game.id == game_id).options(selectinload(Game.runs)) + ) + game = result.scalar_one_or_none() + if game is None: + raise HTTPException(status_code=404, detail="Game not found") + + if game.runs: + raise HTTPException( + status_code=409, + detail="Cannot delete game with existing runs. Delete the runs first.", + ) + + # Delete routes (and their route_encounters via cascade) + routes = await session.execute( + select(Route).where(Route.game_id == game_id) + ) + for route in routes.scalars().all(): + await session.delete(route) + + await session.delete(game) + await session.commit() + + +@router.post("/{game_id}/routes", response_model=RouteResponse, status_code=201) +async def create_route( + game_id: int, data: RouteCreate, session: AsyncSession = Depends(get_session) +): + game = await session.get(Game, game_id) + if game is None: + raise HTTPException(status_code=404, detail="Game not found") + + route = Route(game_id=game_id, **data.model_dump()) + session.add(route) + await session.commit() + await session.refresh(route) + return route + + +@router.put("/{game_id}/routes/reorder", response_model=list[RouteResponse]) +async def reorder_routes( + game_id: int, + data: RouteReorderRequest, + session: AsyncSession = Depends(get_session), +): + game = await session.get(Game, game_id) + if game is None: + raise HTTPException(status_code=404, detail="Game not found") + + for item in data.routes: + route = await session.get(Route, item.id) + if route is None or route.game_id != game_id: + raise HTTPException( + status_code=400, + detail=f"Route {item.id} not found in this game", + ) + route.order = item.order + + await session.commit() + + result = await session.execute( + select(Route).where(Route.game_id == game_id).order_by(Route.order) + ) + return result.scalars().all() + + +@router.put("/{game_id}/routes/{route_id}", response_model=RouteResponse) +async def update_route( + game_id: int, + route_id: int, + data: RouteUpdate, + session: AsyncSession = Depends(get_session), +): + route = await session.get(Route, route_id) + if route is None or route.game_id != game_id: + raise HTTPException(status_code=404, detail="Route not found in this game") + + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(route, field, value) + + await session.commit() + await session.refresh(route) + return route + + +@router.delete("/{game_id}/routes/{route_id}", status_code=204) +async def delete_route( + game_id: int, + route_id: int, + session: AsyncSession = Depends(get_session), +): + result = await session.execute( + select(Route) + .where(Route.id == route_id, Route.game_id == game_id) + .options(selectinload(Route.encounters)) + ) + route = result.scalar_one_or_none() + if route is None: + raise HTTPException(status_code=404, detail="Route not found in this game") + + if route.encounters: + raise HTTPException( + status_code=409, + detail="Cannot delete route with existing encounters. Delete the encounters first.", + ) + + await session.delete(route) + await session.commit() diff --git a/backend/src/app/api/pokemon.py b/backend/src/app/api/pokemon.py index 64e5820..3143ecd 100644 --- a/backend/src/app/api/pokemon.py +++ b/backend/src/app/api/pokemon.py @@ -1,17 +1,96 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, selectinload from app.core.database import get_session from app.models.pokemon import Pokemon from app.models.route import Route from app.models.route_encounter import RouteEncounter -from app.schemas.pokemon import PokemonResponse, RouteEncounterDetailResponse +from app.schemas.pokemon import ( + BulkImportItem, + BulkImportResult, + PokemonCreate, + PokemonResponse, + PokemonUpdate, + RouteEncounterCreate, + RouteEncounterDetailResponse, + RouteEncounterUpdate, +) router = APIRouter() +@router.get("/pokemon", response_model=list[PokemonResponse]) +async def list_pokemon( + search: str | None = Query(None), + limit: int = Query(50, ge=1, le=500), + offset: int = Query(0, ge=0), + session: AsyncSession = Depends(get_session), +): + query = select(Pokemon) + if search: + query = query.where( + func.lower(Pokemon.name).contains(search.lower()) + ) + query = query.order_by(Pokemon.national_dex).offset(offset).limit(limit) + result = await session.execute(query) + return result.scalars().all() + + +@router.post("/pokemon/bulk-import", response_model=BulkImportResult) +async def bulk_import_pokemon( + items: list[BulkImportItem], + session: AsyncSession = Depends(get_session), +): + created = 0 + updated = 0 + errors: list[str] = [] + + for item in items: + try: + existing = await session.execute( + select(Pokemon).where(Pokemon.national_dex == item.national_dex) + ) + pokemon = existing.scalar_one_or_none() + + if pokemon is not None: + pokemon.name = item.name + pokemon.types = item.types + if item.sprite_url is not None: + pokemon.sprite_url = item.sprite_url + updated += 1 + else: + pokemon = Pokemon(**item.model_dump()) + session.add(pokemon) + created += 1 + except Exception as e: + errors.append(f"Dex #{item.national_dex} ({item.name}): {e}") + + await session.commit() + return BulkImportResult(created=created, updated=updated, errors=errors) + + +@router.post("/pokemon", response_model=PokemonResponse, status_code=201) +async def create_pokemon( + data: PokemonCreate, session: AsyncSession = Depends(get_session) +): + existing = await session.execute( + select(Pokemon).where(Pokemon.national_dex == data.national_dex) + ) + if existing.scalar_one_or_none() is not None: + raise HTTPException( + status_code=409, + detail=f"Pokemon with national dex #{data.national_dex} already exists", + ) + + pokemon = Pokemon(**data.model_dump()) + session.add(pokemon) + await session.commit() + await session.refresh(pokemon) + return pokemon + + @router.get("/pokemon/{pokemon_id}", response_model=PokemonResponse) async def get_pokemon( pokemon_id: int, session: AsyncSession = Depends(get_session) @@ -22,6 +101,61 @@ async def get_pokemon( return pokemon +@router.put("/pokemon/{pokemon_id}", response_model=PokemonResponse) +async def update_pokemon( + pokemon_id: int, + data: PokemonUpdate, + session: AsyncSession = Depends(get_session), +): + pokemon = await session.get(Pokemon, pokemon_id) + if pokemon is None: + raise HTTPException(status_code=404, detail="Pokemon not found") + + update_data = data.model_dump(exclude_unset=True) + if "national_dex" in update_data: + existing = await session.execute( + select(Pokemon).where( + Pokemon.national_dex == update_data["national_dex"], + Pokemon.id != pokemon_id, + ) + ) + if existing.scalar_one_or_none() is not None: + raise HTTPException( + status_code=409, + detail=f"Pokemon with national dex #{update_data['national_dex']} already exists", + ) + + for field, value in update_data.items(): + setattr(pokemon, field, value) + + await session.commit() + await session.refresh(pokemon) + return pokemon + + +@router.delete("/pokemon/{pokemon_id}", status_code=204) +async def delete_pokemon( + pokemon_id: int, session: AsyncSession = Depends(get_session) +): + result = await session.execute( + select(Pokemon) + .where(Pokemon.id == pokemon_id) + .options(selectinload(Pokemon.encounters)) + ) + pokemon = result.scalar_one_or_none() + if pokemon is None: + raise HTTPException(status_code=404, detail="Pokemon not found") + + if pokemon.encounters: + raise HTTPException( + status_code=409, + detail="Cannot delete pokemon with existing encounters.", + ) + + await session.delete(pokemon) + await session.commit() + + @router.get( "/routes/{route_id}/pokemon", response_model=list[RouteEncounterDetailResponse], @@ -41,3 +175,88 @@ async def list_route_encounters( .order_by(RouteEncounter.encounter_rate.desc()) ) return result.scalars().unique().all() + + +@router.post( + "/routes/{route_id}/pokemon", + response_model=RouteEncounterDetailResponse, + status_code=201, +) +async def add_route_encounter( + route_id: int, + data: RouteEncounterCreate, + session: AsyncSession = Depends(get_session), +): + route = await session.get(Route, route_id) + if route is None: + raise HTTPException(status_code=404, detail="Route not found") + + pokemon = await session.get(Pokemon, data.pokemon_id) + if pokemon is None: + raise HTTPException(status_code=404, detail="Pokemon not found") + + encounter = RouteEncounter(route_id=route_id, **data.model_dump()) + session.add(encounter) + await session.commit() + + # Reload with pokemon relationship + result = await session.execute( + select(RouteEncounter) + .where(RouteEncounter.id == encounter.id) + .options(joinedload(RouteEncounter.pokemon)) + ) + return result.scalar_one() + + +@router.put( + "/routes/{route_id}/pokemon/{encounter_id}", + response_model=RouteEncounterDetailResponse, +) +async def update_route_encounter( + route_id: int, + encounter_id: int, + data: RouteEncounterUpdate, + session: AsyncSession = Depends(get_session), +): + result = await session.execute( + select(RouteEncounter) + .where(RouteEncounter.id == encounter_id, RouteEncounter.route_id == route_id) + .options(joinedload(RouteEncounter.pokemon)) + ) + encounter = result.scalar_one_or_none() + if encounter is None: + raise HTTPException(status_code=404, detail="Route encounter not found") + + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(encounter, field, value) + + await session.commit() + await session.refresh(encounter) + + # Reload with pokemon relationship + result = await session.execute( + select(RouteEncounter) + .where(RouteEncounter.id == encounter.id) + .options(joinedload(RouteEncounter.pokemon)) + ) + return result.scalar_one() + + +@router.delete("/routes/{route_id}/pokemon/{encounter_id}", status_code=204) +async def remove_route_encounter( + route_id: int, + encounter_id: int, + session: AsyncSession = Depends(get_session), +): + encounter = await session.execute( + select(RouteEncounter).where( + RouteEncounter.id == encounter_id, + RouteEncounter.route_id == route_id, + ) + ) + encounter = encounter.scalar_one_or_none() + if encounter is None: + raise HTTPException(status_code=404, detail="Route encounter not found") + + await session.delete(encounter) + await session.commit() diff --git a/backend/src/app/schemas/__init__.py b/backend/src/app/schemas/__init__.py index affe407..4a6626f 100644 --- a/backend/src/app/schemas/__init__.py +++ b/backend/src/app/schemas/__init__.py @@ -4,25 +4,51 @@ from app.schemas.encounter import ( EncounterResponse, EncounterUpdate, ) -from app.schemas.game import GameDetailResponse, GameResponse, RouteResponse +from app.schemas.game import ( + GameCreate, + GameDetailResponse, + GameResponse, + GameUpdate, + RouteCreate, + RouteReorderRequest, + RouteResponse, + RouteUpdate, +) from app.schemas.pokemon import ( + BulkImportItem, + BulkImportResult, + PokemonCreate, PokemonResponse, + PokemonUpdate, + RouteEncounterCreate, RouteEncounterDetailResponse, RouteEncounterResponse, + RouteEncounterUpdate, ) from app.schemas.run import RunCreate, RunDetailResponse, RunResponse, RunUpdate __all__ = [ + "BulkImportItem", + "BulkImportResult", "EncounterCreate", "EncounterDetailResponse", "EncounterResponse", "EncounterUpdate", + "GameCreate", "GameDetailResponse", "GameResponse", - "RouteResponse", + "GameUpdate", + "PokemonCreate", "PokemonResponse", + "PokemonUpdate", + "RouteCreate", + "RouteEncounterCreate", "RouteEncounterDetailResponse", "RouteEncounterResponse", + "RouteEncounterUpdate", + "RouteReorderRequest", + "RouteResponse", + "RouteUpdate", "RunCreate", "RunDetailResponse", "RunResponse", diff --git a/backend/src/app/schemas/game.py b/backend/src/app/schemas/game.py index d6c45eb..0841c9d 100644 --- a/backend/src/app/schemas/game.py +++ b/backend/src/app/schemas/game.py @@ -20,3 +20,43 @@ class GameResponse(CamelModel): class GameDetailResponse(GameResponse): routes: list[RouteResponse] = [] + + +# --- Admin schemas --- + + +class GameCreate(CamelModel): + name: str + slug: str + generation: int + region: str + box_art_url: str | None = None + release_year: int | None = None + + +class GameUpdate(CamelModel): + name: str | None = None + slug: str | None = None + generation: int | None = None + region: str | None = None + box_art_url: str | None = None + release_year: int | None = None + + +class RouteCreate(CamelModel): + name: str + order: int + + +class RouteUpdate(CamelModel): + name: str | None = None + order: int | None = None + + +class RouteReorderItem(CamelModel): + id: int + order: int + + +class RouteReorderRequest(CamelModel): + routes: list[RouteReorderItem] diff --git a/backend/src/app/schemas/pokemon.py b/backend/src/app/schemas/pokemon.py index b02d883..6c48c8b 100644 --- a/backend/src/app/schemas/pokemon.py +++ b/backend/src/app/schemas/pokemon.py @@ -1,3 +1,5 @@ +from pydantic import BaseModel + from app.schemas.base import CamelModel @@ -21,3 +23,48 @@ class RouteEncounterResponse(CamelModel): class RouteEncounterDetailResponse(RouteEncounterResponse): pokemon: PokemonResponse + + +# --- Admin schemas --- + + +class PokemonCreate(CamelModel): + national_dex: int + name: str + types: list[str] + sprite_url: str | None = None + + +class PokemonUpdate(CamelModel): + national_dex: int | None = None + name: str | None = None + types: list[str] | None = None + sprite_url: str | None = None + + +class RouteEncounterCreate(CamelModel): + pokemon_id: int + encounter_method: str + encounter_rate: int + min_level: int + max_level: int + + +class RouteEncounterUpdate(CamelModel): + encounter_method: str | None = None + encounter_rate: int | None = None + min_level: int | None = None + max_level: int | None = None + + +class BulkImportItem(BaseModel): + national_dex: int + name: str + types: list[str] + sprite_url: str | None = None + + +class BulkImportResult(CamelModel): + created: int + updated: int + errors: list[str] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a2a090..b643e94 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,8 @@ -import { Routes, Route } from 'react-router-dom' +import { Routes, Route, Navigate } from 'react-router-dom' import { Layout } from './components' +import { AdminLayout } from './components/admin' import { Home, NewRun, RunList, RunDashboard, RunEncounters } from './pages' +import { AdminGames, AdminGameDetail, AdminPokemon, AdminRouteDetail } from './pages/admin' function App() { return ( @@ -11,6 +13,13 @@ function App() { } /> } /> } /> + }> + } /> + } /> + } /> + } /> + } /> + ) diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..311e2f8 --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,71 @@ +import { api } from './client' +import type { + Game, + Route, + Pokemon, + RouteEncounterDetail, + CreateGameInput, + UpdateGameInput, + CreateRouteInput, + UpdateRouteInput, + RouteReorderItem, + CreatePokemonInput, + UpdatePokemonInput, + BulkImportResult, + CreateRouteEncounterInput, + UpdateRouteEncounterInput, +} from '../types' + +// Games +export const createGame = (data: CreateGameInput) => + api.post('/games', data) + +export const updateGame = (id: number, data: UpdateGameInput) => + api.put(`/games/${id}`, data) + +export const deleteGame = (id: number) => + api.del(`/games/${id}`) + +// Routes +export const createRoute = (gameId: number, data: CreateRouteInput) => + api.post(`/games/${gameId}/routes`, data) + +export const updateRoute = (gameId: number, routeId: number, data: UpdateRouteInput) => + api.put(`/games/${gameId}/routes/${routeId}`, data) + +export const deleteRoute = (gameId: number, routeId: number) => + api.del(`/games/${gameId}/routes/${routeId}`) + +export const reorderRoutes = (gameId: number, routes: RouteReorderItem[]) => + api.put(`/games/${gameId}/routes/reorder`, { routes }) + +// Pokemon +export const listPokemon = (search?: string, limit = 50, offset = 0) => { + const params = new URLSearchParams() + if (search) params.set('search', search) + params.set('limit', String(limit)) + params.set('offset', String(offset)) + return api.get(`/pokemon?${params}`) +} + +export const createPokemon = (data: CreatePokemonInput) => + api.post('/pokemon', data) + +export const updatePokemon = (id: number, data: UpdatePokemonInput) => + api.put(`/pokemon/${id}`, data) + +export const deletePokemon = (id: number) => + api.del(`/pokemon/${id}`) + +export const bulkImportPokemon = (items: Array<{ nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) => + api.post('/pokemon/bulk-import', items) + +// Route Encounters +export const addRouteEncounter = (routeId: number, data: CreateRouteEncounterInput) => + api.post(`/routes/${routeId}/pokemon`, data) + +export const updateRouteEncounter = (routeId: number, encounterId: number, data: UpdateRouteEncounterInput) => + api.put(`/routes/${routeId}/pokemon/${encounterId}`, data) + +export const removeRouteEncounter = (routeId: number, encounterId: number) => + api.del(`/routes/${routeId}/pokemon/${encounterId}`) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a0e84cc..4547c0b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -46,6 +46,12 @@ export const api = { body: JSON.stringify(body), }), + put: (path: string, body: unknown) => + request(path, { + method: 'PUT', + body: JSON.stringify(body), + }), + del: (path: string) => request(path, { method: 'DELETE' }), } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9bd02d6..460a044 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -24,6 +24,12 @@ export function Layout() { > My Runs + + Admin + diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx new file mode 100644 index 0000000..ef5ef0c --- /dev/null +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -0,0 +1,39 @@ +import { NavLink, Outlet } from 'react-router-dom' + +const navItems = [ + { to: '/admin/games', label: 'Games' }, + { to: '/admin/pokemon', label: 'Pokemon' }, +] + +export function AdminLayout() { + return ( +
+

Admin Panel

+
+ +
+ +
+
+
+ ) +} diff --git a/frontend/src/components/admin/AdminTable.tsx b/frontend/src/components/admin/AdminTable.tsx new file mode 100644 index 0000000..040493b --- /dev/null +++ b/frontend/src/components/admin/AdminTable.tsx @@ -0,0 +1,78 @@ +import { type ReactNode } from 'react' + +export interface Column { + header: string + accessor: (row: T) => ReactNode + className?: string +} + +interface AdminTableProps { + columns: Column[] + data: T[] + isLoading?: boolean + emptyMessage?: string + onRowClick?: (row: T) => void + keyFn: (row: T) => string | number +} + +export function AdminTable({ + columns, + data, + isLoading, + emptyMessage = 'No data found.', + onRowClick, + keyFn, +}: AdminTableProps) { + if (isLoading) { + return ( +
+ Loading... +
+ ) + } + + if (data.length === 0) { + return ( +
+ {emptyMessage} +
+ ) + } + + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {data.map((row) => ( + onRowClick(row) : undefined} + className={onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''} + > + {columns.map((col) => ( + + ))} + + ))} + +
+ {col.header} +
+ {col.accessor(row)} +
+
+ ) +} diff --git a/frontend/src/components/admin/BulkImportModal.tsx b/frontend/src/components/admin/BulkImportModal.tsx new file mode 100644 index 0000000..b368c5f --- /dev/null +++ b/frontend/src/components/admin/BulkImportModal.tsx @@ -0,0 +1,106 @@ +import { type FormEvent, useState } from 'react' +import type { BulkImportResult } from '../../types' + +interface BulkImportModalProps { + onSubmit: (items: Array<{ nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) => Promise + onClose: () => void +} + +const EXAMPLE = `[ + { "nationalDex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] }, + { "nationalDex": 4, "name": "Charmander", "types": ["Fire"] } +]` + +export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) { + const [json, setJson] = useState('') + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setResult(null) + + let items: unknown[] + try { + items = JSON.parse(json) + if (!Array.isArray(items)) throw new Error('Must be an array') + } catch { + setError('Invalid JSON. Must be an array of pokemon objects.') + return + } + + setIsSubmitting(true) + try { + const res = await onSubmit(items as Array<{ nationalDex: number; name: string; types: string[] }>) + setResult(res) + } catch (err) { + setError(err instanceof Error ? err.message : 'Import failed') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+
+
+

Bulk Import Pokemon

+
+
+
+
+ +