diff --git a/src/App.tsx b/src/App.tsx index 98b87db..75049b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ import { useRXGroupsStore } from './store/rxGroupsStore'; import { useEncryptionKeysStore } from './store/encryptionKeysStore'; import { useRadioStore } from './store/radioStore'; import { useRadioConnection } from './hooks/useRadioConnection'; +import { useAlert } from './hooks/useAlert'; import { importChannelsFromCSV, importContactsFromCSV } from './services/csv'; import type { CodeplugData } from './services/codeplugExport'; import { sampleChannels, sampleContacts, sampleZones } from './utils/sampleData'; @@ -36,8 +37,7 @@ import { useLogStore } from './store/logStore'; function App() { const [activeTab, setActiveTab] = useState('channels'); const [showStartupModal, setShowStartupModal] = useState(true); - const [alertOpen, setAlertOpen] = useState(false); - const [alertMessage, setAlertMessage] = useState(''); + const { alertOpen, alertMessage, alertTitle, showAlert, closeAlert } = useAlert('Import'); const { setChannels, channels } = useChannelsStore(); const { setContacts } = useContactsStore(); const { setZones } = useZonesStore(); @@ -192,11 +192,9 @@ function App() { `• ${codeplugData.rxGroups?.length ?? 0} RX group(s)`, `• ${codeplugData.encryptionKeys?.length ?? 0} encryption key(s)`, ].filter(Boolean); - setAlertMessage(`Successfully imported codeplug!\n\n${lines.join('\n')}`); - setAlertOpen(true); + showAlert(`Successfully imported codeplug!\n\n${lines.join('\n')}`); } catch (error) { - setAlertMessage(`Failed to import codeplug: ${error instanceof Error ? error.message : 'Unknown error'}`); - setAlertOpen(true); + showAlert(`Failed to import codeplug: ${error instanceof Error ? error.message : 'Unknown error'}`); } } else { // Legacy CSV import support @@ -207,26 +205,21 @@ function App() { if (result.success && result.channels) { setChannels(result.channels); setShowStartupModal(false); - setAlertMessage(`Successfully imported ${result.channels.length} channels`); - setAlertOpen(true); + showAlert(`Successfully imported ${result.channels.length} channels`); } else { - setAlertMessage(`Import failed: ${result.errors?.join(', ') || 'Unknown error'}`); - setAlertOpen(true); + showAlert(`Import failed: ${result.errors?.join(', ') || 'Unknown error'}`); } } else if (fileName.includes('contact')) { const result = importContactsFromCSV(text); if (result.success && result.contacts) { setContacts(result.contacts); setShowStartupModal(false); - setAlertMessage(`Successfully imported ${result.contacts.length} contacts`); - setAlertOpen(true); + showAlert(`Successfully imported ${result.contacts.length} contacts`); } else { - setAlertMessage(`Import failed: ${result.errors?.join(', ') || 'Unknown error'}`); - setAlertOpen(true); + showAlert(`Import failed: ${result.errors?.join(', ') || 'Unknown error'}`); } } else { - setAlertMessage('File must be a codeplug (.neonplug) or CSV file containing "channel" or "contact" in the filename'); - setAlertOpen(true); + showAlert('File must be a codeplug (.neonplug) or CSV file containing "channel" or "contact" in the filename'); } } @@ -307,8 +300,8 @@ function App() { /> setAlertOpen(false)} - title="Import" + onClose={closeAlert} + title={alertTitle} message={alertMessage} confirmLabel="OK" variant="alert" diff --git a/src/components/channels/ChannelsTab.tsx b/src/components/channels/ChannelsTab.tsx index 926823b..1b0fb9f 100644 --- a/src/components/channels/ChannelsTab.tsx +++ b/src/components/channels/ChannelsTab.tsx @@ -1,4 +1,5 @@ import React, { useState, useMemo, useCallback, useRef } from 'react'; +import { formatPlural } from '../../utils/formatPlural'; import { useChannelsStore } from '../../store/channelsStore'; import { useRadioSettingsStore } from '../../store/radioSettingsStore'; import { useRadioCapabilities } from '../../hooks/useRadioCapabilities'; @@ -127,7 +128,7 @@ export const ChannelsTab: React.FC = () => {

Channels

- {filteredChannels.length - vfoChannels.length} channel{(filteredChannels.length - vfoChannels.length) !== 1 ? 's' : ''} {vfoChannels.length > 0 && `(${vfoChannels.length} VFO${vfoChannels.length !== 1 ? 's' : ''})`} + {filteredChannels.length - vfoChannels.length} {formatPlural(filteredChannels.length - vfoChannels.length, 'channel')} {vfoChannels.length > 0 && `(${vfoChannels.length} ${formatPlural(vfoChannels.length, 'VFO')})`}
{selectedStates.length > 0 && (

- {selectedStates.length} state{selectedStates.length !== 1 ? 's' : ''} selected + {selectedStates.length} {formatPlural(selectedStates.length, 'state')} selected

)} @@ -670,7 +671,7 @@ export const ContactsTab: React.FC = () => { {selectedCountries.length > 0 && ( - {selectedCountries.length} countr{selectedCountries.length === 1 ? 'y' : 'ies'} selected + {selectedCountries.length} {formatPlural(selectedCountries.length, 'country', 'countries')} selected )} @@ -699,7 +700,7 @@ export const ContactsTab: React.FC = () => {

CSV Contacts

- {contacts.length} / {contactCapacity.toLocaleString()} contact{contacts.length !== 1 ? 's' : ''} + {contacts.length} / {contactCapacity.toLocaleString()} {formatPlural(contacts.length, 'contact')}
diff --git a/src/components/contacts/ContactsTable.tsx b/src/components/contacts/ContactsTable.tsx index b66afb2..12d8f2e 100644 --- a/src/components/contacts/ContactsTable.tsx +++ b/src/components/contacts/ContactsTable.tsx @@ -1,4 +1,5 @@ import React, { useState, useMemo } from 'react'; +import { formatPlural } from '../../utils/formatPlural'; import { useContactsStore } from '../../store/contactsStore'; import { EmptyState } from '../ui/EmptyState'; import { Card } from '../ui/Card'; @@ -75,7 +76,7 @@ export const ContactsTable: React.FC = () => {
{searchQuery && (
- Found {filteredContacts.length.toLocaleString()} contact{filteredContacts.length !== 1 ? 's' : ''} matching "{searchQuery}" + Found {filteredContacts.length.toLocaleString()} {formatPlural(filteredContacts.length, 'contact')} matching "{searchQuery}"
)} @@ -130,7 +131,7 @@ export const ContactsTable: React.FC = () => { {filteredContacts.length > 0 && totalPages > 1 && (
- Showing {currentPage * CONTACTS_PER_PAGE + 1}-{Math.min((currentPage + 1) * CONTACTS_PER_PAGE, filteredContacts.length)} of {filteredContacts.length.toLocaleString()} contact{filteredContacts.length !== 1 ? 's' : ''} + Showing {currentPage * CONTACTS_PER_PAGE + 1}-{Math.min((currentPage + 1) * CONTACTS_PER_PAGE, filteredContacts.length)} of {filteredContacts.length.toLocaleString()} {formatPlural(filteredContacts.length, 'contact')} {searchQuery && ` (${contacts.length.toLocaleString()} total)`}
diff --git a/src/components/diagnostics/DiagnosticsTab.tsx b/src/components/diagnostics/DiagnosticsTab.tsx index ceb651d..d090388 100644 --- a/src/components/diagnostics/DiagnosticsTab.tsx +++ b/src/components/diagnostics/DiagnosticsTab.tsx @@ -1,4 +1,5 @@ import React, { useState, useMemo, useRef, useEffect } from 'react'; +import { useAlert } from '../../hooks/useAlert'; import { useRadioStore } from '../../store/radioStore'; import { useRadioCapabilities } from '../../hooks/useRadioCapabilities'; import { useRadioSettingsStore } from '../../store/radioSettingsStore'; @@ -79,8 +80,7 @@ export const DiagnosticsTab: React.FC = () => { const [txContactLookupChannel, setTxContactLookupChannel] = useState(''); const [showBootImageSection, setShowBootImageSection] = useState(true); const [inspectBootImageOffset, setInspectBootImageOffset] = useState(''); - const [alertOpen, setAlertOpen] = useState(false); - const [alertMessage, setAlertMessage] = useState(''); + const { alertOpen, alertMessage, alertTitle, showAlert, closeAlert } = useAlert(); const { logs, clearLogs, maxLogs, setMaxLogs } = useLogStore(); @@ -355,8 +355,7 @@ export const DiagnosticsTab: React.FC = () => { } if (blocksAdded === 0) { - setAlertMessage('No metadata blocks available to download. Please read from radio first.'); - setAlertOpen(true); + showAlert('No metadata blocks available to download. Please read from radio first.'); return; } @@ -374,15 +373,13 @@ export const DiagnosticsTab: React.FC = () => { URL.revokeObjectURL(url); } catch (error) { console.error('Error creating zip:', error); - setAlertMessage('Failed to create zip file. See console for details.'); - setAlertOpen(true); + showAlert('Failed to create zip file. See console for details.'); } }; const handleExportExpectedWriteHex = () => { if (channels.length === 0) { - setAlertMessage('No channels available to export.'); - setAlertOpen(true); + showAlert('No channels available to export.'); return; } @@ -422,8 +419,7 @@ export const DiagnosticsTab: React.FC = () => { const handleExportExpectedWriteBin = () => { if (channels.length === 0) { - setAlertMessage('No channels available to export.'); - setAlertOpen(true); + showAlert('No channels available to export.'); return; } @@ -452,8 +448,7 @@ export const DiagnosticsTab: React.FC = () => { const handleFullDebugExport = async () => { if (channels.length === 0 && zones.length === 0 && logs.length === 0 && blockMetadata.size === 0 && blockData.size === 0) { - setAlertMessage('No data or logs to export. Please read from radio first.'); - setAlertOpen(true); + showAlert('No data or logs to export. Please read from radio first.'); return; } @@ -570,15 +565,13 @@ export const DiagnosticsTab: React.FC = () => { URL.revokeObjectURL(url); } catch (error) { console.error('Error creating export:', error); - setAlertMessage('Failed to create export. See console for details.'); - setAlertOpen(true); + showAlert('Failed to create export. See console for details.'); } }; const handleWriteBlocksExport = () => { if (writeBlockData.size === 0) { - setAlertMessage('No write blocks available. Please write to radio first.'); - setAlertOpen(true); + showAlert('No write blocks available. Please write to radio first.'); return; } @@ -589,8 +582,7 @@ export const DiagnosticsTab: React.FC = () => { const handleMetadataAnalysisExport = () => { if (blockMetadata.size === 0) { - setAlertMessage('No block metadata available. Please read from radio first.'); - setAlertOpen(true); + showAlert('No block metadata available. Please read from radio first.'); return; } @@ -2195,8 +2187,7 @@ export const DiagnosticsTab: React.FC = () => { URL.revokeObjectURL(url); } catch (error) { console.error('Error generating zip:', error); - setAlertMessage('Failed to generate zip file'); - setAlertOpen(true); + showAlert('Failed to generate zip file'); } }} className="px-3 py-1 text-xs text-yellow-400 hover:text-yellow-300 border border-yellow-600/30 hover:border-yellow-400 rounded transition-colors" @@ -3281,8 +3272,7 @@ export const DiagnosticsTab: React.FC = () => { // Parse CPS CSV format const lines = content.split('\n').filter(line => line.trim()); if (lines.length < 2) { - setAlertMessage('CSV must have at least a header row and one data row'); - setAlertOpen(true); + showAlert('CSV must have at least a header row and one data row'); return; } @@ -3822,8 +3812,8 @@ export const DiagnosticsTab: React.FC = () => {
setAlertOpen(false)} - title="Notice" + onClose={closeAlert} + title={alertTitle} message={alertMessage} confirmLabel="OK" variant="alert" diff --git a/src/components/digital/DigitalTab.tsx b/src/components/digital/DigitalTab.tsx index e962559..2a45c0a 100644 --- a/src/components/digital/DigitalTab.tsx +++ b/src/components/digital/DigitalTab.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; +import { useAlert } from '../../hooks/useAlert'; import { useRadioStore } from '../../store/radioStore'; import { useRadioCapabilities } from '../../hooks/useRadioCapabilities'; import { useEncryptionKeysStore } from '../../store/encryptionKeysStore'; @@ -111,8 +112,7 @@ export const DigitalTab: React.FC = () => { const handleAddContact = () => { if (quickContacts.length >= talkGroupsMax) { - setAlertMessage(`Maximum of ${talkGroupsMax} talk groups allowed.`); - setAlertOpen(true); + showAlert(`Maximum of ${talkGroupsMax} talk groups allowed.`); return; } addContact({ @@ -123,8 +123,7 @@ export const DigitalTab: React.FC = () => { }); }; - const [alertOpen, setAlertOpen] = useState(false); - const [alertMessage, setAlertMessage] = useState(''); + const { alertOpen, alertMessage, alertTitle, showAlert, closeAlert } = useAlert(); const [deleteConfirm, setDeleteConfirm] = useState< { type: 'contact'; index: number } | { type: 'message'; index: number } | { type: 'radioId'; index: number } | null >(null); @@ -135,8 +134,7 @@ export const DigitalTab: React.FC = () => { const handleAddMessage = () => { if (messages.length >= 20) { - setAlertMessage('Maximum of 20 quick messages allowed.'); - setAlertOpen(true); + showAlert('Maximum of 20 quick messages allowed.'); return; } const newIndex = messages.length; @@ -154,8 +152,7 @@ export const DigitalTab: React.FC = () => { const handleAddRadioId = () => { if (radioIds.length >= dmrRadioIdsMax) { - setAlertMessage(`Maximum of ${dmrRadioIdsMax} DMR Radio IDs allowed.`); - setAlertOpen(true); + showAlert(`Maximum of ${dmrRadioIdsMax} DMR Radio IDs allowed.`); return; } const newIndex = radioIds.length; @@ -273,8 +270,7 @@ export const DigitalTab: React.FC = () => { const dmrIdValue = parseInt(e.target.value, 10); if (isNaN(dmrIdValue) || dmrIdValue < 0 || dmrIdValue > 0xFFFFFF) return; if (dmrIdValue > 0 && !isValidDMRId(dmrIdValue)) { - setAlertMessage('DMR ID must be between 1 and 9,999,999 (0 = none).'); - setAlertOpen(true); + showAlert('DMR ID must be between 1 and 9,999,999 (0 = none).'); return; } updateRadioId(radioId.index, { @@ -813,8 +809,8 @@ export const DigitalTab: React.FC = () => { /> setAlertOpen(false)} - title="Notice" + onClose={closeAlert} + title={alertTitle} message={alertMessage} confirmLabel="OK" variant="alert" diff --git a/src/components/import/sources/AirportSource.tsx b/src/components/import/sources/AirportSource.tsx index 61c343b..584ca56 100644 --- a/src/components/import/sources/AirportSource.tsx +++ b/src/components/import/sources/AirportSource.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { formatPlural } from '../../../utils/formatPlural'; import { useImportStores } from '../../../hooks/useImportStores'; import { getNextChannelNumber, selectionCardClass } from '../../../utils/importHelpers'; import { generateAirportChannels } from '../../../services/airportChannels'; @@ -108,7 +109,7 @@ export const AirportSource: React.FC = ({ <>
- Found {airports.length} Airport{airports.length !== 1 ? 's' : ''} + Found {airports.length} {formatPlural(airports.length, 'Airport')}
@@ -195,7 +196,7 @@ export const AirportSource: React.FC = ({ > {isAddingAirports ? 'Adding Airport Channels...' - : `Add ${selectedAirports.size} Airport Channel${selectedAirports.size !== 1 ? 's' : ''}`} + : `Add ${selectedAirports.size} ${formatPlural(selectedAirports.size, 'Airport Channel')}`} )} diff --git a/src/components/import/sources/ChirpSource.tsx b/src/components/import/sources/ChirpSource.tsx index 278247a..e13b3f1 100644 --- a/src/components/import/sources/ChirpSource.tsx +++ b/src/components/import/sources/ChirpSource.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef } from 'react'; +import { formatPlural } from '../../../utils/formatPlural'; import { useChannelsStore } from '../../../store/channelsStore'; import { getNextChannelNumber } from '../../../utils/importHelpers'; import { importChannelsFromChirpCSV, exportChannelsToChirpCSV, downloadCSV } from '../../../services/csv'; @@ -85,7 +86,7 @@ export const ChirpSource: React.FC = ({ onError }) => { setChirpImportResult({ operation: 'export', channels: analogChannels.length, - errors: [`Exported ${analogChannels.length} analog channel${analogChannels.length !== 1 ? 's' : ''}. ${digitalCount} digital channel${digitalCount !== 1 ? 's' : ''} excluded (CHIRP doesn't support digital).`], + errors: [`Exported ${analogChannels.length} ${formatPlural(analogChannels.length, 'analog channel')}. ${digitalCount} ${formatPlural(digitalCount, 'digital channel')} excluded (CHIRP doesn't support digital).`], }); } else { setChirpImportResult({ @@ -169,8 +170,8 @@ export const ChirpSource: React.FC = ({ onError }) => {
{chirpImportResult.operation === 'import' - ? `Imported ${chirpImportResult.channels} channel${chirpImportResult.channels !== 1 ? 's' : ''}` - : `Exported ${chirpImportResult.channels} channel${chirpImportResult.channels !== 1 ? 's' : ''}`} + ? `Imported ${chirpImportResult.channels} ${formatPlural(chirpImportResult.channels, 'channel')}` + : `Exported ${chirpImportResult.channels} ${formatPlural(chirpImportResult.channels, 'channel')}`}
{chirpImportResult.errors && chirpImportResult.errors.length > 0 && (
diff --git a/src/components/import/sources/FixedChannelsSource.tsx b/src/components/import/sources/FixedChannelsSource.tsx index 90df13f..48eff2f 100644 --- a/src/components/import/sources/FixedChannelsSource.tsx +++ b/src/components/import/sources/FixedChannelsSource.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { formatPlural } from '../../../utils/formatPlural'; import { useImportStores } from '../../../hooks/useImportStores'; import { getNextChannelNumber } from '../../../utils/importHelpers'; import { getAvailableFixedChannelSets, getChannelsForSet } from '../../../services/fixedChannels'; @@ -238,7 +239,7 @@ export const FixedChannelsSource: React.FC = ({ > {isAddingFixed ? 'Adding...' - : `Add ${selectedFixedSets.size} Channel Set${selectedFixedSets.size !== 1 ? 's' : ''}`} + : `Add ${selectedFixedSets.size} ${formatPlural(selectedFixedSets.size, 'Channel Set')}`} )} diff --git a/src/components/import/sources/RptrsSource.tsx b/src/components/import/sources/RptrsSource.tsx index e376429..87982d9 100644 --- a/src/components/import/sources/RptrsSource.tsx +++ b/src/components/import/sources/RptrsSource.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { formatPlural } from '../../../utils/formatPlural'; import { useImportStores } from '../../../hooks/useImportStores'; import { getNextChannelNumber, selectionCardClass } from '../../../utils/importHelpers'; import { generateRptrsChannels } from '../../../services/rptrsChannels'; @@ -134,7 +135,7 @@ export const RptrsSource: React.FC = ({ r.city.toLowerCase().includes(filter) || r.state.toLowerCase().includes(filter) || r.ipsc_network.toLowerCase().includes(filter); - }).length} of {rptrs.length} DMR Repeater{rptrs.length !== 1 ? 's' : ''} + }).length} of {rptrs.length} {formatPlural(rptrs.length, 'DMR Repeater')} {rptrsSearchFilter.trim() && ` (filtered)`} @@ -233,7 +234,7 @@ export const RptrsSource: React.FC = ({ > {isAddingRptrs ? 'Adding DMR Repeater Channels...' - : `Add ${selectedRptrs.size} DMR Repeater Channel${selectedRptrs.size !== 1 ? 's' : ''}`} + : `Add ${selectedRptrs.size} ${formatPlural(selectedRptrs.size, 'DMR Repeater Channel')}`}
)} diff --git a/src/components/import/sources/TaflSource.tsx b/src/components/import/sources/TaflSource.tsx index be90fe7..8b20ef9 100644 --- a/src/components/import/sources/TaflSource.tsx +++ b/src/components/import/sources/TaflSource.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { formatPlural } from '../../../utils/formatPlural'; import { useImportStores } from '../../../hooks/useImportStores'; import { getNextChannelNumber } from '../../../utils/importHelpers'; import { generateTaflChannels } from '../../../services/taflChannels'; @@ -318,7 +319,7 @@ export const TaflSource: React.FC = ({ > {isAddingTafl ? 'Adding TAFL Channels...' - : `Add ${selectedTaflEntries.size} TAFL Channel${selectedTaflEntries.size !== 1 ? 's' : ''}`} + : `Add ${selectedTaflEntries.size} ${formatPlural(selectedTaflEntries.size, 'TAFL Channel')}`} )} diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index 8ff41c0..e0dab61 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -20,6 +20,7 @@ import { migrateCodeplug, type MigrationLoss } from '../../services/codeplugMigr import { saveSnapshot, getSnapshots, getSnapshotData, clearSnapshots, type SnapshotEventType } from '../../services/codeplugSnapshots'; // Codeplug export/import are lazy loaded when needed import { useRadioConnection } from '../../hooks/useRadioConnection'; +import { useAlert } from '../../hooks/useAlert'; import { ReadProgressModal } from '../ui/ReadProgressModal'; import { ConfirmModal } from '../ui/ConfirmModal'; import { isWebSerialSupported } from '../../utils/browserSupport'; @@ -49,9 +50,7 @@ export const Toolbar: React.FC = () => { const [lastOperationMode, setLastOperationMode] = useState<'read' | 'write' | null>(null); const [writeWarningOpen, setWriteWarningOpen] = useState(false); const [writeWarningMessage, setWriteWarningMessage] = useState(''); - const [alertOpen, setAlertOpen] = useState(false); - const [alertMessage, setAlertMessage] = useState(''); - const [alertTitle, setAlertTitle] = useState('Notice'); + const { alertOpen, alertMessage, alertTitle, showAlert, closeAlert } = useAlert(); const [convertModalOpen, setConvertModalOpen] = useState(false); const [convertTargetModel, setConvertTargetModel] = useState(() => getMigrationTargetModels()[0] ?? 'DM-32UV'); const [readDropdownOpen, setReadDropdownOpen] = useState(false); @@ -179,10 +178,8 @@ export const Toolbar: React.FC = () => { setSelectedRadioModel(convertTargetModel); setConvertModalOpen(false); const targetLabel = getRadioPickerOptions().find((o) => o.modelId === convertTargetModel)?.label ?? convertTargetModel; - setAlertTitle('Convert'); const lossText = formatMigrationLoss(loss); - setAlertMessage(`Codeplug converted for ${targetLabel}. ${lossText}`); - setAlertOpen(true); + showAlert(`Codeplug converted for ${targetLabel}. ${lossText}`, 'Convert'); }; const handleConvertDownload = async () => { @@ -247,14 +244,10 @@ export const Toolbar: React.FC = () => { `• ${rxCount} RX group(s)`, `• ${encCount} encryption key(s)`, ].filter(Boolean); - setAlertTitle('Import'); - setAlertMessage(`Successfully imported codeplug!\n\n${lines.join('\n')}`); - setAlertOpen(true); + showAlert(`Successfully imported codeplug!\n\n${lines.join('\n')}`, 'Import'); saveSnapshot(codeplugData, { eventType: 'import', fileName: file.name }); } catch (error) { - setAlertTitle('Import'); - setAlertMessage(error instanceof Error ? error.message : 'Failed to import codeplug'); - setAlertOpen(true); + showAlert(error instanceof Error ? error.message : 'Failed to import codeplug', 'Import'); } // Reset file input @@ -369,8 +362,7 @@ export const Toolbar: React.FC = () => { const handleWrite = () => { if (channels.length === 0 && zones.length === 0 && scanLists.length === 0) { - setAlertMessage('No data to write (channels, zones, or scan lists)'); - setAlertOpen(true); + showAlert('No data to write (channels, zones, or scan lists)'); return; } // Run radio-specific validations only when model is known; combine with experimental warning in one modal @@ -442,9 +434,7 @@ export const Toolbar: React.FC = () => { setRXGroups(data.rxGroups ?? []); setEncryptionKeys(data.encryptionKeys ?? []); setSnapshotsModalOpen(false); - setAlertTitle('Restore'); - setAlertMessage(`Restored codeplug: ${data.channels.length} channels, ${data.zones.length} zones`); - setAlertOpen(true); + showAlert(`Restored codeplug: ${data.channels.length} channels, ${data.zones.length} zones`, 'Restore'); }; const handleClearSnapshots = () => { @@ -575,7 +565,7 @@ export const Toolbar: React.FC = () => { /> { setAlertOpen(false); setAlertTitle('Notice'); }} + onClose={closeAlert} title={alertTitle} message={alertMessage} confirmLabel="OK" diff --git a/src/components/rxgroups/RXGroupsList.tsx b/src/components/rxgroups/RXGroupsList.tsx index a36bbf8..c331954 100644 --- a/src/components/rxgroups/RXGroupsList.tsx +++ b/src/components/rxgroups/RXGroupsList.tsx @@ -1,4 +1,6 @@ import React, { useState } from 'react'; +import { useAlert } from '../../hooks/useAlert'; +import { formatPlural } from '../../utils/formatPlural'; import { useRXGroupsStore } from '../../store/rxGroupsStore'; import { useQuickContactsStore } from '../../store/quickContactsStore'; import type { RXGroup } from '../../models/RXGroup'; @@ -13,13 +15,11 @@ export const RXGroupsList: React.FC = () => { const [editingName, setEditingName] = useState(null); const [editingNameValue, setEditingNameValue] = useState(''); const [groupToDelete, setGroupToDelete] = useState<{ index: number; name: string } | null>(null); - const [alertOpen, setAlertOpen] = useState(false); - const [alertMessage, setAlertMessage] = useState(''); + const { alertOpen, alertMessage, alertTitle, showAlert, closeAlert } = useAlert(); const handleAddGroup = () => { if (groups.length >= 32) { - setAlertMessage('Maximum of 32 RX groups allowed.'); - setAlertOpen(true); + showAlert('Maximum of 32 RX groups allowed.'); return; } if (newGroupName.trim()) { @@ -100,7 +100,7 @@ export const RXGroupsList: React.FC = () => { )} - {group.talkGroupIndices.length} talk group{group.talkGroupIndices.length !== 1 ? 's' : ''} + {group.talkGroupIndices.length} {formatPlural(group.talkGroupIndices.length, 'talk group')} {group.talkGroupIndices.length > 0 && ( @@ -179,7 +179,7 @@ export const RXGroupsList: React.FC = () => { )} {selectedGroupData ? ( - { setAlertMessage(msg); setAlertOpen(true); }} /> + ) : ( { /> setAlertOpen(false)} - title="Notice" + onClose={closeAlert} + title={alertTitle} message={alertMessage} confirmLabel="OK" variant="alert" diff --git a/src/components/scanlists/ScanListsList.tsx b/src/components/scanlists/ScanListsList.tsx index 5b1b008..6aac457 100644 --- a/src/components/scanlists/ScanListsList.tsx +++ b/src/components/scanlists/ScanListsList.tsx @@ -1,4 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; +import { useAlert } from '../../hooks/useAlert'; +import { formatPlural } from '../../utils/formatPlural'; import { createPortal } from 'react-dom'; import { useScanListsStore } from '../../store/scanListsStore'; import { useChannelsStore } from '../../store/channelsStore'; @@ -16,15 +18,13 @@ export const ScanListsList: React.FC = () => { const [editingScanList, setEditingScanList] = useState(null); const [editScanListName, setEditScanListName] = useState(''); const [scanListToDelete, setScanListToDelete] = useState(null); - const [alertOpen, setAlertOpen] = useState(false); - const [alertMessage, setAlertMessage] = useState(''); + const { alertOpen, alertMessage, alertTitle, showAlert, closeAlert } = useAlert(); const selectedScanListData = scanLists.find(sl => sl.name === selectedScanList); const handleAddScanList = () => { if (scanLists.length >= 32) { - setAlertMessage('Maximum of 32 scan lists allowed.'); - setAlertOpen(true); + showAlert('Maximum of 32 scan lists allowed.'); return; } if (!newScanListName.trim()) { @@ -32,8 +32,7 @@ export const ScanListsList: React.FC = () => { } // Check if name already exists if (scanLists.some(sl => sl.name === newScanListName.trim())) { - setAlertMessage('A scan list with this name already exists.'); - setAlertOpen(true); + showAlert('A scan list with this name already exists.'); return; } const scanListName = newScanListName.trim().slice(0, 16); @@ -74,8 +73,7 @@ export const ScanListsList: React.FC = () => { setEditingScanList(null); setEditScanListName(''); } else { - setAlertMessage('Invalid scan list name or name already exists. Scan list names must be 1-16 characters and unique.'); - setAlertOpen(true); + showAlert('Invalid scan list name or name already exists. Scan list names must be 1-16 characters and unique.'); } }; @@ -141,7 +139,7 @@ export const ScanListsList: React.FC = () => { <> {scanList.name} - {scanList.channels.length} channel{scanList.channels.length !== 1 ? 's' : ''} + {scanList.channels.length} {formatPlural(scanList.channels.length, 'channel')} )} @@ -186,7 +184,7 @@ export const ScanListsList: React.FC = () => { {selectedScanListData ? ( - { setAlertMessage(msg); setAlertOpen(true); }} /> + ) : ( { /> setAlertOpen(false)} - title="Notice" + onClose={closeAlert} + title={alertTitle} message={alertMessage} confirmLabel="OK" variant="alert" diff --git a/src/components/scanlists/ScanListsTab.tsx b/src/components/scanlists/ScanListsTab.tsx index 5995536..c5f85a8 100644 --- a/src/components/scanlists/ScanListsTab.tsx +++ b/src/components/scanlists/ScanListsTab.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useScanListsStore } from '../../store/scanListsStore'; +import { formatPlural } from '../../utils/formatPlural'; import { ScanListsList } from './ScanListsList'; export const ScanListsTab: React.FC = () => { @@ -10,7 +11,7 @@ export const ScanListsTab: React.FC = () => {

Scan Lists

- {scanLists.length} scan list{scanLists.length !== 1 ? 's' : ''} + {scanLists.length} {formatPlural(scanLists.length, 'scan list')}
diff --git a/src/components/ui/DebugPanel.tsx b/src/components/ui/DebugPanel.tsx index a971390..a82bdab 100644 --- a/src/components/ui/DebugPanel.tsx +++ b/src/components/ui/DebugPanel.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { useAlert } from '../../hooks/useAlert'; import { useChannelsStore } from '../../store/channelsStore'; import { useZonesStore } from '../../store/zonesStore'; import { useRadioStore } from '../../store/radioStore'; @@ -19,8 +20,7 @@ export const DebugPanel: React.FC = () => { const [logs, setLogs] = useState([]); const [maxLogs] = useState(100); const [showProtocolLogs, setShowProtocolLogs] = useState(true); - const [alertOpen, setAlertOpen] = useState(false); - const [alertMessage, setAlertMessage] = useState(''); + const { alertOpen, alertMessage, alertTitle, showAlert, closeAlert } = useAlert(); const logEndRef = useRef(null); const { channels, rawChannelData } = useChannelsStore(); const { zones, rawZoneData } = useZonesStore(); @@ -122,8 +122,7 @@ export const DebugPanel: React.FC = () => { const handleDebugExport = async () => { if (channels.length === 0 && zones.length === 0 && allLogs.length === 0 && blockMetadata.size === 0 && blockData.size === 0) { - setAlertMessage('No data or logs to export. Please read from radio first.'); - setAlertOpen(true); + showAlert('No data or logs to export. Please read from radio first.'); return; } @@ -265,15 +264,13 @@ export const DebugPanel: React.FC = () => { URL.revokeObjectURL(url); } catch (error) { console.error('Error creating export:', error); - setAlertMessage('Failed to create export. See console for details.'); - setAlertOpen(true); + showAlert('Failed to create export. See console for details.'); } }; const handleWriteBlocksExport = () => { if (writeBlockData.size === 0) { - setAlertMessage('No write blocks available. Please write to radio first.'); - setAlertOpen(true); + showAlert('No write blocks available. Please write to radio first.'); return; } @@ -284,8 +281,7 @@ export const DebugPanel: React.FC = () => { const handleMetadataAnalysisExport = () => { if (blockMetadata.size === 0) { - setAlertMessage('No block metadata available. Please read from radio first.'); - setAlertOpen(true); + showAlert('No block metadata available. Please read from radio first.'); return; } @@ -444,8 +440,8 @@ export const DebugPanel: React.FC = () => { setAlertOpen(false)} - title="Notice" + onClose={closeAlert} + title={alertTitle} message={alertMessage} confirmLabel="OK" variant="alert" diff --git a/src/components/zones/ZonesList.tsx b/src/components/zones/ZonesList.tsx index 06f7db0..a5b2d95 100644 --- a/src/components/zones/ZonesList.tsx +++ b/src/components/zones/ZonesList.tsx @@ -1,4 +1,6 @@ import React, { useState } from 'react'; +import { useAlert } from '../../hooks/useAlert'; +import { formatPlural } from '../../utils/formatPlural'; import { useZonesStore } from '../../store/zonesStore'; import { useChannelsStore } from '../../store/channelsStore'; import type { Zone } from '../../models/Zone'; @@ -14,8 +16,7 @@ export const ZonesList: React.FC = () => { const [editingZoneId, setEditingZoneId] = useState(null); const [editZoneName, setEditZoneName] = useState(''); const [zoneToDelete, setZoneToDelete] = useState<{ id: string; name: string } | null>(null); - const [alertOpen, setAlertOpen] = useState(false); - const [alertMessage, setAlertMessage] = useState(''); + const { alertOpen, alertMessage, alertTitle, showAlert, closeAlert } = useAlert(); const handleAddZone = () => { if (newZoneName.trim()) { @@ -48,8 +49,7 @@ export const ZonesList: React.FC = () => { setEditingZoneId(null); setEditZoneName(''); } else { - setAlertMessage('Invalid zone name. Zone names must be 1-10 characters.'); - setAlertOpen(true); + showAlert('Invalid zone name. Zone names must be 1-10 characters.'); } }; @@ -119,7 +119,7 @@ export const ZonesList: React.FC = () => { <> {zone.name} - {zone.channels.length} channel{zone.channels.length !== 1 ? 's' : ''} + {zone.channels.length} {formatPlural(zone.channels.length, 'channel')} )} @@ -165,7 +165,7 @@ export const ZonesList: React.FC = () => { {selectedZoneData ? (
- { setAlertMessage(msg); setAlertOpen(true); }} /> +
) : ( { /> setAlertOpen(false)} - title="Notice" + onClose={closeAlert} + title={alertTitle} message={alertMessage} confirmLabel="OK" variant="alert" diff --git a/src/components/zones/ZonesTab.tsx b/src/components/zones/ZonesTab.tsx index 736d8a6..b899ce5 100644 --- a/src/components/zones/ZonesTab.tsx +++ b/src/components/zones/ZonesTab.tsx @@ -3,6 +3,7 @@ import { useZonesStore } from '../../store/zonesStore'; import { useChannelsStore } from '../../store/channelsStore'; import { useLogStore } from '../../store/logStore'; import { ZonesList } from './ZonesList'; +import { formatPlural } from '../../utils/formatPlural'; export const ZonesTab: React.FC = () => { const { zones, updateZone } = useZonesStore(); @@ -32,7 +33,7 @@ export const ZonesTab: React.FC = () => {

Zones

- {zones.length} zone{zones.length !== 1 ? 's' : ''} + {zones.length} {formatPlural(zones.length, 'zone')}
diff --git a/src/hooks/useAlert.ts b/src/hooks/useAlert.ts new file mode 100644 index 0000000..a2ded9e --- /dev/null +++ b/src/hooks/useAlert.ts @@ -0,0 +1,20 @@ +import { useState, useCallback } from 'react'; + +export function useAlert(defaultTitle = 'Notice') { + const [alertOpen, setAlertOpen] = useState(false); + const [alertMessage, setAlertMessage] = useState(''); + const [alertTitle, setAlertTitle] = useState(defaultTitle); + + const showAlert = useCallback((message: string, title?: string) => { + setAlertMessage(message); + if (title !== undefined) setAlertTitle(title); + setAlertOpen(true); + }, []); + + const closeAlert = useCallback(() => { + setAlertOpen(false); + setAlertTitle(defaultTitle); + }, [defaultTitle]); + + return { alertOpen, alertMessage, alertTitle, showAlert, closeAlert }; +} diff --git a/src/utils/formatPlural.ts b/src/utils/formatPlural.ts new file mode 100644 index 0000000..9859c52 --- /dev/null +++ b/src/utils/formatPlural.ts @@ -0,0 +1,3 @@ +export function formatPlural(count: number, singular: string, plural?: string): string { + return count === 1 ? singular : (plural ?? singular + 's'); +} diff --git a/tests/unit/formatHelpers.test.ts b/tests/unit/formatHelpers.test.ts new file mode 100644 index 0000000..8dd4dc4 --- /dev/null +++ b/tests/unit/formatHelpers.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { formatAddress, formatBytes, clamp } from '../../src/utils/formatHelpers'; + +describe('formatAddress', () => { + it('returns N/A when address is undefined', () => { + expect(formatAddress(undefined)).toBe('N/A'); + expect(formatAddress()).toBe('N/A'); + }); + + it('formats zero as six-digit hex', () => { + expect(formatAddress(0)).toBe('0x000000'); + }); + + it('pads short addresses to six digits', () => { + expect(formatAddress(0x42)).toBe('0x000042'); + expect(formatAddress(0x1234)).toBe('0x001234'); + }); + + it('formats a full six-digit address', () => { + expect(formatAddress(0xABCDEF)).toBe('0xABCDEF'); + }); + + it('uses uppercase hex digits', () => { + expect(formatAddress(0xabcdef)).toBe('0xABCDEF'); + }); + + it('formats max safe 24-bit address', () => { + expect(formatAddress(0xFFFFFF)).toBe('0xFFFFFF'); + }); +}); + +describe('formatBytes', () => { + it('returns "0 B" for zero bytes', () => { + expect(formatBytes(0)).toBe('0 B'); + }); + + it('formats bytes under 1 KB', () => { + expect(formatBytes(1)).toBe('1.0 B'); + expect(formatBytes(512)).toBe('512.0 B'); + expect(formatBytes(1023)).toBe('1023.0 B'); + }); + + it('formats exactly 1 KB', () => { + expect(formatBytes(1024)).toBe('1.0 KB'); + }); + + it('formats kilobytes', () => { + expect(formatBytes(1536)).toBe('1.5 KB'); + expect(formatBytes(2048)).toBe('2.0 KB'); + }); + + it('formats megabytes', () => { + expect(formatBytes(1024 * 1024)).toBe('1.0 MB'); + expect(formatBytes(1024 * 1024 * 1.5)).toBe('1.5 MB'); + }); + + it('formats gigabytes', () => { + expect(formatBytes(1024 * 1024 * 1024)).toBe('1.0 GB'); + }); +}); + +describe('clamp', () => { + it('returns the value when within range', () => { + expect(clamp(5, 0, 10)).toBe(5); + expect(clamp(0, 0, 10)).toBe(0); + expect(clamp(10, 0, 10)).toBe(10); + }); + + it('clamps to min when value is below range', () => { + expect(clamp(-1, 0, 10)).toBe(0); + expect(clamp(-100, 0, 10)).toBe(0); + }); + + it('clamps to max when value is above range', () => { + expect(clamp(11, 0, 10)).toBe(10); + expect(clamp(1000, 0, 10)).toBe(10); + }); + + it('works with negative ranges', () => { + expect(clamp(-5, -10, -1)).toBe(-5); + expect(clamp(0, -10, -1)).toBe(-1); + expect(clamp(-20, -10, -1)).toBe(-10); + }); + + it('works when min equals max', () => { + expect(clamp(5, 3, 3)).toBe(3); + expect(clamp(0, 3, 3)).toBe(3); + }); +}); diff --git a/tests/unit/formatPlural.test.ts b/tests/unit/formatPlural.test.ts new file mode 100644 index 0000000..b2a6461 --- /dev/null +++ b/tests/unit/formatPlural.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { formatPlural } from '../../src/utils/formatPlural'; + +describe('formatPlural', () => { + it('returns singular form when count is 1', () => { + expect(formatPlural(1, 'channel')).toBe('channel'); + }); + + it('returns plural form when count is 0', () => { + expect(formatPlural(0, 'channel')).toBe('channels'); + }); + + it('returns plural form when count is 2', () => { + expect(formatPlural(2, 'channel')).toBe('channels'); + }); + + it('returns plural form for large counts', () => { + expect(formatPlural(100, 'contact')).toBe('contacts'); + }); + + it('appends s for default plural', () => { + expect(formatPlural(3, 'zone')).toBe('zones'); + expect(formatPlural(3, 'scan list')).toBe('scan lists'); + }); + + it('uses custom plural when provided and count is not 1', () => { + expect(formatPlural(2, 'country', 'countries')).toBe('countries'); + expect(formatPlural(0, 'country', 'countries')).toBe('countries'); + }); + + it('returns singular when count is 1 even with custom plural', () => { + expect(formatPlural(1, 'country', 'countries')).toBe('country'); + }); + + it('works with negative counts as non-singular', () => { + expect(formatPlural(-1, 'channel')).toBe('channels'); + }); +});