@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
@@ -11,7 +11,15 @@ import { useViewerStore } from '@/store';
11
11
  import { useIfc } from '@/hooks/useIfc';
12
12
  import { useWebGPU } from '@/hooks/useWebGPU';
13
13
  import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus } from 'lucide-react';
14
- import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
14
+ import type { MeshData, CoordinateInfo, GeometryResult } from '@ifc-lite/geometry';
15
+
16
+ const ZERO_VEC3 = { x: 0, y: 0, z: 0 };
17
+ const DEFAULT_COORDINATE_INFO: CoordinateInfo = {
18
+ originShift: ZERO_VEC3,
19
+ originalBounds: { min: ZERO_VEC3, max: ZERO_VEC3 },
20
+ shiftedBounds: { min: ZERO_VEC3, max: ZERO_VEC3 },
21
+ hasLargeCoordinates: false,
22
+ };
15
23
 
16
24
  export function ViewportContainer() {
17
25
  const { geometryResult, ifcDataStore, loadFile, loading, models, clearAllModels, loadFilesSequentially } = useIfc();
@@ -74,8 +82,8 @@ export function ViewportContainer() {
74
82
  meshes: allMeshes,
75
83
  totalVertices,
76
84
  totalTriangles,
77
- coordinateInfo: mergedCoordinateInfo,
78
- };
85
+ coordinateInfo: mergedCoordinateInfo ?? DEFAULT_COORDINATE_INFO,
86
+ } satisfies GeometryResult;
79
87
  }
80
88
 
81
89
  // Legacy mode (no federation): use original geometryResult
@@ -0,0 +1,134 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * BCFCreateTopicForm - New topic creation form for the BCF panel.
7
+ */
8
+
9
+ import React, { useCallback, useState } from 'react';
10
+ import { X } from 'lucide-react';
11
+ import { Button } from '@/components/ui/button';
12
+ import { Input } from '@/components/ui/input';
13
+ import { Label } from '@/components/ui/label';
14
+ import {
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from '@/components/ui/select';
21
+ import type { BCFTopic } from '@ifc-lite/bcf';
22
+ import { TOPIC_TYPES, PRIORITIES } from './bcfHelpers';
23
+
24
+ // ============================================================================
25
+ // Types
26
+ // ============================================================================
27
+
28
+ export interface BCFCreateTopicFormProps {
29
+ onSubmit: (topic: Partial<BCFTopic>) => void;
30
+ onCancel: () => void;
31
+ author: string;
32
+ }
33
+
34
+ // ============================================================================
35
+ // Component
36
+ // ============================================================================
37
+
38
+ export function BCFCreateTopicForm({ onSubmit, onCancel, author: _author }: BCFCreateTopicFormProps) {
39
+ const [title, setTitle] = useState('');
40
+ const [description, setDescription] = useState('');
41
+ const [topicType, setTopicType] = useState('Issue');
42
+ const [priority, setPriority] = useState('Medium');
43
+
44
+ const handleSubmit = useCallback(
45
+ (e: React.FormEvent) => {
46
+ e.preventDefault();
47
+ if (title.trim()) {
48
+ onSubmit({
49
+ title: title.trim(),
50
+ description: description.trim() || undefined,
51
+ topicType,
52
+ priority,
53
+ });
54
+ }
55
+ },
56
+ [title, description, topicType, priority, onSubmit]
57
+ );
58
+
59
+ return (
60
+ <form onSubmit={handleSubmit} className="p-3 space-y-4">
61
+ <div className="flex items-center justify-between mb-2">
62
+ <h3 className="font-medium">New Topic</h3>
63
+ <Button variant="ghost" size="sm" type="button" onClick={onCancel}>
64
+ <X className="h-4 w-4" />
65
+ </Button>
66
+ </div>
67
+
68
+ <div className="space-y-2">
69
+ <Label htmlFor="title">Title *</Label>
70
+ <Input
71
+ id="title"
72
+ value={title}
73
+ onChange={(e) => setTitle(e.target.value)}
74
+ placeholder="Brief description of the issue"
75
+ required
76
+ />
77
+ </div>
78
+
79
+ <div className="space-y-2">
80
+ <Label htmlFor="description">Description</Label>
81
+ <textarea
82
+ id="description"
83
+ value={description}
84
+ onChange={(e) => setDescription(e.target.value)}
85
+ placeholder="Detailed description (optional)"
86
+ className="w-full min-h-[80px] px-3 py-2 text-sm rounded-md border border-input bg-background"
87
+ />
88
+ </div>
89
+
90
+ <div className="grid grid-cols-2 gap-3">
91
+ <div className="space-y-2">
92
+ <Label>Type</Label>
93
+ <Select value={topicType} onValueChange={setTopicType}>
94
+ <SelectTrigger>
95
+ <SelectValue />
96
+ </SelectTrigger>
97
+ <SelectContent>
98
+ {TOPIC_TYPES.map((type) => (
99
+ <SelectItem key={type} value={type}>
100
+ {type}
101
+ </SelectItem>
102
+ ))}
103
+ </SelectContent>
104
+ </Select>
105
+ </div>
106
+
107
+ <div className="space-y-2">
108
+ <Label>Priority</Label>
109
+ <Select value={priority} onValueChange={setPriority}>
110
+ <SelectTrigger>
111
+ <SelectValue />
112
+ </SelectTrigger>
113
+ <SelectContent>
114
+ {PRIORITIES.map((p) => (
115
+ <SelectItem key={p} value={p}>
116
+ {p}
117
+ </SelectItem>
118
+ ))}
119
+ </SelectContent>
120
+ </Select>
121
+ </div>
122
+ </div>
123
+
124
+ <div className="flex gap-2 justify-end pt-2">
125
+ <Button variant="outline" type="button" onClick={onCancel}>
126
+ Cancel
127
+ </Button>
128
+ <Button type="submit" disabled={!title.trim()}>
129
+ Create Topic
130
+ </Button>
131
+ </div>
132
+ </form>
133
+ );
134
+ }
@@ -0,0 +1,388 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * BCFTopicDetail - Topic detail view with comments, viewpoints, and editing.
7
+ */
8
+
9
+ import React, { useCallback, useState, useMemo } from 'react';
10
+ import {
11
+ X,
12
+ MessageSquare,
13
+ Camera,
14
+ ChevronLeft,
15
+ Send,
16
+ Trash2,
17
+ User,
18
+ MousePointer2,
19
+ Focus,
20
+ EyeOff,
21
+ } from 'lucide-react';
22
+ import { Button } from '@/components/ui/button';
23
+ import { Input } from '@/components/ui/input';
24
+ import { Badge } from '@/components/ui/badge';
25
+ import {
26
+ Select,
27
+ SelectContent,
28
+ SelectItem,
29
+ SelectTrigger,
30
+ SelectValue,
31
+ } from '@/components/ui/select';
32
+ import { ScrollArea } from '@/components/ui/scroll-area';
33
+ import { Separator } from '@/components/ui/separator';
34
+ import type { BCFTopic, BCFViewpoint } from '@ifc-lite/bcf';
35
+ import { PriorityBadge, formatDate, formatDateTime, TOPIC_STATUSES } from './bcfHelpers';
36
+
37
+ // ============================================================================
38
+ // Types
39
+ // ============================================================================
40
+
41
+ export interface BCFTopicDetailProps {
42
+ topic: BCFTopic;
43
+ onBack: () => void;
44
+ onAddComment: (text: string, viewpointGuid?: string) => void;
45
+ onAddViewpoint: () => void;
46
+ onActivateViewpoint: (viewpoint: BCFViewpoint) => void;
47
+ onDeleteViewpoint: (viewpointGuid: string) => void;
48
+ onUpdateStatus: (status: string) => void;
49
+ onDeleteTopic: () => void;
50
+ // Viewer state info for capture feedback
51
+ selectionCount: number;
52
+ hasIsolation: boolean;
53
+ hasHiddenEntities: boolean;
54
+ }
55
+
56
+ // ============================================================================
57
+ // Component
58
+ // ============================================================================
59
+
60
+ export function BCFTopicDetail({
61
+ topic,
62
+ onBack,
63
+ onAddComment,
64
+ onAddViewpoint,
65
+ onActivateViewpoint,
66
+ onDeleteViewpoint,
67
+ onUpdateStatus,
68
+ onDeleteTopic,
69
+ selectionCount,
70
+ hasIsolation,
71
+ hasHiddenEntities,
72
+ }: BCFTopicDetailProps) {
73
+ const [commentText, setCommentText] = useState('');
74
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
75
+ const [selectedViewpointGuid, setSelectedViewpointGuid] = useState<string | null>(null);
76
+
77
+ // Get the selected viewpoint for display
78
+ const selectedViewpoint = useMemo(() => {
79
+ if (!selectedViewpointGuid) return null;
80
+ return topic.viewpoints.find(vp => vp.guid === selectedViewpointGuid) || null;
81
+ }, [selectedViewpointGuid, topic.viewpoints]);
82
+
83
+ const handleSubmitComment = useCallback(() => {
84
+ if (commentText.trim()) {
85
+ // Associate comment with selected viewpoint if one is selected
86
+ onAddComment(commentText.trim(), selectedViewpointGuid || undefined);
87
+ setCommentText('');
88
+ setSelectedViewpointGuid(null); // Clear selection after commenting
89
+ }
90
+ }, [commentText, onAddComment, selectedViewpointGuid]);
91
+
92
+ const handleKeyDown = useCallback(
93
+ (e: React.KeyboardEvent) => {
94
+ if (e.key === 'Enter' && !e.shiftKey) {
95
+ e.preventDefault();
96
+ handleSubmitComment();
97
+ }
98
+ },
99
+ [handleSubmitComment]
100
+ );
101
+
102
+ return (
103
+ <div className="flex flex-col h-full relative">
104
+ {/* Header */}
105
+ <div className="flex items-center gap-2 px-3 py-2 border-b border-border">
106
+ <Button variant="ghost" size="sm" onClick={onBack}>
107
+ <ChevronLeft className="h-4 w-4" />
108
+ </Button>
109
+ <h3 className="font-medium text-sm flex-1 truncate">{topic.title}</h3>
110
+ <Button
111
+ variant="ghost"
112
+ size="sm"
113
+ onClick={() => setShowDeleteConfirm(true)}
114
+ className="text-destructive hover:text-destructive"
115
+ >
116
+ <Trash2 className="h-4 w-4" />
117
+ </Button>
118
+ </div>
119
+
120
+ <ScrollArea className="flex-1">
121
+ <div className="p-3 space-y-4">
122
+ {/* Topic Info */}
123
+ <div className="space-y-2">
124
+ <div className="flex items-center gap-2 flex-wrap">
125
+ <Select value={topic.topicStatus || 'Open'} onValueChange={onUpdateStatus}>
126
+ <SelectTrigger className="h-7 w-auto">
127
+ <SelectValue />
128
+ </SelectTrigger>
129
+ <SelectContent>
130
+ {TOPIC_STATUSES.map((status) => (
131
+ <SelectItem key={status} value={status}>
132
+ {status}
133
+ </SelectItem>
134
+ ))}
135
+ </SelectContent>
136
+ </Select>
137
+ <PriorityBadge priority={topic.priority} />
138
+ {topic.topicType && (
139
+ <Badge variant="outline" className="text-xs">
140
+ {topic.topicType}
141
+ </Badge>
142
+ )}
143
+ </div>
144
+
145
+ {topic.description && (
146
+ <p className="text-sm text-muted-foreground">{topic.description}</p>
147
+ )}
148
+
149
+ <div className="text-xs text-muted-foreground space-y-1">
150
+ <p>
151
+ Created by {topic.creationAuthor} on{' '}
152
+ {formatDateTime(topic.creationDate)}
153
+ </p>
154
+ {topic.assignedTo && <p>Assigned to: {topic.assignedTo}</p>}
155
+ {topic.dueDate && <p>Due: {formatDate(topic.dueDate)}</p>}
156
+ </div>
157
+ </div>
158
+
159
+ <Separator />
160
+
161
+ {/* Viewpoints */}
162
+ <div>
163
+ <div className="flex items-center justify-between mb-2">
164
+ <h4 className="text-sm font-medium">Viewpoints</h4>
165
+ <Button variant="outline" size="sm" onClick={onAddViewpoint}>
166
+ <Camera className="h-3 w-3 mr-1" />
167
+ Capture
168
+ </Button>
169
+ </div>
170
+
171
+ {/* Capture info - what will be included */}
172
+ {(selectionCount > 0 || hasIsolation || hasHiddenEntities) && (
173
+ <div className="mb-2 p-2 bg-muted/50 rounded-md text-xs text-muted-foreground">
174
+ <p className="font-medium mb-1">Capture will include:</p>
175
+ <ul className="space-y-0.5">
176
+ {selectionCount > 0 && (
177
+ <li className="flex items-center gap-1">
178
+ <MousePointer2 className="h-3 w-3" />
179
+ {selectionCount} selected {selectionCount === 1 ? 'object' : 'objects'}
180
+ </li>
181
+ )}
182
+ {hasIsolation && (
183
+ <li className="flex items-center gap-1">
184
+ <Focus className="h-3 w-3" />
185
+ Isolated objects (others hidden)
186
+ </li>
187
+ )}
188
+ {hasHiddenEntities && !hasIsolation && (
189
+ <li className="flex items-center gap-1">
190
+ <EyeOff className="h-3 w-3" />
191
+ Hidden objects
192
+ </li>
193
+ )}
194
+ </ul>
195
+ </div>
196
+ )}
197
+
198
+ {topic.viewpoints.length === 0 ? (
199
+ <p className="text-xs text-muted-foreground">No viewpoints captured</p>
200
+ ) : (
201
+ <div className="space-y-2">
202
+ {topic.viewpoints.map((vp) => {
203
+ const isSelected = selectedViewpointGuid === vp.guid;
204
+ const commentCount = topic.comments.filter(c => c.viewpointGuid === vp.guid).length;
205
+ return (
206
+ <div
207
+ key={vp.guid}
208
+ className={`rounded-md overflow-hidden border-2 transition-colors ${
209
+ isSelected ? 'border-primary bg-primary/5' : 'border-border'
210
+ }`}
211
+ >
212
+ {/* Snapshot */}
213
+ <div className="relative group">
214
+ {vp.snapshot ? (
215
+ <img
216
+ src={vp.snapshot}
217
+ alt="Viewpoint"
218
+ className="w-full aspect-video object-cover cursor-pointer hover:opacity-90 transition-opacity"
219
+ onClick={() => onActivateViewpoint(vp)}
220
+ />
221
+ ) : (
222
+ <div
223
+ className="w-full aspect-video bg-muted flex items-center justify-center cursor-pointer hover:bg-muted/80 transition-colors"
224
+ onClick={() => onActivateViewpoint(vp)}
225
+ >
226
+ <Camera className="h-6 w-6 text-muted-foreground" />
227
+ </div>
228
+ )}
229
+ {/* Delete button - hover only */}
230
+ <Button
231
+ variant="destructive"
232
+ size="icon"
233
+ className="absolute top-1 right-1 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
234
+ onClick={(e) => {
235
+ e.stopPropagation();
236
+ onDeleteViewpoint(vp.guid);
237
+ }}
238
+ >
239
+ <Trash2 className="h-3 w-3" />
240
+ </Button>
241
+ </div>
242
+ {/* Action bar - always visible */}
243
+ <div className="flex items-center justify-between px-2 py-1.5 bg-muted/30">
244
+ <Button
245
+ variant={isSelected ? 'default' : 'ghost'}
246
+ size="sm"
247
+ className="h-7 text-xs gap-1"
248
+ onClick={() => setSelectedViewpointGuid(isSelected ? null : vp.guid)}
249
+ >
250
+ <MessageSquare className="h-3 w-3" />
251
+ {commentCount > 0 ? `${commentCount} comment${commentCount > 1 ? 's' : ''}` : 'Comment'}
252
+ </Button>
253
+ <Button
254
+ variant="ghost"
255
+ size="sm"
256
+ className="h-7 text-xs"
257
+ onClick={() => onActivateViewpoint(vp)}
258
+ >
259
+ Go to view
260
+ </Button>
261
+ </div>
262
+ </div>
263
+ );
264
+ })}
265
+ </div>
266
+ )}
267
+ </div>
268
+
269
+ <Separator />
270
+
271
+ {/* Comments */}
272
+ <div>
273
+ <h4 className="text-sm font-medium mb-2">
274
+ Comments ({topic.comments.length})
275
+ </h4>
276
+
277
+ <div className="space-y-3">
278
+ {topic.comments.map((comment) => {
279
+ // Find associated viewpoint if any
280
+ const associatedViewpoint = comment.viewpointGuid
281
+ ? topic.viewpoints.find(vp => vp.guid === comment.viewpointGuid)
282
+ : null;
283
+ return (
284
+ <div
285
+ key={comment.guid}
286
+ className="bg-muted/50 rounded-md p-2 text-sm"
287
+ >
288
+ {/* Show associated viewpoint thumbnail if present */}
289
+ {associatedViewpoint?.snapshot && (
290
+ <div
291
+ className="mb-2 rounded overflow-hidden border border-border cursor-pointer hover:opacity-80 transition-opacity"
292
+ onClick={() => onActivateViewpoint(associatedViewpoint)}
293
+ >
294
+ <img
295
+ src={associatedViewpoint.snapshot}
296
+ alt="Associated viewpoint"
297
+ className="w-full h-16 object-cover"
298
+ />
299
+ </div>
300
+ )}
301
+ <div className="flex items-center gap-2 mb-1 text-xs text-muted-foreground">
302
+ <User className="h-3 w-3" />
303
+ <span>{comment.author.split('@')[0]}</span>
304
+ <span>-</span>
305
+ <span>{formatDateTime(comment.date)}</span>
306
+ {comment.viewpointGuid && (
307
+ <span className="flex items-center gap-0.5">
308
+ <Camera className="h-3 w-3" />
309
+ </span>
310
+ )}
311
+ </div>
312
+ <p className="whitespace-pre-wrap">{comment.comment}</p>
313
+ </div>
314
+ );
315
+ })}
316
+ </div>
317
+ </div>
318
+ </div>
319
+ </ScrollArea>
320
+
321
+ {/* Comment Input */}
322
+ <div className="border-t border-border p-3">
323
+ {/* Show selected viewpoint indicator */}
324
+ {selectedViewpoint && (
325
+ <div className="flex items-center gap-2 mb-2 p-2 bg-primary/10 rounded-md">
326
+ {selectedViewpoint.snapshot && (
327
+ <img
328
+ src={selectedViewpoint.snapshot}
329
+ alt="Selected viewpoint"
330
+ className="w-10 h-10 object-cover rounded"
331
+ />
332
+ )}
333
+ <div className="flex-1 min-w-0">
334
+ <p className="text-xs text-muted-foreground">Commenting on viewpoint</p>
335
+ </div>
336
+ <Button
337
+ variant="ghost"
338
+ size="icon"
339
+ className="h-6 w-6 shrink-0"
340
+ onClick={() => setSelectedViewpointGuid(null)}
341
+ >
342
+ <X className="h-3 w-3" />
343
+ </Button>
344
+ </div>
345
+ )}
346
+ <div className="flex gap-2">
347
+ <Input
348
+ placeholder={selectedViewpoint ? "Add comment on viewpoint..." : "Add a comment..."}
349
+ value={commentText}
350
+ onChange={(e) => setCommentText(e.target.value)}
351
+ onKeyDown={handleKeyDown}
352
+ className="flex-1"
353
+ />
354
+ <Button size="icon" onClick={handleSubmitComment} disabled={!commentText.trim()}>
355
+ <Send className="h-4 w-4" />
356
+ </Button>
357
+ </div>
358
+ </div>
359
+
360
+ {/* Delete Confirmation */}
361
+ {showDeleteConfirm && (
362
+ <div className="absolute inset-0 bg-background/90 flex items-center justify-center p-4">
363
+ <div className="bg-card border rounded-lg p-4 max-w-xs">
364
+ <h4 className="font-medium mb-2">Delete Topic?</h4>
365
+ <p className="text-sm text-muted-foreground mb-4">
366
+ This will permanently delete this topic and all its comments and viewpoints.
367
+ </p>
368
+ <div className="flex gap-2 justify-end">
369
+ <Button variant="outline" size="sm" onClick={() => setShowDeleteConfirm(false)}>
370
+ Cancel
371
+ </Button>
372
+ <Button
373
+ variant="destructive"
374
+ size="sm"
375
+ onClick={() => {
376
+ onDeleteTopic();
377
+ setShowDeleteConfirm(false);
378
+ }}
379
+ >
380
+ Delete
381
+ </Button>
382
+ </div>
383
+ </div>
384
+ </div>
385
+ )}
386
+ </div>
387
+ );
388
+ }