@ifc-lite/viewer 1.1.7 → 1.5.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.
- package/LICENSE +373 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/Arrow.dom-B0e15b_b.js +20 -0
- package/dist/assets/arrow2-bb-jcVEo.js +2 -0
- package/dist/assets/arrow2_bg-4Y7xYo54.wasm +0 -0
- package/dist/assets/arrow2_bg-BlXl-cSQ.js +1 -0
- package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
- package/dist/assets/desktop-cache-oPzaWXYE.js +1 -0
- package/dist/assets/event-DIOks52T.js +1 -0
- package/dist/assets/ifc-cache-BAN4vcd4.js +1 -0
- package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
- package/dist/assets/index-Dgd6vzw_.js +65252 -0
- package/dist/assets/index-v3mcCUPN.css +1 -0
- package/dist/assets/native-bridge-Ci7NLjlZ.js +111 -0
- package/dist/assets/wasm-bridge-Dc82YpdZ.js +1 -0
- package/dist/favicon-16x16-cropped.png +0 -0
- package/dist/favicon-16x16.png +0 -0
- package/dist/favicon-192x192-cropped.png +0 -0
- package/dist/favicon-192x192.png +0 -0
- package/dist/favicon-32x32-cropped.png +0 -0
- package/dist/favicon-32x32.png +0 -0
- package/dist/favicon-48x48-cropped.png +0 -0
- package/dist/favicon-48x48.png +0 -0
- package/dist/favicon-512x512-cropped.png +0 -0
- package/dist/favicon-512x512.png +0 -0
- package/dist/favicon-64x64-cropped.png +0 -0
- package/dist/favicon-64x64.png +0 -0
- package/dist/favicon-96x96-cropped.png +0 -0
- package/dist/favicon-96x96.png +0 -0
- package/dist/favicon-square-512.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +3 -0
- package/dist/index.html +44 -0
- package/dist/logo.png +0 -0
- package/dist/manifest.json +48 -0
- package/index.html +33 -2
- package/package.json +34 -17
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16-cropped.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-192x192-cropped.png +0 -0
- package/public/favicon-192x192.png +0 -0
- package/public/favicon-32x32-cropped.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon-48x48-cropped.png +0 -0
- package/public/favicon-48x48.png +0 -0
- package/public/favicon-512x512-cropped.png +0 -0
- package/public/favicon-512x512.png +0 -0
- package/public/favicon-64x64-cropped.png +0 -0
- package/public/favicon-64x64.png +0 -0
- package/public/favicon-96x96-cropped.png +0 -0
- package/public/favicon-96x96.png +0 -0
- package/public/favicon-square-512.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +3 -0
- package/public/logo.png +0 -0
- package/public/manifest.json +48 -0
- package/src/App.tsx +2 -0
- package/src/components/ui/alert.tsx +62 -0
- package/src/components/ui/badge.tsx +39 -0
- package/src/components/ui/dialog.tsx +120 -0
- package/src/components/ui/label.tsx +27 -0
- package/src/components/ui/select.tsx +151 -0
- package/src/components/ui/switch.tsx +30 -0
- package/src/components/ui/table.tsx +120 -0
- package/src/components/ui/tabs.tsx +1 -1
- package/src/components/viewer/BCFPanel.tsx +1164 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +875 -0
- package/src/components/viewer/DataConnector.tsx +840 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +536 -0
- package/src/components/viewer/EntityContextMenu.tsx +45 -17
- package/src/components/viewer/ExportChangesButton.tsx +195 -0
- package/src/components/viewer/ExportDialog.tsx +402 -0
- package/src/components/viewer/HierarchyPanel.tsx +1132 -218
- package/src/components/viewer/IDSPanel.tsx +661 -0
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +245 -39
- package/src/components/viewer/MainToolbar.tsx +418 -94
- package/src/components/viewer/PropertiesPanel.tsx +1355 -91
- package/src/components/viewer/PropertyEditor.tsx +611 -0
- package/src/components/viewer/Section2DPanel.tsx +3313 -0
- package/src/components/viewer/SheetSetupPanel.tsx +502 -0
- package/src/components/viewer/StatusBar.tsx +27 -16
- package/src/components/viewer/TitleBlockEditor.tsx +437 -0
- package/src/components/viewer/ToolOverlays.tsx +935 -127
- package/src/components/viewer/ViewerLayout.tsx +40 -11
- package/src/components/viewer/Viewport.tsx +1276 -336
- package/src/components/viewer/ViewportContainer.tsx +554 -18
- package/src/components/viewer/ViewportOverlays.tsx +24 -7
- package/src/hooks/useBCF.ts +504 -0
- package/src/hooks/useIDS.ts +1065 -0
- package/src/hooks/useIfc.ts +1534 -205
- package/src/hooks/useIfcCache.ts +279 -0
- package/src/hooks/useKeyboardShortcuts.ts +50 -8
- package/src/hooks/useModelSelection.ts +61 -0
- package/src/hooks/useViewerSelectors.ts +218 -0
- package/src/hooks/useWebGPU.ts +80 -0
- package/src/index.css +265 -27
- package/src/lib/platform.ts +23 -0
- package/src/services/cacheService.ts +142 -0
- package/src/services/desktop-cache.ts +143 -0
- package/src/services/fs-cache.ts +212 -0
- package/src/services/ifc-cache.ts +14 -6
- package/src/store/constants.ts +85 -0
- package/src/store/index.ts +214 -0
- package/src/store/slices/bcfSlice.ts +372 -0
- package/src/store/slices/cameraSlice.ts +63 -0
- package/src/store/slices/dataSlice.test.ts +226 -0
- package/src/store/slices/dataSlice.ts +112 -0
- package/src/store/slices/drawing2DSlice.ts +340 -0
- package/src/store/slices/hoverSlice.ts +40 -0
- package/src/store/slices/idsSlice.ts +310 -0
- package/src/store/slices/loadingSlice.ts +33 -0
- package/src/store/slices/measurementSlice.test.ts +217 -0
- package/src/store/slices/measurementSlice.ts +293 -0
- package/src/store/slices/modelSlice.test.ts +271 -0
- package/src/store/slices/modelSlice.ts +211 -0
- package/src/store/slices/mutationSlice.ts +502 -0
- package/src/store/slices/sectionSlice.test.ts +125 -0
- package/src/store/slices/sectionSlice.ts +58 -0
- package/src/store/slices/selectionSlice.test.ts +286 -0
- package/src/store/slices/selectionSlice.ts +263 -0
- package/src/store/slices/sheetSlice.ts +565 -0
- package/src/store/slices/uiSlice.ts +58 -0
- package/src/store/slices/visibilitySlice.test.ts +304 -0
- package/src/store/slices/visibilitySlice.ts +277 -0
- package/src/store/types.test.ts +135 -0
- package/src/store/types.ts +248 -0
- package/src/store.ts +40 -515
- package/src/utils/ifcConfig.ts +82 -0
- package/src/utils/localParsingUtils.ts +287 -0
- package/src/utils/serverDataModel.ts +783 -0
- package/src/utils/spatialHierarchy.ts +283 -0
- package/src/utils/viewportUtils.ts +334 -0
- package/src/vite-env.d.ts +23 -0
- package/src/webgpu-types.d.ts +128 -0
- package/src-tauri/Cargo.toml +29 -0
- package/src-tauri/build.rs +7 -0
- package/src-tauri/capabilities/default.json +18 -0
- package/src-tauri/icons/128x128.png +0 -0
- package/src-tauri/icons/128x128@2x.png +0 -0
- package/src-tauri/icons/32x32.png +0 -0
- package/src-tauri/icons/Square107x107Logo.png +0 -0
- package/src-tauri/icons/Square142x142Logo.png +0 -0
- package/src-tauri/icons/Square150x150Logo.png +0 -0
- package/src-tauri/icons/Square284x284Logo.png +0 -0
- package/src-tauri/icons/Square30x30Logo.png +0 -0
- package/src-tauri/icons/Square310x310Logo.png +0 -0
- package/src-tauri/icons/Square44x44Logo.png +0 -0
- package/src-tauri/icons/Square71x71Logo.png +0 -0
- package/src-tauri/icons/Square89x89Logo.png +0 -0
- package/src-tauri/icons/StoreLogo.png +0 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.ico +0 -0
- package/src-tauri/icons/icon.png +0 -0
- package/src-tauri/src/lib.rs +21 -0
- package/src-tauri/src/main.rs +10 -0
- package/src-tauri/tauri.conf.json +39 -0
- package/vite.config.ts +174 -26
- package/public/ifc-lite_bg.wasm +0 -0
- package/public/web-ifc.wasm +0 -0
- package/src/components/Viewport.tsx +0 -723
- package/src/components/viewer/BoxSelectionOverlay.tsx +0 -53
|
@@ -0,0 +1,1164 @@
|
|
|
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
|
+
* BCFPanel - BIM Collaboration Format issue management panel
|
|
7
|
+
*
|
|
8
|
+
* Provides:
|
|
9
|
+
* - Topic list with filtering
|
|
10
|
+
* - Topic detail view with comments
|
|
11
|
+
* - Viewpoint thumbnails with activation
|
|
12
|
+
* - Create/edit topics and comments
|
|
13
|
+
* - Import/export BCF files
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { useCallback, useState, useMemo, useRef } from 'react';
|
|
17
|
+
import {
|
|
18
|
+
X,
|
|
19
|
+
Plus,
|
|
20
|
+
MessageSquare,
|
|
21
|
+
Camera,
|
|
22
|
+
Upload,
|
|
23
|
+
Download,
|
|
24
|
+
ChevronLeft,
|
|
25
|
+
Send,
|
|
26
|
+
Trash2,
|
|
27
|
+
Edit2,
|
|
28
|
+
User,
|
|
29
|
+
Calendar,
|
|
30
|
+
AlertCircle,
|
|
31
|
+
CheckCircle,
|
|
32
|
+
Clock,
|
|
33
|
+
Filter,
|
|
34
|
+
MousePointer2,
|
|
35
|
+
Focus,
|
|
36
|
+
EyeOff,
|
|
37
|
+
} from 'lucide-react';
|
|
38
|
+
import { Button } from '@/components/ui/button';
|
|
39
|
+
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
|
+
import { Badge } from '@/components/ui/badge';
|
|
51
|
+
import { useViewerStore } from '@/store';
|
|
52
|
+
import type { BCFTopic, BCFComment, BCFViewpoint } from '@ifc-lite/bcf';
|
|
53
|
+
import {
|
|
54
|
+
readBCF,
|
|
55
|
+
writeBCF,
|
|
56
|
+
createBCFProject,
|
|
57
|
+
createBCFTopic,
|
|
58
|
+
createBCFComment,
|
|
59
|
+
} from '@ifc-lite/bcf';
|
|
60
|
+
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
|
+
}
|
|
822
|
+
|
|
823
|
+
// ============================================================================
|
|
824
|
+
// Main BCF Panel Component
|
|
825
|
+
// ============================================================================
|
|
826
|
+
|
|
827
|
+
interface BCFPanelProps {
|
|
828
|
+
onClose: () => void;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export function BCFPanel({ onClose }: BCFPanelProps) {
|
|
832
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
833
|
+
|
|
834
|
+
// Store state
|
|
835
|
+
const bcfProject = useViewerStore((s) => s.bcfProject);
|
|
836
|
+
const setBcfProject = useViewerStore((s) => s.setBcfProject);
|
|
837
|
+
const activeTopicId = useViewerStore((s) => s.activeTopicId);
|
|
838
|
+
const setActiveTopic = useViewerStore((s) => s.setActiveTopic);
|
|
839
|
+
const addTopic = useViewerStore((s) => s.addTopic);
|
|
840
|
+
const updateTopic = useViewerStore((s) => s.updateTopic);
|
|
841
|
+
const deleteTopic = useViewerStore((s) => s.deleteTopic);
|
|
842
|
+
const addComment = useViewerStore((s) => s.addComment);
|
|
843
|
+
const addViewpoint = useViewerStore((s) => s.addViewpoint);
|
|
844
|
+
const deleteViewpoint = useViewerStore((s) => s.deleteViewpoint);
|
|
845
|
+
const bcfAuthor = useViewerStore((s) => s.bcfAuthor);
|
|
846
|
+
const setBcfAuthor = useViewerStore((s) => s.setBcfAuthor);
|
|
847
|
+
const setBcfLoading = useViewerStore((s) => s.setBcfLoading);
|
|
848
|
+
|
|
849
|
+
// Viewer state for capture feedback
|
|
850
|
+
const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
|
|
851
|
+
const selectedEntityIds = useViewerStore((s) => s.selectedEntityIds);
|
|
852
|
+
const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
|
|
853
|
+
const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
|
|
854
|
+
|
|
855
|
+
// Computed capture state info
|
|
856
|
+
const selectionCount = useMemo(() => {
|
|
857
|
+
let count = selectedEntityId !== null ? 1 : 0;
|
|
858
|
+
count += selectedEntityIds.size;
|
|
859
|
+
if (selectedEntityId !== null && selectedEntityIds.has(selectedEntityId)) {
|
|
860
|
+
count--; // Avoid double-counting
|
|
861
|
+
}
|
|
862
|
+
return count;
|
|
863
|
+
}, [selectedEntityId, selectedEntityIds]);
|
|
864
|
+
const hasIsolation = isolatedEntities !== null && isolatedEntities.size > 0;
|
|
865
|
+
const hasHiddenEntities = hiddenEntities.size > 0;
|
|
866
|
+
const setBcfError = useViewerStore((s) => s.setBcfError);
|
|
867
|
+
const models = useViewerStore((s) => s.models);
|
|
868
|
+
|
|
869
|
+
// BCF hook for camera/snapshot integration
|
|
870
|
+
const { createViewpointFromState, applyViewpoint } = useBCF();
|
|
871
|
+
|
|
872
|
+
// Local state
|
|
873
|
+
const [statusFilter, setStatusFilter] = useState('all');
|
|
874
|
+
const [showCreateForm, setShowCreateForm] = useState(false);
|
|
875
|
+
const [showAuthorDialog, setShowAuthorDialog] = useState(false);
|
|
876
|
+
const [tempAuthor, setTempAuthor] = useState(bcfAuthor);
|
|
877
|
+
|
|
878
|
+
// Get topics list
|
|
879
|
+
const topics = useMemo(() => {
|
|
880
|
+
if (!bcfProject) return [];
|
|
881
|
+
return Array.from(bcfProject.topics.values());
|
|
882
|
+
}, [bcfProject]);
|
|
883
|
+
|
|
884
|
+
// Get active topic
|
|
885
|
+
const activeTopic = useMemo(() => {
|
|
886
|
+
if (!bcfProject || !activeTopicId) return null;
|
|
887
|
+
return bcfProject.topics.get(activeTopicId) || null;
|
|
888
|
+
}, [bcfProject, activeTopicId]);
|
|
889
|
+
|
|
890
|
+
// Get a default project name from loaded models
|
|
891
|
+
const getDefaultProjectName = useCallback(() => {
|
|
892
|
+
if (models.size === 0) {
|
|
893
|
+
// No models loaded, use date-based name
|
|
894
|
+
const date = new Date().toISOString().split('T')[0];
|
|
895
|
+
return `BCF_Issues_${date}`;
|
|
896
|
+
}
|
|
897
|
+
// Use first model's name (without extension) + "_Issues"
|
|
898
|
+
const firstModel = models.values().next().value;
|
|
899
|
+
if (firstModel?.name) {
|
|
900
|
+
const baseName = firstModel.name.replace(/\.(ifc|ifczip)$/i, '');
|
|
901
|
+
return `${baseName}_Issues`;
|
|
902
|
+
}
|
|
903
|
+
return `BCF_Issues_${new Date().toISOString().split('T')[0]}`;
|
|
904
|
+
}, [models]);
|
|
905
|
+
|
|
906
|
+
// Initialize project if needed
|
|
907
|
+
const ensureProject = useCallback(() => {
|
|
908
|
+
if (!bcfProject) {
|
|
909
|
+
setBcfProject(createBCFProject({ name: getDefaultProjectName() }));
|
|
910
|
+
}
|
|
911
|
+
}, [bcfProject, setBcfProject, getDefaultProjectName]);
|
|
912
|
+
|
|
913
|
+
// Import BCF file
|
|
914
|
+
const handleImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
915
|
+
const file = e.target.files?.[0];
|
|
916
|
+
if (!file) return;
|
|
917
|
+
|
|
918
|
+
try {
|
|
919
|
+
setBcfLoading(true);
|
|
920
|
+
setBcfError(null);
|
|
921
|
+
const project = await readBCF(file);
|
|
922
|
+
setBcfProject(project);
|
|
923
|
+
} catch (error) {
|
|
924
|
+
console.error('Failed to import BCF:', error);
|
|
925
|
+
setBcfError(error instanceof Error ? error.message : 'Failed to import BCF file');
|
|
926
|
+
} finally {
|
|
927
|
+
setBcfLoading(false);
|
|
928
|
+
if (fileInputRef.current) {
|
|
929
|
+
fileInputRef.current.value = '';
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}, [setBcfProject, setBcfLoading, setBcfError]);
|
|
933
|
+
|
|
934
|
+
// Export BCF file
|
|
935
|
+
const handleExport = useCallback(async () => {
|
|
936
|
+
if (!bcfProject) return;
|
|
937
|
+
|
|
938
|
+
try {
|
|
939
|
+
setBcfLoading(true);
|
|
940
|
+
const blob = await writeBCF(bcfProject);
|
|
941
|
+
const url = URL.createObjectURL(blob);
|
|
942
|
+
const a = document.createElement('a');
|
|
943
|
+
a.href = url;
|
|
944
|
+
// Use project name, or generate from model name, or date-based fallback
|
|
945
|
+
const fileName = bcfProject.name || getDefaultProjectName();
|
|
946
|
+
a.download = `${fileName}.bcfzip`;
|
|
947
|
+
document.body.appendChild(a);
|
|
948
|
+
a.click();
|
|
949
|
+
document.body.removeChild(a);
|
|
950
|
+
URL.revokeObjectURL(url);
|
|
951
|
+
} catch (error) {
|
|
952
|
+
console.error('Failed to export BCF:', error);
|
|
953
|
+
setBcfError(error instanceof Error ? error.message : 'Failed to export BCF file');
|
|
954
|
+
} finally {
|
|
955
|
+
setBcfLoading(false);
|
|
956
|
+
}
|
|
957
|
+
}, [bcfProject, setBcfLoading, setBcfError, getDefaultProjectName]);
|
|
958
|
+
|
|
959
|
+
// Create new topic
|
|
960
|
+
const handleCreateTopic = useCallback(
|
|
961
|
+
(data: Partial<BCFTopic>) => {
|
|
962
|
+
ensureProject();
|
|
963
|
+
const topic = createBCFTopic({
|
|
964
|
+
title: data.title || 'Untitled',
|
|
965
|
+
description: data.description,
|
|
966
|
+
author: bcfAuthor,
|
|
967
|
+
topicType: data.topicType,
|
|
968
|
+
topicStatus: 'Open',
|
|
969
|
+
priority: data.priority,
|
|
970
|
+
});
|
|
971
|
+
addTopic(topic);
|
|
972
|
+
setShowCreateForm(false);
|
|
973
|
+
},
|
|
974
|
+
[ensureProject, bcfAuthor, addTopic]
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
// Add comment to topic (optionally associated with a viewpoint)
|
|
978
|
+
const handleAddComment = useCallback(
|
|
979
|
+
(text: string, viewpointGuid?: string) => {
|
|
980
|
+
if (!activeTopicId) return;
|
|
981
|
+
const comment = createBCFComment({
|
|
982
|
+
author: bcfAuthor,
|
|
983
|
+
comment: text,
|
|
984
|
+
viewpointGuid, // Associate with viewpoint if provided
|
|
985
|
+
});
|
|
986
|
+
addComment(activeTopicId, comment);
|
|
987
|
+
},
|
|
988
|
+
[activeTopicId, bcfAuthor, addComment]
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
// Capture viewpoint from current viewer state
|
|
992
|
+
const handleCaptureViewpoint = useCallback(async () => {
|
|
993
|
+
if (!activeTopicId) return;
|
|
994
|
+
|
|
995
|
+
// Create viewpoint from current camera, section plane, and selection state
|
|
996
|
+
const viewpoint = await createViewpointFromState({
|
|
997
|
+
includeSnapshot: true,
|
|
998
|
+
includeSelection: true,
|
|
999
|
+
includeHidden: true,
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
if (viewpoint) {
|
|
1003
|
+
addViewpoint(activeTopicId, viewpoint);
|
|
1004
|
+
} else {
|
|
1005
|
+
console.warn('[BCFPanel] Failed to capture viewpoint - no camera available');
|
|
1006
|
+
}
|
|
1007
|
+
}, [activeTopicId, addViewpoint, createViewpointFromState]);
|
|
1008
|
+
|
|
1009
|
+
// Activate viewpoint - apply camera and state to viewer
|
|
1010
|
+
const handleActivateViewpoint = useCallback((viewpoint: BCFViewpoint) => {
|
|
1011
|
+
applyViewpoint(viewpoint, true); // Animate to viewpoint
|
|
1012
|
+
}, [applyViewpoint]);
|
|
1013
|
+
|
|
1014
|
+
// Delete viewpoint
|
|
1015
|
+
const handleDeleteViewpoint = useCallback(
|
|
1016
|
+
(viewpointGuid: string) => {
|
|
1017
|
+
if (!activeTopicId) return;
|
|
1018
|
+
deleteViewpoint(activeTopicId, viewpointGuid);
|
|
1019
|
+
},
|
|
1020
|
+
[activeTopicId, deleteViewpoint]
|
|
1021
|
+
);
|
|
1022
|
+
|
|
1023
|
+
// Update topic status
|
|
1024
|
+
const handleUpdateStatus = useCallback(
|
|
1025
|
+
(status: string) => {
|
|
1026
|
+
if (!activeTopicId) return;
|
|
1027
|
+
updateTopic(activeTopicId, { topicStatus: status, modifiedAuthor: bcfAuthor });
|
|
1028
|
+
},
|
|
1029
|
+
[activeTopicId, updateTopic, bcfAuthor]
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
// Delete topic
|
|
1033
|
+
const handleDeleteTopic = useCallback(() => {
|
|
1034
|
+
if (!activeTopicId) return;
|
|
1035
|
+
deleteTopic(activeTopicId);
|
|
1036
|
+
setActiveTopic(null);
|
|
1037
|
+
}, [activeTopicId, deleteTopic, setActiveTopic]);
|
|
1038
|
+
|
|
1039
|
+
// Save author
|
|
1040
|
+
const handleSaveAuthor = useCallback(() => {
|
|
1041
|
+
if (tempAuthor.trim()) {
|
|
1042
|
+
setBcfAuthor(tempAuthor.trim());
|
|
1043
|
+
}
|
|
1044
|
+
setShowAuthorDialog(false);
|
|
1045
|
+
}, [tempAuthor, setBcfAuthor]);
|
|
1046
|
+
|
|
1047
|
+
return (
|
|
1048
|
+
<div className="flex flex-col h-full bg-background">
|
|
1049
|
+
{/* Header */}
|
|
1050
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
|
1051
|
+
<div className="flex items-center gap-2">
|
|
1052
|
+
<MessageSquare className="h-4 w-4" />
|
|
1053
|
+
<h2 className="font-medium text-sm">BCF Issues</h2>
|
|
1054
|
+
{topics.length > 0 && (
|
|
1055
|
+
<Badge variant="secondary" className="text-xs">
|
|
1056
|
+
{topics.length}
|
|
1057
|
+
</Badge>
|
|
1058
|
+
)}
|
|
1059
|
+
</div>
|
|
1060
|
+
<div className="flex items-center gap-1">
|
|
1061
|
+
<input
|
|
1062
|
+
ref={fileInputRef}
|
|
1063
|
+
type="file"
|
|
1064
|
+
accept=".bcf,.bcfzip"
|
|
1065
|
+
onChange={handleImport}
|
|
1066
|
+
className="hidden"
|
|
1067
|
+
/>
|
|
1068
|
+
<Button
|
|
1069
|
+
variant="ghost"
|
|
1070
|
+
size="icon"
|
|
1071
|
+
className="h-7 w-7"
|
|
1072
|
+
onClick={() => fileInputRef.current?.click()}
|
|
1073
|
+
title="Import BCF"
|
|
1074
|
+
>
|
|
1075
|
+
<Upload className="h-4 w-4" />
|
|
1076
|
+
</Button>
|
|
1077
|
+
<Button
|
|
1078
|
+
variant="ghost"
|
|
1079
|
+
size="icon"
|
|
1080
|
+
className="h-7 w-7"
|
|
1081
|
+
onClick={handleExport}
|
|
1082
|
+
disabled={!bcfProject || topics.length === 0}
|
|
1083
|
+
title="Export BCF"
|
|
1084
|
+
>
|
|
1085
|
+
<Download className="h-4 w-4" />
|
|
1086
|
+
</Button>
|
|
1087
|
+
<Button
|
|
1088
|
+
variant="ghost"
|
|
1089
|
+
size="icon"
|
|
1090
|
+
className="h-7 w-7"
|
|
1091
|
+
onClick={() => {
|
|
1092
|
+
setTempAuthor(bcfAuthor);
|
|
1093
|
+
setShowAuthorDialog(true);
|
|
1094
|
+
}}
|
|
1095
|
+
title="Set author"
|
|
1096
|
+
>
|
|
1097
|
+
<User className="h-4 w-4" />
|
|
1098
|
+
</Button>
|
|
1099
|
+
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
|
|
1100
|
+
<X className="h-4 w-4" />
|
|
1101
|
+
</Button>
|
|
1102
|
+
</div>
|
|
1103
|
+
</div>
|
|
1104
|
+
|
|
1105
|
+
{/* Content */}
|
|
1106
|
+
<div className="flex-1 overflow-hidden relative">
|
|
1107
|
+
{showCreateForm ? (
|
|
1108
|
+
<CreateTopicForm
|
|
1109
|
+
onSubmit={handleCreateTopic}
|
|
1110
|
+
onCancel={() => setShowCreateForm(false)}
|
|
1111
|
+
author={bcfAuthor}
|
|
1112
|
+
/>
|
|
1113
|
+
) : activeTopic ? (
|
|
1114
|
+
<TopicDetail
|
|
1115
|
+
topic={activeTopic}
|
|
1116
|
+
onBack={() => setActiveTopic(null)}
|
|
1117
|
+
onAddComment={handleAddComment}
|
|
1118
|
+
onAddViewpoint={handleCaptureViewpoint}
|
|
1119
|
+
onActivateViewpoint={handleActivateViewpoint}
|
|
1120
|
+
onDeleteViewpoint={handleDeleteViewpoint}
|
|
1121
|
+
onUpdateStatus={handleUpdateStatus}
|
|
1122
|
+
onDeleteTopic={handleDeleteTopic}
|
|
1123
|
+
selectionCount={selectionCount}
|
|
1124
|
+
hasIsolation={hasIsolation}
|
|
1125
|
+
hasHiddenEntities={hasHiddenEntities}
|
|
1126
|
+
/>
|
|
1127
|
+
) : (
|
|
1128
|
+
<TopicList
|
|
1129
|
+
topics={topics}
|
|
1130
|
+
onSelectTopic={setActiveTopic}
|
|
1131
|
+
onCreateTopic={() => setShowCreateForm(true)}
|
|
1132
|
+
statusFilter={statusFilter}
|
|
1133
|
+
onStatusFilterChange={setStatusFilter}
|
|
1134
|
+
author={bcfAuthor}
|
|
1135
|
+
onSetAuthor={setBcfAuthor}
|
|
1136
|
+
/>
|
|
1137
|
+
)}
|
|
1138
|
+
|
|
1139
|
+
{/* Author Dialog */}
|
|
1140
|
+
{showAuthorDialog && (
|
|
1141
|
+
<div className="absolute inset-0 bg-background/90 flex items-center justify-center p-4">
|
|
1142
|
+
<div className="bg-card border rounded-lg p-4 w-full max-w-xs">
|
|
1143
|
+
<h4 className="font-medium mb-3">Set Author Email</h4>
|
|
1144
|
+
<Input
|
|
1145
|
+
value={tempAuthor}
|
|
1146
|
+
onChange={(e) => setTempAuthor(e.target.value)}
|
|
1147
|
+
placeholder="your@email.com"
|
|
1148
|
+
className="mb-4"
|
|
1149
|
+
/>
|
|
1150
|
+
<div className="flex gap-2 justify-end">
|
|
1151
|
+
<Button variant="outline" size="sm" onClick={() => setShowAuthorDialog(false)}>
|
|
1152
|
+
Cancel
|
|
1153
|
+
</Button>
|
|
1154
|
+
<Button size="sm" onClick={handleSaveAuthor}>
|
|
1155
|
+
Save
|
|
1156
|
+
</Button>
|
|
1157
|
+
</div>
|
|
1158
|
+
</div>
|
|
1159
|
+
</div>
|
|
1160
|
+
)}
|
|
1161
|
+
</div>
|
|
1162
|
+
</div>
|
|
1163
|
+
);
|
|
1164
|
+
}
|