vibepulse 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +7 -13
  2. package/bin/vibepulse.js +1 -0
  3. package/dist/index.js +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/docs/session-status-detection.md +258 -0
  6. package/next.config.ts +11 -0
  7. package/package.json +17 -11
  8. package/postcss.config.mjs +7 -0
  9. package/public/file.svg +1 -0
  10. package/public/globe.svg +1 -0
  11. package/public/next.svg +1 -0
  12. package/public/readme-cover.png +0 -0
  13. package/public/vercel.svg +1 -0
  14. package/public/window.svg +1 -0
  15. package/src/app/api/opencode-config/route.ts +304 -0
  16. package/src/app/api/opencode-config/status/route.ts +31 -0
  17. package/src/app/api/opencode-events/route.ts +86 -0
  18. package/src/app/api/opencode-models/route.test.ts +135 -0
  19. package/src/app/api/opencode-models/route.ts +58 -0
  20. package/src/app/api/profiles/[id]/apply/route.ts +49 -0
  21. package/src/app/api/profiles/[id]/route.ts +160 -0
  22. package/src/app/api/profiles/route.ts +107 -0
  23. package/src/app/api/sessions/[id]/archive/route.ts +35 -0
  24. package/src/app/api/sessions/[id]/delete/route.ts +26 -0
  25. package/src/app/api/sessions/[id]/route.ts +45 -0
  26. package/src/app/api/sessions/route.ts +596 -0
  27. package/src/app/favicon.ico +0 -0
  28. package/src/app/globals.css +66 -0
  29. package/src/app/layout.tsx +37 -0
  30. package/src/app/page.tsx +239 -0
  31. package/src/components/ErrorBoundary.tsx +72 -0
  32. package/src/components/KanbanBoard.tsx +442 -0
  33. package/src/components/LoadingState.tsx +37 -0
  34. package/src/components/ProjectCard.tsx +382 -0
  35. package/src/components/QueryProvider.tsx +25 -0
  36. package/src/components/SessionCard.tsx +291 -0
  37. package/src/components/SessionList.tsx +60 -0
  38. package/src/components/opencode-config/AgentConfigForm.test.tsx +66 -0
  39. package/src/components/opencode-config/AgentConfigForm.tsx +445 -0
  40. package/src/components/opencode-config/AgentModelSelector.tsx +284 -0
  41. package/src/components/opencode-config/AgentsConfigPanel.tsx +162 -0
  42. package/src/components/opencode-config/ConfigButton.tsx +43 -0
  43. package/src/components/opencode-config/ConfigPanel.tsx +91 -0
  44. package/src/components/opencode-config/FullscreenConfigPanel.tsx +360 -0
  45. package/src/components/opencode-config/categories/CategoriesList.tsx +328 -0
  46. package/src/components/opencode-config/categories/CategoriesManager.test.tsx +97 -0
  47. package/src/components/opencode-config/categories/CategoriesManager.tsx +174 -0
  48. package/src/components/opencode-config/categories/CategoryConfigForm.tsx +384 -0
  49. package/src/components/opencode-config/profiles/ProfileCard.tsx +140 -0
  50. package/src/components/opencode-config/profiles/ProfileEditor.tsx +446 -0
  51. package/src/components/opencode-config/profiles/ProfileList.tsx +398 -0
  52. package/src/components/opencode-config/profiles/ProfileManager.test.tsx +122 -0
  53. package/src/components/opencode-config/profiles/ProfileManager.tsx +293 -0
  54. package/src/components/ui/Tabs.tsx +59 -0
  55. package/src/hooks/useOpencodeSync.ts +378 -0
  56. package/src/index.ts +2 -0
  57. package/src/lib/notificationSound.ts +266 -0
  58. package/src/lib/opencodeConfig.test.ts +81 -0
  59. package/src/lib/opencodeConfig.ts +48 -0
  60. package/src/lib/opencodeDiscovery.ts +154 -0
  61. package/src/lib/profiles/storage.ts +264 -0
  62. package/src/lib/transform.ts +84 -0
  63. package/src/test/setup.ts +8 -0
  64. package/src/types/index.ts +89 -0
  65. package/src/types/opencodeConfig.ts +133 -0
  66. package/src/types/testing-library-vitest.d.ts +17 -0
  67. package/tsconfig.json +34 -0
  68. package/tsconfig.lib.json +17 -0
@@ -0,0 +1,442 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import { KanbanColumn, KanbanCard, OpencodeSession } from '@/types';
5
+ import { ProjectCard } from './ProjectCard';
6
+ import { transformSessions } from '@/lib/transform';
7
+ import { LoadingState } from './LoadingState';
8
+ import { playAttentionSound, playCompleteSound } from '@/lib/notificationSound';
9
+ import { useEffect, useMemo, useRef, useState } from 'react';
10
+
11
+ const WAITING_STORAGE_KEY = 'vibepulse:waiting-sessions';
12
+ const SNAPSHOT_STORAGE_KEY = 'vibepulse:last-sessions-snapshot';
13
+ const START_COMMAND_TEMPLATE = 'opencode --port <PORT>';
14
+ const CARD_ANIMATION_DURATION_MS = 250;
15
+
16
+ const COLUMNS: { id: KanbanColumn; title: string }[] = [
17
+ { id: 'idle', title: 'Idle' },
18
+ { id: 'busy', title: 'Busy' },
19
+ { id: 'review', title: 'Needs Attention' },
20
+ { id: 'done', title: 'Archived' },
21
+ ];
22
+
23
+ interface KanbanBoardProps {
24
+ filterDays: number;
25
+ onProcessHintsChange?: (hints: ProcessHint[]) => void;
26
+ }
27
+
28
+ type SessionsFetchError = Error & {
29
+ kind?: 'opencode_unavailable' | 'request_failed';
30
+ hint?: string;
31
+ status?: number;
32
+ };
33
+
34
+ type ProcessHint = {
35
+ pid: number;
36
+ directory: string;
37
+ projectName: string;
38
+ reason: 'process_without_api_port';
39
+ };
40
+
41
+ type SessionSnapshot = {
42
+ savedAt: number;
43
+ sessions: OpencodeSession[];
44
+ processHints: ProcessHint[];
45
+ };
46
+
47
+ type SessionsResponse = {
48
+ sessions: OpencodeSession[];
49
+ processHints?: ProcessHint[];
50
+ };
51
+
52
+ export function KanbanBoard({ filterDays, onProcessHintsChange }: KanbanBoardProps) {
53
+ const waitingStateRef = useRef<Record<string, boolean>>({});
54
+ const waitingInitRef = useRef(false);
55
+ const statusStateRef = useRef<Record<string, 'idle' | 'busy' | 'retry'>>({});
56
+ const statusInitRef = useRef(false);
57
+ const [copyFeedback, setCopyFeedback] = useState<'idle' | 'copied' | 'failed'>('idle');
58
+ const [staleSnapshot, setStaleSnapshot] = useState<SessionSnapshot | null>(null);
59
+
60
+ const { data, isLoading, error, dataUpdatedAt, refetch, isFetching } = useQuery<SessionsResponse>({
61
+ queryKey: ['sessions'],
62
+ queryFn: async () => {
63
+ try {
64
+ const res = await fetch('/api/sessions');
65
+ if (!res.ok) {
66
+ let payload: { error?: string; hint?: string } | null = null;
67
+ try {
68
+ payload = await res.json();
69
+ } catch {
70
+ payload = null;
71
+ }
72
+
73
+ const isUnavailable =
74
+ res.status === 503 && payload?.error === 'OpenCode server not found';
75
+ const fetchError = new Error(
76
+ isUnavailable
77
+ ? payload?.error || 'OpenCode server not found'
78
+ : payload?.error || `Failed to load sessions (${res.status})`
79
+ ) as SessionsFetchError;
80
+
81
+ fetchError.kind = isUnavailable ? 'opencode_unavailable' : 'request_failed';
82
+ fetchError.hint = payload?.hint;
83
+ fetchError.status = res.status;
84
+ throw fetchError;
85
+ }
86
+
87
+ return res.json();
88
+ } catch (error) {
89
+ if (error instanceof Error && (error as SessionsFetchError).kind) {
90
+ throw error;
91
+ }
92
+
93
+ const fetchError = new Error('Unable to connect to session service') as SessionsFetchError;
94
+ fetchError.kind = 'request_failed';
95
+ throw fetchError;
96
+ }
97
+ },
98
+ refetchInterval: 5000,
99
+ refetchIntervalInBackground: true,
100
+ refetchOnReconnect: true,
101
+ });
102
+
103
+ const activeError = error as SessionsFetchError | null;
104
+
105
+ useEffect(() => {
106
+ if (typeof window === 'undefined') return;
107
+ try {
108
+ const raw = localStorage.getItem(SNAPSHOT_STORAGE_KEY);
109
+ if (!raw) return;
110
+ const parsed = JSON.parse(raw) as SessionSnapshot;
111
+ if (!parsed || !Array.isArray(parsed.sessions) || typeof parsed.savedAt !== 'number') {
112
+ return;
113
+ }
114
+ if (!Array.isArray(parsed.processHints)) {
115
+ parsed.processHints = [];
116
+ }
117
+ if (parsed.sessions.length === 0) return;
118
+ setStaleSnapshot(parsed);
119
+ } catch {
120
+ setStaleSnapshot(null);
121
+ }
122
+ }, []);
123
+
124
+ useEffect(() => {
125
+ if (typeof window === 'undefined') return;
126
+ if (!data?.sessions || data.sessions.length === 0) return;
127
+
128
+ const snapshot: SessionSnapshot = {
129
+ savedAt: Date.now(),
130
+ sessions: data.sessions,
131
+ processHints: data.processHints ?? [],
132
+ };
133
+
134
+ try {
135
+ localStorage.setItem(SNAPSHOT_STORAGE_KEY, JSON.stringify(snapshot));
136
+ setStaleSnapshot(snapshot);
137
+ } catch {
138
+ setStaleSnapshot(snapshot);
139
+ }
140
+ }, [data?.processHints, data?.sessions]);
141
+
142
+ const handleCopyStartCommand = async () => {
143
+ try {
144
+ await navigator.clipboard.writeText(START_COMMAND_TEMPLATE);
145
+ setCopyFeedback('copied');
146
+ setTimeout(() => setCopyFeedback('idle'), 1500);
147
+ } catch {
148
+ setCopyFeedback('failed');
149
+ setTimeout(() => setCopyFeedback('idle'), 2000);
150
+ }
151
+ };
152
+
153
+ const sourceSessions = useMemo(() => {
154
+ if (data?.sessions) return data.sessions;
155
+ if (activeError && staleSnapshot?.sessions?.length) {
156
+ return staleSnapshot.sessions;
157
+ }
158
+ return [];
159
+ }, [activeError, data?.sessions, staleSnapshot?.sessions]);
160
+
161
+ const isShowingStaleData = !!activeError && !data?.sessions && !!staleSnapshot?.sessions?.length;
162
+
163
+ const processHints = useMemo(() => {
164
+ if (data?.processHints) {
165
+ return data.processHints;
166
+ }
167
+ if (isShowingStaleData && staleSnapshot?.processHints) {
168
+ return staleSnapshot.processHints;
169
+ }
170
+ return [];
171
+ }, [data?.processHints, isShowingStaleData, staleSnapshot?.processHints]);
172
+
173
+ useEffect(() => {
174
+ onProcessHintsChange?.(processHints);
175
+ }, [onProcessHintsChange, processHints]);
176
+
177
+ const enrichedSessions = useMemo(() => {
178
+ if (!sourceSessions.length) return [];
179
+
180
+ let persistedWaiting: Record<string, boolean> = {};
181
+ if (typeof window !== 'undefined') {
182
+ try {
183
+ persistedWaiting = JSON.parse(localStorage.getItem(WAITING_STORAGE_KEY) || '{}');
184
+ } catch {
185
+ persistedWaiting = {};
186
+ }
187
+ }
188
+
189
+ return sourceSessions.map((s) => {
190
+ const persisted = !!persistedWaiting[s.id];
191
+ return {
192
+ ...s,
193
+ waitingForUser: !!s.waitingForUser || (s.realTimeStatus === 'retry' && persisted),
194
+ };
195
+ });
196
+ }, [sourceSessions]);
197
+
198
+ useEffect(() => {
199
+ if (typeof window === 'undefined') return;
200
+ const nextPersistedWaiting: Record<string, boolean> = {};
201
+ for (const session of enrichedSessions as Array<{ id: string; waitingForUser?: boolean }>) {
202
+ if (session.waitingForUser) {
203
+ nextPersistedWaiting[session.id] = true;
204
+ }
205
+ }
206
+ localStorage.setItem(WAITING_STORAGE_KEY, JSON.stringify(nextPersistedWaiting));
207
+ }, [enrichedSessions]);
208
+
209
+ useEffect(() => {
210
+ const nextWaiting: Record<string, boolean> = {};
211
+ let shouldPlayAttention = false;
212
+
213
+ for (const session of enrichedSessions as Array<{ id: string; waitingForUser?: boolean }>) {
214
+ const waiting = !!session.waitingForUser;
215
+ nextWaiting[session.id] = waiting;
216
+
217
+ if (waitingInitRef.current && waiting && !waitingStateRef.current[session.id]) {
218
+ shouldPlayAttention = true;
219
+ }
220
+ }
221
+
222
+ waitingStateRef.current = nextWaiting;
223
+
224
+ if (!waitingInitRef.current) {
225
+ waitingInitRef.current = true;
226
+ return;
227
+ }
228
+
229
+ if (shouldPlayAttention) {
230
+ setTimeout(() => playAttentionSound(), CARD_ANIMATION_DURATION_MS);
231
+ }
232
+ }, [enrichedSessions]);
233
+
234
+ useEffect(() => {
235
+ const nextStatus: Record<string, 'idle' | 'busy' | 'retry'> = {};
236
+
237
+ for (const session of enrichedSessions as Array<{ id: string; realTimeStatus?: string }>) {
238
+ const normalized =
239
+ session.realTimeStatus === 'busy' || session.realTimeStatus === 'retry'
240
+ ? session.realTimeStatus
241
+ : 'idle';
242
+ nextStatus[session.id] = normalized;
243
+ }
244
+
245
+ if (!statusInitRef.current) {
246
+ statusInitRef.current = true;
247
+ statusStateRef.current = nextStatus;
248
+ return;
249
+ }
250
+
251
+ let shouldPlayComplete = false;
252
+
253
+ for (const [id, currentStatus] of Object.entries(nextStatus)) {
254
+ const previousStatus = statusStateRef.current[id];
255
+ if ((previousStatus === 'busy' || previousStatus === 'retry') && currentStatus === 'idle') {
256
+ shouldPlayComplete = true;
257
+ break;
258
+ }
259
+ }
260
+
261
+ statusStateRef.current = nextStatus;
262
+
263
+ if (shouldPlayComplete && !isShowingStaleData) {
264
+ setTimeout(() => playCompleteSound(), CARD_ANIMATION_DURATION_MS);
265
+ }
266
+ }, [enrichedSessions, isShowingStaleData]);
267
+
268
+ const cards: KanbanCard[] = useMemo(() => {
269
+ const allCards = transformSessions(enrichedSessions);
270
+ if (filterDays === 0) {
271
+ return allCards;
272
+ }
273
+ const cutoff = dataUpdatedAt - filterDays * 24 * 60 * 60 * 1000;
274
+ return allCards.filter((card) => card.updatedAt >= cutoff);
275
+ }, [dataUpdatedAt, enrichedSessions, filterDays]);
276
+
277
+ if (isLoading) {
278
+ return <LoadingState />;
279
+ }
280
+
281
+ if (error && !isShowingStaleData) {
282
+ const isOpencodeUnavailable = activeError?.kind === 'opencode_unavailable';
283
+ const title = isOpencodeUnavailable ? 'OpenCode is not running' : 'Failed to load sessions';
284
+ const description = isOpencodeUnavailable
285
+ ? activeError?.hint || 'Run OpenCode with an exposed API port, for example `opencode --port <PORT>`.'
286
+ : activeError?.message || 'An error occurred while loading sessions';
287
+
288
+ return (
289
+ <div className="flex-1 flex items-center justify-center p-8">
290
+ <div className="max-w-md w-full text-center">
291
+ <div className="flex items-center justify-center w-12 h-12 mx-auto mb-4 bg-red-100 dark:bg-red-900/30 rounded-full">
292
+ <svg
293
+ className="w-6 h-6 text-red-600 dark:text-red-400"
294
+ fill="none"
295
+ stroke="currentColor"
296
+ viewBox="0 0 24 24"
297
+ aria-hidden="true"
298
+ >
299
+ <path
300
+ strokeLinecap="round"
301
+ strokeLinejoin="round"
302
+ strokeWidth={2}
303
+ d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
304
+ />
305
+ </svg>
306
+ </div>
307
+ <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
308
+ {title}
309
+ </h2>
310
+ <p className="text-gray-600 dark:text-gray-400 mb-4">
311
+ {description}
312
+ </p>
313
+ <div className="flex items-center justify-center gap-2">
314
+ <button
315
+ type="button"
316
+ onClick={() => refetch()}
317
+ className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
318
+ disabled={isFetching}
319
+ >
320
+ {isFetching ? 'Retrying...' : 'Retry'}
321
+ </button>
322
+ {isOpencodeUnavailable ? (
323
+ <button
324
+ type="button"
325
+ onClick={handleCopyStartCommand}
326
+ className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-md transition-colors"
327
+ >
328
+ {copyFeedback === 'copied'
329
+ ? 'Copied'
330
+ : copyFeedback === 'failed'
331
+ ? 'Copy Failed'
332
+ : 'Copy Start Command'}
333
+ </button>
334
+ ) : null}
335
+ </div>
336
+ </div>
337
+ </div>
338
+ );
339
+ }
340
+
341
+ if (!cards || cards.length === 0) {
342
+ return (
343
+ <div className="flex-1 flex items-center justify-center p-8">
344
+ <div className="max-w-md w-full text-center">
345
+ <div className="flex items-center justify-center w-12 h-12 mx-auto mb-4 bg-gray-100 dark:bg-zinc-800 rounded-full">
346
+ <svg
347
+ className="w-6 h-6 text-gray-500 dark:text-gray-400"
348
+ fill="none"
349
+ stroke="currentColor"
350
+ viewBox="0 0 24 24"
351
+ aria-hidden="true"
352
+ >
353
+ <path
354
+ strokeLinecap="round"
355
+ strokeLinejoin="round"
356
+ strokeWidth={2}
357
+ d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
358
+ />
359
+ </svg>
360
+ </div>
361
+ <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
362
+ No sessions yet
363
+ </h2>
364
+ <p className="text-gray-600 dark:text-gray-400 mb-4">
365
+ OpenCode is running, but no sessions are available.
366
+ </p>
367
+ <p className="text-sm text-gray-500 dark:text-gray-500">
368
+ Start a conversation in OpenCode and this board will update automatically.
369
+ </p>
370
+ </div>
371
+ </div>
372
+ );
373
+ }
374
+
375
+ // Group cards by project
376
+ const groupByProject = (columnCards: KanbanCard[]) => {
377
+ const groups = new Map<string, KanbanCard[]>();
378
+ for (const card of columnCards) {
379
+ const key = card.projectName || 'Unknown Project';
380
+ if (!groups.has(key)) groups.set(key, []);
381
+ groups.get(key)!.push(card);
382
+ }
383
+ return groups;
384
+ };
385
+
386
+ return (
387
+ <div className="flex-1 overflow-x-auto scrollbar-thin scroll-smooth">
388
+ {isShowingStaleData ? (
389
+ <div className="px-4 pt-4 pb-0">
390
+ <div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-amber-800 dark:border-amber-900/40 dark:bg-amber-900/20 dark:text-amber-200">
391
+ <div className="flex items-center gap-2 text-xs font-medium">
392
+ <span className="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[10px] uppercase tracking-wide dark:bg-amber-900/40">
393
+ Stale Data
394
+ </span>
395
+ <span>
396
+ Last seen at {staleSnapshot ? new Date(staleSnapshot.savedAt).toLocaleString() : '--'}
397
+ </span>
398
+ <span className="text-amber-700/80 dark:text-amber-300/80">Read-only snapshot while OpenCode is unreachable.</span>
399
+ </div>
400
+ </div>
401
+ </div>
402
+ ) : null}
403
+ <div className="flex gap-6 h-full min-w-max p-4">
404
+ {COLUMNS.map((column) => {
405
+ const columnCards = cards
406
+ .filter((c) => c.status === column.id)
407
+ .sort((a, b) => a.sortOrder - b.sortOrder);
408
+ const projectGroups = groupByProject(columnCards);
409
+
410
+ return (
411
+ <div
412
+ key={column.id}
413
+ className="flex-shrink-0 w-80 bg-gray-100 dark:bg-zinc-800/80 rounded-xl p-4 flex flex-col shadow-sm"
414
+ >
415
+ <div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-200 dark:border-zinc-700">
416
+ <h2 className="font-semibold text-gray-700 dark:text-gray-300">
417
+ {column.title}
418
+ </h2>
419
+ <span className="px-2.5 py-0.5 bg-gray-200 dark:bg-zinc-700 text-gray-600 dark:text-gray-400 text-xs font-medium rounded-full">
420
+ {columnCards.length}
421
+ </span>
422
+ </div>
423
+ <div className="flex-1 overflow-y-auto scrollbar-thin pr-1">
424
+ <div className="space-y-3">
425
+ {Array.from(projectGroups.entries()).map(([projectName, groupCards]) => (
426
+ <ProjectCard
427
+ key={projectName}
428
+ projectName={projectName}
429
+ branch={groupCards[0].branch}
430
+ cards={groupCards}
431
+ readOnly={isShowingStaleData}
432
+ />
433
+ ))}
434
+ </div>
435
+ </div>
436
+ </div>
437
+ );
438
+ })}
439
+ </div>
440
+ </div>
441
+ );
442
+ }
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+
3
+ export function LoadingState() {
4
+ return (
5
+ <div className="h-[calc(100vh-8rem)] overflow-x-auto">
6
+ <div className="flex gap-4 h-full min-w-max p-4">
7
+ {/* Render 4 column skeletons matching the Kanban columns */}
8
+ {[1, 2, 3, 4].map((col) => (
9
+ <div
10
+ key={col}
11
+ className="flex-shrink-0 w-80 bg-gray-100 dark:bg-zinc-800 rounded-lg p-4 animate-pulse"
12
+ >
13
+ {/* Column header skeleton */}
14
+ <div className="h-6 bg-gray-200 dark:bg-zinc-700 rounded w-24 mb-4" />
15
+ {/* Card skeletons */}
16
+ <div className="space-y-3">
17
+ {[1, 2, 3].map((card) => (
18
+ <div
19
+ key={card}
20
+ className="p-4 bg-white dark:bg-zinc-900 rounded-lg shadow border border-gray-200 dark:border-zinc-700"
21
+ >
22
+ {/* Title skeleton */}
23
+ <div className="h-5 bg-gray-200 dark:bg-zinc-700 rounded w-3/4 mb-3" />
24
+ {/* Metadata skeleton */}
25
+ <div className="flex gap-2">
26
+ <div className="h-4 bg-gray-200 dark:bg-zinc-700 rounded w-20" />
27
+ <div className="h-4 bg-gray-200 dark:bg-zinc-700 rounded w-16" />
28
+ </div>
29
+ </div>
30
+ ))}
31
+ </div>
32
+ </div>
33
+ ))}
34
+ </div>
35
+ </div>
36
+ );
37
+ }