FERS 1.0.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
AssetLibraryView.tsx
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
3
4import DeleteIcon from '@mui/icons-material/Delete';
5import EditIcon from '@mui/icons-material/Edit';
6import FileDownloadIcon from '@mui/icons-material/FileDownload';
7import FileUploadIcon from '@mui/icons-material/FileUpload';
8import LibraryAddIcon from '@mui/icons-material/LibraryAdd';
9import SaveIcon from '@mui/icons-material/Save';
10import {
11 Alert,
12 Box,
13 Button,
14 Checkbox,
15 Chip,
16 Divider,
17 IconButton,
18 Paper,
19 Stack,
20 TextField,
21 Tooltip,
22 Typography,
23} from '@mui/material';
24import { open, save } from '@tauri-apps/plugin-dialog';
25import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
26import { useEffect, useMemo, useState } from 'react';
27import ConfirmDialog from '@/components/ConfirmDialog';
28import {
29 createAssetLibraryFile,
30 parseAssetTemplates,
31 useAssetLibraryStore,
32} from '@/stores/assetLibrary';
33import type { AssetLibraryTemplate } from '@/stores/assetTemplates';
34import { useScenarioStore } from '@/stores/scenarioStore';
35
36const assetKindLabels: Record<AssetLibraryTemplate['kind'], string> = {
37 waveform: 'Waveform',
38 timing: 'Timing',
39 antenna: 'Antenna',
40 platform: 'Platform',
41};
42
43const assetKindOrder: AssetLibraryTemplate['kind'][] = [
44 'waveform',
45 'timing',
46 'antenna',
47 'platform',
48];
49
50function formatTimestamp(timestamp: string): string {
51 const date = new Date(timestamp);
52 if (Number.isNaN(date.getTime())) {
53 return timestamp;
54 }
55
56 return date.toLocaleString();
57}
58
59function describeTemplate(template: AssetLibraryTemplate): string {
60 switch (template.kind) {
61 case 'waveform':
62 return `${template.payload.waveformType}, ${template.payload.carrier_frequency} Hz`;
63 case 'timing':
64 return `${template.payload.frequency} Hz timing`;
65 case 'antenna':
66 return `${template.payload.pattern} antenna`;
67 case 'platform': {
68 const dependencyCount =
69 template.dependencies.waveforms.length +
70 template.dependencies.timings.length +
71 template.dependencies.antennas.length;
72 return `${template.payload.components.length} components, ${dependencyCount} dependencies`;
73 }
74 }
75}
76
77export function AssetLibraryView() {
78 const templates = useAssetLibraryStore((state) => state.templates);
79 const isLoading = useAssetLibraryStore((state) => state.isLoading);
80 const error = useAssetLibraryStore((state) => state.error);
81 const loadCatalog = useAssetLibraryStore((state) => state.loadCatalog);
82 const addTemplates = useAssetLibraryStore((state) => state.addTemplates);
83 const updateTemplateName = useAssetLibraryStore(
84 (state) => state.updateTemplateName
85 );
86 const deleteTemplate = useAssetLibraryStore(
87 (state) => state.deleteTemplate
88 );
89 const insertAssetTemplate = useScenarioStore(
90 (state) => state.insertAssetTemplate
91 );
92 const showSuccess = useScenarioStore((state) => state.showSuccess);
93 const showError = useScenarioStore((state) => state.showError);
94
95 const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
96 const [renamingId, setRenamingId] = useState<string | null>(null);
97 const [renameValue, setRenameValue] = useState('');
98 const [searchQuery, setSearchQuery] = useState('');
99 const [deleteCandidate, setDeleteCandidate] =
100 useState<AssetLibraryTemplate | null>(null);
101
102 useEffect(() => {
103 void loadCatalog();
104 }, [loadCatalog]);
105
106 const selectedTemplates = useMemo(
107 () => templates.filter((template) => selectedIds.has(template.id)),
108 [selectedIds, templates]
109 );
110
111 const filteredTemplates = useMemo(() => {
112 const normalizedQuery = searchQuery.trim().toLocaleLowerCase();
113 if (!normalizedQuery) {
114 return templates;
115 }
116
117 return templates.filter((template) =>
118 template.name.toLocaleLowerCase().includes(normalizedQuery)
119 );
120 }, [searchQuery, templates]);
121
122 const filteredTemplateIds = useMemo(
123 () => filteredTemplates.map((template) => template.id),
124 [filteredTemplates]
125 );
126
127 const allFilteredSelected =
128 filteredTemplateIds.length > 0 &&
129 filteredTemplateIds.every((templateId) => selectedIds.has(templateId));
130 const someFilteredSelected = filteredTemplateIds.some((templateId) =>
131 selectedIds.has(templateId)
132 );
133
134 const templatesByKind = useMemo(() => {
135 return assetKindOrder.map((kind) => ({
136 kind,
137 templates: filteredTemplates.filter(
138 (template) => template.kind === kind
139 ),
140 }));
141 }, [filteredTemplates]);
142
143 const handleToggleSelection = (templateId: string) => {
144 setSelectedIds((current) => {
145 const next = new Set(current);
146 if (next.has(templateId)) {
147 next.delete(templateId);
148 } else {
149 next.add(templateId);
150 }
151 return next;
152 });
153 };
154
155 const handleSelectAllFiltered = () => {
156 setSelectedIds((current) => {
157 const next = new Set(current);
158 filteredTemplateIds.forEach((templateId) => next.add(templateId));
159 return next;
160 });
161 };
162
163 const handleDeselectFiltered = () => {
164 setSelectedIds((current) => {
165 const next = new Set(current);
166 filteredTemplateIds.forEach((templateId) =>
167 next.delete(templateId)
168 );
169 return next;
170 });
171 };
172
173 const handleImport = async () => {
174 try {
175 const selectedPath = await open({
176 title: 'Import Asset Templates',
177 multiple: false,
178 filters: [
179 {
180 name: 'FERS Asset Templates',
181 extensions: ['json'],
182 },
183 ],
184 });
185
186 if (typeof selectedPath !== 'string') {
187 return;
188 }
189
190 const raw = await readTextFile(selectedPath);
191 const importedTemplates = parseAssetTemplates(JSON.parse(raw));
192 const count = await addTemplates(importedTemplates);
193 showSuccess(
194 `Imported ${count} asset template${count === 1 ? '' : 's'}.`
195 );
196 } catch (importError) {
197 const message =
198 importError instanceof Error
199 ? importError.message
200 : String(importError);
201 showError(`Asset import failed: ${message}`);
202 }
203 };
204
205 const handleExportSelected = async () => {
206 if (selectedTemplates.length === 0) {
207 return;
208 }
209
210 try {
211 const filePath = await save({
212 title: 'Export Asset Templates',
213 defaultPath: 'fers-assets.fersasset.json',
214 filters: [
215 {
216 name: 'FERS Asset Templates',
217 extensions: ['json'],
218 },
219 ],
220 });
221
222 if (!filePath) {
223 return;
224 }
225
226 await writeTextFile(
227 filePath,
228 JSON.stringify(
229 createAssetLibraryFile(selectedTemplates),
230 null,
231 2
232 )
233 );
234 showSuccess(
235 `Exported ${selectedTemplates.length} asset template${selectedTemplates.length === 1 ? '' : 's'}.`
236 );
237 } catch (exportError) {
238 const message =
239 exportError instanceof Error
240 ? exportError.message
241 : String(exportError);
242 showError(`Asset export failed: ${message}`);
243 }
244 };
245
246 const handleLoadTemplate = (template: AssetLibraryTemplate) => {
247 const result = insertAssetTemplate(template);
248 showSuccess(
249 `${result.insertedName ?? template.name} loaded into scenario.`
250 );
251 };
252
253 const handleStartRename = (template: AssetLibraryTemplate) => {
254 setRenamingId(template.id);
255 setRenameValue(template.name);
256 };
257
258 const handleSaveRename = async () => {
259 if (!renamingId) {
260 return;
261 }
262
263 try {
264 await updateTemplateName(renamingId, renameValue);
265 setRenamingId(null);
266 setRenameValue('');
267 showSuccess('Asset template renamed.');
268 } catch (renameError) {
269 const message =
270 renameError instanceof Error
271 ? renameError.message
272 : String(renameError);
273 showError(`Rename failed: ${message}`);
274 }
275 };
276
277 const handleConfirmDelete = async () => {
278 if (!deleteCandidate) {
279 return;
280 }
281
282 try {
283 await deleteTemplate(deleteCandidate.id);
284 setSelectedIds((current) => {
285 const next = new Set(current);
286 next.delete(deleteCandidate.id);
287 return next;
288 });
289 showSuccess('Asset template deleted.');
290 } catch (deleteError) {
291 const message =
292 deleteError instanceof Error
293 ? deleteError.message
294 : String(deleteError);
295 showError(`Delete failed: ${message}`);
296 } finally {
297 setDeleteCandidate(null);
298 }
299 };
300
301 return (
302 <Box
303 sx={{
304 height: '100%',
305 width: '100%',
306 overflow: 'auto',
307 p: 3,
308 }}
309 >
310 <Stack
311 direction={{ xs: 'column', sm: 'row' }}
312 spacing={2}
313 sx={{ mb: 3 }}
314 alignItems={{ xs: 'stretch', sm: 'center' }}
315 justifyContent="space-between"
316 >
317 <Box>
318 <Typography variant="h4">Asset Library</Typography>
319 <Typography color="text.secondary">
320 Save reusable waveforms, timings, antennas, and
321 platforms for new scenarios.
322 </Typography>
323 </Box>
324 <Stack direction="row" spacing={1}>
325 <Button
326 variant="outlined"
327 startIcon={<FileUploadIcon />}
328 onClick={handleImport}
329 >
330 Import
331 </Button>
332 <Button
333 variant="contained"
334 startIcon={<FileDownloadIcon />}
335 onClick={handleExportSelected}
336 disabled={selectedTemplates.length === 0}
337 >
338 Export Selected
339 </Button>
340 </Stack>
341 </Stack>
342
343 {error && (
344 <Alert severity="warning" sx={{ mb: 2 }}>
345 {error}
346 </Alert>
347 )}
348
349 <Stack
350 direction={{ xs: 'column', sm: 'row' }}
351 spacing={1}
352 alignItems={{ xs: 'stretch', sm: 'center' }}
353 sx={{ mb: 2 }}
354 >
355 <TextField
356 label="Search by name"
357 value={searchQuery}
358 onChange={(event) => setSearchQuery(event.target.value)}
359 size="small"
360 fullWidth
361 />
362 <Button
363 variant="outlined"
364 onClick={handleSelectAllFiltered}
365 disabled={
366 filteredTemplateIds.length === 0 || allFilteredSelected
367 }
368 sx={{ whiteSpace: 'nowrap' }}
369 >
370 Select All
371 </Button>
372 <Button
373 variant="outlined"
374 onClick={handleDeselectFiltered}
375 disabled={!someFilteredSelected}
376 sx={{ whiteSpace: 'nowrap' }}
377 >
378 Deselect
379 </Button>
380 </Stack>
381
382 {isLoading ? (
383 <Alert severity="info">Loading asset library.</Alert>
384 ) : templates.length === 0 ? (
385 <Alert severity="info">
386 Save assets from the Scenario properties panel to build your
387 library.
388 </Alert>
389 ) : filteredTemplates.length === 0 ? (
390 <Alert severity="info">No saved assets match that name.</Alert>
391 ) : (
392 <Stack spacing={2}>
393 {templatesByKind.map(
394 ({ kind, templates: kindTemplates }) =>
395 kindTemplates.length > 0 ? (
396 <Box key={kind}>
397 <Typography
398 variant="overline"
399 color="text.secondary"
400 >
401 {assetKindLabels[kind]}
402 </Typography>
403 <Stack spacing={1}>
404 {kindTemplates.map((template) => (
405 <Paper
406 key={template.id}
407 variant="outlined"
408 sx={{
409 p: 1,
410 borderRadius: 1,
411 }}
412 >
413 <Stack
414 direction={{
415 xs: 'column',
416 sm: 'row',
417 }}
418 spacing={1}
419 alignItems={{
420 xs: 'stretch',
421 sm: 'center',
422 }}
423 >
424 <Checkbox
425 size="small"
426 checked={selectedIds.has(
427 template.id
428 )}
429 onChange={() =>
430 handleToggleSelection(
431 template.id
432 )
433 }
434 sx={{
435 p: 0.5,
436 alignSelf: {
437 xs: 'flex-start',
438 sm: 'center',
439 },
440 }}
441 />
442 <Box
443 sx={{
444 flex: 1,
445 minWidth: 0,
446 }}
447 >
448 <Stack
449 direction="row"
450 spacing={1}
451 alignItems="center"
452 sx={{ mb: 0.25 }}
453 >
454 <Chip
455 label={
456 assetKindLabels[
457 template
458 .kind
459 ]
460 }
461 size="small"
462 sx={{
463 height: 22,
464 }}
465 />
466 <Typography
467 variant="caption"
468 color="text.secondary"
469 >
470 Updated{' '}
471 {formatTimestamp(
472 template.updatedAt
473 )}
474 </Typography>
475 </Stack>
476 {renamingId ===
477 template.id ? (
478 <Stack
479 direction="row"
480 spacing={0.5}
481 >
482 <TextField
483 size="small"
484 value={
485 renameValue
486 }
487 onChange={(
488 event
489 ) =>
490 setRenameValue(
491 event
492 .target
493 .value
494 )
495 }
496 fullWidth
497 />
498 <IconButton
499 size="small"
500 aria-label="Save asset template name"
501 onClick={() =>
502 void handleSaveRename()
503 }
504 >
505 <SaveIcon />
506 </IconButton>
507 </Stack>
508 ) : (
509 <Typography
510 variant="subtitle1"
511 sx={{
512 overflow:
513 'hidden',
514 textOverflow:
515 'ellipsis',
516 }}
517 >
518 {template.name}
519 </Typography>
520 )}
521 <Typography
522 variant="body2"
523 color="text.secondary"
524 sx={{
525 overflow:
526 'hidden',
527 textOverflow:
528 'ellipsis',
529 }}
530 >
531 {describeTemplate(
532 template
533 )}
534 </Typography>
535 </Box>
536 <Divider
537 flexItem
538 orientation="vertical"
539 sx={{
540 display: {
541 xs: 'none',
542 sm: 'block',
543 },
544 }}
545 />
546 <Stack
547 direction="row"
548 spacing={0.5}
549 justifyContent="flex-end"
550 >
551 <Button
552 size="small"
553 variant="contained"
554 startIcon={
555 <LibraryAddIcon />
556 }
557 onClick={() =>
558 handleLoadTemplate(
559 template
560 )
561 }
562 >
563 Load
564 </Button>
565 <Tooltip title="Rename">
566 <IconButton
567 size="small"
568 onClick={() =>
569 handleStartRename(
570 template
571 )
572 }
573 >
574 <EditIcon />
575 </IconButton>
576 </Tooltip>
577 <Tooltip title="Delete">
578 <IconButton
579 size="small"
580 color="error"
581 onClick={() =>
582 setDeleteCandidate(
583 template
584 )
585 }
586 >
587 <DeleteIcon />
588 </IconButton>
589 </Tooltip>
590 </Stack>
591 </Stack>
592 </Paper>
593 ))}
594 </Stack>
595 </Box>
596 ) : null
597 )}
598 </Stack>
599 )}
600
601 <ConfirmDialog
602 open={deleteCandidate !== null}
603 title="Delete Asset Template?"
604 message={`Delete ${deleteCandidate?.name ?? 'this asset template'} from the library?`}
605 onConfirm={() => void handleConfirmDelete()}
606 onCancel={() => setDeleteCandidate(null)}
607 />
608 </Box>
609 );
610}