Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/** @type {import('next').NextConfig} */
const withPwa = require("next-pwa")({
dest: "public",
// Stale SW caches break dev HMR and cause blank pages after rebuilds.
disable: process.env.NODE_ENV === "development",
});
const nextConfig = withPwa({
reactStrictMode: true,
Expand Down
168 changes: 92 additions & 76 deletions pages/new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { ResetPlayersModal } from "../src/ResetPlayersModal";
import { PlayerNameEdit } from "../src/PlayerNameEdit";
import { disambiguateNames, renameWithDisambiguation } from "../src/playerNames";
import { v4 as uuidv4 } from "uuid";
import clsx from "clsx";

type NamePair = [string, string];
type SetupPlayer = { id: string; name: string };
Expand Down Expand Up @@ -123,6 +124,7 @@ function NewGame() {
// Load last time's players and court names.
useEffect(() => {
const loaded = [...state.players]
.filter((id) => playersById[id])
.map((id) => ({
id,
name: playersById[id].name,
Expand Down Expand Up @@ -252,7 +254,7 @@ function NewGame() {
size="sm"
color="secondary"
variant="flat"
onClick={() => handleResetPlayers()}
onPress={() => handleResetPlayers()}
>
Reset players
</Button>
Expand Down Expand Up @@ -286,7 +288,7 @@ function NewGame() {
aria-label="Add players in text box"
isIconOnly
type="button"
onClick={() => handleAddPlayers()}
onPress={() => handleAddPlayers()}
>
<AddUser />
</Button>
Expand All @@ -300,96 +302,110 @@ function NewGame() {
return (
<Fragment key={id}>
<div
className={`flex items-center gap-1 rounded-lg px-1 ${
linking
? "ring-2 ring-primary bg-primary-50"
: paired
? "bg-secondary-50"
: ""
}`}
className={clsx(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5",
linking && "ring-2 ring-primary bg-primary-50",
paired && !linking && "bg-secondary-50"
)}
>
<User
primaryColor={paired ? "#7828c8" : "#888"}
size="medium"
/>
<span className="text-sm text-gray-500 w-4">
<span className="shrink-0">
<User
primaryColor={paired ? "#7828c8" : "#888"}
size="medium"
/>
</span>
<span className="text-sm text-gray-500 w-4 shrink-0 tabular-nums">
{index + 1}
</span>
<PlayerNameEdit
name={name}
onSave={(newName) => {
const namesById = renameWithDisambiguation(
players.map((p) => ({ id: p.id, name: p.name })),
id,
newName
);
const oldName = name;
const nextPlayers = players.map((p) => ({
...p,
name: namesById[p.id] ?? p.name,
}));
setFixedPairs(
renameInPairs(fixedPairs, oldName, namesById[id] ?? newName)
);
if (linkingPlayer === oldName) {
setLinkingPlayer(namesById[id] ?? newName);
}
setPlayers(nextPlayers);
}}
/>
{paired ? (
<>
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<PlayerNameEdit
compact
editTrigger="click"
name={name}
onSave={(newName) => {
const namesById = renameWithDisambiguation(
players.map((p) => ({ id: p.id, name: p.name })),
id,
newName
);
const oldName = name;
const nextPlayers = players.map((p) => ({
...p,
name: namesById[p.id] ?? p.name,
}));
setFixedPairs(
renameInPairs(
fixedPairs,
oldName,
namesById[id] ?? newName
)
);
if (linkingPlayer === oldName) {
setLinkingPlayer(namesById[id] ?? newName);
}
setPlayers(nextPlayers);
}}
/>
{paired ? (
<span
className="text-xs text-secondary whitespace-nowrap"
className="inline-flex shrink-0 items-center gap-1 text-sm font-normal text-secondary"
title={`Paired with ${partner}`}
>
↔ {partner}
<PairLinkIcon size={14} color="#7828c8" />
{partner}
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{paired ? (
<Button
variant="flat"
color="secondary"
size="sm"
aria-label={`Unlink ${name} from ${partner}`}
isIconOnly
onPress={() => handleUnlink(name)}
>
<Delete primaryColor="#7828c8" size="small" />
<PairLinkIcon color="#7828c8" size={16} />
</Button>
</>
) : (
) : (
<Button
variant={linking ? "solid" : "flat"}
color={linking ? "primary" : "default"}
size="sm"
aria-label={
linkingPlayer && linkingPlayer !== name
? `Pair ${linkingPlayer} with ${name}`
: `Link ${name} as a fixed pair`
}
isIconOnly
onPress={() => handleLinkClick(name)}
>
<PairLinkIcon
color={linking ? "#fff" : "#888"}
size={16}
/>
</Button>
)}
<Button
variant={linking ? "solid" : "flat"}
color={linking ? "primary" : "default"}
aria-label={
linkingPlayer && linkingPlayer !== name
? `Pair ${linkingPlayer} with ${name}`
: `Link ${name} as a fixed pair`
}
variant="flat"
color="default"
size="sm"
aria-label={`Remove player named ${name}`}
isIconOnly
onPress={() => handleLinkClick(name)}
onPress={() => {
setFixedPairs(removePairForPlayer(fixedPairs, name));
if (linkingPlayer === name) setLinkingPlayer(null);
setPlayers((current) => {
const before = current;
const next = current.filter((p) => p.id !== id);
return applySetupDisambiguation(next, before);
});
}}
>
<PairLinkIcon
color={linking ? "#fff" : "#888"}
size={16}
/>
<Delete />
</Button>
)}
<Button
variant="flat"
color="default"
aria-label={`Remove player named ${name}`}
isIconOnly
onPress={() => {
setFixedPairs(removePairForPlayer(fixedPairs, name));
if (linkingPlayer === name) setLinkingPlayer(null);
setPlayers((current) => {
const before = current;
const next = current.filter((p) => p.id !== id);
return applySetupDisambiguation(next, before);
});
}}
>
<Delete />
</Button>
</div>
</div>
</Fragment>
);
Expand Down Expand Up @@ -468,7 +484,7 @@ function NewGame() {
size="sm"
color="secondary"
variant="flat"
onClick={() =>
onPress={() =>
setCourtNames(
Array.from(
new Array(Math.max(parseInt(courts) || 0, 0)),
Expand All @@ -484,7 +500,7 @@ function NewGame() {
size="sm"
color="primary"
variant="flat"
onClick={() =>
onPress={() =>
setCourtNames(
Array.from(
new Array(Math.max(parseInt(courts) || 0, 0)),
Expand All @@ -500,7 +516,7 @@ function NewGame() {
size="sm"
color="secondary"
variant="flat"
onClick={() =>
onPress={() =>
setCourtNames(
Array.from(
new Array(Math.max(parseInt(courts) || 0, 0)),
Expand Down
2 changes: 1 addition & 1 deletion pages/rounds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export default function Rounds() {
Math.min(roundIndex, Math.max(state.rounds.length - 1, 0))
);
const round = state.rounds[displayIndex];
const volunteers = state.volunteerSitoutsByRound[displayIndex];
const volunteers = state.volunteerSitoutsByRound?.[displayIndex] ?? [];
const { sitOuts = [], matches = [] } = round || {};
const isHistoricalRound = displayIndex < state.rounds.length - 1;

Expand Down
10 changes: 7 additions & 3 deletions src/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,13 @@ const grandstander = Grandstander({ subsets: ["latin"] });

export function Layout({ children }: { children: React.ReactNode }) {
useEffect(() => {
new CircleType(document.getElementById("jumbled"))
.radius(200)
.forceHeight(false);
const el = document.getElementById("jumbled");
if (!el) return;
try {
new CircleType(el).radius(200).forceHeight(false);
} catch {
// Non-fatal: logo still renders without curved text.
}
}, []);
const router = useRouter();
useLoadState();
Expand Down
54 changes: 52 additions & 2 deletions src/PlayerNameEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ export function PlayerNameEdit({
onSave,
className,
disabled = false,
compact = false,
editTrigger = "icon",
"aria-label": ariaLabel = "Player name",
}: {
name: string;
onSave: (newName: string) => void;
className?: string;
disabled?: boolean;
/** When true, name does not grow to fill the row (for setup lists). */
compact?: boolean;
/** `icon` — pencil on the left; `click` — tap the name to edit (no pencil). */
editTrigger?: "icon" | "click";
"aria-label"?: string;
}) {
const [editing, setEditing] = useState(false);
Expand Down Expand Up @@ -74,20 +80,64 @@ export function PlayerNameEdit({
);
}

if (editTrigger === "click") {
if (disabled) {
return (
<span
className={clsx(
"min-w-0 truncate",
compact ? "max-w-[9rem] sm:max-w-none" : "flex-1",
className
)}
>
{name}
</span>
);
}
return (
<Button
variant="light"
aria-label={`Edit name for ${name}`}
className={clsx(
"h-auto min-h-0 min-w-0 justify-start px-1 font-normal text-inherit",
compact ? "max-w-[9rem] sm:max-w-none" : "flex-1",
className
)}
onPress={() => setEditing(true)}
>
<span className="truncate text-left">{name}</span>
</Button>
);
}

return (
<div className={clsx("flex items-center gap-1 flex-1 min-w-0", className)}>
<span className="flex-1 min-w-0 truncate">{name}</span>
<div
className={clsx(
"flex items-center gap-1 min-w-0",
compact ? "flex-none" : "flex-1",
className
)}
>
{!disabled ? (
<Button
variant="flat"
size="sm"
isIconOnly
className="shrink-0"
aria-label={`Edit name for ${name}`}
onPress={() => setEditing(true)}
>
<Edit size="small" />
</Button>
) : null}
<span
className={clsx(
"min-w-0 truncate",
compact ? "max-w-[9rem] sm:max-w-none" : "flex-1"
)}
>
{name}
</span>
</div>
);
}
3 changes: 2 additions & 1 deletion src/PlayersModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export function PlayersModal({
</ModalHeader>
<ModalBody>
<p className="text-lg">
Add or remove players, link fixed pairs, or tap the pencil to rename.
Add or remove players, link fixed pairs, or tap a name to rename.
Renames apply immediately. For roster changes, either{" "}
<span className="font-bold">redo the current round</span> (because
you haven&apos;t played yet) or{" "}
Expand Down Expand Up @@ -210,6 +210,7 @@ export function PlayersModal({
{player.delete ? "❌ " : ""}
<PlayerNameEdit
name={player.name}
editTrigger="click"
disabled={false}
onSave={(newName) => handleRename(player.id, newName)}
/>
Expand Down
12 changes: 7 additions & 5 deletions src/SitoutsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ export function SitoutsModal({
value={volunteers}
onValueChange={setVolunteers}
>
{state.players.map((player) => (
<Checkbox value={player} key={player}>
{state.playersById[player].name}
</Checkbox>
))}
{state.players
.filter((player) => state.playersById[player])
.map((player) => (
<Checkbox value={player} key={player}>
{state.playersById[player].name}
</Checkbox>
))}
</CheckboxGroup>
</ModalBody>
<ModalFooter>
Expand Down
Loading