1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
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';
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';
29 createAssetLibraryFile,
32} from '@/stores/assetLibrary';
33import type { AssetLibraryTemplate } from '@/stores/assetTemplates';
34import { useScenarioStore } from '@/stores/scenarioStore';
36const assetKindLabels: Record<AssetLibraryTemplate['kind'], string> = {
43const assetKindOrder: AssetLibraryTemplate['kind'][] = [
50function formatTimestamp(timestamp: string): string {
51 const date = new Date(timestamp);
52 if (Number.isNaN(date.getTime())) {
56 return date.toLocaleString();
59function describeTemplate(template: AssetLibraryTemplate): string {
60 switch (template.kind) {
62 return `${template.payload.waveformType}, ${template.payload.carrier_frequency} Hz`;
64 return `${template.payload.frequency} Hz timing`;
66 return `${template.payload.pattern} antenna`;
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`;
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
86 const deleteTemplate = useAssetLibraryStore(
87 (state) => state.deleteTemplate
89 const insertAssetTemplate = useScenarioStore(
90 (state) => state.insertAssetTemplate
92 const showSuccess = useScenarioStore((state) => state.showSuccess);
93 const showError = useScenarioStore((state) => state.showError);
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);
106 const selectedTemplates = useMemo(
107 () => templates.filter((template) => selectedIds.has(template.id)),
108 [selectedIds, templates]
111 const filteredTemplates = useMemo(() => {
112 const normalizedQuery = searchQuery.trim().toLocaleLowerCase();
113 if (!normalizedQuery) {
117 return templates.filter((template) =>
118 template.name.toLocaleLowerCase().includes(normalizedQuery)
120 }, [searchQuery, templates]);
122 const filteredTemplateIds = useMemo(
123 () => filteredTemplates.map((template) => template.id),
127 const allFilteredSelected =
128 filteredTemplateIds.length > 0 &&
129 filteredTemplateIds.every((templateId) => selectedIds.has(templateId));
130 const someFilteredSelected = filteredTemplateIds.some((templateId) =>
131 selectedIds.has(templateId)
134 const templatesByKind = useMemo(() => {
135 return assetKindOrder.map((kind) => ({
137 templates: filteredTemplates.filter(
138 (template) => template.kind === kind
141 }, [filteredTemplates]);
143 const handleToggleSelection = (templateId: string) => {
144 setSelectedIds((current) => {
145 const next = new Set(current);
146 if (next.has(templateId)) {
147 next.delete(templateId);
149 next.add(templateId);
155 const handleSelectAllFiltered = () => {
156 setSelectedIds((current) => {
157 const next = new Set(current);
158 filteredTemplateIds.forEach((templateId) => next.add(templateId));
163 const handleDeselectFiltered = () => {
164 setSelectedIds((current) => {
165 const next = new Set(current);
166 filteredTemplateIds.forEach((templateId) =>
167 next.delete(templateId)
173 const handleImport = async () => {
175 const selectedPath = await open({
176 title: 'Import Asset Templates',
180 name: 'FERS Asset Templates',
181 extensions: ['json'],
186 if (typeof selectedPath !== 'string') {
190 const raw = await readTextFile(selectedPath);
191 const importedTemplates = parseAssetTemplates(JSON.parse(raw));
192 const count = await addTemplates(importedTemplates);
194 `Imported ${count} asset template${count === 1 ? '' : 's'}.`
196 } catch (importError) {
198 importError instanceof Error
199 ? importError.message
200 : String(importError);
201 showError(`Asset import failed: ${message}`);
205 const handleExportSelected = async () => {
206 if (selectedTemplates.length === 0) {
211 const filePath = await save({
212 title: 'Export Asset Templates',
213 defaultPath: 'fers-assets.fersasset.json',
216 name: 'FERS Asset Templates',
217 extensions: ['json'],
229 createAssetLibraryFile(selectedTemplates),
235 `Exported ${selectedTemplates.length} asset template${selectedTemplates.length === 1 ? '' : 's'}.`
237 } catch (exportError) {
239 exportError instanceof Error
240 ? exportError.message
241 : String(exportError);
242 showError(`Asset export failed: ${message}`);
246 const handleLoadTemplate = (template: AssetLibraryTemplate) => {
247 const result = insertAssetTemplate(template);
249 `${result.insertedName ?? template.name} loaded into scenario.`
253 const handleStartRename = (template: AssetLibraryTemplate) => {
254 setRenamingId(template.id);
255 setRenameValue(template.name);
258 const handleSaveRename = async () => {
264 await updateTemplateName(renamingId, renameValue);
267 showSuccess('Asset template renamed.');
268 } catch (renameError) {
270 renameError instanceof Error
271 ? renameError.message
272 : String(renameError);
273 showError(`Rename failed: ${message}`);
277 const handleConfirmDelete = async () => {
278 if (!deleteCandidate) {
283 await deleteTemplate(deleteCandidate.id);
284 setSelectedIds((current) => {
285 const next = new Set(current);
286 next.delete(deleteCandidate.id);
289 showSuccess('Asset template deleted.');
290 } catch (deleteError) {
292 deleteError instanceof Error
293 ? deleteError.message
294 : String(deleteError);
295 showError(`Delete failed: ${message}`);
297 setDeleteCandidate(null);
311 direction={{ xs: 'column', sm: 'row' }}
314 alignItems={{ xs: 'stretch', sm: 'center' }}
315 justifyContent="space-between"
318 <Typography variant="h4">Asset Library</Typography>
319 <Typography color="text.secondary">
320 Save reusable waveforms, timings, antennas, and
321 platforms for new scenarios.
324 <Stack direction="row" spacing={1}>
327 startIcon={<FileUploadIcon />}
328 onClick={handleImport}
334 startIcon={<FileDownloadIcon />}
335 onClick={handleExportSelected}
336 disabled={selectedTemplates.length === 0}
344 <Alert severity="warning" sx={{ mb: 2 }}>
350 direction={{ xs: 'column', sm: 'row' }}
352 alignItems={{ xs: 'stretch', sm: 'center' }}
356 label="Search by name"
358 onChange={(event) => setSearchQuery(event.target.value)}
364 onClick={handleSelectAllFiltered}
366 filteredTemplateIds.length === 0 || allFilteredSelected
368 sx={{ whiteSpace: 'nowrap' }}
374 onClick={handleDeselectFiltered}
375 disabled={!someFilteredSelected}
376 sx={{ whiteSpace: 'nowrap' }}
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
389 ) : filteredTemplates.length === 0 ? (
390 <Alert severity="info">No saved assets match that name.</Alert>
393 {templatesByKind.map(
394 ({ kind, templates: kindTemplates }) =>
395 kindTemplates.length > 0 ? (
399 color="text.secondary"
401 {assetKindLabels[kind]}
404 {kindTemplates.map((template) => (
426 checked={selectedIds.has(
430 handleToggleSelection(
468 color="text.secondary"
500 aria-label="Save asset template name"
502 void handleSaveRename()
523 color="text.secondary"
538 orientation="vertical"
549 justifyContent="flex-end"
565 <Tooltip title="Rename">
577 <Tooltip title="Delete">
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)}