Skip to content

Commit e44cbab

Browse files
Convert browse into a modal that adds to existing selection
Clicking "browse" in the heading now opens a modal overlay instead of navigating to /browse. Events selected in the modal are added to the current selection (up to 3). Already-selected events are greyed out. This avoids routing issues (especially offline) and lets users add events from the browse list without losing their current selection. The standalone /browse page is kept for direct access and SEO. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6e87fb2 commit e44cbab

5 files changed

Lines changed: 268 additions & 2 deletions

File tree

src/components/BrowseModal.tsx

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"use client";
2+
3+
import { useState, useMemo, useEffect, useRef } from "react";
4+
import type { Event } from "@/lib/types";
5+
import { EVENT_TYPES } from "@/lib/types";
6+
import { formatYear } from "@/lib/date-utils";
7+
import { ERAS, groupByEra } from "@/lib/eras";
8+
import CategoryIcon from "@/components/CategoryIcon";
9+
import browseStyles from "@/styles/Browse.module.css";
10+
import modalStyles from "@/styles/BrowseModal.module.css";
11+
12+
interface BrowseModalProps {
13+
events: Event[];
14+
selectedIds: number[];
15+
onSelect: (event: Event) => void;
16+
onClose: () => void;
17+
}
18+
19+
function capitalize(s: string): string {
20+
return s.charAt(0).toUpperCase() + s.slice(1);
21+
}
22+
23+
export default function BrowseModal({ events, selectedIds, onSelect, onClose }: BrowseModalProps) {
24+
const modalRef = useRef<HTMLDivElement>(null);
25+
const [openEras, setOpenEras] = useState<Set<string>>(() => new Set());
26+
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
27+
28+
const eras = useMemo(() => {
29+
const groups = groupByEra(events);
30+
return ERAS.map((era) => ({
31+
id: era.id,
32+
label: era.label,
33+
description: era.description,
34+
events: groups.get(era.id) || [],
35+
}));
36+
}, [events]);
37+
38+
const categoryCounts = useMemo(() => {
39+
const map = new Map<string, number>();
40+
for (const e of events) {
41+
map.set(e.type, (map.get(e.type) || 0) + 1);
42+
}
43+
return map;
44+
}, [events]);
45+
46+
const filteredEras = useMemo(() => {
47+
if (!categoryFilter) return eras;
48+
return eras.map((era) => ({
49+
...era,
50+
events: era.events.filter((e) => e.type === categoryFilter),
51+
}));
52+
}, [eras, categoryFilter]);
53+
54+
const totalFiltered = filteredEras.reduce((sum, e) => sum + e.events.length, 0);
55+
56+
const toggle = (id: string) => {
57+
setOpenEras((prev) => {
58+
const next = new Set(prev);
59+
if (next.has(id)) next.delete(id);
60+
else next.add(id);
61+
return next;
62+
});
63+
};
64+
65+
// Focus trap + Escape
66+
useEffect(() => {
67+
function handleKey(e: KeyboardEvent) {
68+
if (e.key === "Escape") onClose();
69+
if (e.key === "Tab" && modalRef.current) {
70+
const focusable = modalRef.current.querySelectorAll<HTMLElement>(
71+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
72+
);
73+
if (focusable.length === 0) return;
74+
const first = focusable[0];
75+
const last = focusable[focusable.length - 1];
76+
if (e.shiftKey && document.activeElement === first) {
77+
e.preventDefault();
78+
last.focus();
79+
} else if (!e.shiftKey && document.activeElement === last) {
80+
e.preventDefault();
81+
first.focus();
82+
}
83+
}
84+
}
85+
document.addEventListener("keydown", handleKey);
86+
modalRef.current?.focus();
87+
return () => document.removeEventListener("keydown", handleKey);
88+
}, [onClose]);
89+
90+
return (
91+
<div className={modalStyles.overlay} onClick={onClose} role="presentation">
92+
<div
93+
ref={modalRef}
94+
className={modalStyles.modal}
95+
onClick={(e) => e.stopPropagation()}
96+
role="dialog"
97+
aria-modal="true"
98+
aria-labelledby="browse-title"
99+
tabIndex={-1}
100+
>
101+
<div className={modalStyles.header}>
102+
<h3 id="browse-title" className={modalStyles.title}>Browse by era</h3>
103+
<button
104+
className={modalStyles.closeButton}
105+
onClick={onClose}
106+
aria-label="Close"
107+
>
108+
&times;
109+
</button>
110+
</div>
111+
<p className={browseStyles.subtitle}>
112+
{categoryFilter
113+
? `${totalFiltered} ${categoryFilter} events.`
114+
: `${totalFiltered} events.`}
115+
{" "}Click to add to your timeline.
116+
</p>
117+
<div className={browseStyles.chips}>
118+
{EVENT_TYPES.map((type) => (
119+
<button
120+
key={type}
121+
className={`${browseStyles.chip}${categoryFilter === type ? ` ${browseStyles.chipActive}` : ""}`}
122+
onClick={() => setCategoryFilter(categoryFilter === type ? null : type)}
123+
aria-pressed={categoryFilter === type}
124+
title={capitalize(type)}
125+
>
126+
<CategoryIcon type={type} size={16} />
127+
<span>{capitalize(type)} ({categoryCounts.get(type) || 0})</span>
128+
</button>
129+
))}
130+
</div>
131+
<div className={browseStyles.eras}>
132+
{filteredEras.map((era) => {
133+
if (era.events.length === 0) return null;
134+
const isOpen = openEras.has(era.id);
135+
return (
136+
<div key={era.id} className={browseStyles.era}>
137+
<button
138+
className={browseStyles.eraHeader}
139+
onClick={() => toggle(era.id)}
140+
aria-expanded={isOpen}
141+
aria-controls={`browse-era-${era.id}`}
142+
>
143+
<div className={browseStyles.eraInfo}>
144+
<span className={browseStyles.eraLabel}>{era.label}</span>
145+
<span className={browseStyles.eraDescription}>{era.description}</span>
146+
</div>
147+
<span className={browseStyles.eraCount}>{era.events.length} events</span>
148+
<span className={`${browseStyles.chevron} ${isOpen ? browseStyles.chevronOpen : ""}`} aria-hidden="true">
149+
&#9662;
150+
</span>
151+
</button>
152+
{isOpen && (
153+
<div id={`browse-era-${era.id}`} className={browseStyles.eventList} role="list">
154+
{era.events.map((event) => {
155+
const alreadySelected = selectedIds.includes(event.id);
156+
return (
157+
<button
158+
key={event.id}
159+
className={`${browseStyles.eventItem} ${alreadySelected ? modalStyles.eventDisabled : ""}`}
160+
role="listitem"
161+
disabled={alreadySelected}
162+
onClick={() => {
163+
onSelect(event);
164+
onClose();
165+
}}
166+
>
167+
<span className={browseStyles.eventIcon}>
168+
<CategoryIcon type={event.type} size={20} />
169+
</span>
170+
<span className={browseStyles.eventName}>{capitalize(event.name)}</span>
171+
<span className={browseStyles.eventYear}>{formatYear(event.year)}</span>
172+
</button>
173+
);
174+
})}
175+
</div>
176+
)}
177+
</div>
178+
);
179+
})}
180+
</div>
181+
</div>
182+
</div>
183+
);
184+
}

src/components/Chooser/Chooser.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { useState, useCallback, useMemo, useEffect } from "react";
44
import dynamic from "next/dynamic";
5-
import Link from "next/link";
65
import { useRouter } from "next/navigation";
76
import type { Event, MarkerData, SegmentData } from "@/lib/types";
87
import { useLocalEvents } from "@/hooks/useLocalEvents";
@@ -22,6 +21,7 @@ import styles from "@/styles/Chooser.module.css";
2221

2322
const HelpModal = dynamic(() => import("@/components/HelpModal"));
2423
const SettingsModal = dynamic(() => import("@/components/SettingsModal"));
24+
const BrowseModal = dynamic(() => import("@/components/BrowseModal"));
2525

2626
interface ChooserProps {
2727
allEvents: Event[];
@@ -52,6 +52,7 @@ export default function Chooser({
5252
const [editingSlot, setEditingSlot] = useState<number | null>(null);
5353
const [showSettings, setShowSettings] = useState(false);
5454
const [showHelp, setShowHelp] = useState(false);
55+
const [showBrowse, setShowBrowse] = useState(false);
5556
const [isOffline, setIsOffline] = useState(false);
5657

5758
// Merge server + local events for the search list
@@ -229,7 +230,7 @@ export default function Chooser({
229230
<>
230231
<div className={styles.chooser}>
231232
<div className={styles.headingRow}>
232-
<p className={styles.heading}>Pick some events or <Link href="/browse" className={styles.browseLink}>browse</Link></p>
233+
<p className={styles.heading}>Pick some events or <button className={styles.browseLink} onClick={() => setShowBrowse(true)}>browse</button></p>
233234
<button
234235
className={styles.iconButton}
235236
onClick={() => setShowHelp(true)}
@@ -364,6 +365,18 @@ export default function Chooser({
364365
)}
365366
<Timeline markers={timeline.markers} segments={timeline.segments} />
366367
</div>
368+
{showBrowse && (
369+
<BrowseModal
370+
events={mergedEvents}
371+
selectedIds={currentIds}
372+
onSelect={(event) => {
373+
if (allSelected.length < 3) {
374+
handleSelect(allSelected.length, event);
375+
}
376+
}}
377+
onClose={() => setShowBrowse(false)}
378+
/>
379+
)}
367380
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
368381
{showSettings && (
369382
<SettingsModal

src/styles/Browse.module.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,14 @@
134134
text-decoration: none;
135135
color: var(--color-text);
136136
transition: background 0.1s;
137+
/* Support both <a> and <button> usage */
138+
width: 100%;
139+
background: none;
140+
border: none;
141+
font-family: inherit;
142+
font-size: inherit;
143+
cursor: pointer;
144+
text-align: left;
137145
}
138146

139147
.eventItem:hover {

src/styles/BrowseModal.module.css

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
.overlay {
2+
position: fixed;
3+
top: 0;
4+
left: 0;
5+
right: 0;
6+
bottom: 0;
7+
background: rgba(0, 0, 0, 0.5);
8+
display: flex;
9+
align-items: center;
10+
justify-content: center;
11+
z-index: 1000;
12+
}
13+
14+
.modal {
15+
background: var(--color-bg-surface);
16+
border-radius: 12px;
17+
padding: 24px;
18+
max-width: 640px;
19+
width: 90%;
20+
max-height: 85vh;
21+
overflow-y: auto;
22+
}
23+
24+
.modal:focus {
25+
outline: none;
26+
}
27+
28+
.header {
29+
display: flex;
30+
align-items: center;
31+
justify-content: space-between;
32+
margin-bottom: 8px;
33+
}
34+
35+
.title {
36+
margin: 0;
37+
}
38+
39+
.closeButton {
40+
background: none;
41+
border: none;
42+
font-size: 1.5rem;
43+
cursor: pointer;
44+
color: var(--color-text-light);
45+
line-height: 1;
46+
padding: 0 4px;
47+
}
48+
49+
.closeButton:hover {
50+
color: var(--color-text);
51+
}
52+
53+
.eventDisabled {
54+
opacity: 0.4;
55+
pointer-events: none;
56+
}

src/styles/Chooser.module.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
.browseLink {
2121
color: var(--color-primary);
2222
text-decoration: underline;
23+
background: none;
24+
border: none;
25+
font: inherit;
26+
cursor: pointer;
27+
padding: 0;
2328
}
2429

2530
.iconButton {

0 commit comments

Comments
 (0)