Skip to content

Commit 1e61c16

Browse files
committed
feat: add SentimentVerbPicker component for statement wizard
1 parent 2a75bd0 commit 1e61c16

File tree

1 file changed

+227
-0
lines changed

1 file changed

+227
-0
lines changed
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
'use client';
2+
3+
import React, { useState } from 'react';
4+
import { ChevronLeft } from 'lucide-react';
5+
import { Button } from '../ui/button';
6+
import verbData from '../../../data/verbs.json';
7+
import categoryStructure from '../../../data/categoryStructure.json';
8+
import type { Verb, Category } from '../../../types/entries';
9+
import { getContrastColor } from '../../../utils/colorUtils';
10+
11+
/**
12+
* findCategoryByName:
13+
* Recursively searches the category tree for a node matching the given "name".
14+
*/
15+
function findCategoryByName(root: Category, name: string): Category | null {
16+
if (root.name === name) return root;
17+
if (root.children) {
18+
for (const child of root.children) {
19+
const found = findCategoryByName(child, name);
20+
if (found) return found;
21+
}
22+
}
23+
return null;
24+
}
25+
26+
/**
27+
* getAllDescendants:
28+
* Returns an array of all category names under "node" (including the node's own).
29+
*/
30+
function getAllDescendants(node: Category): string[] {
31+
const result: string[] = [node.name];
32+
if (node.children) {
33+
for (const child of node.children) {
34+
result.push(...getAllDescendants(child));
35+
}
36+
}
37+
return result;
38+
}
39+
40+
/**
41+
* getVerbColor:
42+
* For a given verb, returns the color of the first matching category in the entire tree.
43+
* If no match is found, returns 'transparent'.
44+
*/
45+
function getVerbColor(verb: Verb, root: Category): string {
46+
for (const catName of verb.categories) {
47+
const cat = findCategoryByName(root, catName);
48+
if (cat) {
49+
return cat.color;
50+
}
51+
}
52+
return 'transparent';
53+
}
54+
55+
interface SentimentVerbPickerProps {
56+
selectedVerb: string;
57+
onVerbSelect: (verb: Verb) => void;
58+
}
59+
60+
const SentimentVerbPicker: React.FC<SentimentVerbPickerProps> = ({
61+
selectedVerb,
62+
onVerbSelect,
63+
}) => {
64+
// Root of your category structure
65+
const rootCategory = categoryStructure.root as Category;
66+
67+
// If currentCategory is null => "All" is selected (no filter).
68+
const [currentCategory, setCurrentCategory] = useState<Category | null>(null);
69+
70+
// ---------------------------------
71+
// FILTERING LOGIC
72+
// ---------------------------------
73+
/**
74+
* If no category is selected => show all verbs.
75+
* Otherwise => gather the union of the current category's descendants.
76+
*/
77+
let allowedNames: string[] = [];
78+
if (currentCategory) {
79+
allowedNames = getAllDescendants(currentCategory);
80+
}
81+
82+
const filteredVerbs = (verbData.verbs as Verb[]).filter((verb) => {
83+
// "All" => no filtering
84+
if (!currentCategory) return true;
85+
// Otherwise => any intersection with the category's descendant names
86+
return verb.categories.some((catName) => allowedNames.includes(catName));
87+
});
88+
89+
// ---------------------------------
90+
// FILTER BAR
91+
// ---------------------------------
92+
/**
93+
* Layout logic:
94+
* - If no category => we show [All (highlighted)] + top-level children of root
95+
* - If a category has children => show [Back Icon] + that category's children
96+
* - If a category is a leaf => show [Back Icon] + leaf name
97+
*/
98+
function renderFilterBar() {
99+
const topLevelChildren = rootCategory.children ?? [];
100+
const isAllSelected = !currentCategory;
101+
const hasChildren =
102+
currentCategory?.children && currentCategory.children.length > 0;
103+
104+
return (
105+
<div className='flex items-center gap-4 px-4 py-2 border-b bg-gray-100 overflow-x-auto'>
106+
{/* CASE 1: No category => show "All" + top-level categories */}
107+
{isAllSelected && (
108+
<>
109+
<Button variant='default' className='text-sm'>
110+
All
111+
</Button>
112+
{topLevelChildren.map((cat) => (
113+
<Button
114+
key={cat.id}
115+
variant='outline'
116+
className='flex items-center gap-1 text-sm'
117+
style={{
118+
backgroundColor: cat.color,
119+
color: getContrastColor(cat.color),
120+
borderColor: cat.color,
121+
}}
122+
onClick={() => setCurrentCategory(cat)}
123+
>
124+
<span>{cat.icon}</span>
125+
<span>{cat.displayName}</span>
126+
</Button>
127+
))}
128+
</>
129+
)}
130+
131+
{/* CASE 2: A category with children => show [Back] + child categories */}
132+
{!isAllSelected && hasChildren && currentCategory && (
133+
<>
134+
<Button
135+
variant='ghost'
136+
className='p-2'
137+
onClick={() => setCurrentCategory(null)}
138+
>
139+
<ChevronLeft size={24} />
140+
</Button>
141+
{currentCategory.children.map((child) => (
142+
<Button
143+
key={child.id}
144+
variant='outline'
145+
className='flex items-center gap-1 text-sm'
146+
style={{
147+
backgroundColor: child.color,
148+
color: getContrastColor(child.color),
149+
borderColor: child.color,
150+
}}
151+
onClick={() => setCurrentCategory(child)}
152+
>
153+
<span>{child.icon}</span>
154+
<span>{child.displayName}</span>
155+
</Button>
156+
))}
157+
</>
158+
)}
159+
160+
{/* CASE 3: A leaf category => show [Back] + the leaf's name */}
161+
{!isAllSelected && !hasChildren && currentCategory && (
162+
<>
163+
<Button
164+
variant='ghost'
165+
className='p-2'
166+
onClick={() => setCurrentCategory(null)}
167+
>
168+
<ChevronLeft size={24} />
169+
</Button>
170+
<Button
171+
variant='default'
172+
className='flex items-center gap-1 text-sm'
173+
style={{
174+
backgroundColor: currentCategory.color,
175+
color: getContrastColor(currentCategory.color),
176+
borderColor: currentCategory.color,
177+
}}
178+
>
179+
<span>{currentCategory.icon}</span>
180+
<span>{currentCategory.displayName}</span>
181+
</Button>
182+
</>
183+
)}
184+
</div>
185+
);
186+
}
187+
188+
// ---------------------------------
189+
// VERB GRID
190+
// ---------------------------------
191+
function renderVerbGrid() {
192+
return (
193+
<div className='grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-4 p-4 overflow-auto'>
194+
{filteredVerbs
195+
.sort((a, b) => a.name.localeCompare(b.name))
196+
.map((verb) => {
197+
const tileColor = getVerbColor(verb, rootCategory);
198+
const isSelected = verb.name === selectedVerb;
199+
return (
200+
<Button
201+
key={verb.name}
202+
onClick={() => onVerbSelect(verb)}
203+
variant={isSelected ? 'default' : 'outline'}
204+
className='flex items-center justify-center p-4 rounded-lg shadow-md'
205+
style={{
206+
backgroundColor: isSelected ? tileColor : 'transparent',
207+
color: isSelected ? getContrastColor(tileColor) : 'inherit',
208+
borderColor: tileColor,
209+
}}
210+
>
211+
<span className='font-medium'>{verb.name}</span>
212+
</Button>
213+
);
214+
})}
215+
</div>
216+
);
217+
}
218+
219+
return (
220+
<div className='flex flex-col h-full'>
221+
{renderFilterBar()}
222+
{renderVerbGrid()}
223+
</div>
224+
);
225+
};
226+
227+
export default SentimentVerbPicker;

0 commit comments

Comments
 (0)