diff --git a/src/App.tsx b/src/App.tsx index 5fec1fd..b9bd1be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,36 +1,36 @@ -import { useEffect, useState } from 'react'; -import { Navigate, Route, Routes } from 'react-router-dom'; -import { Navbar } from './components/navbar/Navbar'; -import { SkipLink } from './components/skipLink/SkipLink'; -import { ProtectedRoute } from './components/ProtectedRoute'; -import { AuthProvider } from './components/auth/AuthProvider'; +import { useEffect, useState } from 'react' +import { Navigate, Route, Routes } from 'react-router-dom' +import { Navbar } from './components/navbar/Navbar' +import { SkipLink } from './components/skipLink/SkipLink' +import { ProtectedRoute } from './components/ProtectedRoute' +import { AuthProvider } from './components/auth/AuthProvider' // One page for each route (src/pages/). The parts of the pages are in src/components/. -import AboutPage from '@/pages/AboutPage'; -import CalculatorPage from '@/pages/CalculatorPage'; -import ElementDetailPage from '@/pages/ElementDetailPage'; -import ElementsPage from '@/pages/ElementsPage'; -import LoginPage from '@/pages/LoginPage'; -import NotFoundPage from '@/pages/NotFoundPage'; -import YourGymnastsPage from '@/pages/YourGymnastsPage'; +import AboutPage from '@/pages/AboutPage' +import CalculatorPage from '@/pages/CalculatorPage' +import ElementDetailPage from '@/pages/ElementDetailPage' +import ElementsPage from '@/pages/ElementsPage' +import LoginPage from '@/pages/LoginPage' +import NotFoundPage from '@/pages/NotFoundPage' +import YourGymnastsPage from '@/pages/YourGymnastsPage' -import './index.css'; -import './styles/variables.css'; -import './styles/global.css'; +import './index.css' +import './styles/variables.css' +import './styles/global.css' export function App() { const [isDarkMode, setIsDarkMode] = useState( () => window.localStorage.getItem('theme') === 'dark', - ); + ) useEffect(() => { - document.documentElement.dataset.theme = isDarkMode ? 'dark' : 'light'; - window.localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); + document.documentElement.dataset.theme = isDarkMode ? 'dark' : 'light' + window.localStorage.setItem('theme', isDarkMode ? 'dark' : 'light') return () => { - delete document.documentElement.dataset.theme; - }; - }, [isDarkMode]); + delete document.documentElement.dataset.theme + } + }, [isDarkMode]) return ( @@ -59,7 +59,7 @@ export function App() { - ); + ) } -export default App; \ No newline at end of file +export default App diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index c4b2876..94f735d 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,10 +1,10 @@ -import { Navigate, Outlet, useLocation } from 'react-router-dom'; -import { useAuth } from '@/components/auth/useAuth'; -import PageTitle from '@/components/PageTitle'; +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import { useAuth } from '@/components/auth/useAuth' +import PageTitle from '@/components/PageTitle' interface ProtectedRouteProps { /** Where an anonymous visitor is sent. The page they wanted is kept, so the login can send them back. */ - redirectPath?: string; + redirectPath?: string } /** @@ -16,8 +16,8 @@ interface ProtectedRouteProps { export const ProtectedRoute = ({ redirectPath = '/login', }: ProtectedRouteProps) => { - const { status } = useAuth(); - const location = useLocation(); + const { status } = useAuth() + const location = useLocation() if (status === 'loading') { return ( @@ -25,12 +25,12 @@ export const ProtectedRoute = ({ Checking your login

Checking your login…

- ); + ) } if (status === 'anonymous') { - return ; + return } - return ; -}; + return +} diff --git a/src/components/gymnasts/DevLoginButton.tsx b/src/components/gymnasts/DevLoginButton.tsx index 9efb942..50ab423 100644 --- a/src/components/gymnasts/DevLoginButton.tsx +++ b/src/components/gymnasts/DevLoginButton.tsx @@ -1,8 +1,8 @@ -import { useState, type FormEvent } from "react"; -import { Button } from "@/components/ui/button"; -import { PILL } from "@/components/ui/pill"; -import { devLogin } from "@/lib/apollo/client"; -import { describeError } from "@/lib/apollo/errors"; +import { useState, type FormEvent } from 'react' +import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' +import { devLogin } from '@/lib/apollo/client' +import { describeError } from '@/lib/apollo/errors' /** * Logs in as the coach with the e-mail address that the person types, without @@ -14,24 +14,24 @@ import { describeError } from "@/lib/apollo/errors"; export default function DevLoginButton({ onLoggedIn, }: { - onLoggedIn: () => Promise; + onLoggedIn: () => Promise }) { - const [email, setEmail] = useState(""); - const [isLoggingIn, setIsLoggingIn] = useState(false); - const [error, setError] = useState(""); + const [email, setEmail] = useState('') + const [isLoggingIn, setIsLoggingIn] = useState(false) + const [error, setError] = useState('') async function logIn(event: FormEvent) { - event.preventDefault(); - if (isLoggingIn) return; - setIsLoggingIn(true); - setError(""); + event.preventDefault() + if (isLoggingIn) return + setIsLoggingIn(true) + setError('') try { - await devLogin(email); - await onLoggedIn(); + await devLogin(email) + await onLoggedIn() } catch (problem) { - setError(describeError(problem).message); + setError(describeError(problem).message) } finally { - setIsLoggingIn(false); + setIsLoggingIn(false) } } @@ -58,11 +58,11 @@ export default function DevLoginButton({ required value={email} onChange={(event) => { - setEmail(event.target.value); - setError(""); + setEmail(event.target.value) + setError('') }} aria-invalid={Boolean(error)} - aria-describedby={error ? "dev-login-error" : undefined} + aria-describedby={error ? 'dev-login-error' : undefined} className="h-11 w-full rounded-md border border-(--field-border) bg-(--field-bg) px-3 text-(--text-color) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color)" /> {error && (

)} - ); + ) } diff --git a/src/components/gymnasts/GymnastList.tsx b/src/components/gymnasts/GymnastList.tsx index 20c5f8f..86c95da 100644 --- a/src/components/gymnasts/GymnastList.tsx +++ b/src/components/gymnasts/GymnastList.tsx @@ -1,20 +1,17 @@ -import GymnastRow from "@/components/gymnasts/GymnastRow"; -import type { GymnastSummary } from "@/lib/gymnasts"; +import GymnastRow from '@/components/gymnasts/GymnastRow' +import type { GymnastSummary } from '@/lib/gymnasts' export default function GymnastList({ gymnasts, onDeleted, onFailed, }: { - gymnasts: GymnastSummary[]; - onDeleted: (name: string) => void; - onFailed: (message: string) => void; + gymnasts: GymnastSummary[] + onDeleted: (name: string) => void + onFailed: (message: string) => void }) { return ( -

+

Gymnast list

@@ -35,5 +32,5 @@ export default function GymnastList({ )}
- ); -} \ No newline at end of file + ) +} diff --git a/src/components/navbar/Navbar.tsx b/src/components/navbar/Navbar.tsx index ffe806e..da1a20e 100644 --- a/src/components/navbar/Navbar.tsx +++ b/src/components/navbar/Navbar.tsx @@ -1,41 +1,41 @@ -import React, { useState } from 'react'; -import { Menu, X } from 'lucide-react'; -import { Link, NavLink, useNavigate } from 'react-router-dom'; -import { useAuth } from '@/components/auth/useAuth'; -import { logout } from '@/lib/apollo/client'; +import React, { useState } from 'react' +import { Menu, X } from 'lucide-react' +import { Link, NavLink, useNavigate } from 'react-router-dom' +import { useAuth } from '@/components/auth/useAuth' +import { logout } from '@/lib/apollo/client' interface NavbarProps { - isDarkMode: boolean; - onToggleDarkMode: () => void; + isDarkMode: boolean + onToggleDarkMode: () => void } export const Navbar: React.FC = ({ isDarkMode, onToggleDarkMode, }) => { - const [menuOpen, setMenuOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false) // The same answer as the pages read: who is logged in, as the server says it. - const { status } = useAuth(); - const isLoggedIn = status === 'authenticated'; - const navigate = useNavigate(); + const { status } = useAuth() + const isLoggedIn = status === 'authenticated' + const navigate = useNavigate() async function logOut() { - setMenuOpen(false); + setMenuOpen(false) // Leave a page that is for coaches first, so the visitor is not sent to the // login page by the page they are leaving. - void navigate('/elements'); - await logout(); + void navigate('/elements') + await logout() } const focusRing = - 'focus-visible:outline-2 focus-visible:outline-(--text-color) focus-visible:outline-offset-4'; + 'focus-visible:outline-2 focus-visible:outline-(--text-color) focus-visible:outline-offset-4' const authButton = - 'inline-flex min-h-6 min-w-6 shrink items-center whitespace-nowrap rounded-corner border border-border-color bg-transparent px-[clamp(11px,12px,13px)] py-xs font-text text-[clamp(var(--font-size-row-title),1.2vw,1rem)] text-text-color'; + 'inline-flex min-h-6 min-w-6 shrink items-center whitespace-nowrap rounded-corner border border-border-color bg-transparent px-[clamp(11px,12px,13px)] py-xs font-text text-[clamp(var(--font-size-row-title),1.2vw,1rem)] text-text-color' const navLinks = [ { path: '/elements', label: 'Search' }, { path: '/calculator', label: 'Calculator' }, { path: '/about', label: 'About' }, ...(isLoggedIn ? [{ path: '/gymnasts', label: 'Gymnasts' }] : []), - ]; + ] return (
@@ -84,9 +84,7 @@ export const Navbar: React.FC = ({ aria-hidden="true" className="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-primary-color text-xs transition-transform duration-200 ease-in-out" style={{ - transform: isDarkMode - ? 'translateX(22px)' - : 'translateX(0)', + transform: isDarkMode ? 'translateX(22px)' : 'translateX(0)', }} > {isDarkMode ? '🌙' : '☀️'} @@ -104,7 +102,10 @@ export const Navbar: React.FC = ({ Log out ) : ( - + Log in ))} @@ -125,5 +126,5 @@ export const Navbar: React.FC = ({
- ); -}; \ No newline at end of file + ) +} diff --git a/src/components/skipLink/SkipLink.tsx b/src/components/skipLink/SkipLink.tsx index 9fb8807..89eebb5 100644 --- a/src/components/skipLink/SkipLink.tsx +++ b/src/components/skipLink/SkipLink.tsx @@ -1,5 +1,5 @@ interface SkipLinkProps { - mainId?: string; + mainId?: string } export const SkipLink = ({ mainId = 'main-content' }: SkipLinkProps) => { @@ -10,5 +10,5 @@ export const SkipLink = ({ mainId = 'main-content' }: SkipLinkProps) => { > Skip to main content - ); -}; \ No newline at end of file + ) +} diff --git a/src/lib/devLogin.ts b/src/lib/devLogin.ts index 08df1a1..e6ad724 100644 --- a/src/lib/devLogin.ts +++ b/src/lib/devLogin.ts @@ -3,4 +3,4 @@ * build has no development login (see "Login for development" in docs/API.md). */ export const isDevLoginEnabled = (): boolean => - import.meta.env.VITE_DEV_LOGIN === "true"; + import.meta.env.VITE_DEV_LOGIN === 'true' diff --git a/src/lib/gymnasts.ts b/src/lib/gymnasts.ts index 91072b2..aa625c6 100644 --- a/src/lib/gymnasts.ts +++ b/src/lib/gymnasts.ts @@ -3,20 +3,20 @@ import { nameProblem, normalizeName, type NameProblem, -} from "../../shared/names"; +} from '../../shared/names' export type GymnastSummary = { - id: string; - name: string; -}; + id: string + name: string +} export function getGymnastInitials(name: string): string { - const words = name.trim().split(/\s+/).filter(Boolean); + const words = name.trim().split(/\s+/).filter(Boolean) - if (words.length === 0) return "?"; - if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + if (words.length === 0) return '?' + if (words.length === 1) return words[0].slice(0, 2).toUpperCase() - return `${words[0][0]}${words.at(-1)?.[0] ?? ""}`.toUpperCase(); + return `${words[0][0]}${words.at(-1)?.[0] ?? ''}`.toUpperCase() } /** @@ -25,10 +25,10 @@ export function getGymnastInitials(name: string): string { * emoji is two. Twice the limit never stops a valid name; the exact check is * getGymnastNameError. */ -export const GYMNAST_NAME_INPUT_MAX_LENGTH = MAX_NAME_LENGTH * 2; +export const GYMNAST_NAME_INPUT_MAX_LENGTH = MAX_NAME_LENGTH * 2 /** The most characters the form lets a gymnast name have. It is the rule of the server. */ -export const MAX_GYMNAST_NAME_LENGTH = MAX_NAME_LENGTH; +export const MAX_GYMNAST_NAME_LENGTH = MAX_NAME_LENGTH /** * What is typed or pasted in the name field, cut to the most characters a name @@ -36,25 +36,25 @@ export const MAX_GYMNAST_NAME_LENGTH = MAX_NAME_LENGTH; * (code points) like the server does, so an emoji is one and 70 of them fit. */ export function limitGymnastNameInput(value: string): string { - const characters = [...value]; + const characters = [...value] return characters.length > MAX_GYMNAST_NAME_LENGTH - ? characters.slice(0, MAX_GYMNAST_NAME_LENGTH).join("") - : value; + ? characters.slice(0, MAX_GYMNAST_NAME_LENGTH).join('') + : value } /** The name as the server stores it (the rule is in shared/names.ts). */ export function normalizeGymnastName(name: string): string { - return normalizeName(name); + return normalizeName(name) } const NAME_MESSAGES: Record = { - empty: "Name is required", - "too-long": `Names must be ${MAX_NAME_LENGTH} characters or fewer.`, -}; + empty: 'Name is required', + 'too-long': `Names must be ${MAX_NAME_LENGTH} characters or fewer.`, +} export function getGymnastNameError(name: string): string | undefined { - const problem = nameProblem(name); + const problem = nameProblem(name) - return problem ? NAME_MESSAGES[problem] : undefined; + return problem ? NAME_MESSAGES[problem] : undefined } diff --git a/src/pages/YourGymnastsPage.tsx b/src/pages/YourGymnastsPage.tsx index c84213c..8f9168a 100644 --- a/src/pages/YourGymnastsPage.tsx +++ b/src/pages/YourGymnastsPage.tsx @@ -1,102 +1,102 @@ -import { useEffect, useRef, useState } from "react"; -import { useMutation, useQuery } from "@apollo/client/react"; -import { Link } from "react-router-dom"; -import { Check, Plus, X } from "lucide-react"; -import { useAuth } from "@/components/auth/useAuth"; -import PageTitle from "@/components/PageTitle"; -import { Button } from "@/components/ui/button"; -import { PILL } from "@/components/ui/pill"; -import GymnastList from "@/components/gymnasts/GymnastList"; -import { describeError } from "@/lib/apollo/errors"; +import { useEffect, useRef, useState } from 'react' +import { useMutation, useQuery } from '@apollo/client/react' +import { Link } from 'react-router-dom' +import { Check, Plus, X } from 'lucide-react' +import { useAuth } from '@/components/auth/useAuth' +import PageTitle from '@/components/PageTitle' +import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' +import GymnastList from '@/components/gymnasts/GymnastList' +import { describeError } from '@/lib/apollo/errors' import { CREATE_GYMNAST_MUTATION, GYMNASTS_QUERY, -} from "@/lib/gymnastOperations"; +} from '@/lib/gymnastOperations' import { GYMNAST_NAME_INPUT_MAX_LENGTH, getGymnastNameError, limitGymnastNameInput, normalizeGymnastName, -} from "@/lib/gymnasts"; +} from '@/lib/gymnasts' export default function YourGymnastsPage() { - const { data, error: loadError, loading, refetch } = useQuery(GYMNASTS_QUERY); + const { data, error: loadError, loading, refetch } = useQuery(GYMNASTS_QUERY) // The list is fetched again after a new gymnast: the API sorts the gymnasts // by name, so the cache cannot know where the new one belongs. const [createGymnast, { loading: isSaving }] = useMutation( CREATE_GYMNAST_MUTATION, { refetchQueries: [GYMNASTS_QUERY] }, - ); - const [isFormOpen, setIsFormOpen] = useState(false); - const [name, setName] = useState(""); - const [error, setError] = useState(""); - const [announcement, setAnnouncement] = useState(""); - const nameInput = useRef(null); - const addButton = useRef(null); - const wasFormOpen = useRef(false); + ) + const [isFormOpen, setIsFormOpen] = useState(false) + const [name, setName] = useState('') + const [error, setError] = useState('') + const [announcement, setAnnouncement] = useState('') + const nameInput = useRef(null) + const addButton = useRef(null) + const wasFormOpen = useRef(false) // A second submit before the first has an answer (Enter twice) must not // create a second gymnast. The state of the mutation changes one render too late. - const isSubmitting = useRef(false); + const isSubmitting = useRef(false) // The same answer as the menu reads. Nobody logged in, or a session that has // ended, is not the same as a coach with no gymnasts, so it must not look like // an empty list. (A visitor who is not logged in does not get here from the // menu: ProtectedRoute sends them to the login page. This is for a session // that ends while the page is open.) - const isLoggedOut = useAuth().status === "anonymous"; - const [deleteError, setDeleteError] = useState(""); - const loadProblem = loadError ? describeError(loadError) : null; + const isLoggedOut = useAuth().status === 'anonymous' + const [deleteError, setDeleteError] = useState('') + const loadProblem = loadError ? describeError(loadError) : null useEffect(() => { if (isFormOpen) { - nameInput.current?.focus(); - wasFormOpen.current = true; + nameInput.current?.focus() + wasFormOpen.current = true } else if (wasFormOpen.current) { - addButton.current?.focus(); - wasFormOpen.current = false; + addButton.current?.focus() + wasFormOpen.current = false } - }, [isFormOpen]); + }, [isFormOpen]) // The button of the deleted gymnast is gone, so the focus goes to the button // that adds one, and the visitor is told what happened. function onDeleted(deletedName: string) { - setDeleteError(""); - setAnnouncement(`${deletedName} deleted.`); - addButton.current?.focus(); + setDeleteError('') + setAnnouncement(`${deletedName} deleted.`) + addButton.current?.focus() } function closeForm() { - setIsFormOpen(false); - setName(""); - setError(""); + setIsFormOpen(false) + setName('') + setError('') } async function addGymnast(event: React.FormEvent) { - event.preventDefault(); - if (isSubmitting.current) return; - const normalizedName = normalizeGymnastName(name); - const nameError = getGymnastNameError(name); + event.preventDefault() + if (isSubmitting.current) return + const normalizedName = normalizeGymnastName(name) + const nameError = getGymnastNameError(name) if (nameError) { - setError(nameError); - nameInput.current?.focus(); - return; + setError(nameError) + nameInput.current?.focus() + return } - isSubmitting.current = true; + isSubmitting.current = true try { const result = await createGymnast({ variables: { name: normalizedName }, - }); - const addedName = result.data?.createGymnast.name ?? normalizedName; - setAnnouncement(`${addedName} added to your gymnasts.`); - closeForm(); + }) + const addedName = result.data?.createGymnast.name ?? normalizedName + setAnnouncement(`${addedName} added to your gymnasts.`) + closeForm() } catch (problem) { // The form stays open with what the user wrote, so it can be corrected. - setError(describeError(problem).message); - nameInput.current?.focus(); + setError(describeError(problem).message) + nameInput.current?.focus() } finally { - isSubmitting.current = false; + isSubmitting.current = false } } @@ -115,9 +115,9 @@ export default function YourGymnastsPage() { type="button" className={PILL} onClick={() => { - setError(""); - setAnnouncement(""); - setIsFormOpen(true); + setError('') + setAnnouncement('') + setIsFormOpen(true) }} >