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

+
+
+
+
+ +