This commit is contained in:
Zeeshaun
2026-05-06 15:51:10 +00:00
parent 9e56dae161
commit 4d098a9ec9
24 changed files with 2184 additions and 1464 deletions
+1392 -792
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -9,15 +9,18 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@radix-ui/react-dialog": "^1.1.15",
"@emotion/styled": "^11.14.1", "autoprefixer": "^10.5.0",
"@mui/icons-material": "^9.0.0", "class-variance-authority": "^0.7.1",
"@mui/material": "^9.0.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"mongoose": "^8.14.3", "mongoose": "^8.14.3",
"next": "^15.3.2", "next": "^15.3.2",
"postcss": "^8.5.14",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^3.4.17",
"zod": "^3.24.4" "zod": "^3.24.4"
}, },
"devDependencies": { "devDependencies": {
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+38 -22
View File
@@ -1,27 +1,43 @@
:root { @tailwind base;
--font-sans: "Avenir Next", "Segoe UI", "Trebuchet MS", sans-serif; @tailwind components;
--font-display: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif; @tailwind utilities;
@layer base {
:root {
color-scheme: light;
}
* {
@apply border-border;
}
html {
font-size: 16px;
}
body {
@apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image:
radial-gradient(circle at top left, rgba(15, 92, 77, 0.16), transparent 28%),
radial-gradient(circle at top right, rgba(207, 111, 46, 0.18), transparent 24%),
linear-gradient(180deg, #fcfaf5 0%, #f7f1e5 50%, #f2e7d4 100%);
}
a {
@apply text-inherit no-underline;
}
} }
* { @layer components {
box-sizing: border-box; .app-surface {
} @apply rounded-3xl border border-white/70 bg-card/85 shadow-panel backdrop-blur;
}
html { .field-label {
font-size: 16px; @apply mb-2 block text-sm font-medium text-muted-foreground;
} }
body { .field-select {
margin: 0; @apply flex h-11 w-full rounded-2xl border border-input bg-white/90 px-4 py-2 text-sm text-foreground shadow-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring;
min-height: 100vh; }
background:
radial-gradient(circle at top left, rgba(15, 92, 77, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(207, 111, 46, 0.18), transparent 24%),
linear-gradient(180deg, #fbf7ef 0%, #f4efe3 44%, #efe5d4 100%);
font-family: var(--font-sans);
}
a {
color: inherit;
text-decoration: none;
} }
+1 -4
View File
@@ -1,5 +1,4 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { AppThemeProvider } from "@/components/AppThemeProvider";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -10,9 +9,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="en"> <html lang="en">
<body> <body>{children}</body>
<AppThemeProvider>{children}</AppThemeProvider>
</body>
</html> </html>
); );
} }
+42 -90
View File
@@ -1,15 +1,15 @@
"use client"; "use client";
import SportsBaseballRoundedIcon from "@mui/icons-material/SportsBaseballRounded"; import { BarChart3, ClipboardList, ShieldCheck, Trophy, Users } from "lucide-react";
import { Box, Chip, Container, Paper, Tab, Tabs, Typography } from "@mui/material";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const navItems = [ const navItems = [
{ href: "/dashboard", label: "Dashboard" }, { href: "/dashboard", label: "Dashboard", icon: BarChart3 },
{ href: "/roster", label: "Roster" }, { href: "/roster", label: "Roster", icon: Users },
{ href: "/lineup", label: "Generate Lineup" }, { href: "/lineup", label: "Generate Lineup", icon: ClipboardList },
{ href: "/settings", label: "Pitching Rules" } { href: "/settings", label: "Pitching Rules", icon: ShieldCheck }
]; ];
export function AppShell({ export function AppShell({
@@ -24,95 +24,47 @@ export function AppShell({
actions?: React.ReactNode; actions?: React.ReactNode;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const currentTab = navItems.find((item) => pathname === item.href)?.href ?? false;
return ( return (
<Container maxWidth="lg" sx={{ py: { xs: 2, md: 4 } }}> <div className="container py-4 md:py-8">
<Paper <header className="relative overflow-hidden rounded-[2rem] bg-[linear-gradient(135deg,rgba(8,35,47,0.98)_0%,rgba(14,87,72,0.96)_52%,rgba(203,105,48,0.92)_100%)] p-6 text-white shadow-glow md:p-10">
elevation={0} <div className="pointer-events-none absolute inset-0 bg-hero-grid bg-[size:38px_38px] opacity-20" />
sx={{ <div className="relative flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
p: { xs: 3, md: 4 }, <div className="max-w-3xl">
borderRadius: 7, <div className="mb-4 inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-medium text-white/90 backdrop-blur">
color: "common.white", <Trophy className="h-4 w-4" />
background: Little League Coach Console
"linear-gradient(135deg, rgba(9,45,64,0.98) 0%, rgba(15,92,77,0.96) 50%, rgba(207,111,46,0.92) 100%)", </div>
boxShadow: "0 24px 64px rgba(16, 41, 52, 0.24)" <h1 className="font-display text-4xl font-semibold leading-none tracking-tight md:text-6xl">{title}</h1>
}} <p className="mt-4 max-w-2xl text-base text-white/80 md:text-lg">{subtitle}</p>
> </div>
<Box {actions ? <div className="relative flex items-start">{actions}</div> : null}
sx={{ </div>
display: "flex", </header>
flexDirection: { xs: "column", md: "row" },
justifyContent: "space-between",
gap: 3
}}
>
<Box sx={{ maxWidth: 760 }}>
<Chip
icon={<SportsBaseballRoundedIcon />}
label="Little League Coach Console"
sx={{
mb: 2,
color: "common.white",
bgcolor: "rgba(255,255,255,0.14)",
"& .MuiChip-icon": { color: "common.white" }
}}
/>
<Typography variant="h1" sx={{ fontSize: { xs: "2.6rem", md: "4rem" }, lineHeight: 0.95 }}>
{title}
</Typography>
<Typography sx={{ mt: 1.5, maxWidth: 620, color: "rgba(255,255,255,0.84)" }}>{subtitle}</Typography>
</Box>
{actions ? <Box sx={{ display: "flex", alignItems: "flex-start" }}>{actions}</Box> : null}
</Box>
</Paper>
<Paper <nav className="app-surface mt-4 flex flex-wrap gap-2 p-2">
elevation={0} {navItems.map((item) => {
sx={{ const Icon = item.icon;
mt: 2, const active = pathname === item.href;
p: 1, return (
borderRadius: 5, <Link
border: "1px solid rgba(15, 38, 47, 0.08)",
backgroundColor: "rgba(255,255,255,0.72)",
backdropFilter: "blur(10px)"
}}
>
<Tabs
value={currentTab}
variant="scrollable"
scrollButtons="auto"
aria-label="Primary"
sx={{
minHeight: 0,
"& .MuiTabs-indicator": { display: "none" }
}}
>
{navItems.map((item) => (
<Tab
key={item.href} key={item.href}
component={Link}
href={item.href} href={item.href}
value={item.href} className={cn(
label={item.label} "inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition",
sx={{ active
minHeight: 0, ? "bg-white text-foreground shadow-panel"
borderRadius: 999, : "text-muted-foreground hover:bg-white/80 hover:text-foreground"
color: "text.secondary", )}
"&.Mui-selected": { >
color: "text.primary", <Icon className="h-4 w-4" />
bgcolor: "background.paper", {item.label}
boxShadow: "0 10px 24px rgba(16, 41, 52, 0.08)" </Link>
} );
}} })}
/> </nav>
))}
</Tabs>
</Paper>
<Box component="main" sx={{ display: "grid", gap: 2.25, mt: 2.5 }}> <main className="mt-5 grid gap-5">{children}</main>
{children} </div>
</Box>
</Container>
); );
} }
@@ -1,96 +0,0 @@
"use client";
import { CssBaseline, ThemeProvider, createTheme } from "@mui/material";
const theme = createTheme({
palette: {
mode: "light",
primary: {
main: "#0f5c4d",
light: "#2d7a69",
dark: "#0a4338"
},
secondary: {
main: "#cf6f2e",
light: "#e38a4a",
dark: "#a55720"
},
background: {
default: "#f6f1e7",
paper: "#fffdf8"
},
success: {
main: "#1f7a52"
},
warning: {
main: "#ba7b11"
},
error: {
main: "#ba4637"
}
},
shape: {
borderRadius: 18
},
typography: {
fontFamily: "var(--font-sans)",
h1: {
fontFamily: "var(--font-display)",
fontWeight: 700,
letterSpacing: "-0.03em"
},
h2: {
fontFamily: "var(--font-display)",
fontWeight: 700,
letterSpacing: "-0.02em"
},
h3: {
fontWeight: 700
},
button: {
fontWeight: 700,
textTransform: "none"
}
},
components: {
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: "none"
}
}
},
MuiButton: {
defaultProps: {
disableElevation: true
},
styleOverrides: {
root: {
borderRadius: 999,
paddingInline: 18
}
}
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 22
}
}
},
MuiTextField: {
defaultProps: {
size: "small"
}
}
}
});
export function AppThemeProvider({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
);
}
+29 -15
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material"; import * as Dialog from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
type ConfirmDialogProps = { type ConfirmDialogProps = {
open: boolean; open: boolean;
@@ -22,19 +24,31 @@ export function ConfirmDialog({
onCancel onCancel
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
return ( return (
<Dialog open={open} onClose={onCancel} fullWidth maxWidth="xs"> <Dialog.Root open={open} onOpenChange={(nextOpen) => (!nextOpen ? onCancel() : undefined)}>
<DialogTitle>{title}</DialogTitle> <Dialog.Portal>
<DialogContent> <Dialog.Overlay className="fixed inset-0 z-50 bg-slate-950/50 backdrop-blur-sm" />
<Typography color="text.secondary">{description}</Typography> <Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-[1.75rem] border border-white/60 bg-card p-6 shadow-glow focus:outline-none">
</DialogContent> <div className="flex items-start justify-between gap-4">
<DialogActions sx={{ px: 3, pb: 2.5 }}> <div>
<Button variant="outlined" color="inherit" onClick={onCancel}> <Dialog.Title className="font-display text-2xl font-semibold">{title}</Dialog.Title>
{cancelLabel} <Dialog.Description className="mt-2 text-sm text-muted-foreground">{description}</Dialog.Description>
</Button> </div>
<Button variant="contained" color="error" onClick={onConfirm}> <Dialog.Close asChild>
{confirmLabel} <button className="rounded-full p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground" aria-label="Close">
</Button> <X className="h-4 w-4" />
</DialogActions> </button>
</Dialog> </Dialog.Close>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="secondary" onClick={onCancel}>
{cancelLabel}
</Button>
<Button variant="destructive" onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
); );
} }
+26 -33
View File
@@ -1,11 +1,12 @@
"use client"; "use client";
import { Alert, Box, Card, CardContent, Divider, Stack, Typography } from "@mui/material";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { EmptyState } from "@/components/EmptyState"; import { EmptyState } from "@/components/EmptyState";
import { SectionCard } from "@/components/SectionCard"; import { SectionCard } from "@/components/SectionCard";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { Alert } from "@/components/ui/alert";
import { Card, CardContent } from "@/components/ui/card";
import { fetchJson } from "@/lib/client"; import { fetchJson } from "@/lib/client";
import type { DashboardResponse } from "@/lib/types"; import type { DashboardResponse } from "@/lib/types";
@@ -37,67 +38,59 @@ export function DashboardClient() {
title="Game Day Snapshot" title="Game Day Snapshot"
subtitle="Check roster health, pitcher rest, and lineup readiness before first pitch." subtitle="Check roster health, pitcher rest, and lineup readiness before first pitch."
> >
{error ? <Alert severity="error">{error}</Alert> : null} {error ? <Alert tone="error">{error}</Alert> : null}
{loading ? ( {loading ? (
<SectionCard title="Loading" description="Pulling current roster and pitching data." /> <SectionCard title="Loading" description="Pulling current roster and pitching data." />
) : data && data.rosterCount > 0 ? ( ) : data && data.rosterCount > 0 ? (
<> <>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", lg: "repeat(4, 1fr)" }, gap: 2 }}> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{[ {[
{ label: "Rostered Players", value: data.rosterCount }, { label: "Rostered Players", value: data.rosterCount },
{ label: "Active Today", value: data.activePlayers }, { label: "Active Today", value: data.activePlayers },
{ label: "Pitchers Available", value: data.availablePitchers, color: "success.main" }, { label: "Pitchers Available", value: data.availablePitchers, color: "text-emerald-700" },
{ label: "Need Rest", value: data.needsRest, color: "warning.main" } { label: "Need Rest", value: data.needsRest, color: "text-amber-700" }
].map((item) => ( ].map((item) => (
<Card key={item.label} elevation={0} sx={{ border: "1px solid rgba(15, 38, 47, 0.08)" }}> <Card key={item.label} className="overflow-hidden">
<CardContent> <CardContent className="pt-6">
<Typography color="text.secondary" variant="body2"> <p className="text-sm text-muted-foreground">{item.label}</p>
{item.label} <p className={`mt-2 font-display text-5xl leading-none ${item.color ?? "text-foreground"}`}>
</Typography>
<Typography variant="h2" sx={{ mt: 1, color: item.color ?? "text.primary", fontSize: "2.35rem" }}>
{item.value} {item.value}
</Typography> </p>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</Box> </div>
<SectionCard <SectionCard
title="Pitcher Availability" title="Pitcher Availability"
description="Availability is based on the latest appearance and your configured rest rules." description="Availability is based on the latest appearance and your configured rest rules."
> >
<Stack divider={<Divider flexItem />} spacing={0}> <div className="divide-y divide-border">
{data.recentPitching.map((entry) => ( {data.recentPitching.map((entry) => (
<Box <div
key={entry.playerId} key={entry.playerId}
sx={{ className="flex flex-col gap-3 py-4 md:flex-row md:items-start md:justify-between"
display: "flex",
flexDirection: { xs: "column", md: "row" },
justifyContent: "space-between",
gap: 1.5,
py: 2
}}
> >
<Box> <div>
<Typography variant="h6">{entry.playerName}</Typography> <h3 className="text-lg font-semibold">{entry.playerName}</h3>
<Typography color="text.secondary"> <p className="text-sm text-muted-foreground">
{entry.lastPitchedDate {entry.lastPitchedDate
? `${entry.pitchCount} pitches on ${entry.lastPitchedDate}` ? `${entry.pitchCount} pitches on ${entry.lastPitchedDate}`
: "No pitching history recorded yet."} : "No pitching history recorded yet."}
</Typography> </p>
</Box> </div>
<Stack spacing={0.75} sx={{ alignItems: { xs: "flex-start", md: "flex-end" } }}> <div className="flex flex-col items-start gap-2 md:items-end">
<StatusBadge status={entry.status} /> <StatusBadge status={entry.status} />
<Typography variant="body2" color="text.secondary"> <p className="text-sm text-muted-foreground">
{entry.daysSinceLastAppearance === null {entry.daysSinceLastAppearance === null
? "No history" ? "No history"
: `${entry.daysSinceLastAppearance} day(s) since last outing`} : `${entry.daysSinceLastAppearance} day(s) since last outing`}
</Typography> </p>
</Stack> </div>
</Box> </div>
))} ))}
</Stack> </div>
</SectionCard> </SectionCard>
</> </>
) : ( ) : (
+4 -15
View File
@@ -1,5 +1,3 @@
import { Box, Typography } from "@mui/material";
export function EmptyState({ export function EmptyState({
title, title,
description, description,
@@ -10,19 +8,10 @@ export function EmptyState({
action?: React.ReactNode; action?: React.ReactNode;
}) { }) {
return ( return (
<Box <div className="rounded-[1.75rem] border border-dashed border-border bg-muted/50 px-6 py-10 text-center">
sx={{ <h3 className="font-display text-2xl font-semibold">{title}</h3>
p: { xs: 3, md: 4 }, <p className="mx-auto mt-2 max-w-xl text-sm text-muted-foreground">{description}</p>
borderRadius: 4,
border: "1px dashed rgba(15, 38, 47, 0.16)",
background:
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(248,243,235,0.9) 100%)",
textAlign: "center"
}}
>
<Typography variant="h5">{title}</Typography>
<Typography sx={{ mt: 1, color: "text.secondary" }}>{description}</Typography>
{action} {action}
</Box> </div>
); );
} }
+120 -118
View File
@@ -1,13 +1,16 @@
"use client"; "use client";
import { Alert, Box, Button, Card, CardContent, Checkbox, Divider, FormControlLabel, MenuItem, Stack, TextField, Typography } from "@mui/material";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { EmptyState } from "@/components/EmptyState"; import { EmptyState } from "@/components/EmptyState";
import { SectionCard } from "@/components/SectionCard"; import { SectionCard } from "@/components/SectionCard";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { FIELD_POSITIONS } from "@/lib/constants"; import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { fetchJson } from "@/lib/client"; import { fetchJson } from "@/lib/client";
import { FIELD_POSITIONS } from "@/lib/constants";
import { formatPlayerName } from "@/lib/utils"; import { formatPlayerName } from "@/lib/utils";
import type { GeneratedLineup, LineupRecord, PlayerRecord } from "@/lib/types"; import type { GeneratedLineup, LineupRecord, PlayerRecord } from "@/lib/types";
@@ -40,9 +43,10 @@ export function LineupClient() {
const selectablePitchers = useMemo( const selectablePitchers = useMemo(
() => () =>
players.filter((player) => players.filter(
selectedPlayerIds.includes(player._id) && (player) =>
(showAllPitchers || player.pitcherAvailability.status !== "needs-rest") selectedPlayerIds.includes(player._id) &&
(showAllPitchers || player.pitcherAvailability.status !== "needs-rest")
), ),
[players, selectedPlayerIds, showAllPitchers] [players, selectedPlayerIds, showAllPitchers]
); );
@@ -95,102 +99,101 @@ export function LineupClient() {
title="Generate Lineup" title="Generate Lineup"
subtitle="Choose the active group, pick an eligible starter, then adjust the suggested lineup as needed." subtitle="Choose the active group, pick an eligible starter, then adjust the suggested lineup as needed."
> >
{message ? <Alert severity="success">{message}</Alert> : null} {message ? <Alert tone="success">{message}</Alert> : null}
{error ? <Alert severity="error">{error}</Alert> : null} {error ? <Alert tone="error">{error}</Alert> : null}
<SectionCard title="Game Setup" description="Start with the players who are available today."> <SectionCard title="Game Setup" description="Start with the players who are available today.">
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, minmax(0, 1fr))" }, gap: 2 }}> <div className="grid gap-5 md:grid-cols-2">
<TextField <label>
label="Game Date" <span className="field-label">Game Date</span>
type="date" <Input type="date" value={gameDate} onChange={(event) => setGameDate(event.target.value)} />
value={gameDate} </label>
onChange={(event) => setGameDate(event.target.value)} <label>
slotProps={{ inputLabel: { shrink: true } }} <span className="field-label">Starting Pitcher</span>
/> <select
<TextField select label="Starting Pitcher" value={startingPitcherId} onChange={(event) => setStartingPitcherId(event.target.value)}> className="field-select"
{selectablePitchers.map((player) => ( value={startingPitcherId}
<MenuItem key={player._id} value={player._id}> onChange={(event) => setStartingPitcherId(event.target.value)}
{formatPlayerName(player.firstName, player.lastName)} >
</MenuItem> {selectablePitchers.map((player) => (
))} <option key={player._id} value={player._id}>
</TextField> {formatPlayerName(player.firstName, player.lastName)}
<Box sx={{ gridColumn: "1 / -1" }}> </option>
<FormControlLabel ))}
control={<Checkbox checked={showAllPitchers} onChange={(event) => setShowAllPitchers(event.target.checked)} />} </select>
label="Show pitchers who need rest" </label>
<label className="md:col-span-2 flex items-center gap-3 rounded-2xl border border-border bg-white/70 px-4 py-3 text-sm font-medium">
<input
type="checkbox"
checked={showAllPitchers}
onChange={(event) => setShowAllPitchers(event.target.checked)}
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
/> />
</Box> Show pitchers who need rest
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.25} sx={{ gridColumn: "1 / -1" }}> </label>
<Button variant="contained" disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}> <div className="md:col-span-2 flex flex-wrap gap-3">
<Button disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
Generate Suggested Lineup Generate Suggested Lineup
</Button> </Button>
<Button variant="outlined" color="inherit" disabled={!generated} onClick={() => void save()}> <Button variant="secondary" disabled={!generated} onClick={() => void save()}>
Save Lineup Save Lineup
</Button> </Button>
</Stack> </div>
</Box> </div>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "repeat(2, 1fr)" }, gap: 1.5, mt: 3 }}> <div className="mt-6 grid gap-4 lg:grid-cols-2">
{players.map((player) => ( {players.map((player) => {
<Card const selected = selectedPlayerIds.includes(player._id);
key={player._id} return (
elevation={0} <Card
sx={{ key={player._id}
border: selectedPlayerIds.includes(player._id) ? "1px solid" : "1px solid rgba(15, 38, 47, 0.08)", className={selected ? "border-primary/50 bg-primary/5 ring-1 ring-primary/20" : ""}
borderColor: selectedPlayerIds.includes(player._id) ? "primary.main" : "rgba(15, 38, 47, 0.08)",
backgroundColor: selectedPlayerIds.includes(player._id) ? "rgba(15, 92, 77, 0.05)" : "transparent"
}}
>
<CardContent
sx={{
display: "flex",
flexDirection: { xs: "column", sm: "row" },
justifyContent: "space-between",
alignItems: { xs: "flex-start", sm: "center" },
gap: 1.5
}}
> >
<Stack direction="row" spacing={1.25} sx={{ alignItems: "flex-start" }}> <CardContent className="flex items-start justify-between gap-4 pt-6">
<Checkbox <label className="flex flex-1 cursor-pointer items-start gap-4">
checked={selectedPlayerIds.includes(player._id)} <input
onChange={(event) => type="checkbox"
setSelectedPlayerIds((current) => checked={selected}
event.target.checked ? [...current, player._id] : current.filter((id) => id !== player._id) onChange={(event) =>
) setSelectedPlayerIds((current) =>
} event.target.checked
/> ? [...current, player._id]
<Box> : current.filter((id) => id !== player._id)
<Typography variant="h6">{formatPlayerName(player.firstName, player.lastName)}</Typography> )
<Typography color="text.secondary"> }
#{player.jerseyNumber} {player.primaryPosition} className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
</Typography> />
</Box> <span>
</Stack> <span className="block text-lg font-semibold">
<StatusBadge status={player.pitcherAvailability.status} /> {formatPlayerName(player.firstName, player.lastName)}
</CardContent> </span>
</Card> <span className="mt-1 block text-sm text-muted-foreground">
))} #{player.jerseyNumber} {player.primaryPosition}
</Box> </span>
</span>
</label>
<StatusBadge status={player.pitcherAvailability.status} />
</CardContent>
</Card>
);
})}
</div>
</SectionCard> </SectionCard>
{generated ? ( {generated ? (
<> <>
<SectionCard title="Batting Order" description="Adjust any slot manually before saving."> <SectionCard title="Batting Order" description="Adjust any slot manually before saving.">
<Stack divider={<Divider flexItem />} spacing={0}> <div className="divide-y divide-border">
{generated.battingOrder.map((slot, index) => ( {generated.battingOrder.map((slot, index) => (
<Box <div key={`${slot.playerId}-${index}`} className="grid gap-4 py-4 md:grid-cols-[180px_1fr] md:items-center">
key={`${slot.playerId}-${index}`} <div>
sx={{ <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
display: "grid", Spot
gridTemplateColumns: { xs: "1fr", md: "180px 1fr" }, </p>
alignItems: "center", <p className="mt-1 text-lg font-semibold">{index + 1}</p>
gap: 2, </div>
py: 2 <select
}} className="field-select"
>
<Typography variant="h6">Spot {index + 1}</Typography>
<TextField
select
value={slot.playerId} value={slot.playerId}
onChange={(event) => { onChange={(event) => {
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value); const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
@@ -210,39 +213,40 @@ export function LineupClient() {
}} }}
> >
{selectedPlayers.map((player) => ( {selectedPlayers.map((player) => (
<MenuItem key={player._id} value={player._id}> <option key={player._id} value={player._id}>
{formatPlayerName(player.firstName, player.lastName)} {formatPlayerName(player.firstName, player.lastName)}
</MenuItem> </option>
))} ))}
</TextField> </select>
</Box> </div>
))} ))}
</Stack> </div>
</SectionCard> </SectionCard>
<SectionCard title="Defensive Alignment" description="Assignments follow position preferences, but every field is editable."> <SectionCard
<Stack divider={<Divider flexItem />} spacing={0}> title="Defensive Alignment"
description="Assignments follow position preferences, but every field is editable."
>
<div className="divide-y divide-border">
{FIELD_POSITIONS.map((position) => { {FIELD_POSITIONS.map((position) => {
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position); const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
return ( return (
<Box <div key={position} className="grid gap-4 py-4 md:grid-cols-[180px_1fr] md:items-center">
key={position} <div>
sx={{ <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
display: "grid", Position
gridTemplateColumns: { xs: "1fr", md: "180px 1fr" }, </p>
alignItems: "center", <p className="mt-1 text-lg font-semibold">{position}</p>
gap: 2, </div>
py: 2 <select
}} className="field-select"
>
<Typography variant="h6">{position}</Typography>
<TextField
select
value={assignment?.playerId ?? ""} value={assignment?.playerId ?? ""}
onChange={(event) => { onChange={(event) => {
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value); const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
if (!nextPlayer) return; if (!nextPlayer) return;
const nextAssignments = generated.defensiveAssignments.filter((entry) => entry.position !== position); const nextAssignments = generated.defensiveAssignments.filter(
(entry) => entry.position !== position
);
nextAssignments.push({ nextAssignments.push({
playerId: nextPlayer._id, playerId: nextPlayer._id,
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName), playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName),
@@ -254,25 +258,23 @@ export function LineupClient() {
}); });
}} }}
> >
<MenuItem value="">Unassigned</MenuItem> <option value="">Unassigned</option>
{selectedPlayers.map((player) => ( {selectedPlayers.map((player) => (
<MenuItem key={player._id} value={player._id}> <option key={player._id} value={player._id}>
{formatPlayerName(player.firstName, player.lastName)} {formatPlayerName(player.firstName, player.lastName)}
</MenuItem> </option>
))} ))}
</TextField> </select>
</Box> </div>
); );
})} })}
</Stack> </div>
{generated.notes.length > 0 ? ( {generated.notes.length > 0 ? (
<Box sx={{ mt: 2, pl: 2, borderLeft: "4px solid", borderColor: "warning.main" }}> <div className="rounded-[1.5rem] border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
{generated.notes.map((note) => ( {generated.notes.map((note) => (
<Typography key={note} color="text.secondary"> <p key={note}>{note}</p>
{note}
</Typography>
))} ))}
</Box> </div>
) : null} ) : null}
</SectionCard> </SectionCard>
</> </>
+78 -82
View File
@@ -1,7 +1,9 @@
"use client"; "use client";
import { Box, Button, Checkbox, FormControlLabel, MenuItem, Stack, TextField } from "@mui/material";
import { HANDEDNESS_OPTIONS, POSITIONS } from "@/lib/constants"; import { HANDEDNESS_OPTIONS, POSITIONS } from "@/lib/constants";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { PlayerInput, PlayerRecord } from "@/lib/types"; import type { PlayerInput, PlayerRecord } from "@/lib/types";
const defaultForm: PlayerInput = { const defaultForm: PlayerInput = {
@@ -46,88 +48,82 @@ export function PlayerForm({
submitLabel: string; submitLabel: string;
}) { }) {
return ( return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, minmax(0, 1fr))" }, gap: 2 }}> <div className="grid gap-5 md:grid-cols-2">
<TextField label="First Name" value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} /> <label>
<TextField label="Last Name" value={value.lastName} onChange={(event) => onChange({ ...value, lastName: event.target.value })} /> <span className="field-label">First Name</span>
<TextField <Input value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
label="Jersey #" </label>
type="number" <label>
value={value.jerseyNumber} <span className="field-label">Last Name</span>
onChange={(event) => onChange({ ...value, jerseyNumber: Number(event.target.value) })} <Input value={value.lastName} onChange={(event) => onChange({ ...value, lastName: event.target.value })} />
/> </label>
<TextField <label>
select <span className="field-label">Jersey #</span>
label="Bats / Throws" <Input type="number" value={value.jerseyNumber} onChange={(event) => onChange({ ...value, jerseyNumber: Number(event.target.value) })} />
value={value.batsThrows} </label>
onChange={(event) => onChange({ ...value, batsThrows: event.target.value as PlayerInput["batsThrows"] })} <label>
> <span className="field-label">Bats / Throws</span>
{HANDEDNESS_OPTIONS.map((option) => ( <select className="field-select" value={value.batsThrows} onChange={(event) => onChange({ ...value, batsThrows: event.target.value as PlayerInput["batsThrows"] })}>
<MenuItem key={option} value={option}> {HANDEDNESS_OPTIONS.map((option) => (
{option} <option key={option} value={option}>
</MenuItem> {option}
))} </option>
</TextField> ))}
<TextField </select>
select </label>
label="Primary Position" <label>
value={value.primaryPosition} <span className="field-label">Primary Position</span>
onChange={(event) => onChange({ ...value, primaryPosition: event.target.value as PlayerInput["primaryPosition"] })} <select className="field-select" value={value.primaryPosition} onChange={(event) => onChange({ ...value, primaryPosition: event.target.value as PlayerInput["primaryPosition"] })}>
> {POSITIONS.map((position) => (
{POSITIONS.map((position) => ( <option key={position} value={position}>
<MenuItem key={position} value={position}> {position}
{position} </option>
</MenuItem> ))}
))} </select>
</TextField> </label>
<TextField <label>
select <span className="field-label">Secondary Position</span>
label="Secondary Position" <select
value={value.secondaryPosition ?? ""} className="field-select"
onChange={(event) => value={value.secondaryPosition ?? ""}
onChange({ ...value, secondaryPosition: event.target.value ? (event.target.value as PlayerInput["secondaryPosition"]) : null }) onChange={(event) => onChange({ ...value, secondaryPosition: event.target.value ? (event.target.value as PlayerInput["secondaryPosition"]) : null })}
} >
> <option value="">None</option>
<MenuItem value="">None</MenuItem> {POSITIONS.map((position) => (
{POSITIONS.map((position) => ( <option key={position} value={position}>
<MenuItem key={position} value={position}> {position}
{position} </option>
</MenuItem> ))}
))} </select>
</TextField> </label>
<TextField <label>
select <span className="field-label">Tertiary Position</span>
label="Tertiary Position" <select
value={value.tertiaryPosition ?? ""} className="field-select"
onChange={(event) => value={value.tertiaryPosition ?? ""}
onChange({ ...value, tertiaryPosition: event.target.value ? (event.target.value as PlayerInput["tertiaryPosition"]) : null }) onChange={(event) => onChange({ ...value, tertiaryPosition: event.target.value ? (event.target.value as PlayerInput["tertiaryPosition"]) : null })}
} >
> <option value="">None</option>
<MenuItem value="">None</MenuItem> {POSITIONS.map((position) => (
{POSITIONS.map((position) => ( <option key={position} value={position}>
<MenuItem key={position} value={position}> {position}
{position} </option>
</MenuItem> ))}
))} </select>
</TextField> </label>
<Stack sx={{ justifyContent: "center" }}> <label className="flex items-center gap-3 rounded-2xl border border-border bg-white/70 px-4 py-3 text-sm font-medium">
<FormControlLabel <input type="checkbox" checked={value.isActive} onChange={(event) => onChange({ ...value, isActive: event.target.checked })} className="h-4 w-4 rounded border-border text-primary focus:ring-primary" />
control={<Checkbox checked={value.isActive} onChange={(event) => onChange({ ...value, isActive: event.target.checked })} />} Active for selection
label="Active for selection" </label>
/> <label className="md:col-span-2">
</Stack> <span className="field-label">Notes</span>
<TextField <Textarea rows={4} value={value.notes ?? ""} onChange={(event) => onChange({ ...value, notes: event.target.value })} />
label="Notes" </label>
multiline <div className="md:col-span-2">
rows={4} <Button onClick={onSubmit}>
value={value.notes ?? ""}
onChange={(event) => onChange({ ...value, notes: event.target.value })}
sx={{ gridColumn: "1 / -1" }}
/>
<Box sx={{ gridColumn: "1 / -1" }}>
<Button variant="contained" onClick={onSubmit}>
{submitLabel} {submitLabel}
</Button> </Button>
</Box> </div>
</Box> </div>
); );
} }
+160 -98
View File
@@ -1,13 +1,17 @@
"use client"; "use client";
import { Alert, Box, Button, Card, CardContent, Divider, MenuItem, Stack, TextField, Typography } from "@mui/material"; import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { ConfirmDialog } from "@/components/ConfirmDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog";
import { EmptyState } from "@/components/EmptyState"; import { EmptyState } from "@/components/EmptyState";
import { PlayerForm, emptyPlayerForm, toPlayerForm } from "@/components/PlayerForm"; import { PlayerForm, emptyPlayerForm, toPlayerForm } from "@/components/PlayerForm";
import { SectionCard } from "@/components/SectionCard"; import { SectionCard } from "@/components/SectionCard";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { fetchJson } from "@/lib/client"; import { fetchJson } from "@/lib/client";
import { formatPlayerName } from "@/lib/utils"; import { formatPlayerName } from "@/lib/utils";
import type { PitchingAppearanceRecord, PlayerInput, PlayerRecord } from "@/lib/types"; import type { PitchingAppearanceRecord, PlayerInput, PlayerRecord } from "@/lib/types";
@@ -20,6 +24,7 @@ export function RosterClient() {
const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null); const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null);
const [playerPendingDelete, setPlayerPendingDelete] = useState<PlayerRecord | null>(null); const [playerPendingDelete, setPlayerPendingDelete] = useState<PlayerRecord | null>(null);
const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm()); const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm());
const playerFormRef = useRef<HTMLDivElement | null>(null);
const [appearanceForm, setAppearanceForm] = useState({ const [appearanceForm, setAppearanceForm] = useState({
playerId: "", playerId: "",
gameDate: new Date().toISOString().slice(0, 10), gameDate: new Date().toISOString().slice(0, 10),
@@ -60,6 +65,23 @@ export function RosterClient() {
} }
}, [editingPlayer]); }, [editingPlayer]);
useEffect(() => {
if (editingPlayerId) {
playerFormRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, [editingPlayerId]);
function startEditingPlayer(playerId: string) {
setEditingPlayerId(playerId);
setMessage(null);
setError(null);
}
function stopEditingPlayer() {
setEditingPlayerId(null);
setPlayerForm(emptyPlayerForm());
}
async function submitPlayer() { async function submitPlayer() {
try { try {
const url = editingPlayerId ? `/api/players/${editingPlayerId}` : "/api/players"; const url = editingPlayerId ? `/api/players/${editingPlayerId}` : "/api/players";
@@ -116,135 +138,175 @@ export function RosterClient() {
onConfirm={() => (playerPendingDelete ? void deletePlayer(playerPendingDelete) : undefined)} onConfirm={() => (playerPendingDelete ? void deletePlayer(playerPendingDelete) : undefined)}
/> />
{message ? <Alert severity="success">{message}</Alert> : null} {message ? <Alert tone="success">{message}</Alert> : null}
{error ? <Alert severity="error">{error}</Alert> : null} {error ? <Alert tone="error">{error}</Alert> : null}
<div ref={playerFormRef}>
<SectionCard
title={editingPlayerId ? "Edit Player" : "Add Player"}
description="Keep player details simple so a coach can update them quickly before game time."
>
{editingPlayer ? (
<Alert tone="info" className="flex items-center justify-between gap-4">
<span>Editing {formatPlayerName(editingPlayer.firstName, editingPlayer.lastName)}.</span>
<Button variant="ghost" size="sm" onClick={stopEditingPlayer}>
Cancel
</Button>
</Alert>
) : null}
<PlayerForm
value={playerForm}
onChange={setPlayerForm}
onSubmit={() => void submitPlayer()}
submitLabel={editingPlayerId ? "Save Changes" : "Add Player"}
/>
</SectionCard>
</div>
<SectionCard <SectionCard
title={editingPlayerId ? "Edit Player" : "Add Player"} title="Pitch Count Log"
description="Keep player details simple so a coach can update them quickly before game time." description="Record the latest outing to keep rest recommendations accurate."
> >
<PlayerForm value={playerForm} onChange={setPlayerForm} onSubmit={() => void submitPlayer()} submitLabel={editingPlayerId ? "Save Changes" : "Add Player"} /> <div className="grid gap-5 md:grid-cols-3">
</SectionCard> <label>
<span className="field-label">Player</span>
<SectionCard title="Pitch Count Log" description="Record the latest outing to keep rest recommendations accurate."> <select
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 2 }}> className="field-select"
<TextField value={appearanceForm.playerId}
select onChange={(event) =>
label="Player" setAppearanceForm((current) => ({ ...current, playerId: event.target.value }))
value={appearanceForm.playerId} }
onChange={(event) => setAppearanceForm((current) => ({ ...current, playerId: event.target.value }))} >
> {players.map((player) => (
{players.map((player) => ( <option key={player._id} value={player._id}>
<MenuItem key={player._id} value={player._id}> {formatPlayerName(player.firstName, player.lastName)}
{formatPlayerName(player.firstName, player.lastName)} </option>
</MenuItem> ))}
))} </select>
</TextField> </label>
<TextField <label>
label="Game Date" <span className="field-label">Game Date</span>
type="date" <Input
value={appearanceForm.gameDate} type="date"
onChange={(event) => setAppearanceForm((current) => ({ ...current, gameDate: event.target.value }))} value={appearanceForm.gameDate}
slotProps={{ inputLabel: { shrink: true } }} onChange={(event) =>
/> setAppearanceForm((current) => ({ ...current, gameDate: event.target.value }))
<TextField }
label="Pitches Thrown" />
type="number" </label>
value={appearanceForm.pitchCount} <label>
onChange={(event) => setAppearanceForm((current) => ({ ...current, pitchCount: Number(event.target.value) }))} <span className="field-label">Pitches Thrown</span>
/> <Input
<TextField type="number"
label="Notes" value={appearanceForm.pitchCount}
multiline onChange={(event) =>
rows={3} setAppearanceForm((current) => ({
value={appearanceForm.notes} ...current,
onChange={(event) => setAppearanceForm((current) => ({ ...current, notes: event.target.value }))} pitchCount: Number(event.target.value)
sx={{ gridColumn: "1 / -1" }} }))
/> }
<Box sx={{ gridColumn: "1 / -1" }}> />
<Button variant="contained" onClick={() => void logAppearance()} disabled={players.length === 0}> </label>
<label className="md:col-span-3">
<span className="field-label">Notes</span>
<Textarea
rows={3}
value={appearanceForm.notes}
onChange={(event) =>
setAppearanceForm((current) => ({ ...current, notes: event.target.value }))
}
/>
</label>
<div className="md:col-span-3">
<Button onClick={() => void logAppearance()} disabled={players.length === 0}>
Save Pitching Appearance Save Pitching Appearance
</Button> </Button>
</Box> </div>
</Box> </div>
</SectionCard> </SectionCard>
<SectionCard title="Current Roster" description="Tap edit for fast changes and check pitcher status at a glance."> <SectionCard
title="Current Roster"
description="Tap edit for fast changes and check pitcher status at a glance."
>
{loading ? ( {loading ? (
<Typography color="text.secondary">Loading roster...</Typography> <p className="text-sm text-muted-foreground">Loading roster...</p>
) : players.length > 0 ? ( ) : players.length > 0 ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "repeat(2, 1fr)" }, gap: 2 }}> <div className="grid gap-4 xl:grid-cols-2">
{players.map((player) => ( {players.map((player) => (
<Card key={player._id} elevation={0} sx={{ border: "1px solid rgba(15, 38, 47, 0.08)" }}> <Card
<CardContent sx={{ display: "grid", gap: 2 }}> key={player._id}
<Box className={
sx={{ editingPlayerId === player._id
display: "flex", ? "border-secondary/60 bg-secondary/5 ring-1 ring-secondary/30"
flexDirection: { xs: "column", sm: "row" }, : ""
justifyContent: "space-between", }
gap: 1.5 >
}} <CardContent className="grid gap-5 pt-6">
> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<Box> <div>
<Typography variant="h6"> <h3 className="text-xl font-semibold">
#{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)} #{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)}
</Typography> </h3>
<Typography color="text.secondary"> <p className="mt-1 text-sm text-muted-foreground">
{player.primaryPosition} {player.primaryPosition}
{player.secondaryPosition ? ` / ${player.secondaryPosition}` : ""} {player.secondaryPosition ? ` / ${player.secondaryPosition}` : ""}
{player.tertiaryPosition ? ` / ${player.tertiaryPosition}` : ""} {player.tertiaryPosition ? ` / ${player.tertiaryPosition}` : ""}
</Typography> </p>
</Box> </div>
<StatusBadge status={player.pitcherAvailability.status} /> <StatusBadge status={player.pitcherAvailability.status} />
</Box> </div>
<Divider /> <div className="grid gap-4 rounded-[1.5rem] bg-muted/60 p-4 sm:grid-cols-3">
<div>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(3, 1fr)" }, gap: 1.5 }}> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<Box>
<Typography variant="caption" color="text.secondary">
Bats/Throws Bats/Throws
</Typography> </p>
<Typography>{player.batsThrows}</Typography> <p className="mt-1 text-sm font-medium">{player.batsThrows}</p>
</Box> </div>
<Box> <div>
<Typography variant="caption" color="text.secondary"> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Status Status
</Typography> </p>
<Typography>{player.isActive ? "Active" : "Inactive"}</Typography> <p className="mt-1 text-sm font-medium">
</Box> {player.isActive ? "Active" : "Inactive"}
<Box> </p>
<Typography variant="caption" color="text.secondary"> </div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Pitching Pitching
</Typography> </p>
<Typography> <p className="mt-1 text-sm font-medium">
{player.pitcherAvailability.lastPitchedDate {player.pitcherAvailability.lastPitchedDate
? `${player.pitcherAvailability.lastPitchCount} pitches on ${player.pitcherAvailability.lastPitchedDate}` ? `${player.pitcherAvailability.lastPitchCount} pitches on ${player.pitcherAvailability.lastPitchedDate}`
: "No history"} : "No history"}
</Typography> </p>
</Box> </div>
</Box> </div>
{player.notes ? ( {player.notes ? (
<Box sx={{ p: 1.5, borderRadius: 3, bgcolor: "rgba(15, 92, 77, 0.06)" }}> <div className="rounded-[1.5rem] border border-border/70 bg-white/70 px-4 py-3 text-sm text-muted-foreground">
<Typography color="text.secondary">{player.notes}</Typography> {player.notes}
</Box> </div>
) : null} ) : null}
<Stack direction="row" spacing={1.25}> <div className="flex flex-wrap gap-3">
<Button variant="outlined" color="inherit" onClick={() => setEditingPlayerId(player._id)}> <Button variant="secondary" onClick={() => startEditingPlayer(player._id)}>
Edit {editingPlayerId === player._id ? "Editing" : "Edit"}
</Button> </Button>
<Button variant="contained" color="error" onClick={() => setPlayerPendingDelete(player)}> <Button variant="destructive" onClick={() => setPlayerPendingDelete(player)}>
Delete Delete
</Button> </Button>
</Stack> </div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</Box> </div>
) : ( ) : (
<EmptyState title="No players yet" description="Add your first player above to start building the roster." /> <EmptyState
title="No players yet"
description="Add your first player above to start building the roster."
/>
)} )}
</SectionCard> </SectionCard>
</AppShell> </AppShell>
+7 -20
View File
@@ -1,4 +1,4 @@
import { Card, CardContent, Stack, Typography } from "@mui/material"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export function SectionCard({ export function SectionCard({
title, title,
@@ -10,25 +10,12 @@ export function SectionCard({
children?: React.ReactNode; children?: React.ReactNode;
}) { }) {
return ( return (
<Card <Card>
elevation={0} <CardHeader className="pb-4">
sx={{ <CardTitle>{title}</CardTitle>
border: "1px solid rgba(15, 38, 47, 0.08)", {description ? <CardDescription>{description}</CardDescription> : null}
backgroundColor: "rgba(255,255,255,0.8)", </CardHeader>
backdropFilter: "blur(10px)" <CardContent className="space-y-5">{children}</CardContent>
}}
>
<CardContent sx={{ p: { xs: 2, md: 3 }, "&:last-child": { pb: { xs: 2, md: 3 } } }}>
<Stack spacing={description ? 2.5 : 2}>
<Stack spacing={0.75}>
<Typography variant="h2" sx={{ fontSize: { xs: "1.65rem", md: "2rem" } }}>
{title}
</Typography>
{description ? <Typography color="text.secondary">{description}</Typography> : null}
</Stack>
{children}
</Stack>
</CardContent>
</Card> </Card>
); );
} }
+60 -68
View File
@@ -1,9 +1,11 @@
"use client"; "use client";
import { Alert, Box, Button, Stack, Table, TableBody, TableCell, TableHead, TableRow, TextField } from "@mui/material";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { SectionCard } from "@/components/SectionCard"; import { SectionCard } from "@/components/SectionCard";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { fetchJson } from "@/lib/client"; import { fetchJson } from "@/lib/client";
import { DEFAULT_PITCHING_RULES } from "@/lib/constants"; import { DEFAULT_PITCHING_RULES } from "@/lib/constants";
import type { PitchingRuleRecord } from "@/lib/types"; import type { PitchingRuleRecord } from "@/lib/types";
@@ -51,78 +53,68 @@ export function SettingsClient() {
title="Pitching Rest Rules" title="Pitching Rest Rules"
subtitle="Tune the rest windows your league uses so eligibility stays accurate and easy to trust." subtitle="Tune the rest windows your league uses so eligibility stays accurate and easy to trust."
> >
{message ? <Alert severity="success">{message}</Alert> : null} {message ? <Alert tone="success">{message}</Alert> : null}
{error ? <Alert severity="error">{error}</Alert> : null} {error ? <Alert tone="error">{error}</Alert> : null}
<SectionCard title="Rule Table" description="Each row maps a pitch-count range to required rest days."> <SectionCard title="Rule Table" description="Each row maps a pitch-count range to required rest days.">
<Box sx={{ overflowX: "auto" }}> <div className="overflow-hidden rounded-[1.75rem] border border-border bg-white/70">
<Table size="small"> <div className="grid grid-cols-3 gap-4 border-b border-border bg-muted/60 px-4 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<TableHead> <span>Min Pitches</span>
<TableRow> <span>Max Pitches</span>
<TableCell>Min Pitches</TableCell> <span>Rest Days</span>
<TableCell>Max Pitches</TableCell> </div>
<TableCell>Rest Days</TableCell> <div className="divide-y divide-border">
</TableRow> {rules.map((rule, index) => (
</TableHead> <div key={`${rule.minPitches}-${index}`} className="grid grid-cols-3 gap-4 px-4 py-4">
<TableBody> <Input
{rules.map((rule, index) => ( type="number"
<TableRow key={`${rule.minPitches}-${index}`}> value={rule.minPitches}
<TableCell> onChange={(event) =>
<TextField setRules((current) =>
type="number" current.map((entry, entryIndex) =>
value={rule.minPitches} entryIndex === index ? { ...entry, minPitches: Number(event.target.value) } : entry
onChange={(event) => )
setRules((current) => )
current.map((entry, entryIndex) => }
entryIndex === index ? { ...entry, minPitches: Number(event.target.value) } : entry />
) <Input
) type="number"
} value={rule.maxPitches ?? ""}
/> placeholder="No max"
</TableCell> onChange={(event) =>
<TableCell> setRules((current) =>
<TextField current.map((entry, entryIndex) =>
type="number" entryIndex === index
value={rule.maxPitches ?? ""} ? { ...entry, maxPitches: event.target.value ? Number(event.target.value) : null }
placeholder="No max" : entry
onChange={(event) => )
setRules((current) => )
current.map((entry, entryIndex) => }
entryIndex === index />
? { ...entry, maxPitches: event.target.value ? Number(event.target.value) : null } <Input
: entry type="number"
) value={rule.requiredRestDays}
) onChange={(event) =>
} setRules((current) =>
/> current.map((entry, entryIndex) =>
</TableCell> entryIndex === index
<TableCell> ? { ...entry, requiredRestDays: Number(event.target.value) }
<TextField : entry
type="number" )
value={rule.requiredRestDays} )
onChange={(event) => }
setRules((current) => />
current.map((entry, entryIndex) => </div>
entryIndex === index ? { ...entry, requiredRestDays: Number(event.target.value) } : entry ))}
) </div>
) </div>
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.25}> <div className="flex flex-wrap gap-3">
<Button variant="outlined" color="inherit" onClick={() => setRules(DEFAULT_PITCHING_RULES)}> <Button variant="secondary" onClick={() => setRules(DEFAULT_PITCHING_RULES)}>
Reset to Example Rules Reset to Example Rules
</Button> </Button>
<Button variant="contained" onClick={() => void saveRules()}> <Button onClick={() => void saveRules()}>Save Rules</Button>
Save Rules </div>
</Button>
</Stack>
</SectionCard> </SectionCard>
</AppShell> </AppShell>
); );
+13 -5
View File
@@ -1,9 +1,17 @@
import { Chip } from "@mui/material";
import type { PitcherAvailability } from "@/lib/types"; import type { PitcherAvailability } from "@/lib/types";
import { statusLabel } from "@/lib/utils"; import { cn, statusLabel } from "@/lib/utils";
export function StatusBadge({ status }: { status: PitcherAvailability }) { export function StatusBadge({ status }: { status: PitcherAvailability }) {
const color = status === "available" ? "success" : status === "needs-rest" ? "error" : "default"; return (
<span
return <Chip label={statusLabel(status)} color={color} variant={status === "unknown" ? "outlined" : "filled"} size="small" />; className={cn(
"inline-flex rounded-full px-3 py-1 text-xs font-semibold",
status === "available" && "bg-emerald-100 text-emerald-800",
status === "needs-rest" && "bg-red-100 text-red-800",
status === "unknown" && "bg-slate-100 text-slate-700"
)}
>
{statusLabel(status)}
</span>
);
} }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Alert({
className,
tone = "default",
...props
}: React.HTMLAttributes<HTMLDivElement> & { tone?: "default" | "success" | "error" | "info" }) {
const toneClasses =
tone === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-900"
: tone === "error"
? "border-red-200 bg-red-50 text-red-900"
: tone === "info"
? "border-sky-200 bg-sky-50 text-sky-900"
: "border-border bg-card text-foreground";
return <div className={cn("rounded-2xl border px-4 py-3 text-sm shadow-sm", toneClasses, className)} {...props} />;
}
+40
View File
@@ -0,0 +1,40 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-full text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-panel hover:bg-primary/90",
secondary: "bg-card text-foreground border border-border hover:bg-muted",
ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
destructive: "bg-destructive text-destructive-foreground shadow-panel hover:bg-destructive/90"
},
size: {
default: "h-11 px-5",
sm: "h-9 px-4 text-xs",
lg: "h-12 px-6",
icon: "h-10 w-10 rounded-full"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
)
);
Button.displayName = "Button";
export { Button, buttonVariants };
+30
View File
@@ -0,0 +1,30 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-3xl border border-white/60 bg-card/90 text-card-foreground shadow-panel backdrop-blur",
className
)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("font-display text-2xl font-semibold tracking-tight", className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("px-6 pb-6", className)} {...props} />;
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-11 w-full rounded-2xl border border-input bg-white/90 px-4 py-2 text-sm text-foreground shadow-sm transition placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"min-h-[120px] w-full rounded-2xl border border-input bg-white/90 px-4 py-3 text-sm text-foreground shadow-sm transition placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };
+3 -1
View File
@@ -1,7 +1,9 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import type { PitcherAvailability } from "@/lib/types"; import type { PitcherAvailability } from "@/lib/types";
export function cn(...parts: Array<string | false | null | undefined>) { export function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(" "); return twMerge(clsx(parts));
} }
export function formatPlayerName(firstName: string, lastName: string) { export function formatPlayerName(firstName: string, lastName: string) {
+67
View File
@@ -0,0 +1,67 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "1rem",
screens: {
"2xl": "1280px"
}
},
extend: {
colors: {
border: "hsl(32 22% 86%)",
input: "hsl(32 22% 86%)",
ring: "hsl(157 63% 27%)",
background: "hsl(42 43% 96%)",
foreground: "hsl(205 32% 12%)",
primary: {
DEFAULT: "hsl(157 63% 27%)",
foreground: "hsl(48 100% 98%)"
},
secondary: {
DEFAULT: "hsl(25 69% 54%)",
foreground: "hsl(48 100% 98%)"
},
muted: {
DEFAULT: "hsl(36 28% 92%)",
foreground: "hsl(211 15% 42%)"
},
accent: {
DEFAULT: "hsl(194 52% 18%)",
foreground: "hsl(48 100% 98%)"
},
card: {
DEFAULT: "hsl(48 100% 99%)",
foreground: "hsl(205 32% 12%)"
},
destructive: {
DEFAULT: "hsl(6 72% 54%)",
foreground: "hsl(48 100% 98%)"
}
},
borderRadius: {
xl: "1rem",
"2xl": "1.5rem",
"3xl": "2rem"
},
boxShadow: {
glow: "0 24px 80px rgba(13, 52, 63, 0.15)",
panel: "0 10px 30px rgba(23, 34, 45, 0.08)"
},
fontFamily: {
sans: ['"Inter"', '"Segoe UI"', "sans-serif"],
display: ['"Cal Sans"', '"Avenir Next"', '"Segoe UI"', "sans-serif"]
},
backgroundImage: {
"hero-grid":
"linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.08) 1px, transparent 1px)"
}
}
},
plugins: []
};
export default config;
File diff suppressed because one or more lines are too long