/
/
/
1import { useState, useMemo } from 'react';
2import { Search, Check, RefreshCw } from 'lucide-react';
3import type { TopicSelectorProps } from '../api/types';
4
5export function TopicSelector({
6 topics,
7 selectedTopics,
8 onTopicsChange,
9 disabled = false,
10}: TopicSelectorProps) {
11 const [searchTerm, setSearchTerm] = useState('');
12 const [showSelected, setShowSelected] = useState(false);
13
14 const filteredTopics = useMemo(() => {
15 let filtered = topics;
16
17 if (searchTerm) {
18 filtered = topics.filter(topic =>
19 topic.toLowerCase().includes(searchTerm.toLowerCase())
20 );
21 }
22
23 if (showSelected) {
24 filtered = filtered.filter(topic => selectedTopics.includes(topic));
25 }
26
27 return filtered.sort();
28 }, [topics, searchTerm, showSelected, selectedTopics]);
29
30 const handleTopicToggle = (topic: string) => {
31 if (disabled) return;
32
33 const newSelectedTopics = selectedTopics.includes(topic)
34 ? selectedTopics.filter(t => t !== topic)
35 : [...selectedTopics, topic];
36
37 onTopicsChange(newSelectedTopics);
38 };
39
40 const handleSelectAll = () => {
41 if (disabled) return;
42 onTopicsChange(filteredTopics);
43 };
44
45 const handleDeselectAll = () => {
46 if (disabled) return;
47 onTopicsChange([]);
48 };
49
50 const selectedCount = selectedTopics.length;
51 const totalCount = topics.length;
52
53 return (
54 <div className="bg-white rounded-lg shadow-md p-6">
55 <div className="flex items-center justify-between mb-4">
56 <h2 className="text-xl font-semibold text-gray-800">
57 Topic Selection
58 <span className="text-sm font-normal text-gray-500 ml-2">
59 ({selectedCount}/{totalCount} selected)
60 </span>
61 </h2>
62 </div>
63
64 {/* Search and Filters */}
65 <div className="mb-4 space-y-3">
66 <div className="relative">
67 <Search size={16} className="absolute left-3 top-3 text-gray-400" />
68 <input
69 type="text"
70 placeholder="Search topics..."
71 value={searchTerm}
72 onChange={(e) => setSearchTerm(e.target.value)}
73 className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
74 disabled={disabled}
75 />
76 </div>
77
78 <div className="flex flex-wrap gap-2">
79 <button
80 onClick={handleSelectAll}
81 disabled={disabled || filteredTopics.length === 0}
82 className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
83 >
84 Select All ({filteredTopics.length})
85 </button>
86
87 <button
88 onClick={handleDeselectAll}
89 disabled={disabled || selectedCount === 0}
90 className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
91 >
92 Deselect All
93 </button>
94
95 <button
96 onClick={() => setShowSelected(!showSelected)}
97 className={`px-3 py-1 text-sm rounded-md transition-colors ${
98 showSelected
99 ? 'bg-green-100 text-green-700 hover:bg-green-200'
100 : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
101 }`}
102 >
103 {showSelected ? 'Show All' : `Show Selected (${selectedCount})`}
104 </button>
105 </div>
106 </div>
107
108 {/* Topics List */}
109 <div className="border border-gray-200 rounded-md max-h-96 overflow-y-auto">
110 {filteredTopics.length === 0 ? (
111 <div className="p-4 text-center text-gray-500">
112 {topics.length === 0 ? (
113 <div className="space-y-2">
114 <RefreshCw size={24} className="mx-auto text-gray-300" />
115 <div>No topics available</div>
116 <div className="text-xs">Make sure ROS2 nodes are running</div>
117 </div>
118 ) : (
119 <div>No topics match your search</div>
120 )}
121 </div>
122 ) : (
123 <div className="divide-y divide-gray-200">
124 {filteredTopics.map((topic) => {
125 const isSelected = selectedTopics.includes(topic);
126
127 return (
128 <div
129 key={topic}
130 className={`p-3 cursor-pointer transition-colors ${
131 disabled
132 ? 'cursor-not-allowed opacity-50'
133 : 'hover:bg-gray-50'
134 } ${
135 isSelected ? 'bg-blue-50 border-l-4 border-l-blue-500' : ''
136 }`}
137 onClick={() => handleTopicToggle(topic)}
138 >
139 <div className="flex items-center justify-between">
140 <div className="flex items-center space-x-3">
141 <div
142 className={`w-4 h-4 rounded border-2 flex items-center justify-center ${
143 isSelected
144 ? 'bg-blue-500 border-blue-500 text-white'
145 : 'border-gray-300'
146 }`}
147 >
148 {isSelected && <Check size={12} />}
149 </div>
150
151 <div>
152 <div className="font-mono text-sm text-gray-900">
153 {topic}
154 </div>
155 {/* Add topic type info if available */}
156 <div className="text-xs text-gray-500">
157 Topic ⢠Click to {isSelected ? 'deselect' : 'select'}
158 </div>
159 </div>
160 </div>
161
162 {isSelected && (
163 <div className="text-blue-500">
164 <Check size={16} />
165 </div>
166 )}
167 </div>
168 </div>
169 );
170 })}
171 </div>
172 )}
173 </div>
174
175 {/* Summary */}
176 {selectedCount > 0 && (
177 <div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
178 <div className="flex items-center justify-between">
179 <div className="text-sm text-blue-700">
180 <span className="font-medium">{selectedCount}</span> topic{selectedCount !== 1 ? 's' : ''} selected for recording
181 </div>
182
183 {selectedCount > 5 && (
184 <button
185 onClick={() => setShowSelected(true)}
186 className="text-xs text-blue-600 hover:text-blue-800 underline"
187 >
188 View selected
189 </button>
190 )}
191 </div>
192
193 {selectedCount <= 5 && (
194 <div className="mt-2 space-y-1">
195 {selectedTopics.slice(0, 5).map(topic => (
196 <div key={topic} className="text-xs font-mono text-blue-600">
197 {topic}
198 </div>
199 ))}
200 </div>
201 )}
202 </div>
203 )}
204 </div>
205 );
206}