diff --git a/docs/database-setup.md b/docs/database-setup.md index 2faab14..abb02c2 100644 --- a/docs/database-setup.md +++ b/docs/database-setup.md @@ -64,7 +64,7 @@ themselves are plain files on disk, not stored in the database. Sprites already on disk are not downloaded again. Locally they go to `backend/sprites/` (gitignored), and the Vite development -server serves that folder at `/project2/sprites/` (see `frontend/vite.config.ts`), so no +server serves that folder at `/sprites/` (see `frontend/vite.config.ts`), so no extra web server is needed during development. On the VM, the project is served by Apache under `/project2`, so set `SPRITES_DIR` in `backend/.env` to a folder inside Apache's document root: diff --git a/frontend/.env.example b/frontend/.env.example index 20ff42e..ec3d68c 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,4 @@ -# Optional: base URL for sprites. Defaults to sprites/ under Vite's base path. -# VITE_SPRITES_BASE_URL=/project2/sprites/ +# Base URL used by Pokémon cards to load sprites. +# In development Vite serves backend/sprites/ at /sprites/ (see vite.config.ts). +# When building for the Apache deployment, set VITE_SPRITES_BASE_URL=/project2/sprites/ +VITE_SPRITES_BASE_URL=/sprites/ diff --git a/frontend/README.md b/frontend/README.md index 3e8b3b5..941ba65 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -35,12 +35,10 @@ The backend currently provides queries for retrieving a list of Pokémon and an ## Sprites -Pokémon cards load sprites from `sprites/` under the app's base path (Vite's -`base`), so the URL follows wherever the frontend is served, e.g. -`/project2/sprites/` on the VM. In development the Vite server serves -`backend/sprites/` at `/project2/sprites/`, so no extra configuration is needed after -running the backend import. Set `VITE_SPRITES_BASE_URL` only to load sprites -from somewhere else. +Pokémon cards load sprites from `VITE_SPRITES_BASE_URL` (default `/sprites/`). +In development the Vite server serves `backend/sprites/` at `/sprites/`, so no +extra configuration is needed after running the backend import. When building +for the Apache deployment, set `VITE_SPRITES_BASE_URL=/project2/sprites/`. ## Current Functionality diff --git a/frontend/src/App.css b/frontend/src/App.css index c81d3ea..c6c0014 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -7,6 +7,8 @@ max-width: 1024px; } +/* Header */ + .top-bar { display: grid; grid-template-columns: 1fr auto 1fr; @@ -51,7 +53,13 @@ gap: 0.75rem; } +/* Search button */ + .search-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; background-color: var(--accent); color: aliceblue; border: 0; @@ -62,6 +70,12 @@ transition: filter 0.2s ease, transform 0.1s ease; } +.search-button-icon { + display: none; + width: 1.15rem; + height: 1.15rem; +} + .search-button:hover { filter: brightness(1.1); } @@ -70,6 +84,8 @@ transform: scale(0.98); } +/* Account button */ + .account-button { display: inline-block; padding: 0.6rem 1.2rem; @@ -87,6 +103,8 @@ color: white; } +/* Accessibility */ + .account-button:focus-visible, .search-button:focus-visible, .hamburger:focus-visible { @@ -94,6 +112,13 @@ outline-offset: 3px; } +/* Navigation */ + +.sidebar-header, +.menu-backdrop { + display: none; +} + .nav-links { list-style: none; display: flex; @@ -150,9 +175,12 @@ .main-content { flex: 1; + min-width: 0; background-color: var(--bg); } +/* Format selector */ + #formats { background-color: var(--code-bg); color: var(--text); @@ -171,6 +199,8 @@ border-color: var(--accent); } +/* Add Pokémon button */ + .addPokemon { background-color: var(--accent); color: aliceblue; @@ -191,17 +221,25 @@ transform: scale(0.98); } +/* Mobile layout */ + @media (max-width: 768px) { .top-bar { grid-template-columns: 1fr auto 1fr; - padding: 1rem; - gap: 0.5rem; + padding: 0.6rem 1rem; + gap: 0.6rem; + min-height: 3.75rem; } .top-bar h1 { - font-size: 1.25rem; + min-width: 0; + margin: 0; + font-size: 1rem; letter-spacing: 0; - white-space: normal; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .user-profile { @@ -219,7 +257,27 @@ } .search-button { - padding: 0.5rem 0.9rem; + flex: 0 0 auto; + width: 2.75rem; + height: 2.75rem; + padding: 0; + border-radius: 50%; + } + + .search-button-icon { + display: block; + } + + .search-button-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; } .header-actions { @@ -231,6 +289,8 @@ font-size: 0.85rem; } + /* Hamburger menu */ + .hamburger { display: flex; flex-direction: column; @@ -243,24 +303,108 @@ left: 0; } + .hamburger .hamburger-line { + width: 22px; + height: 2px; + background-color: var(--text-h); + } + + /* Menu backdrop */ + + .menu-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 150; + padding: 0; + border: 0; + background: rgb(0 0 0 / 40%); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + } + + .menu-backdrop.active { + opacity: 1; + pointer-events: auto; + } + + /* Sliding sidebar */ + .sidebar { - display: none; - position: absolute; + display: block; + position: fixed; top: 0; left: 0; - width: 100vw; - background-color: var(--code-bg); - border-right: none; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); - z-index: 100; - padding: 1rem 0; + bottom: 0; + z-index: 200; + width: min(82vw, 20rem); + height: 100dvh; + padding: 1.1rem 1.25rem; + box-sizing: border-box; + overflow-y: auto; + background-color: var(--bg); + border-right: 1px solid var(--border); + box-shadow: 0 10px 30px rgb(0 0 0 / 25%); + transform: translateX(-105%); + visibility: hidden; + transition: transform 0.2s ease, visibility 0.2s ease; } .sidebar.active { - display: block; + transform: translateX(0); + visibility: visible; } - .nav-links.active { + .sidebar-header { display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1.25rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border); + } + + .sidebar-title { + color: var(--text-h); + font-size: 0.95rem; + font-weight: 600; + text-align: left; } -} \ No newline at end of file + + .sidebar-close { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 2.5rem; + height: 2.5rem; + padding: 0; + border: 0; + border-radius: 50%; + background: var(--code-bg); + color: var(--text-h); + cursor: pointer; + } + + .sidebar-close-icon { + width: 1.1rem; + height: 1.1rem; + } + + .sidebar .nav-links { + gap: 0; + } + + .sidebar .nav-links li { + border-bottom: 1px solid var(--border); + } + + .sidebar .nav-links a { + display: block; + padding: 0.9rem 0.25rem; + font-size: 1.05rem; + text-align: left; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e2d6ca..996b7eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ -import "./App.css"; -import { useEffect, useState } from "react"; +import './App.css'; +import { useEffect, useState } from 'react'; import { Link, Navigate, @@ -8,14 +8,18 @@ import { Routes, useNavigate, useOutletContext, -} from "react-router-dom"; -import HomePage from "./assets/pages/home/page.tsx"; -import TeamsPage from "./assets/pages/teams/page.tsx"; -import SearchModal from "./search/SearchModal.tsx"; -import { MAX_TEAM_SIZE, type TeamPokemon } from "./team.ts"; -import { useAuth } from "./auth/AuthContext.tsx"; -import ProtectedRoute from "./auth/ProtectedRoute.tsx"; -import LoginPage from "./auth/LoginPage.tsx"; +} from 'react-router-dom'; +import { + CloseIcon, + SearchIcon, +} from "./assets/components/icons/icons.tsx"; +import HomePage from './assets/pages/home/page.tsx'; +import TeamsPage from './assets/pages/teams/page.tsx'; +import SearchModal from './search/SearchModal.tsx'; +import { MAX_TEAM_SIZE, type TeamPokemon } from './team.ts'; +import { useAuth } from './auth/AuthContext.tsx'; +import ProtectedRoute from './auth/ProtectedRoute.tsx'; +import LoginPage from './auth/LoginPage.tsx'; type ApplicationContext = { format: string; @@ -31,15 +35,11 @@ function ApplicationLayout() { const [menuOpen, setMenuOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const [format, setFormat] = useState( - () => - localStorage.getItem(`pokemon-draft-planner-format-${user?.id}`) || - "gen9", + () => localStorage.getItem(`pokemon-draft-planner-format-${user?.id}`) || 'gen9', ); const [team, setTeam] = useState(() => { - const savedTeam = localStorage.getItem( - `pokemon-draft-planner-team-${user?.id}`, - ); + const savedTeam = localStorage.getItem(`pokemon-draft-planner-team-${user?.id}`); return savedTeam ? JSON.parse(savedTeam) : []; }); @@ -47,10 +47,7 @@ function ApplicationLayout() { useEffect(() => { if (!user) return; - localStorage.setItem( - `pokemon-draft-planner-team-${user.id}`, - JSON.stringify(team), - ); + localStorage.setItem(`pokemon-draft-planner-team-${user.id}`, JSON.stringify(team)); }, [team, user]); const navigate = useNavigate(); @@ -63,7 +60,7 @@ function ApplicationLayout() { logout(); setSearchOpen(false); closeMenu(); - navigate("/login", { replace: true }); + navigate('/login', { replace: true }); } function handleFormatChange(newFormat: string) { @@ -73,17 +70,14 @@ function ApplicationLayout() { function addPokemon(pokemon: TeamPokemon) { setTeam((currentTeam) => - currentTeam.length >= MAX_TEAM_SIZE || - currentTeam.some((member) => member.id === pokemon.id) + currentTeam.length >= MAX_TEAM_SIZE || currentTeam.some((member) => member.id === pokemon.id) ? currentTeam : [...currentTeam, pokemon], ); } function removePokemon(id: string) { - setTeam((currentTeam) => - currentTeam.filter((pokemon) => pokemon.id !== id), - ); + setTeam((currentTeam) => currentTeam.filter((pokemon) => pokemon.id !== id)); } return ( @@ -104,67 +98,43 @@ function ApplicationLayout() { - {user?.username - ? user.username.charAt(0).toUpperCase() + user.username.slice(1) - : ""} + {user?.username ? user.username.charAt(0).toUpperCase() + user.username.slice(1) : ''} -

Pokémon Draft Planner

- -
- - - -
- + +
-
+ + + + + {teamFull && ( +

+ {TEAM_FULL_MESSAGE} +

+ )} + + {team.length > 0 ? ( +
+ {team.map((pokemon) => ( + onRemovePokemon(pokemon.id)} + /> + ))}
+ ) : ( +

+ No Pokémon on your team yet. Add one with "Add Pokémon +" to start building it. +

+ )} + + +
+

Type Matchups

- {teamFull && ( -

- {TEAM_FULL_MESSAGE} -

- )} + {loading &&

Loading type matchups…

} - {team.length > 0 ? ( -
+ {error &&

Could not load type matchups: {error.message}

} + + {!loading && !error && ( + <> +
{team.map((pokemon) => ( - onRemovePokemon(pokemon.id)} - /> +
+

{pokemon.name}

+ +
    + {types.map((type) => { + const multiplier = multiplierFor(pokemon, type); + const category = matchupCategory(multiplier); + + return ( +
  • + + {type}: {formatMultiplier(multiplier)} + +
  • + ); + })} +
+
))} + + {team.length === 0 && ( +

Add Pokémon to your team to see their type matchups.

+ )} + + {team.length > 0 && ( +
    + {MATCHUP_LEGEND.map(({ label, category }) => ( +
  • +
  • + ))} +
+ )}
- ) : ( -

- No Pokémon on your team yet. Add one with "Add Pokémon +" to start - building it. -

- )} -
- -
-

Type Matchups

- {loading &&

Loading type matchups…

} - {error && ( -

Could not load type matchups: {error.message}

- )} - {!loading && !error && ( +
@@ -155,16 +220,19 @@ function HomePage({ ))} + {team.map((pokemon) => ( + {types.map((type) => { const multiplier = multiplierFor(pokemon, type); + return ( @@ -172,6 +240,7 @@ function HomePage({ })} ))} + {team.length === 0 && (
{pokemon.name} 1 ? "weak" : "normal"}`} + className={`matchup-${multiplier === 0 ? 'immune' : multiplier < 1 ? 'resist' : multiplier > 1 ? 'weak' : 'normal'}`} > {formatMultiplier(multiplier)}
@@ -182,16 +251,20 @@ function HomePage({
- )} -
-
-

Stats

-
-
-

Moves

-
- - + + )} + + +
+

Stats

+

Team stat overview will appear here.

+
+ +
+

Moves

+

Team move overview will appear here.

+
+ ); } diff --git a/frontend/src/assets/pages/teams/page.tsx b/frontend/src/assets/pages/teams/page.tsx index 5e3f57f..88a3bf3 100644 --- a/frontend/src/assets/pages/teams/page.tsx +++ b/frontend/src/assets/pages/teams/page.tsx @@ -1,6 +1,8 @@ +import "./teams.css"; + function TeamsPage() { return ( -
+

Teams

Your teams will appear here.

diff --git a/frontend/src/assets/pages/teams/teams.css b/frontend/src/assets/pages/teams/teams.css index e69de29..aadbe1e 100644 --- a/frontend/src/assets/pages/teams/teams.css +++ b/frontend/src/assets/pages/teams/teams.css @@ -0,0 +1,7 @@ +.teams-page { + padding: 1rem; +} + +.teams-page h2 { + margin-top: 0; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 79981c7..91e67b9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -41,6 +41,18 @@ body { margin: 0; } +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + h1, h2 { font-family: var(--heading); diff --git a/frontend/src/search/SearchModal.test.tsx b/frontend/src/search/SearchModal.test.tsx index 51232aa..ac3bd5e 100644 --- a/frontend/src/search/SearchModal.test.tsx +++ b/frontend/src/search/SearchModal.test.tsx @@ -448,4 +448,32 @@ describe("SearchModal", () => { expect(await screen.findByText("Pokemon 1")).toBeTruthy(); }); + + it("locks background scrolling while open and restores it on unmount", () => { + document.documentElement.style.overflow = "auto"; + document.body.style.overflow = "scroll"; + + const { unmount } = render( + + {}} + onRemovePokemon={() => {}} + onClose={() => {}} + /> + , + ); + + expect(document.documentElement.style.overflow).toBe("hidden"); + expect(document.body.style.overflow).toBe("hidden"); + + unmount(); + + expect(document.documentElement.style.overflow).toBe("auto"); + expect(document.body.style.overflow).toBe("scroll"); + + document.documentElement.style.overflow = ""; + document.body.style.overflow = ""; + }); }); diff --git a/frontend/src/search/SearchModal.tsx b/frontend/src/search/SearchModal.tsx index bc1c676..f5903a5 100644 --- a/frontend/src/search/SearchModal.tsx +++ b/frontend/src/search/SearchModal.tsx @@ -1,7 +1,11 @@ import { useQuery } from "@apollo/client/react"; import { useEffect, useState } from "react"; import PokemonCard from "../assets/components/pokemoncard/pokemoncard.tsx"; -import { MAX_TEAM_SIZE, TEAM_FULL_MESSAGE, type TeamPokemon } from "../team.ts"; +import { + MAX_TEAM_SIZE, + TEAM_FULL_MESSAGE, + type TeamPokemon, +} from "../team.ts"; import "./search-modal.css"; import { SEARCH_POKEMONS } from "./search"; @@ -9,6 +13,7 @@ const PAGE_SIZE = 20; type SearchResponse = { searchPokemons: TeamPokemon[]; + searchPokemons: TeamPokemon[]; }; type SortOption = "name-asc" | "name-desc" | "number-asc" | "number-desc"; @@ -30,9 +35,19 @@ type SearchModalProps = { teamIds: string[]; onAddPokemon: (pokemon: TeamPokemon) => void; onRemovePokemon: (id: string) => void; + teamIds: string[]; + onAddPokemon: (pokemon: TeamPokemon) => void; + onRemovePokemon: (id: string) => void; onClose: () => void; }; +function SearchModal({ + format, + teamIds, + onAddPokemon, + onRemovePokemon, + onClose, +}: SearchModalProps) { function SearchModal({ format, teamIds, @@ -73,6 +88,21 @@ function SearchModal({ return () => window.removeEventListener("keydown", handleKeyDown); }, [onClose]); + useEffect(() => { + const root = document.documentElement; + const body = document.body; + const previousRootOverflow = root.style.overflow; + const previousBodyOverflow = body.style.overflow; + + root.style.overflow = "hidden"; + body.style.overflow = "hidden"; + + return () => { + root.style.overflow = previousRootOverflow; + body.style.overflow = previousBodyOverflow; + }; + }, []); + const hasSearchTerm = debouncedSearch.name.trim() !== "" || [ @@ -142,9 +172,9 @@ function SearchModal({ - {teamFull && ( -

- {TEAM_FULL_MESSAGE} + {!format && ( +

+ Select a format on the Home page before searching.

)} @@ -248,7 +278,9 @@ function SearchModal({ isInTeam ? undefined : () => onAddPokemon(pokemon) } onRemoveFromTeam={ - isInTeam ? () => onRemovePokemon(pokemon.id) : undefined + isInTeam + ? () => onRemovePokemon(pokemon.id) + : undefined } addDisabled={teamFull} onAddBlocked={() => diff --git a/frontend/src/search/search-modal.css b/frontend/src/search/search-modal.css index 6447223..43b744d 100644 --- a/frontend/src/search/search-modal.css +++ b/frontend/src/search/search-modal.css @@ -6,14 +6,13 @@ align-items: flex-start; justify-content: center; overflow-y: auto; + overscroll-behavior: contain; padding: min(10vh, 5rem) 1rem 2rem; background: rgb(0 0 0 / 55%); } .search-modal { width: min(100%, 820px); - max-height: 85vh; - overflow-y: auto; padding: 1.5rem; border: 1px solid var(--border); border-radius: 1rem; @@ -173,6 +172,35 @@ color: #c0392b; } +@media (max-width: 768px) { + .search-modal-overlay { + align-items: stretch; + overflow: hidden; + overscroll-behavior: contain; + padding: 0; + } + + .search-modal { + width: 100%; + height: 100dvh; + overflow-y: auto; + overscroll-behavior: contain; + padding: 1rem; + border: 0; + border-radius: 0; + box-shadow: none; + } + + .search-modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + padding: 0; + } +} + @media (max-width: 600px) { .search-modal { padding: 1rem;