Skip to content

Commit 7593335

Browse files
feat: enhance PatternsSidebar with category expansion and improved pattern filtering
1 parent 440ff6b commit 7593335

2 files changed

Lines changed: 130 additions & 90 deletions

File tree

app/page.tsx

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useCallback, useEffect } from "react";
3+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
44

55
import { ControlsToolBar } from "@/components/game/ControlsToolBar";
66
import { SimulationGrid } from "@/components/game/SimulationGrid";
@@ -33,6 +33,13 @@ export default function GameOfLifePage() {
3333
addPattern,
3434
} = useGameOfLife({ initialRows: 50, initialCols: 70 });
3535

36+
const boardViewportRef = useRef<HTMLDivElement | null>(null);
37+
const [boardViewportSize, setBoardViewportSize] = useState({ width: 0, height: 0 });
38+
39+
const handleClear = useCallback(() => {
40+
clear();
41+
}, [clear]);
42+
3643
useEffect(() => {
3744
const handleKeyDown = (event: KeyboardEvent) => {
3845
const { target, key, preventDefault } = event;
@@ -52,14 +59,14 @@ export default function GameOfLifePage() {
5259
randomize();
5360
break;
5461
case "c":
55-
clear();
62+
handleClear();
5663
break;
5764
}
5865
};
5966

6067
window.addEventListener("keydown", handleKeyDown);
6168
return () => window.removeEventListener("keydown", handleKeyDown);
62-
}, [toggle, step, randomize, clear, isRunning]);
69+
}, [toggle, step, randomize, handleClear, isRunning]);
6370

6471
const handleSelectPattern = useCallback(
6572
(pattern: Pattern) => {
@@ -98,31 +105,73 @@ export default function GameOfLifePage() {
98105
input.click();
99106
}, [setGrid]);
100107

101-
const getCellSize = () => {
102-
if (gridSize.cols > 100) return 8;
103-
if (gridSize.cols > 70) return 10;
104-
return 12;
105-
};
108+
useEffect(() => {
109+
const element = boardViewportRef.current;
110+
if (!element) return;
111+
112+
const updateSize = () => {
113+
setBoardViewportSize({ width: element.clientWidth, height: element.clientHeight });
114+
};
115+
116+
updateSize();
117+
118+
const observer = new ResizeObserver(updateSize);
119+
observer.observe(element);
120+
121+
return () => observer.disconnect();
122+
}, []);
123+
124+
const cellSize = useMemo(() => {
125+
const fallbackSize = 12;
126+
127+
if (boardViewportSize.width === 0 || boardViewportSize.height === 0) {
128+
return fallbackSize;
129+
}
130+
131+
const availableCellSize = Math.min(
132+
boardViewportSize.width / gridSize.cols,
133+
boardViewportSize.height / gridSize.rows,
134+
);
135+
136+
return Math.max(4, Math.min(14, Math.floor(availableCellSize)));
137+
}, [boardViewportSize.height, boardViewportSize.width, gridSize.cols, gridSize.rows]);
106138

107139
return (
108-
<div className="flex flex-col h-screen overflow-hidden">
140+
<div className="flex min-h-screen flex-col overflow-hidden lg:h-screen">
109141
<Header gridSize={gridSize} onGridSizeChange={updateGridSize} />
110142

111-
<div className="flex-1 flex overflow-hidden">
112-
<aside className="w-64 p-4 shrink-0">
143+
<div className="flex flex-1 min-h-0 flex-col gap-4 overflow-hidden p-4 lg:flex-row lg:gap-0 lg:p-0">
144+
<aside className="w-full shrink-0 min-h-0 overflow-hidden lg:w-64 lg:p-4">
113145
<PatternsSidebar onSelectPattern={handleSelectPattern} />
114146
</aside>
115147

116-
<main className="flex-1 flex flex-col items-center justify-center gap-6 p-6 overflow-auto">
117-
<SimulationGrid grid={grid} onCellToggle={toggleCell} cellSize={getCellSize()} />
148+
<main className="flex min-w-0 flex-1 flex-col items-center gap-6 overflow-auto px-0 lg:px-6 lg:py-6">
149+
<section className="w-full max-w-3xl rounded-xl border border-border/50 bg-card/60 px-4 py-4 text-sm text-muted-foreground backdrop-blur-md lg:px-5">
150+
<p>
151+
Conway’s Game of Life is a cellular automaton where simple rules create complex
152+
patterns over generations.
153+
</p>
154+
<p className="mt-2">
155+
Start with a predefined pattern or search for one to explore different behaviors.
156+
</p>
157+
</section>
158+
159+
<div
160+
ref={boardViewportRef}
161+
className="w-full min-h-[55vh] min-w-0 overflow-auto lg:flex-1 lg:min-h-0"
162+
>
163+
<div className="flex w-full justify-center lg:justify-center">
164+
<SimulationGrid grid={grid} onCellToggle={toggleCell} cellSize={cellSize} />
165+
</div>
166+
</div>
118167

119168
<div className="flex flex-col items-center gap-2">
120169
<ControlsToolBar
121170
isRunning={isRunning}
122171
speed={speed}
123172
onToggle={toggle}
124173
onStep={step}
125-
onClear={clear}
174+
onClear={handleClear}
126175
onRandomize={randomize}
127176
onSpeedChange={setSpeed}
128177
onExport={handleExport}
@@ -135,7 +184,7 @@ export default function GameOfLifePage() {
135184
</div>
136185
</main>
137186

138-
<aside className="w-64 p-4 shrink-0">
187+
<aside className="w-full shrink-0 lg:w-64 lg:p-4">
139188
<StatsSidebar
140189
generation={generation}
141190
aliveCells={aliveCells}

components/layout/PatternsSidebar.tsx

Lines changed: 66 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { Box, Grid3X3, Rocket, Search, Zap } from "lucide-react";
3+
import { Box, ChevronRight, Rocket, Search, Zap } from "lucide-react";
44
import { useState } from "react";
55

66
import { PATTERNS } from "@/app/const";
@@ -14,58 +14,67 @@ type PatternsSidebarProps = {
1414
onSelectPattern: (pattern: Pattern) => void;
1515
};
1616

17-
type CategoryFilter = "all" | "spaceship" | "oscillator" | "still";
17+
type PatternCategory = "spaceship" | "oscillator" | "still";
1818

1919
const categoryIcons = {
2020
still: Box,
2121
oscillator: Zap,
2222
spaceship: Rocket,
23-
custom: Grid3X3,
2423
};
2524

2625
const categoryLabels = {
2726
still: "Still Lives",
2827
oscillator: "Oscillators",
2928
spaceship: "Spaceships",
30-
custom: "Custom",
3129
};
3230

33-
const categoryFilters: Array<{ value: CategoryFilter; label: string }> = [
34-
{ value: "all", label: "All" },
35-
{ value: "spaceship", label: "Spaceships" },
36-
{ value: "oscillator", label: "Oscillators" },
37-
{ value: "still", label: "Still Lives" },
38-
];
31+
const categoryOrder: PatternCategory[] = ["spaceship", "oscillator", "still"];
3932

4033
export const PatternsSidebar = ({ onSelectPattern }: PatternsSidebarProps) => {
4134
const [search, setSearch] = useState("");
42-
const [selectedCategory, setSelectedCategory] = useState<CategoryFilter>("all");
4335
const [selectedPattern, setSelectedPattern] = useState<{ id: string; name: string } | null>(null);
36+
const [expandedCategories, setExpandedCategories] = useState<Record<PatternCategory, boolean>>({
37+
spaceship: true,
38+
oscillator: true,
39+
still: true,
40+
});
4441

4542
const handleSelectPattern = (pattern: Pattern) => {
4643
setSelectedPattern({ id: pattern.id, name: pattern.name });
4744
onSelectPattern(pattern);
4845
};
4946

5047
const filteredPatterns = PATTERNS.filter((p) => {
51-
const matchesSearch =
48+
return (
5249
p.name.toLowerCase().includes(search.toLowerCase()) ||
53-
p.description.toLowerCase().includes(search.toLowerCase());
54-
const matchesCategory = selectedCategory === "all" || p.category === selectedCategory;
55-
return matchesSearch && matchesCategory;
50+
p.description.toLowerCase().includes(search.toLowerCase())
51+
);
5652
});
5753

5854
const groupedPatterns = filteredPatterns.reduce(
5955
(acc, pattern) => {
60-
if (!acc[pattern.category]) acc[pattern.category] = [];
61-
acc[pattern.category].push(pattern);
56+
if (pattern.category in acc) {
57+
const category = pattern.category as PatternCategory;
58+
acc[category].push(pattern);
59+
}
6260
return acc;
6361
},
64-
{} as Record<string, Pattern[]>,
62+
{
63+
spaceship: [],
64+
oscillator: [],
65+
still: [],
66+
} as Record<PatternCategory, Pattern[]>,
6567
);
6668

69+
const toggleCategory = (category: PatternCategory) => {
70+
setExpandedCategories((prev) => ({
71+
...prev,
72+
[category]: !prev[category],
73+
}));
74+
};
75+
6776
return (
68-
<div className="flex flex-col h-full rounded-xl border border-border/50 bg-card/60 backdrop-blur-md overflow-hidden">
77+
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-border/50 bg-card/60 backdrop-blur-md">
6978
{/* Header */}
7079
<div className="p-4 border-b border-border/50">
7180
<h2 className="text-sm font-semibold text-foreground mb-3">Patterns</h2>
@@ -78,52 +87,49 @@ export const PatternsSidebar = ({ onSelectPattern }: PatternsSidebarProps) => {
7887
className="pl-9 h-9 bg-background/50 border-border/50 text-sm"
7988
/>
8089
</div>
81-
<div className="mt-3 flex flex-wrap gap-2">
82-
{categoryFilters.map((category) => {
83-
const isActive = selectedCategory === category.value;
84-
85-
return (
86-
<button
87-
key={category.value}
88-
type="button"
89-
onClick={() => setSelectedCategory(category.value)}
90-
className={[
91-
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
92-
isActive
93-
? "border-primary/40 bg-primary/15 text-primary"
94-
: "border-border/60 bg-background/40 text-muted-foreground hover:bg-accent/60",
95-
].join(" ")}
96-
>
97-
{category.label}
98-
</button>
99-
);
100-
})}
101-
</div>
10290
</div>
10391

10492
{/* Patterns List */}
105-
<ScrollArea className="flex-1">
93+
<ScrollArea className="min-h-0 flex-1">
10694
<div className="p-3 space-y-4">
107-
{selectedCategory === "all" ? (
108-
Object.entries(groupedPatterns).map(([category, patterns]) => {
109-
const Icon = categoryIcons[category as keyof typeof categoryIcons];
110-
return (
111-
<div key={category}>
112-
<div className="flex items-center gap-2 px-2 mb-2">
113-
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
114-
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
115-
{categoryLabels[category as keyof typeof categoryLabels]}
116-
</span>
117-
</div>
95+
{categoryOrder.map((category) => {
96+
const patterns = groupedPatterns[category];
97+
98+
if (patterns.length === 0) return null;
99+
100+
const Icon = categoryIcons[category];
101+
const isExpanded = expandedCategories[category];
102+
103+
return (
104+
<div key={category}>
105+
<button
106+
type="button"
107+
onClick={() => toggleCategory(category)}
108+
className="mb-2 flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-accent/40"
109+
>
110+
<ChevronRight
111+
className={[
112+
"h-3.5 w-3.5 text-muted-foreground transition-transform",
113+
isExpanded ? "rotate-90" : "rotate-0",
114+
].join(" ")}
115+
/>
116+
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
117+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
118+
{categoryLabels[category]}
119+
</span>
120+
</button>
121+
122+
{isExpanded && (
118123
<div className="space-y-1">
119124
{patterns.map((pattern) => (
120125
<div
121126
key={pattern.id}
122-
className={
127+
className={[
128+
"rounded-lg border transition-colors",
123129
selectedPattern?.id === pattern.id
124-
? "rounded-lg bg-primary/10 ring-1 ring-primary/40"
125-
: "rounded-lg"
126-
}
130+
? "border-primary/50 bg-primary/10 ring-1 ring-primary/30"
131+
: "border-transparent",
132+
].join(" ")}
127133
>
128134
<PatternItem
129135
pattern={pattern}
@@ -132,25 +138,10 @@ export const PatternsSidebar = ({ onSelectPattern }: PatternsSidebarProps) => {
132138
</div>
133139
))}
134140
</div>
135-
</div>
136-
);
137-
})
138-
) : (
139-
<div className="space-y-1">
140-
{filteredPatterns.map((pattern) => (
141-
<div
142-
key={pattern.id}
143-
className={
144-
selectedPattern?.id === pattern.id
145-
? "rounded-lg bg-primary/10 ring-1 ring-primary/40"
146-
: "rounded-lg"
147-
}
148-
>
149-
<PatternItem pattern={pattern} onSelect={() => handleSelectPattern(pattern)} />
150-
</div>
151-
))}
152-
</div>
153-
)}
141+
)}
142+
</div>
143+
);
144+
})}
154145

155146
{filteredPatterns.length === 0 && (
156147
<div className="text-center py-8 text-muted-foreground text-sm">No patterns found</div>

0 commit comments

Comments
 (0)