@ifc-lite/viewer 1.6.0 → 1.7.0

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 (95) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
  5. package/dist/assets/index-yTqs8kgX.css +1 -0
  6. package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
  7. package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +18 -15
  10. package/src/components/viewer/BCFPanel.tsx +7 -789
  11. package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/HierarchyPanel.tsx +110 -842
  14. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  15. package/src/components/viewer/IDSPanel.tsx +126 -17
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  17. package/src/components/viewer/LensPanel.tsx +603 -0
  18. package/src/components/viewer/MainToolbar.tsx +188 -21
  19. package/src/components/viewer/PropertiesPanel.tsx +171 -663
  20. package/src/components/viewer/PropertyEditor.tsx +866 -77
  21. package/src/components/viewer/Section2DPanel.tsx +76 -2648
  22. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  23. package/src/components/viewer/ViewerLayout.tsx +132 -45
  24. package/src/components/viewer/Viewport.tsx +237 -1659
  25. package/src/components/viewer/ViewportContainer.tsx +11 -3
  26. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  27. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  28. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  29. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  30. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  31. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  32. package/src/components/viewer/hierarchy/types.ts +54 -0
  33. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  34. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  35. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  36. package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
  37. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  38. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  39. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  40. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  41. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  42. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  43. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  44. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  45. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  46. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  47. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  48. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  49. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  50. package/src/components/viewer/tools/formatDistance.ts +18 -0
  51. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  52. package/src/components/viewer/useAnimationLoop.ts +166 -0
  53. package/src/components/viewer/useGeometryStreaming.ts +398 -0
  54. package/src/components/viewer/useKeyboardControls.ts +221 -0
  55. package/src/components/viewer/useMouseControls.ts +1009 -0
  56. package/src/components/viewer/useRenderUpdates.ts +165 -0
  57. package/src/components/viewer/useTouchControls.ts +245 -0
  58. package/src/hooks/ids/idsColorSystem.ts +125 -0
  59. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  60. package/src/hooks/ids/idsExportService.ts +444 -0
  61. package/src/hooks/useBCF.ts +7 -0
  62. package/src/hooks/useDrawingExport.ts +627 -0
  63. package/src/hooks/useDrawingGeneration.ts +627 -0
  64. package/src/hooks/useFloorplanView.ts +108 -0
  65. package/src/hooks/useIDS.ts +270 -463
  66. package/src/hooks/useIfc.ts +26 -1628
  67. package/src/hooks/useIfcFederation.ts +803 -0
  68. package/src/hooks/useIfcLoader.ts +508 -0
  69. package/src/hooks/useIfcServer.ts +465 -0
  70. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  71. package/src/hooks/useLens.ts +129 -0
  72. package/src/hooks/useMeasure2D.ts +365 -0
  73. package/src/hooks/useViewControls.ts +218 -0
  74. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  75. package/src/lib/ifc4-pset-definitions.ts +621 -0
  76. package/src/lib/ifc4-qto-definitions.ts +315 -0
  77. package/src/lib/lens/adapter.ts +138 -0
  78. package/src/lib/lens/index.ts +5 -0
  79. package/src/lib/lists/adapter.ts +69 -0
  80. package/src/lib/lists/index.ts +28 -0
  81. package/src/lib/lists/persistence.ts +64 -0
  82. package/src/services/fs-cache.ts +1 -1
  83. package/src/services/tauri-modules.d.ts +25 -0
  84. package/src/store/index.ts +38 -2
  85. package/src/store/slices/cameraSlice.ts +14 -1
  86. package/src/store/slices/dataSlice.ts +14 -1
  87. package/src/store/slices/lensSlice.ts +184 -0
  88. package/src/store/slices/listSlice.ts +74 -0
  89. package/src/store/slices/pinboardSlice.ts +114 -0
  90. package/src/store/types.ts +5 -0
  91. package/src/utils/ifcConfig.ts +16 -3
  92. package/src/utils/serverDataModel.ts +64 -101
  93. package/src/vite-env.d.ts +3 -0
  94. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  95. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -16,40 +16,16 @@
16
16
  import React, { useCallback, useState, useMemo, useRef } from 'react';
17
17
  import {
18
18
  X,
19
- Plus,
20
19
  MessageSquare,
21
- Camera,
22
20
  Upload,
23
21
  Download,
24
- ChevronLeft,
25
- Send,
26
- Trash2,
27
- Edit2,
28
22
  User,
29
- Calendar,
30
- AlertCircle,
31
- CheckCircle,
32
- Clock,
33
- Filter,
34
- MousePointer2,
35
- Focus,
36
- EyeOff,
37
23
  } from 'lucide-react';
38
24
  import { Button } from '@/components/ui/button';
39
25
  import { Input } from '@/components/ui/input';
40
- import { Label } from '@/components/ui/label';
41
- import {
42
- Select,
43
- SelectContent,
44
- SelectItem,
45
- SelectTrigger,
46
- SelectValue,
47
- } from '@/components/ui/select';
48
- import { ScrollArea } from '@/components/ui/scroll-area';
49
- import { Separator } from '@/components/ui/separator';
50
26
  import { Badge } from '@/components/ui/badge';
51
27
  import { useViewerStore } from '@/store';
52
- import type { BCFTopic, BCFComment, BCFViewpoint } from '@ifc-lite/bcf';
28
+ import type { BCFTopic, BCFViewpoint } from '@ifc-lite/bcf';
53
29
  import {
54
30
  readBCF,
55
31
  writeBCF,
@@ -58,767 +34,9 @@ import {
58
34
  createBCFComment,
59
35
  } from '@ifc-lite/bcf';
60
36
  import { useBCF } from '@/hooks/useBCF';
61
-
62
- // ============================================================================
63
- // Constants
64
- // ============================================================================
65
-
66
- const TOPIC_TYPES = ['Issue', 'Request', 'Comment', 'Error', 'Warning', 'Info'];
67
- const TOPIC_STATUSES = ['Open', 'In Progress', 'Resolved', 'Closed'];
68
- const PRIORITIES = ['High', 'Medium', 'Low'];
69
-
70
- // ============================================================================
71
- // Helper Components
72
- // ============================================================================
73
-
74
- function StatusBadge({ status }: { status?: string }) {
75
- const variant = useMemo(() => {
76
- switch (status?.toLowerCase()) {
77
- case 'open':
78
- return 'default';
79
- case 'in progress':
80
- return 'secondary';
81
- case 'resolved':
82
- case 'closed':
83
- return 'outline';
84
- default:
85
- return 'default';
86
- }
87
- }, [status]);
88
-
89
- const Icon = useMemo(() => {
90
- switch (status?.toLowerCase()) {
91
- case 'open':
92
- return AlertCircle;
93
- case 'in progress':
94
- return Clock;
95
- case 'resolved':
96
- case 'closed':
97
- return CheckCircle;
98
- default:
99
- return AlertCircle;
100
- }
101
- }, [status]);
102
-
103
- return (
104
- <Badge variant={variant} className="text-xs gap-1">
105
- <Icon className="h-3 w-3" />
106
- {status || 'Open'}
107
- </Badge>
108
- );
109
- }
110
-
111
- function PriorityBadge({ priority }: { priority?: string }) {
112
- const colorClass = useMemo(() => {
113
- switch (priority?.toLowerCase()) {
114
- case 'high':
115
- return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
116
- case 'medium':
117
- return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
118
- case 'low':
119
- return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
120
- default:
121
- return '';
122
- }
123
- }, [priority]);
124
-
125
- if (!priority) return null;
126
-
127
- return (
128
- <Badge variant="outline" className={`text-xs ${colorClass}`}>
129
- {priority}
130
- </Badge>
131
- );
132
- }
133
-
134
- function formatDate(isoDate: string): string {
135
- try {
136
- const date = new Date(isoDate);
137
- return date.toLocaleDateString(undefined, {
138
- year: 'numeric',
139
- month: 'short',
140
- day: 'numeric',
141
- });
142
- } catch {
143
- return isoDate;
144
- }
145
- }
146
-
147
- function formatDateTime(isoDate: string): string {
148
- try {
149
- const date = new Date(isoDate);
150
- return date.toLocaleString(undefined, {
151
- year: 'numeric',
152
- month: 'short',
153
- day: 'numeric',
154
- hour: '2-digit',
155
- minute: '2-digit',
156
- });
157
- } catch {
158
- return isoDate;
159
- }
160
- }
161
-
162
- // ============================================================================
163
- // Topic List View
164
- // ============================================================================
165
-
166
- interface TopicListProps {
167
- topics: BCFTopic[];
168
- onSelectTopic: (topicId: string) => void;
169
- onCreateTopic: () => void;
170
- statusFilter: string;
171
- onStatusFilterChange: (status: string) => void;
172
- author: string;
173
- onSetAuthor: (author: string) => void;
174
- }
175
-
176
- function TopicList({
177
- topics,
178
- onSelectTopic,
179
- onCreateTopic,
180
- statusFilter,
181
- onStatusFilterChange,
182
- author,
183
- onSetAuthor,
184
- }: TopicListProps) {
185
- const [editingEmail, setEditingEmail] = useState(false);
186
- const [emailInput, setEmailInput] = useState(author);
187
- const isDefaultEmail = author === 'user@example.com';
188
-
189
- const handleSaveEmail = useCallback(() => {
190
- if (emailInput.trim() && emailInput.includes('@')) {
191
- onSetAuthor(emailInput.trim());
192
- setEditingEmail(false);
193
- }
194
- }, [emailInput, onSetAuthor]);
195
- const filteredTopics = useMemo(() => {
196
- if (!statusFilter || statusFilter === 'all') return topics;
197
- return topics.filter(
198
- (t) => t.topicStatus?.toLowerCase() === statusFilter.toLowerCase()
199
- );
200
- }, [topics, statusFilter]);
201
-
202
- // Sort by creation date (newest first)
203
- const sortedTopics = useMemo(() => {
204
- return [...filteredTopics].sort(
205
- (a, b) => new Date(b.creationDate).getTime() - new Date(a.creationDate).getTime()
206
- );
207
- }, [filteredTopics]);
208
-
209
- return (
210
- <div className="flex flex-col h-full">
211
- {/* Filter */}
212
- <div className="flex items-center gap-2 px-3 py-2 border-b border-border">
213
- <Filter className="h-4 w-4 text-muted-foreground" />
214
- <Select value={statusFilter} onValueChange={onStatusFilterChange}>
215
- <SelectTrigger className="h-8 flex-1">
216
- <SelectValue placeholder="All statuses" />
217
- </SelectTrigger>
218
- <SelectContent>
219
- <SelectItem value="all">All statuses</SelectItem>
220
- {TOPIC_STATUSES.map((status) => (
221
- <SelectItem key={status} value={status.toLowerCase()}>
222
- {status}
223
- </SelectItem>
224
- ))}
225
- </SelectContent>
226
- </Select>
227
- <Button size="sm" variant="outline" onClick={onCreateTopic}>
228
- <Plus className="h-4 w-4" />
229
- </Button>
230
- </div>
231
-
232
- {/* Topic List */}
233
- <ScrollArea className="flex-1">
234
- {sortedTopics.length === 0 ? (
235
- <div className="flex flex-col items-center justify-center py-8 px-4 text-muted-foreground text-sm">
236
- <MessageSquare className="h-8 w-8 mb-2 opacity-50" />
237
- <p>No topics</p>
238
- <Button
239
- variant="link"
240
- size="sm"
241
- onClick={onCreateTopic}
242
- className="mt-1"
243
- >
244
- Create first topic
245
- </Button>
246
-
247
- {/* Email setup nudge */}
248
- <div className="mt-6 w-full max-w-xs">
249
- <div className="border border-border rounded-lg p-3 bg-muted/30">
250
- {editingEmail ? (
251
- <div className="space-y-2">
252
- <Label className="text-xs text-muted-foreground">Your email for BCF authorship</Label>
253
- <Input
254
- value={emailInput}
255
- onChange={(e) => setEmailInput(e.target.value)}
256
- placeholder="your@email.com"
257
- className="h-8 text-sm"
258
- onKeyDown={(e) => {
259
- if (e.key === 'Enter') handleSaveEmail();
260
- if (e.key === 'Escape') setEditingEmail(false);
261
- }}
262
- autoFocus
263
- />
264
- <div className="flex gap-2 justify-end">
265
- <Button
266
- variant="ghost"
267
- size="sm"
268
- onClick={() => {
269
- setEmailInput(author);
270
- setEditingEmail(false);
271
- }}
272
- className="h-7 text-xs"
273
- >
274
- Cancel
275
- </Button>
276
- <Button
277
- size="sm"
278
- onClick={handleSaveEmail}
279
- disabled={!emailInput.trim() || !emailInput.includes('@')}
280
- className="h-7 text-xs"
281
- >
282
- Save
283
- </Button>
284
- </div>
285
- </div>
286
- ) : (
287
- <div className="flex items-center justify-between gap-2">
288
- <div className="flex-1 min-w-0">
289
- <p className="text-xs text-muted-foreground mb-0.5">Author</p>
290
- <p className={`text-sm truncate ${isDefaultEmail ? 'text-amber-600 dark:text-amber-400' : 'text-foreground'}`}>
291
- {author}
292
- </p>
293
- </div>
294
- <Button
295
- variant={isDefaultEmail ? 'default' : 'ghost'}
296
- size="sm"
297
- onClick={() => {
298
- setEmailInput(author);
299
- setEditingEmail(true);
300
- }}
301
- className="h-7 text-xs shrink-0"
302
- >
303
- {isDefaultEmail ? 'Set email' : <Edit2 className="h-3 w-3" />}
304
- </Button>
305
- </div>
306
- )}
307
- </div>
308
- {isDefaultEmail && !editingEmail && (
309
- <p className="text-xs text-muted-foreground mt-2 text-center">
310
- Set your email to identify your issues and comments
311
- </p>
312
- )}
313
- </div>
314
- </div>
315
- ) : (
316
- <div className="divide-y divide-border">
317
- {sortedTopics.map((topic) => (
318
- <button
319
- key={topic.guid}
320
- onClick={() => onSelectTopic(topic.guid)}
321
- className="w-full text-left p-3 hover:bg-accent/50 transition-colors"
322
- >
323
- <div className="flex items-start justify-between gap-2 mb-1">
324
- <h4 className="font-medium text-sm line-clamp-1 flex-1">
325
- {topic.title}
326
- </h4>
327
- <StatusBadge status={topic.topicStatus} />
328
- </div>
329
- {topic.description && (
330
- <p className="text-xs text-muted-foreground line-clamp-2 mb-2">
331
- {topic.description}
332
- </p>
333
- )}
334
- <div className="flex items-center gap-2 text-xs text-muted-foreground">
335
- <PriorityBadge priority={topic.priority} />
336
- <span className="flex items-center gap-1">
337
- <User className="h-3 w-3" />
338
- {topic.creationAuthor.split('@')[0]}
339
- </span>
340
- <span className="flex items-center gap-1">
341
- <Calendar className="h-3 w-3" />
342
- {formatDate(topic.creationDate)}
343
- </span>
344
- {topic.comments.length > 0 && (
345
- <span className="flex items-center gap-1">
346
- <MessageSquare className="h-3 w-3" />
347
- {topic.comments.length}
348
- </span>
349
- )}
350
- {topic.viewpoints.length > 0 && (
351
- <span className="flex items-center gap-1">
352
- <Camera className="h-3 w-3" />
353
- {topic.viewpoints.length}
354
- </span>
355
- )}
356
- </div>
357
- </button>
358
- ))}
359
- </div>
360
- )}
361
- </ScrollArea>
362
- </div>
363
- );
364
- }
365
-
366
- // ============================================================================
367
- // Topic Detail View
368
- // ============================================================================
369
-
370
- interface TopicDetailProps {
371
- topic: BCFTopic;
372
- onBack: () => void;
373
- onAddComment: (text: string, viewpointGuid?: string) => void;
374
- onAddViewpoint: () => void;
375
- onActivateViewpoint: (viewpoint: BCFViewpoint) => void;
376
- onDeleteViewpoint: (viewpointGuid: string) => void;
377
- onUpdateStatus: (status: string) => void;
378
- onDeleteTopic: () => void;
379
- // Viewer state info for capture feedback
380
- selectionCount: number;
381
- hasIsolation: boolean;
382
- hasHiddenEntities: boolean;
383
- }
384
-
385
- function TopicDetail({
386
- topic,
387
- onBack,
388
- onAddComment,
389
- onAddViewpoint,
390
- onActivateViewpoint,
391
- onDeleteViewpoint,
392
- onUpdateStatus,
393
- onDeleteTopic,
394
- selectionCount,
395
- hasIsolation,
396
- hasHiddenEntities,
397
- }: TopicDetailProps) {
398
- const [commentText, setCommentText] = useState('');
399
- const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
400
- const [selectedViewpointGuid, setSelectedViewpointGuid] = useState<string | null>(null);
401
-
402
- // Get the selected viewpoint for display
403
- const selectedViewpoint = useMemo(() => {
404
- if (!selectedViewpointGuid) return null;
405
- return topic.viewpoints.find(vp => vp.guid === selectedViewpointGuid) || null;
406
- }, [selectedViewpointGuid, topic.viewpoints]);
407
-
408
- const handleSubmitComment = useCallback(() => {
409
- if (commentText.trim()) {
410
- // Associate comment with selected viewpoint if one is selected
411
- onAddComment(commentText.trim(), selectedViewpointGuid || undefined);
412
- setCommentText('');
413
- setSelectedViewpointGuid(null); // Clear selection after commenting
414
- }
415
- }, [commentText, onAddComment, selectedViewpointGuid]);
416
-
417
- const handleKeyDown = useCallback(
418
- (e: React.KeyboardEvent) => {
419
- if (e.key === 'Enter' && !e.shiftKey) {
420
- e.preventDefault();
421
- handleSubmitComment();
422
- }
423
- },
424
- [handleSubmitComment]
425
- );
426
-
427
- return (
428
- <div className="flex flex-col h-full relative">
429
- {/* Header */}
430
- <div className="flex items-center gap-2 px-3 py-2 border-b border-border">
431
- <Button variant="ghost" size="sm" onClick={onBack}>
432
- <ChevronLeft className="h-4 w-4" />
433
- </Button>
434
- <h3 className="font-medium text-sm flex-1 truncate">{topic.title}</h3>
435
- <Button
436
- variant="ghost"
437
- size="sm"
438
- onClick={() => setShowDeleteConfirm(true)}
439
- className="text-destructive hover:text-destructive"
440
- >
441
- <Trash2 className="h-4 w-4" />
442
- </Button>
443
- </div>
444
-
445
- <ScrollArea className="flex-1">
446
- <div className="p-3 space-y-4">
447
- {/* Topic Info */}
448
- <div className="space-y-2">
449
- <div className="flex items-center gap-2 flex-wrap">
450
- <Select value={topic.topicStatus || 'Open'} onValueChange={onUpdateStatus}>
451
- <SelectTrigger className="h-7 w-auto">
452
- <SelectValue />
453
- </SelectTrigger>
454
- <SelectContent>
455
- {TOPIC_STATUSES.map((status) => (
456
- <SelectItem key={status} value={status}>
457
- {status}
458
- </SelectItem>
459
- ))}
460
- </SelectContent>
461
- </Select>
462
- <PriorityBadge priority={topic.priority} />
463
- {topic.topicType && (
464
- <Badge variant="outline" className="text-xs">
465
- {topic.topicType}
466
- </Badge>
467
- )}
468
- </div>
469
-
470
- {topic.description && (
471
- <p className="text-sm text-muted-foreground">{topic.description}</p>
472
- )}
473
-
474
- <div className="text-xs text-muted-foreground space-y-1">
475
- <p>
476
- Created by {topic.creationAuthor} on{' '}
477
- {formatDateTime(topic.creationDate)}
478
- </p>
479
- {topic.assignedTo && <p>Assigned to: {topic.assignedTo}</p>}
480
- {topic.dueDate && <p>Due: {formatDate(topic.dueDate)}</p>}
481
- </div>
482
- </div>
483
-
484
- <Separator />
485
-
486
- {/* Viewpoints */}
487
- <div>
488
- <div className="flex items-center justify-between mb-2">
489
- <h4 className="text-sm font-medium">Viewpoints</h4>
490
- <Button variant="outline" size="sm" onClick={onAddViewpoint}>
491
- <Camera className="h-3 w-3 mr-1" />
492
- Capture
493
- </Button>
494
- </div>
495
-
496
- {/* Capture info - what will be included */}
497
- {(selectionCount > 0 || hasIsolation || hasHiddenEntities) && (
498
- <div className="mb-2 p-2 bg-muted/50 rounded-md text-xs text-muted-foreground">
499
- <p className="font-medium mb-1">Capture will include:</p>
500
- <ul className="space-y-0.5">
501
- {selectionCount > 0 && (
502
- <li className="flex items-center gap-1">
503
- <MousePointer2 className="h-3 w-3" />
504
- {selectionCount} selected {selectionCount === 1 ? 'object' : 'objects'}
505
- </li>
506
- )}
507
- {hasIsolation && (
508
- <li className="flex items-center gap-1">
509
- <Focus className="h-3 w-3" />
510
- Isolated objects (others hidden)
511
- </li>
512
- )}
513
- {hasHiddenEntities && !hasIsolation && (
514
- <li className="flex items-center gap-1">
515
- <EyeOff className="h-3 w-3" />
516
- Hidden objects
517
- </li>
518
- )}
519
- </ul>
520
- </div>
521
- )}
522
-
523
- {topic.viewpoints.length === 0 ? (
524
- <p className="text-xs text-muted-foreground">No viewpoints captured</p>
525
- ) : (
526
- <div className="space-y-2">
527
- {topic.viewpoints.map((vp) => {
528
- const isSelected = selectedViewpointGuid === vp.guid;
529
- const commentCount = topic.comments.filter(c => c.viewpointGuid === vp.guid).length;
530
- return (
531
- <div
532
- key={vp.guid}
533
- className={`rounded-md overflow-hidden border-2 transition-colors ${
534
- isSelected ? 'border-primary bg-primary/5' : 'border-border'
535
- }`}
536
- >
537
- {/* Snapshot */}
538
- <div className="relative group">
539
- {vp.snapshot ? (
540
- <img
541
- src={vp.snapshot}
542
- alt="Viewpoint"
543
- className="w-full aspect-video object-cover cursor-pointer hover:opacity-90 transition-opacity"
544
- onClick={() => onActivateViewpoint(vp)}
545
- />
546
- ) : (
547
- <div
548
- className="w-full aspect-video bg-muted flex items-center justify-center cursor-pointer hover:bg-muted/80 transition-colors"
549
- onClick={() => onActivateViewpoint(vp)}
550
- >
551
- <Camera className="h-6 w-6 text-muted-foreground" />
552
- </div>
553
- )}
554
- {/* Delete button - hover only */}
555
- <Button
556
- variant="destructive"
557
- size="icon"
558
- className="absolute top-1 right-1 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
559
- onClick={(e) => {
560
- e.stopPropagation();
561
- onDeleteViewpoint(vp.guid);
562
- }}
563
- >
564
- <Trash2 className="h-3 w-3" />
565
- </Button>
566
- </div>
567
- {/* Action bar - always visible */}
568
- <div className="flex items-center justify-between px-2 py-1.5 bg-muted/30">
569
- <Button
570
- variant={isSelected ? 'default' : 'ghost'}
571
- size="sm"
572
- className="h-7 text-xs gap-1"
573
- onClick={() => setSelectedViewpointGuid(isSelected ? null : vp.guid)}
574
- >
575
- <MessageSquare className="h-3 w-3" />
576
- {commentCount > 0 ? `${commentCount} comment${commentCount > 1 ? 's' : ''}` : 'Comment'}
577
- </Button>
578
- <Button
579
- variant="ghost"
580
- size="sm"
581
- className="h-7 text-xs"
582
- onClick={() => onActivateViewpoint(vp)}
583
- >
584
- Go to view
585
- </Button>
586
- </div>
587
- </div>
588
- );
589
- })}
590
- </div>
591
- )}
592
- </div>
593
-
594
- <Separator />
595
-
596
- {/* Comments */}
597
- <div>
598
- <h4 className="text-sm font-medium mb-2">
599
- Comments ({topic.comments.length})
600
- </h4>
601
-
602
- <div className="space-y-3">
603
- {topic.comments.map((comment) => {
604
- // Find associated viewpoint if any
605
- const associatedViewpoint = comment.viewpointGuid
606
- ? topic.viewpoints.find(vp => vp.guid === comment.viewpointGuid)
607
- : null;
608
- return (
609
- <div
610
- key={comment.guid}
611
- className="bg-muted/50 rounded-md p-2 text-sm"
612
- >
613
- {/* Show associated viewpoint thumbnail if present */}
614
- {associatedViewpoint?.snapshot && (
615
- <div
616
- className="mb-2 rounded overflow-hidden border border-border cursor-pointer hover:opacity-80 transition-opacity"
617
- onClick={() => onActivateViewpoint(associatedViewpoint)}
618
- >
619
- <img
620
- src={associatedViewpoint.snapshot}
621
- alt="Associated viewpoint"
622
- className="w-full h-16 object-cover"
623
- />
624
- </div>
625
- )}
626
- <div className="flex items-center gap-2 mb-1 text-xs text-muted-foreground">
627
- <User className="h-3 w-3" />
628
- <span>{comment.author.split('@')[0]}</span>
629
- <span>-</span>
630
- <span>{formatDateTime(comment.date)}</span>
631
- {comment.viewpointGuid && (
632
- <span className="flex items-center gap-0.5">
633
- <Camera className="h-3 w-3" />
634
- </span>
635
- )}
636
- </div>
637
- <p className="whitespace-pre-wrap">{comment.comment}</p>
638
- </div>
639
- );
640
- })}
641
- </div>
642
- </div>
643
- </div>
644
- </ScrollArea>
645
-
646
- {/* Comment Input */}
647
- <div className="border-t border-border p-3">
648
- {/* Show selected viewpoint indicator */}
649
- {selectedViewpoint && (
650
- <div className="flex items-center gap-2 mb-2 p-2 bg-primary/10 rounded-md">
651
- {selectedViewpoint.snapshot && (
652
- <img
653
- src={selectedViewpoint.snapshot}
654
- alt="Selected viewpoint"
655
- className="w-10 h-10 object-cover rounded"
656
- />
657
- )}
658
- <div className="flex-1 min-w-0">
659
- <p className="text-xs text-muted-foreground">Commenting on viewpoint</p>
660
- </div>
661
- <Button
662
- variant="ghost"
663
- size="icon"
664
- className="h-6 w-6 shrink-0"
665
- onClick={() => setSelectedViewpointGuid(null)}
666
- >
667
- <X className="h-3 w-3" />
668
- </Button>
669
- </div>
670
- )}
671
- <div className="flex gap-2">
672
- <Input
673
- placeholder={selectedViewpoint ? "Add comment on viewpoint..." : "Add a comment..."}
674
- value={commentText}
675
- onChange={(e) => setCommentText(e.target.value)}
676
- onKeyDown={handleKeyDown}
677
- className="flex-1"
678
- />
679
- <Button size="icon" onClick={handleSubmitComment} disabled={!commentText.trim()}>
680
- <Send className="h-4 w-4" />
681
- </Button>
682
- </div>
683
- </div>
684
-
685
- {/* Delete Confirmation */}
686
- {showDeleteConfirm && (
687
- <div className="absolute inset-0 bg-background/90 flex items-center justify-center p-4">
688
- <div className="bg-card border rounded-lg p-4 max-w-xs">
689
- <h4 className="font-medium mb-2">Delete Topic?</h4>
690
- <p className="text-sm text-muted-foreground mb-4">
691
- This will permanently delete this topic and all its comments and viewpoints.
692
- </p>
693
- <div className="flex gap-2 justify-end">
694
- <Button variant="outline" size="sm" onClick={() => setShowDeleteConfirm(false)}>
695
- Cancel
696
- </Button>
697
- <Button
698
- variant="destructive"
699
- size="sm"
700
- onClick={() => {
701
- onDeleteTopic();
702
- setShowDeleteConfirm(false);
703
- }}
704
- >
705
- Delete
706
- </Button>
707
- </div>
708
- </div>
709
- </div>
710
- )}
711
- </div>
712
- );
713
- }
714
-
715
- // ============================================================================
716
- // Create Topic Dialog
717
- // ============================================================================
718
-
719
- interface CreateTopicFormProps {
720
- onSubmit: (topic: Partial<BCFTopic>) => void;
721
- onCancel: () => void;
722
- author: string;
723
- }
724
-
725
- function CreateTopicForm({ onSubmit, onCancel, author }: CreateTopicFormProps) {
726
- const [title, setTitle] = useState('');
727
- const [description, setDescription] = useState('');
728
- const [topicType, setTopicType] = useState('Issue');
729
- const [priority, setPriority] = useState('Medium');
730
-
731
- const handleSubmit = useCallback(
732
- (e: React.FormEvent) => {
733
- e.preventDefault();
734
- if (title.trim()) {
735
- onSubmit({
736
- title: title.trim(),
737
- description: description.trim() || undefined,
738
- topicType,
739
- priority,
740
- });
741
- }
742
- },
743
- [title, description, topicType, priority, onSubmit]
744
- );
745
-
746
- return (
747
- <form onSubmit={handleSubmit} className="p-3 space-y-4">
748
- <div className="flex items-center justify-between mb-2">
749
- <h3 className="font-medium">New Topic</h3>
750
- <Button variant="ghost" size="sm" type="button" onClick={onCancel}>
751
- <X className="h-4 w-4" />
752
- </Button>
753
- </div>
754
-
755
- <div className="space-y-2">
756
- <Label htmlFor="title">Title *</Label>
757
- <Input
758
- id="title"
759
- value={title}
760
- onChange={(e) => setTitle(e.target.value)}
761
- placeholder="Brief description of the issue"
762
- required
763
- />
764
- </div>
765
-
766
- <div className="space-y-2">
767
- <Label htmlFor="description">Description</Label>
768
- <textarea
769
- id="description"
770
- value={description}
771
- onChange={(e) => setDescription(e.target.value)}
772
- placeholder="Detailed description (optional)"
773
- className="w-full min-h-[80px] px-3 py-2 text-sm rounded-md border border-input bg-background"
774
- />
775
- </div>
776
-
777
- <div className="grid grid-cols-2 gap-3">
778
- <div className="space-y-2">
779
- <Label>Type</Label>
780
- <Select value={topicType} onValueChange={setTopicType}>
781
- <SelectTrigger>
782
- <SelectValue />
783
- </SelectTrigger>
784
- <SelectContent>
785
- {TOPIC_TYPES.map((type) => (
786
- <SelectItem key={type} value={type}>
787
- {type}
788
- </SelectItem>
789
- ))}
790
- </SelectContent>
791
- </Select>
792
- </div>
793
-
794
- <div className="space-y-2">
795
- <Label>Priority</Label>
796
- <Select value={priority} onValueChange={setPriority}>
797
- <SelectTrigger>
798
- <SelectValue />
799
- </SelectTrigger>
800
- <SelectContent>
801
- {PRIORITIES.map((p) => (
802
- <SelectItem key={p} value={p}>
803
- {p}
804
- </SelectItem>
805
- ))}
806
- </SelectContent>
807
- </Select>
808
- </div>
809
- </div>
810
-
811
- <div className="flex gap-2 justify-end pt-2">
812
- <Button variant="outline" type="button" onClick={onCancel}>
813
- Cancel
814
- </Button>
815
- <Button type="submit" disabled={!title.trim()}>
816
- Create Topic
817
- </Button>
818
- </div>
819
- </form>
820
- );
821
- }
37
+ import { BCFTopicList } from './bcf/BCFTopicList';
38
+ import { BCFTopicDetail } from './bcf/BCFTopicDetail';
39
+ import { BCFCreateTopicForm } from './bcf/BCFCreateTopicForm';
822
40
 
823
41
  // ============================================================================
824
42
  // Main BCF Panel Component
@@ -1105,13 +323,13 @@ export function BCFPanel({ onClose }: BCFPanelProps) {
1105
323
  {/* Content */}
1106
324
  <div className="flex-1 overflow-hidden relative">
1107
325
  {showCreateForm ? (
1108
- <CreateTopicForm
326
+ <BCFCreateTopicForm
1109
327
  onSubmit={handleCreateTopic}
1110
328
  onCancel={() => setShowCreateForm(false)}
1111
329
  author={bcfAuthor}
1112
330
  />
1113
331
  ) : activeTopic ? (
1114
- <TopicDetail
332
+ <BCFTopicDetail
1115
333
  topic={activeTopic}
1116
334
  onBack={() => setActiveTopic(null)}
1117
335
  onAddComment={handleAddComment}
@@ -1125,7 +343,7 @@ export function BCFPanel({ onClose }: BCFPanelProps) {
1125
343
  hasHiddenEntities={hasHiddenEntities}
1126
344
  />
1127
345
  ) : (
1128
- <TopicList
346
+ <BCFTopicList
1129
347
  topics={topics}
1130
348
  onSelectTopic={setActiveTopic}
1131
349
  onCreateTopic={() => setShowCreateForm(true)}