From a8f7adcd707b3efc00abeff06d6a59007f0c61af Mon Sep 17 00:00:00 2001 From: Brock Jenkinson Date: Sun, 3 May 2026 18:55:57 -0400 Subject: [PATCH] feat(v0.9 phase 4): TownManager drawer + retire modal CRUD Replaces CreateTownModal, EditTownModal, and the Phase 2 sidebar bridge stubs (window.prompt switcher, Edit/+New buttons, hemisphere toggle) with a single right-side TownManager drawer mounted at the App layout level. Drawer is a bottom sheet at <=720px. Honors locked Decision 1: the inline edit form has no game `. The game is shown as a read-only badge with the hint "Game can't be changed after creation." `handleSave` builds a patch object that contains only `name` and `hemisphere`, never `gameId`. +- **Decision 5 honored** — `playerName` removed from `Town`, store, CSV export, and tests. No migration callback because Zustand's `persist` simply ignores fields not in the schema. +- **Hemisphere persistence** — the store keeps `hemisphere: Hemisphere` (`'NH'` | `'SH'`, default `'NH'`). The drawer passes `null` from the patch when the game isn't ACNH; `updateTown` ignores nulls so a town's stored hemisphere never gets clobbered. +- **TownManager state lives in a separate `useUIStore`** rather than inside `useAppStore`, so the drawer's open/closed state isn't persisted to localStorage across reloads. +- **Auto-open path replaces the `required` flag** — `CreateTownModal`'s `required={noTowns}` pattern is replaced by `App.tsx` opening the drawer in `forceCreate` mode whenever the store hydrates with zero towns. Same UX (modal-like, can't be dismissed) implemented as a single behavior gate inside the new component. + ## [v0.8.2-alpha] — 2026-05-01 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 5f3cadb..5fcfaef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ npm install # Install dependencies **Tests:** Vitest **Store schema:** 3-level `donated[townId][gameId][itemId]` (as of v0.7); `Town` includes `hemisphere: 'NH' | 'SH'` (as of v0.8) **Migration:** Zustand persist v3 + `bootstrapMigration.ts` — zero data loss for existing users -**Modal pattern:** `CreateTownModal` and `EditTownModal` use always-mounted `isOpen` prop pattern; overlay is a single `flex items-center justify-center` wrapper — no `overflow-y-auto` +**Town CRUD (v0.9 Phase 4):** `TownManager` drawer mounts at the App layout level (above the router), opened via `useUIStore.openTownManager()`. Replaces `CreateTownModal`, `EditTownModal`, and the `TownSwitcher` dropdown. When `towns.length === 0`, App.tsx auto-opens it in `forceCreate` mode (no close/Esc/scrim dismissal). Per Decision 1, edit form has no game ` setName(e.target.value)} + autoFocus + /> + +
+ Game +
+ {game.shortName} + {game.name} +
+ + Game can't be changed after creation. + +
+ {showHemisphere && ( + + )} + +
+ {canDelete && ( + + )} +
+ + +
+
+ + ); + } + + return ( +
+ + +
+ ); +} + +interface NewTownFormProps { + onCancel: () => void; + onCreate: (input: { + name: string; + gameId: GameId; + hemisphere: Hemisphere | null; + }) => void; + cancellable: boolean; +} + +function NewTownForm({ onCancel, onCreate, cancellable }: NewTownFormProps) { + const [name, setName] = useState(''); + const [gameId, setGameId] = useState('ACNH'); + const [hemisphere, setHemisphere] = useState('NH'); + const showHemisphere = HEMISPHERE_GAMES.has(gameId); + + return ( +
+ setName(e.target.value)} + /> + + {showHemisphere && ( +
+ + +
+ )} +
+ {cancellable && ( + + )} + +
+
+ ); +} diff --git a/src/components/modals/CreateTownModal.tsx b/src/components/modals/CreateTownModal.tsx deleted file mode 100644 index 0c72cf6..0000000 --- a/src/components/modals/CreateTownModal.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React, { useState } from 'react'; -import { X } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; -import { useAppStore } from '../../lib/store'; -import { type GameId, GAMES } from '../../lib/types'; -import { TownNameFields } from '../shared/TownNameFields'; - -export function CreateTownModal({ - isOpen, - onClose, - required, -}: { - isOpen: boolean; - onClose: () => void; - required: boolean; -}) { - const createTown = useAppStore(s => s.createTown); - const navigate = useNavigate(); - const [name, setName] = useState(''); - const [playerName, setPlayerName] = useState(''); - const [gameId, setGameId] = useState('ACGCN'); - - if (!isOpen) return null; - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!name.trim() || !playerName.trim()) return; - const town = createTown(name.trim(), playerName.trim(), gameId); - onClose(); - navigate(`/town/${town.id}/home`); - } - - return ( -
-
e.stopPropagation()} - > -
-

- New Town -

- {!required && ( - - )} -
- -
- -
- - -
- - -
-
- ); -} diff --git a/src/components/modals/EditTownModal.tsx b/src/components/modals/EditTownModal.tsx deleted file mode 100644 index c5ca216..0000000 --- a/src/components/modals/EditTownModal.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { X } from 'lucide-react'; -import { useAppStore } from '../../lib/store'; -import { TownNameFields } from '../shared/TownNameFields'; - -interface Props { - isOpen: boolean; - town: { id: string; name: string; playerName: string } | null; - onClose: () => void; -} - -export function EditTownModal({ isOpen, town, onClose }: Props) { - const updateTown = useAppStore(s => s.updateTown); - const [name, setName] = useState(''); - const [playerName, setPlayerName] = useState(''); - - // Sync form state when a different town is opened for editing. - // Depend on the stable primitive id so the effect only fires when the town - // actually changes, not on every parent re-render. - const townId = town?.id; - const townName = town?.name; - const townPlayerName = town?.playerName; - useEffect(() => { - if (townId) { - setName(townName ?? ''); - setPlayerName(townPlayerName ?? ''); - } - }, [townId, townName, townPlayerName]); - - if (!isOpen || !town) return null; - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!name.trim() || !playerName.trim() || !town) return; - updateTown(town.id, name.trim(), playerName.trim()); - onClose(); - } - - return ( -
-
e.stopPropagation()} - > -
-

- Edit Town -

- -
- -
- - - -
-
- ); -} diff --git a/src/components/shared/TownNameFields.tsx b/src/components/shared/TownNameFields.tsx deleted file mode 100644 index 8c3ca04..0000000 --- a/src/components/shared/TownNameFields.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; - -interface Props { - name: string; - playerName: string; - onNameChange: (v: string) => void; - onPlayerNameChange: (v: string) => void; - namePlaceholder?: string; - playerPlaceholder?: string; - autoFocus?: boolean; -} - -const inputStyle = { - borderColor: '#E7DAC4', - backgroundColor: '#FFFDF6', - color: '#2A2A2A', -}; - -const labelStyle = { color: '#5a4a35' }; - -export function TownNameFields({ - name, - playerName, - onNameChange, - onPlayerNameChange, - namePlaceholder, - playerPlaceholder, - autoFocus = true, -}: Props) { - return ( - <> -
- - onNameChange(e.target.value)} - placeholder={namePlaceholder} - className="w-full rounded-[10px] border px-3 py-2 text-sm outline-none" - style={inputStyle} - /> -
-
- - onPlayerNameChange(e.target.value)} - placeholder={playerPlaceholder} - className="w-full rounded-[10px] border px-3 py-2 text-sm outline-none" - style={inputStyle} - /> -
- - ); -} diff --git a/src/index.css b/src/index.css index fde4fab..f04f396 100644 --- a/src/index.css +++ b/src/index.css @@ -402,3 +402,200 @@ .ac-danger-row { flex-direction: column; align-items: stretch; } .ac-danger-btn { width: 100%; } } + +/* ── Town Manager drawer (Phase 4) ── */ +.ac-tm-scrim { + position: fixed; inset: 0; z-index: 9990; + background: rgba(20,18,12,0.32); + -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); + display: flex; justify-content: flex-end; + animation: ac-fade 0.18s ease; +} +@keyframes ac-fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes ac-slide { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } +.ac-tm-drawer { + width: 420px; max-width: 100vw; + background: var(--bg); + border-left: 1px solid var(--border); + display: flex; flex-direction: column; + height: 100vh; + animation: ac-slide 0.22s ease; + box-shadow: -20px 0 60px rgba(0,0,0,0.08); +} +.ac-tm-head { + display: flex; justify-content: space-between; align-items: flex-start; + padding: 22px 24px 18px; + border-bottom: 1px solid var(--border); + gap: 12px; +} +.ac-tm-eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-muted); } +.ac-tm-title { font-family: var(--font-display); font-weight: 500; font-size: 22px; margin: 4px 0 0; letter-spacing: -0.01em; max-width: 280px; } +.ac-tm-close { + width: 32px; height: 32px; border-radius: 8px; + font-size: 22px; line-height: 1; color: var(--ink-muted); + background: transparent; border: none; cursor: pointer; flex: none; +} +.ac-tm-close:hover { background: var(--surface-alt); color: var(--ink); } + +.ac-tm-list { flex: 1; overflow-y: auto; padding: 14px 18px; display: flex; flex-direction: column; gap: 6px; } + +.ac-tm-row { + display: flex; align-items: stretch; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + transition: border-color 0.12s; +} +.ac-tm-row:hover { border-color: var(--border-strong); } +.ac-tm-row-active { border-color: var(--accent); background: var(--accent-soft); } +.ac-tm-row-main { + flex: 1; display: flex; gap: 14px; align-items: center; + padding: 14px 16px; text-align: left; min-width: 0; + background: transparent; border: none; cursor: pointer; + font: inherit; color: inherit; +} +.ac-tm-row-mark { + width: 18px; height: 18px; border-radius: 50%; + border: 1.5px solid; + display: grid; place-items: center; + flex: none; +} +.ac-tm-row-tick { color: var(--accent); font-size: 9px; } +.ac-tm-row-text { flex: 1; min-width: 0; } +.ac-tm-row-name { font-family: var(--font-display); font-weight: 500; font-size: 18px; letter-spacing: -0.01em; color: var(--ink); } +.ac-tm-row-meta { + display: flex; gap: 6px; align-items: center; + font-size: 12px; color: var(--ink-soft); margin-top: 2px; + flex-wrap: wrap; +} +.ac-tm-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + background: var(--surface-alt); + border: 1px solid var(--border); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--ink-soft); +} +.ac-tm-row-active .ac-tm-badge { background: var(--surface); } + +.ac-tm-row-edit { + padding: 0 16px; + color: var(--ink-muted); + border: none; border-left: 1px solid var(--border); + background: transparent; cursor: pointer; + display: grid; place-items: center; + transition: background 0.12s, color 0.12s; +} +.ac-tm-row-edit:hover { background: var(--surface-alt); color: var(--ink); } +.ac-tm-row-active .ac-tm-row-edit { border-left-color: var(--accent); } + +/* Editing state */ +.ac-tm-row-editing { + flex-direction: column; + background: var(--surface); + border-color: var(--border-strong); + padding: 18px; + gap: 14px; +} +.ac-tm-form { display: flex; flex-direction: column; gap: 12px; } +.ac-tm-field { display: flex; flex-direction: column; gap: 4px; } +.ac-tm-field-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-muted); } +.ac-tm-field-hint { font-size: 11px; color: var(--ink-muted); margin-top: 2px; font-style: italic; } +.ac-tm-input { + font: inherit; color: var(--ink); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; + outline: none; +} +.ac-tm-input:focus { border-color: var(--accent); } + +.ac-tm-seg { + display: flex; gap: 4px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 3px; +} +.ac-tm-seg button { + flex: 1; padding: 6px 10px; + border-radius: 6px; + font-size: 13px; + color: var(--ink-soft); + background: transparent; border: none; cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.ac-tm-seg button:hover { color: var(--ink); } +.ac-tm-seg button.ac-tm-seg-on { + background: var(--surface); + color: var(--accent-ink); + font-weight: 500; + box-shadow: 0 1px 2px rgba(0,0,0,0.04); +} + +.ac-tm-row-actions { + display: flex; justify-content: space-between; gap: 8px; + padding-top: 8px; border-top: 1px solid var(--border); +} +.ac-tm-row-actions-right { display: flex; gap: 8px; margin-left: auto; } +.ac-tm-ghost, .ac-tm-primary, .ac-tm-danger { + padding: 7px 14px; border-radius: 8px; + font-size: 13px; font-weight: 500; + background: transparent; border: none; cursor: pointer; + transition: background 0.12s, opacity 0.12s; +} +.ac-tm-ghost { color: var(--ink-soft); } +.ac-tm-ghost:hover { background: var(--surface-alt); color: var(--ink); } +.ac-tm-primary { background: var(--accent); color: var(--surface); } +.ac-tm-primary:hover { opacity: 0.88; } +.ac-tm-primary:disabled { opacity: 0.4; cursor: not-allowed; } +.ac-tm-danger { color: var(--warn); } +.ac-tm-danger:hover { background: var(--warn-soft); } + +.ac-tm-empty { padding: 60px 20px; text-align: center; color: var(--ink-muted); } +.ac-tm-empty-glyph { + width: 56px; height: 56px; border-radius: 50%; + border: 1.5px dashed var(--border-strong); + margin: 0 auto 14px; + display: grid; place-items: center; + color: var(--ink-muted); + font-size: 24px; +} +.ac-tm-empty-title { font-family: var(--font-display); font-size: 18px; color: var(--ink); margin-bottom: 4px; } +.ac-tm-empty-sub { font-size: 13px; } + +.ac-tm-foot { padding: 14px 18px 22px; border-top: 1px solid var(--border); } +.ac-tm-cta { + width: 100%; + display: flex; align-items: center; justify-content: center; gap: 8px; + padding: 12px; + background: var(--surface); + border: 1.5px dashed var(--border-strong); + border-radius: 12px; + color: var(--ink-soft); + font-weight: 500; + font-size: 14px; + cursor: pointer; + transition: border-color 0.12s, color 0.12s, background 0.12s; +} +.ac-tm-cta:hover { border-color: var(--accent); color: var(--accent-ink); background: var(--accent-soft); } +.ac-tm-cta-plus { font-size: 18px; line-height: 1; } +.ac-tm-newform { display: flex; flex-direction: column; gap: 8px; } +.ac-tm-newform-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } + +/* Mobile: bottom sheet */ +@media (max-width: 720px) { + .ac-tm-scrim { align-items: flex-end; justify-content: center; } + .ac-tm-drawer { + width: 100%; height: 88vh; + border-left: none; border-top: 1px solid var(--border); + border-radius: 16px 16px 0 0; + animation: ac-slide-up 0.22s ease; + } + @keyframes ac-slide-up { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } +} diff --git a/src/lib/csvExport.ts b/src/lib/csvExport.ts index d632301..6797540 100644 --- a/src/lib/csvExport.ts +++ b/src/lib/csvExport.ts @@ -49,10 +49,9 @@ export function downloadCSV( data: AllData, donatedMap: Record, donatedAtMap: Record, - townName: string, - playerName: string + townName: string ): void { - const csv = buildCSV(data, donatedMap, donatedAtMap, townName, playerName); + const csv = buildCSV(data, donatedMap, donatedAtMap, townName); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -70,8 +69,7 @@ export function buildCSV( data: AllData, donatedMap: Record, donatedAtMap: Record, - townName: string, - playerName: string + townName: string ): string { const sections: string[] = []; @@ -80,7 +78,6 @@ export function buildCSV( [ row('Animal Crossing GCN — Museum Tracker Export'), row('Town', townName), - row('Player', playerName), row( 'Exported', new Date().toLocaleString('en-US', { diff --git a/src/lib/store.test.ts b/src/lib/store.test.ts index 23081e5..f1f4e84 100644 --- a/src/lib/store.test.ts +++ b/src/lib/store.test.ts @@ -15,34 +15,34 @@ beforeEach(() => { describe('createTown', () => { it('adds a town to the list', () => { - useAppStore.getState().createTown('Pallet', 'Ash'); + useAppStore.getState().createTown('Pallet', 'ACGCN'); expect(useAppStore.getState().towns).toHaveLength(1); expect(useAppStore.getState().towns[0].name).toBe('Pallet'); - expect(useAppStore.getState().towns[0].playerName).toBe('Ash'); + expect(useAppStore.getState().towns[0].gameId).toBe('ACGCN'); }); it('sets the new town as active immediately', () => { - const town = useAppStore.getState().createTown('Pallet', 'Ash'); + const town = useAppStore.getState().createTown('Pallet', 'ACGCN'); expect(useAppStore.getState().activeTownId).toBe(town.id); }); it('returns a town with a non-empty id and ISO createdAt', () => { - const town = useAppStore.getState().createTown('Pallet', 'Ash'); + const town = useAppStore.getState().createTown('Pallet', 'ACGCN'); expect(town.id).toBeTruthy(); expect(() => new Date(town.createdAt)).not.toThrow(); }); it('accumulates multiple towns', () => { - useAppStore.getState().createTown('Pallet', 'Ash'); - const _t2 = useAppStore.getState().createTown('Viridian', 'Brock'); + useAppStore.getState().createTown('Pallet', 'ACGCN'); + const _t2 = useAppStore.getState().createTown('Viridian', 'ACGCN'); expect(useAppStore.getState().towns).toHaveLength(2); }); }); describe('setActiveTown', () => { it('switches the active town', () => { - const t1 = useAppStore.getState().createTown('Pallet', 'Ash'); - const t2 = useAppStore.getState().createTown('Viridian', 'Brock'); + const t1 = useAppStore.getState().createTown('Pallet', 'ACGCN'); + const t2 = useAppStore.getState().createTown('Viridian', 'ACGCN'); // createTown sets active to t2; switch back to t1 useAppStore.getState().setActiveTown(t1.id); expect(useAppStore.getState().activeTownId).toBe(t1.id); @@ -53,13 +53,13 @@ describe('setActiveTown', () => { describe('deleteTown', () => { it('removes the town from the list', () => { - const town = useAppStore.getState().createTown('Pallet', 'Ash'); + const town = useAppStore.getState().createTown('Pallet', 'ACGCN'); useAppStore.getState().deleteTown(town.id); expect(useAppStore.getState().towns).toHaveLength(0); }); it('clears donated data for the deleted town', () => { - const town = useAppStore.getState().createTown('Pallet', 'Ash'); + const town = useAppStore.getState().createTown('Pallet', 'ACGCN'); useAppStore.getState().toggle('fish-001'); expect(useAppStore.getState().donated[town.id]).toBeDefined(); useAppStore.getState().deleteTown(town.id); @@ -68,22 +68,22 @@ describe('deleteTown', () => { }); it('falls back to the first remaining town when the active town is deleted', () => { - const t1 = useAppStore.getState().createTown('Pallet', 'Ash'); - const t2 = useAppStore.getState().createTown('Viridian', 'Brock'); + const t1 = useAppStore.getState().createTown('Pallet', 'ACGCN'); + const t2 = useAppStore.getState().createTown('Viridian', 'ACGCN'); // t2 is active now; delete t2 → should fall back to t1 useAppStore.getState().deleteTown(t2.id); expect(useAppStore.getState().activeTownId).toBe(t1.id); }); it('sets activeTownId to null when the last town is deleted', () => { - const town = useAppStore.getState().createTown('Pallet', 'Ash'); + const town = useAppStore.getState().createTown('Pallet', 'ACGCN'); useAppStore.getState().deleteTown(town.id); expect(useAppStore.getState().activeTownId).toBeNull(); }); it('does not change activeTownId when a non-active town is deleted', () => { - const t1 = useAppStore.getState().createTown('Pallet', 'Ash'); - const t2 = useAppStore.getState().createTown('Viridian', 'Brock'); + const t1 = useAppStore.getState().createTown('Pallet', 'ACGCN'); + const t2 = useAppStore.getState().createTown('Viridian', 'ACGCN'); useAppStore.getState().setActiveTown(t2.id); useAppStore.getState().deleteTown(t1.id); expect(useAppStore.getState().activeTownId).toBe(t2.id); @@ -94,20 +94,20 @@ describe('deleteTown', () => { describe('toggle', () => { it('marks an item as donated for the active town', () => { - useAppStore.getState().createTown('Pallet', 'Ash'); + useAppStore.getState().createTown('Pallet', 'ACGCN'); useAppStore.getState().toggle('fish-001'); expect(useAppStore.getState().isDonated('fish-001')).toBe(true); }); it('un-donates an already-donated item (toggle off)', () => { - useAppStore.getState().createTown('Pallet', 'Ash'); + useAppStore.getState().createTown('Pallet', 'ACGCN'); useAppStore.getState().toggle('fish-001'); useAppStore.getState().toggle('fish-001'); expect(useAppStore.getState().isDonated('fish-001')).toBe(false); }); it('records a donatedAt timestamp when toggled on', () => { - useAppStore.getState().createTown('Pallet', 'Ash'); + useAppStore.getState().createTown('Pallet', 'ACGCN'); const before = Date.now(); useAppStore.getState().toggle('fish-001'); const after = Date.now(); @@ -119,7 +119,7 @@ describe('toggle', () => { }); it('removes the donatedAt timestamp when toggled off', () => { - useAppStore.getState().createTown('Pallet', 'Ash'); + useAppStore.getState().createTown('Pallet', 'ACGCN'); useAppStore.getState().toggle('fish-001'); useAppStore.getState().toggle('fish-001'); expect(useAppStore.getState().getDonatedAt('fish-001')).toBeUndefined(); @@ -132,10 +132,10 @@ describe('toggle', () => { }); it('keeps donations isolated per town', () => { - const t1 = useAppStore.getState().createTown('Pallet', 'Ash'); + const t1 = useAppStore.getState().createTown('Pallet', 'ACGCN'); useAppStore.getState().toggle('fish-001'); - const _t2 = useAppStore.getState().createTown('Viridian', 'Brock'); + const _t2 = useAppStore.getState().createTown('Viridian', 'ACGCN'); // t2 is now active; fish-001 should NOT be donated here expect(useAppStore.getState().isDonated('fish-001')).toBe(false); @@ -153,7 +153,7 @@ describe('isDonated', () => { }); it('returns false for an item that has never been toggled', () => { - useAppStore.getState().createTown('Pallet', 'Ash'); + useAppStore.getState().createTown('Pallet', 'ACGCN'); expect(useAppStore.getState().isDonated('fish-999')).toBe(false); }); }); @@ -170,7 +170,7 @@ describe('getActiveTown', () => { }); it('returns the currently active town', () => { - const town = useAppStore.getState().createTown('Pallet', 'Ash'); + const town = useAppStore.getState().createTown('Pallet', 'ACGCN'); expect(useAppStore.getState().getActiveTown()?.id).toBe(town.id); }); }); diff --git a/src/lib/store.ts b/src/lib/store.ts index 69cb45a..0f7134b 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -8,12 +8,16 @@ export type Hemisphere = 'NH' | 'SH'; export interface Town { id: string; name: string; - playerName: string; gameId: GameId; hemisphere: Hemisphere; createdAt: string; } +export interface TownPatch { + name?: string; + hemisphere?: Hemisphere | null; +} + interface AppState { towns: Town[]; activeTownId: string | null; @@ -22,8 +26,8 @@ interface AppState { // donatedAt[townId][gameId][itemId] = ISO string donatedAt: Record>>; - createTown: (name: string, playerName: string, gameId?: GameId) => Town; - updateTown: (id: string, name: string, playerName: string) => void; + createTown: (name: string, gameId: GameId, hemisphere?: Hemisphere) => Town; + updateTown: (id: string, patch: TownPatch) => void; setTownHemisphere: (id: string, hemisphere: Hemisphere) => void; setActiveTown: (id: string) => void; deleteTown: (id: string) => void; @@ -47,13 +51,12 @@ export const useAppStore = create()( donated: {}, donatedAt: {}, - createTown: (name, playerName, gameId = 'ACGCN') => { + createTown: (name, gameId, hemisphere = 'NH') => { const town: Town = { id: generateId(), name, - playerName, gameId, - hemisphere: 'NH', + hemisphere, createdAt: new Date().toISOString(), }; set(state => ({ @@ -63,11 +66,17 @@ export const useAppStore = create()( return town; }, - updateTown: (id, name, playerName) => + updateTown: (id, patch) => set(state => ({ - towns: state.towns.map(t => - t.id === id ? { ...t, name, playerName } : t - ), + towns: state.towns.map(t => { + if (t.id !== id) return t; + const next: Town = { ...t }; + if (patch.name !== undefined) next.name = patch.name; + if (patch.hemisphere !== undefined && patch.hemisphere !== null) { + next.hemisphere = patch.hemisphere; + } + return next; + }), })), setTownHemisphere: (id, hemisphere) => diff --git a/src/lib/types.ts b/src/lib/types.ts index 7640cd7..b2156d4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -53,6 +53,15 @@ export const GAMES: Record = { }, }; +/** Convenience: list of games for selectors. */ +export const GAME_LIST: Game[] = [ + GAMES.ACGCN, + GAMES.ACWW, + GAMES.ACCF, + GAMES.ACNL, + GAMES.ACNH, +]; + export type Habitat = 'river' | 'ocean' | 'pond' | 'lake' | 'other'; export type CategoryId = 'fish' | 'bugs' | 'fossils' | 'art' | 'sea_creatures'; diff --git a/src/lib/uiStore.ts b/src/lib/uiStore.ts new file mode 100644 index 0000000..b6a9efa --- /dev/null +++ b/src/lib/uiStore.ts @@ -0,0 +1,18 @@ +import { create } from 'zustand'; + +interface UIState { + townManagerOpen: boolean; + /** When true, drawer is locked open (no scrim/Esc dismiss) and forced into create mode. Used when no towns exist. */ + townManagerForceCreate: boolean; + openTownManager: (forceCreate?: boolean) => void; + closeTownManager: () => void; +} + +export const useUIStore = create(set => ({ + townManagerOpen: false, + townManagerForceCreate: false, + openTownManager: (forceCreate = false) => + set({ townManagerOpen: true, townManagerForceCreate: forceCreate }), + closeTownManager: () => + set({ townManagerOpen: false, townManagerForceCreate: false }), +}));