diff --git a/app/src/components/Apps.js b/app/src/components/Apps.js
index 877cc183..5085c65b 100644
--- a/app/src/components/Apps.js
+++ b/app/src/components/Apps.js
@@ -1,302 +1,20 @@
-import React, {useContext, useState} from 'react';
-import {
- Checkbox,
- Divider,
- Icon,
- Input,
- Label,
- Radio,
- SegmentGroup,
- StatisticLabel,
- StatisticValue,
- TableCell,
- TableRow
-} from "semantic-ui-react";
+import React, {useContext} from 'react';
+import {Input, Label, StatisticLabel, StatisticValue} from "semantic-ui-react";
import {Link, Route, Routes} from "react-router";
-import {
- appendChecksum,
- decryptOTP,
- encryptOTP,
- formatMessage,
- generateHtml,
- OTP_CHARS,
- OTP_CHARS_ALPHA,
- stripChecksum,
- validateCharset
-} from "./otp";
import {
ErrorMessage,
humanFileSize,
humanNumber,
mimetypeColor,
PageContainer,
- SimpleAccordion,
toLocaleString,
useTitle
} from "./Common";
import {ThemeContext} from "../contexts/contexts";
-import {Button, Form, Header, Loader, Segment, Statistic, Table, TextArea} from "./Theme";
-import {useStatistics, useVINDecoder} from "../hooks/customHooks";
+import {Header, Loader, Segment, Statistic} from "./Theme";
+import {useStatistics} from "../hooks/customHooks";
import {CalculatorsPage} from "./Calculators";
-function Encrypt({chars, checksum}) {
- // Encrypt happens live as the user types or pastes. Nothing leaves the browser.
- const [otp, setOtp] = useState('');
- const [plaintext, setPlaintext] = useState('');
-
- const handleInputChange = (event, {name, value}) => {
- if (name === 'otp') {
- setOtp(value);
- } else {
- setPlaintext(value);
- }
- };
-
- let ciphertext = '';
- let error = '';
- if (otp && plaintext && chars) {
- try {
- const raw = encryptOTP(otp, plaintext, chars).ciphertext;
- // When enabled, append a checksum character then re-group the whole message+checksum.
- ciphertext = checksum ? formatMessage(appendChecksum(raw, chars)) : raw;
- } catch (e) {
- error = e.message;
- }
- }
-
- return <>
-
-
-
- Key
-
-
-
- Plaintext
-
-
-
- Ciphertext
- {error
- ? {error}
- : {ciphertext || (chars ? 'Enter your message above' : 'Set valid characters in Advanced options')} }
-
-
- >
-}
-
-function Decrypt({chars, checksum}) {
- // Decrypt happens live as the user types or pastes. Nothing leaves the browser.
- const [otp, setOtp] = useState('');
- const [ciphertext, setCiphertext] = useState('');
-
- const handleInputChange = (event, {name, value}) => {
- if (name === 'otp') {
- setOtp(value);
- } else {
- setCiphertext(value);
- }
- };
-
- let plaintext = '';
- let error = '';
- let checksumValid = null; // null = not applicable (checksum off or nothing to check)
- if (otp && ciphertext && chars) {
- try {
- let input = ciphertext;
- if (checksum) {
- // Only remove the trailing character when it actually verifies as a checksum, so a message without
- // one is never silently truncated.
- const {body, valid} = stripChecksum(ciphertext, chars);
- checksumValid = valid;
- input = body;
- }
- plaintext = decryptOTP(otp, input, chars).plaintext;
- } catch (e) {
- error = e.message;
- }
- }
-
- return <>
-
-
-
- Key
-
-
-
- Ciphertext
-
-
-
- Plaintext
- {checksumValid !== null &&
-
- {checksumValid ? 'Checksum is valid' : 'Checksum is invalid'}
-
}
- {error
- ? {error}
- : {plaintext || (chars ? 'Enter the encrypted message above' : 'Set valid characters in Advanced options')} }
-
-
- >
-}
-
-function OTP() {
- useTitle('One Time Pad');
-
- const {t} = useContext(ThemeContext);
-
- // The character set is a property of the pad: encrypt, decrypt, and generation all use the same alphabet.
- const [charsetKind, setCharsetKind] = useState('alphanumeric'); // 'alpha' | 'alphanumeric' | 'custom'
- const [customChars, setCustomChars] = useState('');
- const charsetError = charsetKind === 'custom' ? validateCharset(customChars) : null;
- const chars = charsetKind === 'alpha' ? OTP_CHARS_ALPHA
- : charsetKind === 'custom' ? customChars
- : OTP_CHARS;
- // A null charset means the custom set is not yet usable; encrypt/decrypt/generate are gated on this.
- const activeChars = charsetError ? null : chars;
-
- // Optional error-detection checksum appended to the ciphertext.
- const [checksum, setChecksum] = useState(false);
-
- let cheatSheetURL = `${process.env.PUBLIC_URL}/one-time-pad-cheat-sheet.pdf`;
-
- // Generate a new One-Time Pad entirely in the browser and open it in a new tab so it can be printed. The pad is
- // never sent to or stored on the server.
- const handleGenerateNewPad = () => {
- const blob = new Blob([generateHtml(activeChars)], {type: 'text/html'});
- const url = URL.createObjectURL(blob);
- window.open(url, '_blank');
- // Release the blob handle once the new tab has had time to load it.
- setTimeout(() => URL.revokeObjectURL(url), 60000);
- };
-
- return <>
-
- One-Time Pads can be used to encrypt your communications. This can be done by hand
- (yes, really) or in this app.
-
These messages are never stored and cannot be retrieved.
- Generate New Pad
- Cheat Sheet PDF
-
-
-
-
- setCharsetKind('alpha')}/>
-
-
- setCharsetKind('alphanumeric')}/>
-
-
- setCharsetKind('custom')}/>
-
-
- {charsetKind === 'custom' && <>
- setCustomChars(value)}
- />
- {charsetError && {charsetError} }
- >}
-
-
- setChecksum(!checksum)}
- />
-
-
-
-
-
-
-
- >
-}
-
-function VINDecoder() {
- useTitle('VIN Number Decoder');
- const {t} = useContext(ThemeContext);
-
- const basicKeys = ['Country', 'Manufacturer', 'Region', 'Years'];
- const detailsKeys = ['Body', 'Engine', 'Model', 'Plant', 'Transmission', 'Serial'];
-
- const {value, setValue, vin} = useVINDecoder();
- console.debug(vin);
-
- let body = Enter a VIN number above
;
- if (value && !vin) {
- body = VIN number is invalid
;
- }
- if (vin && vin['country']) {
- let details = No details
;
- if (vin['body']) {
- details =
- {detailsKeys.map(i =>
- {i}
- {vin[i.toLowerCase()] || '(Unknown)'}
- )}
-
;
- }
- body = <>
-
- {basicKeys.map(i =>
- {i}
- {vin[i.toLowerCase()]}
- )}
-
-
- {details}
- >;
- }
-
- return <>
-
- setValue(e.target.value)}
- />
-
-
- {body}
- >
-}
-
-
function StatisticsPage() {
useTitle('Statistics');
@@ -423,9 +141,7 @@ export function MoreRoute(props) {
return
}/>
- }/>
}/>
- }/>
}
diff --git a/app/src/components/Calculators.js b/app/src/components/Calculators.js
index 3a33e63a..306331b2 100644
--- a/app/src/components/Calculators.js
+++ b/app/src/components/Calculators.js
@@ -1,6 +1,6 @@
import React from "react";
import {Breadcrumbs, useTitle} from "./Common";
-import {Button, Icon} from "./Theme";
+import {Button, Header, Icon} from "./Theme";
import {useCalcQuery} from "../hooks/customHooks";
import {TemperatureCalculator} from "./calculators/TemperatureCalculator";
import {ElectricalCalculators} from "./calculators/ElectricalCalculators";
@@ -11,32 +11,63 @@ import {QRCodeCalculator} from "./calculators/QRCodeCalculator";
import {DriveCalculator} from "./calculators/DriveCalculator";
import {WaterCalculator} from "./calculators/WaterCalculator";
import {FoodStorageCalculator} from "./calculators/FoodStorageCalculator";
+import {OneTimePadCalculator} from "./calculators/OneTimePadCalculator";
+import {VinDecoderCalculator} from "./calculators/VinDecoderCalculator";
+
+// Calculators are shown in these titled groups, in this order. Each calculator carries a
+// `group` matching one of these names; any calculator whose group is missing/unknown falls
+// into a trailing "Other" section so a new calculator can never silently disappear.
+//
+// `color` is a Semantic UI named color (colorblind-friendly palette) used to tint the group's
+// buttons. Violet is intentionally avoided — it is the default navbar color and would blend in.
+export const CALCULATOR_GROUPS = [
+ {name: 'Preparedness & Storage', color: 'red'},
+ {name: 'Radio & Communications', color: 'blue'},
+ {name: 'Engineering', color: 'yellow'},
+ {name: 'Tools & Reference', color: 'grey'},
+];
export const useCalculators = () => {
const [calc, setCalc] = useCalcQuery();
const calculators = [
- {key: 'ratio', icon: 'th large', button: 'Ratio', contents: },
- {key: 'electrical', icon: 'lightning', button: 'Electrical', contents: },
- {key: 'drive', icon: 'cogs', button: 'Drive Ratio', contents: },
- {key: 'water', icon: 'tint', button: 'Water Storage', contents: },
- {key: 'ration', icon: 'food', button: 'Food Storage', contents: },
- {key: 'antenna', icon: 'signal', button: 'Antenna', contents: },
- {key: 'temperature', icon: 'thermometer', button: 'Temperature', contents: },
- {key: 'qrCode', icon: 'qrcode', button: 'QR Code', contents: },
+ {key: 'ratio', icon: 'th large', button: 'Ratio', group: 'Engineering', contents: },
+ {key: 'electrical', icon: 'lightning', button: 'Electrical', group: 'Engineering', contents: },
+ {key: 'drive', icon: 'cogs', button: 'Drive Ratio', group: 'Engineering', contents: },
+ {key: 'water', icon: 'tint', button: 'Water Storage', group: 'Preparedness & Storage', contents: },
+ {key: 'ration', icon: 'food', button: 'Food Storage', group: 'Preparedness & Storage', contents: },
+ {key: 'antenna', icon: 'signal', button: 'Antenna', group: 'Radio & Communications', contents: },
+ {key: 'temperature', icon: 'thermometer', button: 'Temperature', group: 'Tools & Reference', contents: },
+ {key: 'qrCode', icon: 'qrcode', button: 'QR Code', group: 'Tools & Reference', contents: },
+ {key: 'otp', icon: 'lock', button: 'One Time Pad', title: 'One Time Pad', group: 'Radio & Communications', contents: },
+ {key: 'vin', icon: 'car', button: 'VIN Decoder', title: 'VIN Decoder', group: 'Tools & Reference', contents: },
];
const activeCalculator = calc ? calculators.find(i => i.key === calc) : null;
- const calculatorLinks = calculators.map(i => {
- const {button, icon, key} = i;
- return
-
+ const calculatorButton = ({button, icon, key}, color) =>
+
+
{icon && }
{button}
-
- });
+ ;
+
+ // Render each defined group as a titled section, then any leftovers under "Other".
+ const groupNames = CALCULATOR_GROUPS.map(g => g.name);
+ const grouped = CALCULATOR_GROUPS.map(g => [g.name, g.color, calculators.filter(c => c.group === g.name)]);
+ const leftovers = calculators.filter(c => !groupNames.includes(c.group));
+ if (leftovers.length > 0) {
+ grouped.push(['Other', undefined, leftovers]);
+ }
+
+ const calculatorLinks = grouped
+ .filter(([, , items]) => items.length > 0)
+ .map(([name, color, items]) =>
+
+
+ {items.map(c => calculatorButton(c, color))}
+
);
return {calc, calculators, activeCalculator, calculatorLinks}
}
diff --git a/app/src/components/Nav.js b/app/src/components/Nav.js
index fa9d8f86..c4077de0 100644
--- a/app/src/components/Nav.js
+++ b/app/src/components/Nav.js
@@ -56,10 +56,8 @@ const allLinks = [
{text: 'Playlists', to: '/playlists', key: 'playlists'},
{text: 'Zim', to: '/zim', key: 'zim'},
{text: 'Inventory', to: '/inventory', key: 'inventory'},
- {text: 'Flasher', to: '/flasher', key: 'flasher'},
- {to: '/more/otp', text: 'One Time Pad', key: 'otp', end: true},
- {to: '/more/vin', text: 'VIN Decoder', key: 'vin', end: true},
{to: '/more/calculators', text: 'Calculators', key: 'calculators', end: true},
+ {text: 'Flasher', to: '/flasher', key: 'flasher'},
{to: '/more/statistics', text: 'Statistics', key: 'statistics', end: true},
];
diff --git a/app/src/components/Search.js b/app/src/components/Search.js
index 2c5bf5e7..addbc6ac 100644
--- a/app/src/components/Search.js
+++ b/app/src/components/Search.js
@@ -29,9 +29,13 @@ const SUGGESTED_APPS = [
{location: '/inventory', title: 'Inventory', description: 'Track and organize your food storage'},
{location: '/map', title: 'Map', description: 'View your Map'},
{location: '/map/manage', title: 'Manage Map', description: 'Manage your Map'},
- {location: '/more/otp', title: 'One Time Pad', description: 'Encrypt and Decrypt messages'},
+ {location: '/more/calculators?calc=otp', title: 'One Time Pad', description: 'Encrypt and Decrypt messages'},
{location: '/more/statistics', title: 'Statistics', description: 'View the statistics of your WROLPi'},
- {location: '/more/vin', title: 'Vin Decoder', description: 'Decode and analyze vehicle VIN numbers'},
+ {
+ location: '/more/calculators?calc=vin',
+ title: 'Vin Decoder',
+ description: 'Decode and analyze vehicle VIN numbers'
+ },
{location: '/videos', title: 'Videos', description: 'View your Videos'},
{location: '/videos/channel', title: 'Channels', description: 'View the Channels of your Videos'},
{location: '/zim', title: 'Zim', description: 'View your Zims'},
diff --git a/app/src/components/calculators/OneTimePadCalculator.js b/app/src/components/calculators/OneTimePadCalculator.js
new file mode 100644
index 00000000..65dfcc64
--- /dev/null
+++ b/app/src/components/calculators/OneTimePadCalculator.js
@@ -0,0 +1,221 @@
+import React, {useContext, useState} from 'react';
+import {Checkbox, Divider, Icon, Input, Radio, SegmentGroup} from "semantic-ui-react";
+import {
+ appendChecksum,
+ decryptOTP,
+ encryptOTP,
+ formatMessage,
+ generateHtml,
+ OTP_CHARS,
+ OTP_CHARS_ALPHA,
+ stripChecksum,
+ validateCharset
+} from "../otp";
+import {ErrorMessage, SimpleAccordion} from "../Common";
+import {ThemeContext} from "../../contexts/contexts";
+import {Button, Form, Header, Segment, TextArea} from "../Theme";
+
+function Encrypt({chars, checksum}) {
+ // Encrypt happens live as the user types or pastes. Nothing leaves the browser.
+ const [otp, setOtp] = useState('');
+ const [plaintext, setPlaintext] = useState('');
+
+ const handleInputChange = (event, {name, value}) => {
+ if (name === 'otp') {
+ setOtp(value);
+ } else {
+ setPlaintext(value);
+ }
+ };
+
+ let ciphertext = '';
+ let error = '';
+ if (otp && plaintext && chars) {
+ try {
+ const raw = encryptOTP(otp, plaintext, chars).ciphertext;
+ // When enabled, append a checksum character then re-group the whole message+checksum.
+ ciphertext = checksum ? formatMessage(appendChecksum(raw, chars)) : raw;
+ } catch (e) {
+ error = e.message;
+ }
+ }
+
+ return <>
+
+
+
+ Key
+
+
+
+ Plaintext
+
+
+
+ Ciphertext
+ {error
+ ? {error}
+ : {ciphertext || (chars ? 'Enter your message above' : 'Set valid characters in Advanced options')} }
+
+
+ >
+}
+
+function Decrypt({chars, checksum}) {
+ // Decrypt happens live as the user types or pastes. Nothing leaves the browser.
+ const [otp, setOtp] = useState('');
+ const [ciphertext, setCiphertext] = useState('');
+
+ const handleInputChange = (event, {name, value}) => {
+ if (name === 'otp') {
+ setOtp(value);
+ } else {
+ setCiphertext(value);
+ }
+ };
+
+ let plaintext = '';
+ let error = '';
+ let checksumValid = null; // null = not applicable (checksum off or nothing to check)
+ if (otp && ciphertext && chars) {
+ try {
+ let input = ciphertext;
+ if (checksum) {
+ // Only remove the trailing character when it actually verifies as a checksum, so a message without
+ // one is never silently truncated.
+ const {body, valid} = stripChecksum(ciphertext, chars);
+ checksumValid = valid;
+ input = body;
+ }
+ plaintext = decryptOTP(otp, input, chars).plaintext;
+ } catch (e) {
+ error = e.message;
+ }
+ }
+
+ return <>
+
+
+
+ Key
+
+
+
+ Ciphertext
+
+
+
+ Plaintext
+ {checksumValid !== null &&
+
+ {checksumValid ? 'Checksum is valid' : 'Checksum is invalid'}
+
}
+ {error
+ ? {error}
+ : {plaintext || (chars ? 'Enter the encrypted message above' : 'Set valid characters in Advanced options')} }
+
+
+ >
+}
+
+export function OneTimePadCalculator() {
+ const {t} = useContext(ThemeContext);
+
+ // The character set is a property of the pad: encrypt, decrypt, and generation all use the same alphabet.
+ const [charsetKind, setCharsetKind] = useState('alphanumeric'); // 'alpha' | 'alphanumeric' | 'custom'
+ const [customChars, setCustomChars] = useState('');
+ const charsetError = charsetKind === 'custom' ? validateCharset(customChars) : null;
+ const chars = charsetKind === 'alpha' ? OTP_CHARS_ALPHA
+ : charsetKind === 'custom' ? customChars
+ : OTP_CHARS;
+ // A null charset means the custom set is not yet usable; encrypt/decrypt/generate are gated on this.
+ const activeChars = charsetError ? null : chars;
+
+ // Optional error-detection checksum appended to the ciphertext.
+ const [checksum, setChecksum] = useState(false);
+
+ let cheatSheetURL = `${process.env.PUBLIC_URL}/one-time-pad-cheat-sheet.pdf`;
+
+ // Generate a new One-Time Pad entirely in the browser and open it in a new tab so it can be printed. The pad is
+ // never sent to or stored on the server.
+ const handleGenerateNewPad = () => {
+ const blob = new Blob([generateHtml(activeChars)], {type: 'text/html'});
+ const url = URL.createObjectURL(blob);
+ window.open(url, '_blank');
+ // Release the blob handle once the new tab has had time to load it.
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
+ };
+
+ return <>
+
+ One-Time Pads can be used to encrypt your communications. This can be done by hand
+ (yes, really) or in this app.
+ These messages are never stored and cannot be retrieved.
+ Generate New Pad
+ Cheat Sheet PDF
+
+
+
+
+ setCharsetKind('alpha')}/>
+
+
+ setCharsetKind('alphanumeric')}/>
+
+
+ setCharsetKind('custom')}/>
+
+
+ {charsetKind === 'custom' && <>
+ setCustomChars(value)}
+ />
+ {charsetError && {charsetError} }
+ >}
+
+
+ setChecksum(!checksum)}
+ />
+
+
+
+
+
+
+
+ >
+}
diff --git a/app/src/components/calculators/OneTimePadCalculator.test.js b/app/src/components/calculators/OneTimePadCalculator.test.js
new file mode 100644
index 00000000..d0d9ad9f
--- /dev/null
+++ b/app/src/components/calculators/OneTimePadCalculator.test.js
@@ -0,0 +1,28 @@
+import React from "react";
+import {fireEvent, screen} from "@testing-library/react";
+import {renderWithProviders} from "../../test-utils";
+import {OneTimePadCalculator} from "./OneTimePadCalculator";
+
+describe('OneTimePadCalculator', () => {
+ test('renders the pad tools', () => {
+ renderWithProviders( );
+ expect(screen.getByText('One-Time Pad')).toBeTruthy();
+ expect(screen.getByText('Generate New Pad')).toBeTruthy();
+ expect(screen.getByText('Encrypt')).toBeTruthy();
+ expect(screen.getByText('Decrypt')).toBeTruthy();
+ });
+
+ test('encrypts live using the entered key', () => {
+ const {container} = renderWithProviders( );
+ // The Encrypt section's Key and Plaintext textareas are the first two on the page.
+ const keys = container.querySelectorAll('textarea[name="otp"]');
+ const plaintext = container.querySelector('textarea[name="plaintext"]');
+ fireEvent.change(keys[0], {target: {value: 'ABCDE'}});
+ fireEvent.change(plaintext, {target: {value: 'HELLO'}});
+ // Ciphertext is rendered into a and differs from the plaintext once a key is present.
+ const pres = container.querySelectorAll('pre');
+ const ciphertext = pres[0].textContent;
+ expect(ciphertext).toBeTruthy();
+ expect(ciphertext).not.toEqual('HELLO');
+ });
+});
diff --git a/app/src/components/calculators/VinDecoderCalculator.js b/app/src/components/calculators/VinDecoderCalculator.js
new file mode 100644
index 00000000..eeefa6aa
--- /dev/null
+++ b/app/src/components/calculators/VinDecoderCalculator.js
@@ -0,0 +1,53 @@
+import React, {useContext} from 'react';
+import {Input, TableCell, TableRow} from "semantic-ui-react";
+import {ThemeContext} from "../../contexts/contexts";
+import {useVINDecoder} from "../../hooks/customHooks";
+import {Header, Table} from "../Theme";
+
+export function VinDecoderCalculator() {
+ const {t} = useContext(ThemeContext);
+
+ const basicKeys = ['Country', 'Manufacturer', 'Region', 'Years'];
+ const detailsKeys = ['Body', 'Engine', 'Model', 'Plant', 'Transmission', 'Serial'];
+
+ const {value, setValue, vin} = useVINDecoder();
+
+ let body = Enter a VIN number above
;
+ if (value && !vin) {
+ body = VIN number is invalid
;
+ }
+ if (vin && vin['country']) {
+ let details = No details
;
+ if (vin['body']) {
+ details =
+ {detailsKeys.map(i =>
+ {i}
+ {vin[i.toLowerCase()] || '(Unknown)'}
+ )}
+
;
+ }
+ body = <>
+
+ {basicKeys.map(i =>
+ {i}
+ {vin[i.toLowerCase()]}
+ )}
+
+
+ {details}
+ >;
+ }
+
+ return <>
+
+ setValue(e.target.value)}
+ />
+
+
+ {body}
+ >
+}
diff --git a/app/src/components/calculators/VinDecoderCalculator.test.js b/app/src/components/calculators/VinDecoderCalculator.test.js
new file mode 100644
index 00000000..f6f30bf7
--- /dev/null
+++ b/app/src/components/calculators/VinDecoderCalculator.test.js
@@ -0,0 +1,14 @@
+import React from "react";
+import {screen} from "@testing-library/react";
+import {renderWithProviders} from "../../test-utils";
+import {VinDecoderCalculator} from "./VinDecoderCalculator";
+
+describe('VinDecoderCalculator', () => {
+ test('renders the decoder and prompts for input', () => {
+ // An empty VIN never triggers an API call (useVINDecoder returns early), so this
+ // renders without any fetch mocking.
+ renderWithProviders( );
+ expect(screen.getByText('VIN Number Decoder')).toBeTruthy();
+ expect(screen.getByText('Enter a VIN number above')).toBeTruthy();
+ });
+});