- How to get map files
+ How to get map files
Subscribe to a map region to automatically download it. Subscriptions will
periodically check for updated maps. Downloads may take a long time depending on
the region size and your internet speed. You can monitor progress on
@@ -432,8 +424,8 @@ function PinEditRow({pin, onSave, onCancel}) {
const [label, setLabel] = useState(pin.label);
const [color, setColor] = useState(pin.color);
- return
-
+ return
+
{PIN_COLORS.map(c =>
setColor(c)}
style={{
width: 20, height: 20, borderRadius: "50%", background: c, cursor: "pointer",
- border: color === c ? "3px solid #333" : "2px solid #ccc",
+ border: color === c ? "3px solid var(--text)" : "2px solid var(--border)",
}}
/>
)}
-
-
-
+
+ setLabel(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && label.trim() && onSave(pin.id, label.trim(), color)}
/>
-
-
{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}
-
{pin.created}
-
- label.trim() && onSave(pin.id, label.trim(), color)}>Save
- Cancel
-
- ;
+
+
{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}
+
{pin.created}
+
+ label.trim() && onSave(pin.id, label.trim(), color)}>Save
+ Cancel
+
+ ;
}
function MapPins() {
@@ -517,38 +509,38 @@ function MapPins() {
onCancel={() => setEditingId(null)}
/>;
}
- return
-
+ return
+
-
- {pin.label}
-
+
+ {pin.label}
+
{e.preventDefault(); handleNavigate(pin);}}>
{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}
-
- {pin.created}
-
- setEditingId(pin.id)}/>
+
+ {pin.created}
+
+ setEditingId(pin.id)}/>
handleDelete(pin.id)}
/>
-
- ;
+
+ ;
};
// --- Mobile headers and row ---
@@ -559,35 +551,35 @@ function MapPins() {
];
const mobileRowFunc = (pin) => {
- return
-
+ return
+
-
-
+
+
{e.preventDefault(); handleNavigate(pin);}}>
{pin.label}
- {pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}
-
-
+ {pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}
+
+
handleDelete(pin.id)}
/>
-
- ;
+
+ ;
};
- const emptyRow =
- No pins. Right-click on the map to add one.
- ;
+ const emptyRow =
+ No pins. Right-click on the map to add one.
+ ;
const lowerFilter = filter.toLowerCase();
const filteredPins = lowerFilter
@@ -598,8 +590,8 @@ function MapPins() {
: pins;
return
- }
placeholder='Filter by label or coordinates...'
value={filter}
onChange={(e) => setFilter(e.target.value)}
@@ -607,7 +599,6 @@ function MapPins() {
/>
}/>
-}
\ No newline at end of file
+}
diff --git a/app/src/components/MapSearchView.js b/app/src/components/MapSearchView.js
index 0437d3ca..e0ec66b8 100644
--- a/app/src/components/MapSearchView.js
+++ b/app/src/components/MapSearchView.js
@@ -1,11 +1,10 @@
import React, {useEffect, useState} from "react";
import {Link, useNavigate} from "react-router";
-import {CardContent, CardDescription, CardGroup, CardHeader, CardMeta} from "semantic-ui-react";
import {getMapSearchStatus, searchMap} from "../api";
-import {Card, Header, Loader, Segment} from "./Theme";
+import {Card, CardGroup, Header, Loading, Panel} from "./ui";
import {Paginator} from "./Common";
import {useLatestRequest, usePages} from "../hooks/customHooks";
-import {QueryContext, ThemeContext} from "../contexts/contexts";
+import {QueryContext} from "../contexts/contexts";
import {MAP_VIEWER_URI} from "./Vars";
function formatPopulation(pop) {
@@ -17,7 +16,6 @@ function formatPopulation(pop) {
function MapPlaceCard({place, index}) {
const navigate = useNavigate();
- const {s} = React.useContext(ThemeContext);
const [iframeSrc, setIframeSrc] = useState(null);
const zoom = place.min_zoom ? Math.max(8, Math.round(13 - place.min_zoom / 3)) : 11;
@@ -40,27 +38,27 @@ function MapPlaceCard({place, index}) {
const pop = formatPopulation(place.population);
if (pop) subtitle.push(`pop. ${pop}`);
- return
-
- {iframeSrc && }
-
-
- {place.name}
- {subtitle.length > 0 && {subtitle.join(" \u00b7 ")} }
-
- {place.lat.toFixed(4)}, {place.lon.toFixed(4)}
-
-
+ const media =
+ {iframeSrc && }
+
;
+
+ return 0 ? subtitle.join(" · ") : undefined}
+ >
+ {place.lat.toFixed(4)}, {place.lon.toFixed(4)}
;
}
@@ -101,33 +99,33 @@ export function MapSearchView() {
}, [data, setTotal]);
if (hasMaps === false) {
- return
+ return
Subscribe to a map region to search for places.
Go to Map Manager
- ;
+ ;
}
if (loading) {
- return ;
+ return ;
}
if (!searchStr) {
- return Enter a search query to find map locations. ;
+ return Enter a search query to find map locations. ;
}
if (results.length === 0) {
- return No map locations found for "{searchStr}". ;
+ return No map locations found for "{searchStr}". ;
}
return
-
+
{results.map((place, i) =>
)}
-
+
;
}
diff --git a/app/src/components/MapViewer.js b/app/src/components/MapViewer.js
index d805f229..9a4765c3 100644
--- a/app/src/components/MapViewer.js
+++ b/app/src/components/MapViewer.js
@@ -7,8 +7,7 @@ import layers from "protomaps-themes-base";
import mlcontour from "@acalcutt/maplibre-contour-pmtiles";
import {addMapPin, deleteMapPin, getMapFiles, getMapPins, searchMap, setMapDefaultLocation} from "../api";
import {MAP_VIEWER_URI} from "./Vars";
-import {Checkbox, Icon, Segment} from "semantic-ui-react";
-import {Header} from "./Theme";
+import {Button, Checkbox, Header, Icon, Panel, TextInput, Toggle} from "./ui";
import {SettingsContext, ThemeContext} from "../contexts/contexts";
// Terrain DEM file prefix for hillshade and contours.
@@ -256,14 +255,17 @@ function LayerControl({map, scaleUnit, onScaleUnitChange, visibilityRef}) {
});
}, [map, visibilityRef]);
+ // A floating control drawn over the map canvas: it is part of the app's interface,
+ // not map data, so it is never filtered — it stays plain, token-colored chrome in
+ // every theme, same as the nav bar.
const panelStyle = {
position: "absolute",
top: 10,
left: 10,
zIndex: 1000,
- background: "white",
- borderRadius: 8,
- boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
+ background: "var(--panel)",
+ border: "1px solid var(--border)",
+ color: "var(--text)",
maxHeight: "calc(100vh - 120px)",
overflowY: "auto",
minWidth: iconOnly ? 0 : 200,
@@ -279,7 +281,7 @@ function LayerControl({map, scaleUnit, onScaleUnitChange, visibilityRef}) {
: {
padding: "8px 12px", cursor: "pointer", fontWeight: 600,
display: "flex", justifyContent: "space-between", alignItems: "center",
- borderBottom: collapsed ? "none" : "1px solid #eee", userSelect: "none",
+ borderBottom: collapsed ? "none" : "1px solid var(--border)", userSelect: "none",
};
return
@@ -302,16 +304,15 @@ function LayerControl({map, scaleUnit, onScaleUnitChange, visibilityRef}) {
toggleGroup(name, checked)}
+ onChange={(e) => toggleGroup(name, e.currentTarget.checked)}
/>
)}
-
-
+ onScaleUnitChange(checked ? "imperial" : "metric")}
+ onChange={(e) => onScaleUnitChange(e.currentTarget.checked ? "imperial" : "metric")}
/>
}
@@ -324,21 +325,22 @@ function AddPinDialog({lat, lon, onSubmit, onCancel}) {
const [label, setLabel] = useState("");
const [color, setColor] = useState("red");
+ // A floating dialog over the canvas — page chrome, not map data, so it is never
+ // filtered and keeps flat, bordered surfaces like the rest of the app (no shadow).
return
Add Pin
-
{lat.toFixed(4)}, {lon.toFixed(4)}
-
{lat.toFixed(4)}, {lon.toFixed(4)}
+ setLabel(e.target.value)}
autoFocus
onKeyDown={e => e.key === "Enter" && label.trim() && onSubmit(label.trim(), color)}
- style={{width: "100%", padding: "6px 8px", marginBottom: 8, border: "1px solid #ccc", borderRadius: 4}}
+ style={{marginBottom: 8}}
/>
{PIN_COLORS.map(c =>
@@ -347,20 +349,21 @@ function AddPinDialog({lat, lon, onSubmit, onCancel}) {
onClick={() => setColor(c)}
style={{
width: 24, height: 24, borderRadius: "50%", background: c, cursor: "pointer",
- border: color === c ? "3px solid #333" : "2px solid #ccc",
+ border: color === c ? "3px solid var(--text)" : "2px solid var(--border)",
}}
/>
)}
- Cancel
- Cancel
+ label.trim() && onSubmit(label.trim(), color)}
disabled={!label.trim()}
- style={{padding: "4px 12px", cursor: "pointer", background: "#6435c9", color: "white", border: "none", borderRadius: 4}}
>
Add
-
+
;
}
@@ -474,6 +477,8 @@ function MapSearch({map}) {
return parts.join(" \u00b7 ");
};
+ // Floating search chrome over the canvas — page interface, not map data, so it is
+ // never filtered. Flat surfaces: bordered, no box-shadow, token colors only.
return results.length > 0 && setShowResults(true)}
onKeyDown={handleKeyDown}
style={{
- width: "100%", padding: "8px 12px", border: "1px solid #ccc", borderRadius: 4,
- fontSize: 14, boxShadow: "0 2px 6px rgba(0,0,0,0.2)", background: "white",
+ width: "100%", padding: "8px 12px", border: "1px solid var(--border)",
+ fontSize: 14, background: "var(--panel)", color: "var(--text)",
}}
/>
{showResults && results.length > 0 &&
{results.map((r, i) =>
handleSelect(r)}
- onMouseEnter={e => e.target.style.background = "#f0f0f0"}
- onMouseLeave={e => e.target.style.background = "transparent"}
- style={{padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid #eee"}}
+ onMouseEnter={e => e.currentTarget.style.background = "var(--head)"}
+ onMouseLeave={e => e.currentTarget.style.background = "transparent"}
+ style={{padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid var(--border)"}}
>
{r.name}
- {resultSubtext(r) &&
{resultSubtext(r)}
}
+ {resultSubtext(r) &&
{resultSubtext(r)}
}
)}
}
;
@@ -906,12 +910,12 @@ export default function MapViewer() {
}, [addingPin, loadPins]);
if (error) {
- return
+ return
Your browser may not support WebGL. You can access the standalone map viewer
at {MAP_VIEWER_URI}
Error: {error}
- ;
+ ;
}
return
diff --git a/app/src/components/Search.js b/app/src/components/Search.js
index ab3d2de4..7df0e380 100644
--- a/app/src/components/Search.js
+++ b/app/src/components/Search.js
@@ -9,9 +9,8 @@ import {searchEstimateFiles, searchEstimateMap, searchEstimateOthers, searchEsti
import {filterToMimetypes, fuzzyMatch, normalizeEstimate, SearchResultsInput, TabLinks} from "./Common";
import _ from "lodash";
import {TagsContext} from "../Tags";
-import {AccordionContent, AccordionTitle, Grid, GridColumn, GridRow, Header as SHeader, Label} from "semantic-ui-react";
-import {Accordion, Header, Icon, Loader, Modal, Segment} from "./Theme";
-import {QueryContext, ThemeContext} from "../contexts/contexts";
+import {Accordion, Header, Icon, Label, Loading, Panel, Stack} from "./ui";
+import {QueryContext} from "../contexts/contexts";
import {KeyboardShortcutsContext} from "../contexts/KeyboardShortcutsContext";
const SUGGESTED_APPS = [
@@ -460,7 +459,7 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
if (description) {
return <>
-
{title}
+
{description}
>
}
@@ -526,56 +525,38 @@ export function SearchIconButton() {
}
function SearchChannelPreview({channel}) {
- const {t} = React.useContext(ThemeContext);
-
- return
-
-
- {channel.name}
-
-
-
+ return
+
+ {channel.name}
+
+
}
function OtherSearchView({loading}) {
const {searchParams} = React.useContext(QueryContext);
- const [activeIndex, setActiveIndex] = React.useState(0);
+ const [activeValue, setActiveValue] = React.useState('channels');
const activeTags = searchParams.getAll('tag');
const {channels, loading: channelsLoading} = useSearchChannels(activeTags);
- const handleClick = (newIndex) => {
- console.log('newIndex', newIndex, 'activeIndex', activeIndex);
- setActiveIndex(activeIndex === newIndex ? null : newIndex);
- }
-
if (loading || channelsLoading) {
- return
-
-
+ return
}
- const channelsAccordion =
- handleClick(0)}
- >
-
-
- Channels
- {normalizeEstimate(channels.length)}
-
-
-
-
- {!_.isEmpty(channels) ?
- channels.map(i => )
- : 'No Channels'}
-
-
- ;
-
- return
- {channelsAccordion}
+ return
+
+
+
+
+ {normalizeEstimate(channels.length)}
+
+
+
+
+ {!_.isEmpty(channels) ?
+ channels.map(i => )
+ : 'No Channels'}
+
+
+
-}
\ No newline at end of file
+}
diff --git a/app/src/components/SearchFilterModal.test.js b/app/src/components/SearchFilterModal.test.js
index 3ddceac5..f5023bed 100644
--- a/app/src/components/SearchFilterModal.test.js
+++ b/app/src/components/SearchFilterModal.test.js
@@ -64,12 +64,12 @@ describe('SearchFilterButton', () => {
expect(screen.getByText('3')).toBeInTheDocument();
});
- it('opens the modal and only shows the sections enabled by props', () => {
+ it('opens the modal and only shows the sections enabled by props', async () => {
renderButton({sorts: videoOrders});
fireEvent.click(screen.getByText('Filter'));
// Enabled sections.
- expect(screen.getByText('Sort By')).toBeInTheDocument();
+ expect(await screen.findByText('Sort By')).toBeInTheDocument();
expect(screen.getByText('Tags')).toBeInTheDocument();
expect(screen.getByText('Results Per Page')).toBeInTheDocument();
@@ -79,13 +79,13 @@ describe('SearchFilterButton', () => {
expect(screen.queryByText('From Year')).toBeNull();
});
- it('applies changes only on close, not as fields change', () => {
+ it('applies changes only on close, not as fields change', async () => {
const spy = jest.fn();
renderButton({sorts: videoOrders}, {}, spy);
fireEvent.click(screen.getByText('Filter'));
// Changing the sort field updates the draft but must NOT perform the search yet.
- fireEvent.click(screen.getByText('Size'));
+ fireEvent.click(await screen.findByText('Size'));
expect(spy).not.toHaveBeenCalled();
// Closing via Done commits the draft in a single query update.
@@ -94,25 +94,25 @@ describe('SearchFilterButton', () => {
expect(spy.mock.calls[0][0]).toMatchObject({order: '-size'});
});
- it('Clear All resets results-per-page to the default on close', () => {
+ it('Clear All resets results-per-page to the default on close', async () => {
const spy = jest.fn();
renderButton({sorts: videoOrders}, {l: '96'}, spy);
fireEvent.click(screen.getByText('Filter'));
- fireEvent.click(screen.getByText('Clear All'));
+ fireEvent.click(await screen.findByText('Clear All'));
fireEvent.click(screen.getByText('Done'));
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toMatchObject({l: 24});
});
- it('does not update the query when closed without changes', () => {
+ it('does not update the query when closed without changes', async () => {
const spy = jest.fn();
renderButton({sorts: videoOrders}, {}, spy);
fireEvent.click(screen.getByText('Filter'));
- fireEvent.click(screen.getByText('Done'));
+ fireEvent.click(await screen.findByText('Done'));
expect(spy).not.toHaveBeenCalled();
});
- it('counts the censored filter and applies it on close when showCensored is set', () => {
+ it('counts the censored filter and applies it on close when showCensored is set', async () => {
const spy = jest.fn();
renderButton({sorts: videoOrders, showCensored: true}, {}, spy);
@@ -121,7 +121,7 @@ describe('SearchFilterButton', () => {
fireEvent.click(screen.getByText('Filter'));
// The Availability section/toggle is shown.
- expect(screen.getByText('Availability')).toBeInTheDocument();
+ expect(await screen.findByText('Availability')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('toggle'));
// Draft only — nothing applied yet.
expect(spy).not.toHaveBeenCalled();
@@ -131,9 +131,10 @@ describe('SearchFilterButton', () => {
expect(spy.mock.calls[0][0]).toMatchObject({censored: 'true'});
});
- it('does not show the Availability section unless showCensored is set', () => {
+ it('does not show the Availability section unless showCensored is set', async () => {
renderButton({sorts: videoOrders});
fireEvent.click(screen.getByText('Filter'));
+ await screen.findByText('Sort By');
expect(screen.queryByText('Availability')).toBeNull();
});
@@ -142,7 +143,7 @@ describe('SearchFilterButton', () => {
expect(screen.getByText('1')).toBeInTheDocument();
});
- it('counts the deep filter and applies it on close when showDeep is set', () => {
+ it('counts the deep filter and applies it on close when showDeep is set', async () => {
const spy = jest.fn();
renderButton({sorts: videoOrders, showDeep: true}, {}, spy);
@@ -151,7 +152,7 @@ describe('SearchFilterButton', () => {
fireEvent.click(screen.getByText('Filter'));
// The Search Depth section/toggle is shown.
- expect(screen.getByText('Search Depth')).toBeInTheDocument();
+ expect(await screen.findByText('Search Depth')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('toggle'));
// Draft only — nothing applied yet.
expect(spy).not.toHaveBeenCalled();
@@ -161,9 +162,10 @@ describe('SearchFilterButton', () => {
expect(spy.mock.calls[0][0]).toMatchObject({deep: 'true'});
});
- it('does not show the Search Depth section unless showDeep is set', () => {
+ it('does not show the Search Depth section unless showDeep is set', async () => {
renderButton({sorts: videoOrders});
fireEvent.click(screen.getByText('Filter'));
+ await screen.findByText('Sort By');
expect(screen.queryByText('Search Depth')).toBeNull();
});
@@ -172,13 +174,13 @@ describe('SearchFilterButton', () => {
expect(screen.getByText('1')).toBeInTheDocument();
});
- it('shows the File Type section when fileFilterOptions are provided', () => {
+ it('shows the File Type section when fileFilterOptions are provided', async () => {
renderButton({
sorts: videoOrders,
fileFilterOptions: [{value: 'pdf', text: 'PDF'}, {value: 'epub', text: 'EPUB'}],
});
fireEvent.click(screen.getByText('Filter'));
- expect(screen.getByText('File Type')).toBeInTheDocument();
+ expect(await screen.findByText('File Type')).toBeInTheDocument();
expect(screen.getByText('PDF')).toBeInTheDocument();
expect(screen.getByText('EPUB')).toBeInTheDocument();
});
diff --git a/app/src/components/Videos.js b/app/src/components/Videos.js
index b3e434d1..da5b109c 100644
--- a/app/src/components/Videos.js
+++ b/app/src/components/Videos.js
@@ -1,4 +1,4 @@
-import React, {useContext, useEffect, useRef, useState} from 'react';
+import React, {useEffect, useRef, useState} from 'react';
import {Link, Outlet, useParams} from "react-router";
import {useHotkeys} from "react-hotkeys-hook";
import {
@@ -15,7 +15,6 @@ import {
HelpHeader,
InfoHeader,
isoDatetimeToAgoPopup,
- mimetypeColor,
PageContainer,
PreviewLink,
scrollToTop,
@@ -27,28 +26,25 @@ import {
} from "./Common"
import VideoPage from "./VideoPlayer";
import {
- CardContent,
- CardDescription,
- CardHeader,
- Container,
- Form,
- FormDropdown,
- FormInput,
- FormField,
- TextArea,
- Image,
- Label,
- PlaceholderHeader,
- PlaceholderLine,
- StatisticLabel,
- StatisticValue,
- TableCell
-} from "semantic-ui-react";
+ ActionInput,
+ Button,
+ Card,
+ Header,
+ Icon,
+ Loading,
+ Modal,
+ NumberInput,
+ Panel,
+ Placeholder,
+ Statistic,
+ StatisticGroup,
+ Table,
+ Textarea,
+ TextInput,
+ Tooltip,
+} from "./ui";
import {useChannel, useSearchOrder, useSearchVideos, useVideo, useVideoStatistics} from "../hooks/customHooks";
import {DeepSearchHint, FileRowTagIcon, FilesView, SearchControlBar} from "./Files";
-import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
-import Icon from "semantic-ui-react/dist/commonjs/elements/Icon";
-import {Button, Card, Header, Loader, Modal, Placeholder, Popup, Segment, Statistic} from "./Theme";
import {BulkTagModal} from "./BulkTagModal";
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
import {
@@ -63,7 +59,7 @@ import {
updateVideoDownloaderConfig,
uploadCookies,
} from "../api";
-import {QueryContext, ThemeContext} from "../contexts/contexts";
+import {QueryContext} from "../contexts/contexts";
import _ from "lodash";
import {defaultFileOrder, defaultSearchOrder} from "./Vars";
import {InputForm, ToggleForm, useForm} from "../hooks/useForm";
@@ -143,11 +139,7 @@ export function VideosPage() {
>;
} else if (channelId) {
- header =
-
-
-
- ;
+ header = ;
}
const onSelect = (path, checked) => {
@@ -184,26 +176,26 @@ export function VideosPage() {
const selectElm =
setBulkTagOpen(true)}
>Tag
onDelete(false)}
>Delete
invertSelection()}
disabled={_.isEmpty(videos)}
>
Invert
clearSelection()}
disabled={_.isEmpty(videos) || _.isEmpty(selectedVideos)}
>
@@ -284,7 +276,6 @@ function VideoFileNameForm({form}) {
const label =
Common yt-dlp variables:
@@ -313,6 +304,13 @@ function VideoFileNameForm({form}) {
/>
}
+const settingsGridStyle = {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
+ gap: '1em',
+ marginBottom: '1em',
+};
+
export function VideosSettingsPage() {
useTitle('Videos Settings');
@@ -380,170 +378,140 @@ export function VideosSettingsPage() {
/>;
return <>
-
+
-
-
+
+
+
-
+
{fetchingReorgCount
@@ -557,15 +525,16 @@ export function VideosSettingsPage() {
}
setBatchModalOpen(true)}
id='reorganize_all_channels_button'
disabled={fetchingReorgCount || channelsNeedingReorg === 0}
loading={fetchingReorgCount}
>
- Reorganize All Channels
+ Reorganize All Channels
-
+
@@ -644,14 +613,16 @@ function CookiesSettingsSection() {
fetchStatus();
};
+ // Status is encoded by color like any other status label; no filled "success/warning"
+ // words map directly to a token, so the wrapper picks the token color explicitly.
const getStatusLabel = () => {
- if (loading) return Loading... ;
- if (!status.cookies_exist) return No cookies stored ;
- if (status.cookies_unlocked) return Unlocked ;
- return Locked ;
+ if (loading) return Loading... ;
+ if (!status.cookies_exist) return No cookies stored ;
+ if (status.cookies_unlocked) return Unlocked ;
+ return Locked ;
};
- return
+ return
Status: {getStatusLabel()}
-
+
{!status.cookies_exist && (
setUploadModalOpen(true)}
disabled={loading}
>
- Upload Cookies
+ Upload Cookies
)}
{status.cookies_exist && !status.cookies_unlocked && (
setUnlockModalOpen(true)}
disabled={loading}
>
- Unlock Cookies
+ Unlock Cookies
)}
{status.cookies_exist && status.cookies_unlocked && (
- Lock Cookies
+ Lock Cookies
)}
{status.cookies_exist && (
<>
setUploadModalOpen(true)}
disabled={loading}
>
- Replace
+ Replace
- Delete
+ Delete
>
)}
-
+
{/* Upload Modal */}
Upload Cookies
-
-
- Cookies Content
- setCookiesContent(e.target.value)}
- rows={10}
- />
-
- setPassword(e.target.value)}
- error={password.length > 0 && password.length < 8 ? 'Password must be at least 8 characters' : null}
+ e.preventDefault()}>
+ setCookiesContent(e.currentTarget.value)}
+ minRows={10}
/>
-
+
+ setPassword(e.currentTarget.value)}
+ error={password.length > 0 && password.length < 8
+ ? 'Password must be at least 8 characters' : null}
+ />
+
+
Remember this password - you'll need it to unlock cookies after each restart.
-
+
- setUploadModalOpen(false)}>Cancel
+ setUploadModalOpen(false)}>Cancel
- Encrypt & Save
+ Encrypt & Save
@@ -769,7 +747,7 @@ function CookiesSettingsSection() {
onClose={() => setUnlockModalOpen(false)}
onSuccess={fetchStatus}
/>
- ;
+ ;
}
export function VideosStatistics() {
@@ -779,7 +757,7 @@ export function VideosStatistics() {
if (statistics === null) {
// Request is pending.
- return
+ return
} else if (statistics === undefined) {
return Unable to fetch Video Statistics
}
@@ -810,26 +788,23 @@ export function VideosStatistics() {
{key: 'tagged_channels', label: 'Tagged Channels'},
];
- const buildSegment = (title, names, stats) => {
- return
-
-
+ const buildPanel = (title, names, stats) => {
+ return
+
+
{names.map(
({key, label}) =>
-
- {stats[key]}
- {label}
-
+
)}
-
-
+
+
}
return <>
- {buildSegment('Videos', videoNames, videos)}
- {buildSegment('Video Comments', commentsNames, videos)}
- {buildSegment('Historical Video', historicalNames, historical)}
- {buildSegment('Channels', channelNames, channels)}
+ {buildPanel('Videos', videoNames, videos)}
+ {buildPanel('Video Comments', commentsNames, videos)}
+ {buildPanel('Historical Video', historicalNames, historical)}
+ {buildPanel('Channels', channelNames, channels)}
>
}
@@ -849,7 +824,6 @@ export function VideosTabLayout() {
export function VideoCard({file}) {
const {video} = file;
- const {s} = useContext(ThemeContext);
const {sort} = useSearchOrder();
const sortField = sort ? sort.replace(/^-/, '') : null;
@@ -863,53 +837,50 @@ export function VideoCard({file}) {
channel_url = `/videos/channel/${channel.id}/video`;
}
- let poster = ;
+ const media =
+
+
+
;
const title = file.title || file.name || video.stem || video.name;
- let header = {title} ;
+ let titleElm =
+ {title}
+ ;
if (video_url) {
// Link to Channel-Video page or Video page.
- header = {header};
+ titleElm = {titleElm};
} else {
// Video is just a lone video file.
- header =
- {header}
+ titleElm =
+ {titleElm}
;
}
- const color = mimetypeColor(file.mimetype);
- return
- {poster}
-
-
-
-
-
-
-
-
-
- {channel &&
- {channel.name}
- }
- {sortField === 'length'
- ? {secondsToFullDuration(file.length || 0)}
- : sortField === 'size'
- ? {humanFileSize(file.size)}
- : sortField === 'view_count'
- ? {humanNumber(video.view_count || 0)} views
- : sortField === 'viewed'
- ? {isoDatetimeToAgoPopup(file.viewed, false)}
- : sortField === 'download_datetime'
- ? {isoDatetimeToAgoPopup(file.download_datetime, false)}
- : {isoDatetimeToAgoPopup(file.published_datetime, false)}
- }
-
-
-
-
+ let detailLine;
+ if (sortField === 'length') {
+ detailLine = secondsToFullDuration(file.length || 0);
+ } else if (sortField === 'size') {
+ detailLine = humanFileSize(file.size);
+ } else if (sortField === 'view_count') {
+ detailLine = `${humanNumber(video.view_count || 0)} views`;
+ } else if (sortField === 'viewed') {
+ detailLine = isoDatetimeToAgoPopup(file.viewed, false);
+ } else if (sortField === 'download_datetime') {
+ detailLine = isoDatetimeToAgoPopup(file.download_datetime, false);
+ } else {
+ detailLine = isoDatetimeToAgoPopup(file.published_datetime, false);
+ }
+
+ const meta = <>
+ {channel &&
+
+ {channel.name}
+
+
}
+ {detailLine}
+ >;
+
+ return
}
export function VideoRowCells({file}) {
@@ -924,10 +895,7 @@ export function VideoRowCells({file}) {
let poster;
if (poster_url) {
poster =
-
+
} else {
poster = ;
@@ -946,17 +914,17 @@ export function VideoRowCells({file}) {
// Fragment for SelectableRow
return
-
-
+
+
+
+
{textEllipsis(file.title || video.stem || video.video_path)}
-
- {dataCell}
+
+ {dataCell}
}
diff --git a/app/src/components/admin/Settings.js b/app/src/components/admin/Settings.js
index 64478c13..8a8ced15 100644
--- a/app/src/components/admin/Settings.js
+++ b/app/src/components/admin/Settings.js
@@ -2,18 +2,6 @@ import React from "react";
import {SettingsContext, StatusContext} from "../../contexts/contexts";
import {checkUpgrade, postRestart, postShutdown, saveSettings as saveSettingsApi, triggerUpgrade} from "../../api";
import {getServices, startService, stopService} from "../../api/controller";
-import {Button, Divider, Form, Header, Loader, Modal, Segment} from "../Theme";
-import {
- ButtonGroup,
- Container,
- Dimmer,
- Dropdown,
- FormDropdown,
- GridColumn,
- GridRow,
- Icon,
- Input
-} from "semantic-ui-react";
import {
APIButton,
ErrorMessage,
@@ -25,15 +13,26 @@ import {
WROLModeMessage
} from "../Common";
import {useConfigs, useDockerized} from "../../hooks/customHooks";
-import {toast} from "react-semantic-toasts-2";
-import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {ConfigsTable} from "./Configs";
import {semanticUIColorMap} from "../Vars";
import {Link} from "react-router";
-import {ThemePicker} from "../ui";
+import {
+ Button,
+ Divider,
+ Grid,
+ Header,
+ Icon,
+ Loading,
+ Modal,
+ Panel,
+ Select,
+ ThemePicker,
+ toast,
+ TextInput,
+} from "../ui";
function ThemeSegment() {
- return
+ return
Choose how WROLPi looks. The choice is saved in this browser, so each device can
differ.
@@ -45,9 +44,9 @@ function ThemeSegment() {
See every part of the interface in the theme you picked.
- View the components
+ View the components
- ;
+ ;
}
export function ShutdownButton() {
@@ -91,7 +90,7 @@ export function ShutdownButton() {
return
+ return
Upgrades are not available in Docker environments. Please upgrade your Docker images manually.
- ;
+ ;
}
// No update available
if (!status?.update_available) {
- return
+ return
Your WROLPi is up to date.
Version: v{status?.version} on branch {status?.git_branch || 'unknown'}
{checking ? 'Checking...' : 'Check for Upgrades'}
- ;
+ ;
}
// Update available
- return
- ;
+ ;
}
export function RestartButton() {
@@ -226,7 +224,7 @@ export function RestartButton() {
return
-
-
+
+
Enable read-only mode. No content can be deleted or modified.
Enable this when the SHTF and you want to prevent any potential loss of data.
-
+
Note: User settings and tags can still be modified.
-
+
);
}
@@ -494,28 +491,22 @@ export function SettingsPage() {
});
}, [JSON.stringify(settings)]);
- const handleInputChange = async (e, name, value) => {
- if (e) {
- e.preventDefault()
- }
+ const handleInputChange = async (name, value) => {
setState({...state, [name]: value});
}
- const handleTimeoutChange = async (e, name, value) => {
- if (e) {
- e.preventDefault()
- }
+ const handleTimeoutChange = async (name, value) => {
// Restrict timeout to numbers.
value = value.replace(/[^\d]/, '');
setState({...state, [name]: value});
}
const timezoneOptions = Intl.supportedValuesOf('timeZone').map(tz => (
- {key: tz, value: tz, text: tz}
+ {value: tz, label: tz}
));
const navColorOptions = Object.keys(semanticUIColorMap).map(i => {
- return {key: i, value: i, text: i.charAt(0).toUpperCase() + i.slice(1)}
+ return {value: i, label: i.charAt(0).toUpperCase() + i.slice(1)}
});
const mediaDirectoryLabel = `${settings.media_directory}/`;
@@ -526,94 +517,92 @@ export function SettingsPage() {
body = <>
Any changes will be written to {settings.media_directory}/config/wrolpi.yaml .
-
-
-
-
-
- handleInputChange(null, 'throttle_on_startup', checked)}
- />
-
-
-
- handleInputChange(null, 'ignore_outdated_zims', checked)}
- />
-
-
-
- handleInputChange(null, 'check_for_upgrades', checked)}
- />
-
-
-
- handleInputChange(null, 'tags_directory', checked)}
- info='When enabled, WROLPi creates a "tags" directory with hardlinks to all tagged files, organized by tag name.'
- />
-
-
-
- handleInputChange(null, 'save_ffprobe_json', checked)}
- info='When enabled, FFprobe results are saved as .ffprobe.json files alongside videos. This speeds up reindexing but uses additional disk space (~5KB per video).'
- />
-
-
-
-
- Log Level: {logLevelToName(state.log_level)}
-
- setState({...state, log_level: parseInt(e.target.value)})}
- style={{marginBottom: '1em'}}
+ e.preventDefault()}>
+
+
+
+ handleInputChange('throttle_on_startup', checked)}
/>
-
- Critical
- Warning
- Info
- Debug
- Trace
-
+
-
+
+ handleInputChange('ignore_outdated_zims', checked)}
+ />
+
-
- e.preventDefault()}>Navbar Color
- setState({...state, nav_color: value})}
- value={state.nav_color}
- />
-
+
+ handleInputChange('check_for_upgrades', checked)}
+ />
+
-
-
+
+ handleInputChange('tags_directory', checked)}
+ info='When enabled, WROLPi creates a "tags" directory with hardlinks to all tagged files, organized by tag name.'
+ />
+
+
+
+ handleInputChange('save_ffprobe_json', checked)}
+ info='When enabled, FFprobe results are saved as .ffprobe.json files alongside videos. This speeds up reindexing but uses additional disk space (~5KB per video).'
+ />
+
+
+
+
+ Log Level: {logLevelToName(state.log_level)}
+
+ setState({...state, log_level: parseInt(e.target.value)})}
+ style={{marginBottom: '1em'}}
+ />
+
+ Critical
+ Warning
+ Info
+ Debug
+ Trace
+
+
+
+
+
+ e.preventDefault()}>Navbar Color
+ setState({...state, nav_color: value})}
+ allowDeselect={false}
+ style={{width: '10em'}}
+ />
+
+
+
@@ -626,7 +615,7 @@ export function SettingsPage() {
label='Download on Startup'
disabled={disabled || state.download_on_startup === null}
checked={state.download_on_startup === true}
- onChange={checked => handleInputChange(null, 'download_on_startup', checked)}
+ onChange={checked => handleInputChange('download_on_startup', checked)}
/>
@@ -639,7 +628,7 @@ export function SettingsPage() {
>}
disabled={disabled || state.require_cookies_unlocked === null}
checked={state.require_cookies_unlocked === true}
- onChange={checked => handleInputChange(null, 'require_cookies_unlocked', checked)}
+ onChange={checked => handleInputChange('require_cookies_unlocked', checked)}
/>
@@ -652,107 +641,112 @@ export function SettingsPage() {
>}
disabled={disabled || state.require_media_mounted === null}
checked={state.require_media_mounted === true}
- onChange={checked => handleInputChange(null, 'require_media_mounted', checked)}
+ onChange={checked => handleInputChange('require_media_mounted', checked)}
/>
-
-
- Wait Between Downloads
-
- >}
+
+
+ Wait Between Downloads
+
+
+
+ handleTimeoutChange(e, 'download_wait', i.value)}
+ onChange={e => handleTimeoutChange('download_wait', e.currentTarget.value)}
/>
-
-
-
- Download Timeout
-
- >}
+
+
+
+ Download Timeout
+
+
+
+ handleTimeoutChange(e, 'download_timeout', i.value)}
+ onChange={e => handleTimeoutChange('download_timeout', e.currentTarget.value)}
/>
-
+
-
-
- Daily Download Limit (per domain)
-
- >}
+
+
+ Daily Download Limit (per domain)
+
+
+
+ handleTimeoutChange(e, 'download_daily_limit_per_domain', i.value)}
+ onChange={e => handleTimeoutChange('download_daily_limit_per_domain', e.currentTarget.value)}
/>
-
-
-
- Daily Download Limit (total)
-
- >}
+
+
+
+ Daily Download Limit (total)
+
+
+
+ handleTimeoutChange(e, 'download_daily_limit_global', i.value)}
+ onChange={e => handleTimeoutChange('download_daily_limit_global', e.currentTarget.value)}
/>
-
+
-
-
- Download Window Start
-
- >}
+
+
+ Download Window Start
+
+
+
+ handleInputChange(e, 'download_window_start', i.value)}
+ onChange={e => handleInputChange('download_window_start', e.currentTarget.value)}
/>
-
+
-
-
- Download Window End
-
- >}
+
+
+ Download Window End
+
+
+
+ handleInputChange(e, 'download_window_end', i.value)}
+ onChange={e => handleInputChange('download_window_end', e.currentTarget.value)}
/>
-
+
-
+
Timezone
- setState({...state, timezone: value || ''})}
+ onChange={value => setState({...state, timezone: value || ''})}
/>
-
+
{status && status.local_time &&
Current local time: {status.local_time.slice(0, 16).replace('T', ' ')}
}
@@ -762,139 +756,124 @@ export function SettingsPage() {
WROLPi will save files to these directories (any video files will be saved to
the videos by default, etc.).
-
-
-
-
-
- Archive Directory
-
- Variables:
-
- %(domain)s - Domain name (e.g., "example.com")
- %(domain_tag)s - Tag name for the domain (empty if no
- tag)
-
- %(tag)s - Alias for the tag name
- %(name)s - Alias for the domain name
-
- >}
- position='top left'
- />
-
- handleInputChange(e, 'archive_destination', d.value)}
- />
-
-
-
-
-
- Videos Directory
-
- Variables:
-
- %(channel_name)s - Channel name
- %(channel_tag)s - Tag name for the channel (empty if no
- tag)
-
- %(channel_domain)s - Domain from the channel URL
- %(tag)s - Alias for the tag name
- %(name)s - Alias for the channel name
-
- >}
- position='top left'
- />
-
- handleInputChange(e, 'videos_destination', d.value)}
- />
-
-
-
-
-
-
- Map Directory
- handleInputChange(e, 'map_destination', d.value)}
- />
-
-
-
-
- Zims Directory
- handleInputChange(e, 'zims_destination', d.value)}
- />
-
-
-
-
-
-
- Playlists Directory
- handleInputChange(e, 'playlists_destination', d.value)}
- />
-
-
-
-
-
- setEditSpecialDirectories(checked)}
+
+
+
+ Archive Directory
+
+ Variables:
+
+ %(domain)s - Domain name (e.g., "example.com")
+ %(domain_tag)s - Tag name for the domain (empty if no
+ tag)
+
+ %(tag)s - Alias for the tag name
+ %(name)s - Alias for the domain name
+
+ >}
+ position='top'
/>
-
-
+
+ {mediaDirectoryLabel}}
+ value={state.archive_destination}
+ disabled={!editSpecialDirectories}
+ onChange={e => handleInputChange('archive_destination', e.currentTarget.value)}
+ />
+
+
+
+ Videos Directory
+
+ Variables:
+
+ %(channel_name)s - Channel name
+ %(channel_tag)s - Tag name for the channel (empty if no
+ tag)
+
+ %(channel_domain)s - Domain from the channel URL
+ %(tag)s - Alias for the tag name
+ %(name)s - Alias for the channel name
+
+ >}
+ position='top'
+ />
+
+ {mediaDirectoryLabel}}
+ value={state.videos_destination}
+ disabled={!editSpecialDirectories}
+ onChange={e => handleInputChange('videos_destination', e.currentTarget.value)}
+ />
+
+
+ Map Directory
+ {mediaDirectoryLabel}}
+ value={state.map_destination}
+ disabled={!editSpecialDirectories}
+ onChange={e => handleInputChange('map_destination', e.currentTarget.value)}
+ />
+
+
+ Zims Directory
+ {mediaDirectoryLabel}}
+ value={state.zims_destination}
+ disabled={!editSpecialDirectories}
+ onChange={e => handleInputChange('zims_destination', e.currentTarget.value)}
+ />
+
+
+ Playlists Directory
+ {mediaDirectoryLabel}}
+ value={state.playlists_destination}
+ disabled={!editSpecialDirectories}
+ onChange={e => handleInputChange('playlists_destination', e.currentTarget.value)}
+ />
+
+
+ setEditSpecialDirectories(checked)}
+ />
+
localSaveSettings(state)}
obeyWROLMode={true}
disabled={disabled}
>Save
-
-
-
+ {pendingSave && Saving settings... }
-
+
>
} else if (ready === false) {
- body =
+ body =
} else {
body = Unable to fetch settings
}
- const configsSegment =
+ const configsSegment =
Install the WROLPi Root CA certificate on your devices to trust all HTTPS connections to this WROLPi.
You only need to do this once per device.
- Download Root CA Certificate
+ Download Root CA Certificate
- ;
+ ;
- return
+ return
-
+
{body}
-
+
@@ -942,10 +921,10 @@ export function SettingsPage() {
-
+
Show All Hints
-
+
{/* Trace Level Modal - shown when user selects trace level in native mode */}
-
+
The API will restart. This page may temporarily lose connection.
>
@@ -979,7 +958,7 @@ export function SettingsPage() {
This will save your settings and restart the API service. The page may temporarily
lose connection during the switch.
-
+
You can switch back to production mode later from the Controller page.
>
@@ -987,21 +966,21 @@ export function SettingsPage() {
Cancel
- Enable Debug Mode
+ Enable Debug Mode
- ;
+
;
}
-
diff --git a/app/src/components/admin/Status.js b/app/src/components/admin/Status.js
index 003235c1..a1f99785 100644
--- a/app/src/components/admin/Status.js
+++ b/app/src/components/admin/Status.js
@@ -1,4 +1,13 @@
-import {Accordion, Header, Progress, Segment, Statistic, Table} from "../Theme";
+import {
+ Accordion,
+ Grid,
+ Header,
+ Panel,
+ Progress,
+ Statistic,
+ StatisticGroup,
+ Table,
+} from "../ui";
import React, {useContext} from "react";
import {
Duration,
@@ -11,20 +20,8 @@ import {
useTitle
} from "../Common";
import {ProgressPlaceholder} from "../Placeholder";
-import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
-import {Media, SettingsContext, StatusContext, ThemeContext} from "../../contexts/contexts";
-import {
- AccordionContent,
- AccordionTitle,
- SegmentGroup,
- TableBody,
- TableCell,
- TableHeader,
- TableHeaderCell,
- TableRow
-} from "semantic-ui-react";
+import {Media, SettingsContext, StatusContext} from "../../contexts/contexts";
import _ from "lodash";
-import Icon from "semantic-ui-react/dist/commonjs/elements/Icon";
function DriveInfo({used, size, percent, mount}) {
let color;
@@ -33,11 +30,11 @@ function DriveInfo({used, size, percent, mount}) {
} else if (percent >= 80) {
color = 'orange';
}
- return
}
@@ -45,7 +42,7 @@ function DiskBandwidthProgress({bytes_ps, total, label, ...props}) {
// Calculate percent so colors can be shown.
let percent = (bytes_ps / total) * 100;
percent = percent || 0;
- let color = null;
+ let color = undefined;
if (percent >= 90) {
color = 'red';
} else if (percent >= 80) {
@@ -62,35 +59,30 @@ function DiskBandwidth({name, bytes_read_ps, bytes_write_ps, max_read_ps, max_wr
bytes_ps={bytes_read_ps}
total={max_read_ps}
label={`${name} read`}
- size='tiny'
- disabled={bytes_read_ps === 0}
/>;
const write = ;
- return
-
- {read}
- {write}
-
+ return
+ {read}
+ {write}
}
function CPUTemperatureStatistic({temperature, high_temperature, critical_temperature, ...props}) {
if (!temperature) {
- return
+ return
}
+ let color;
if ((critical_temperature && temperature >= critical_temperature) || (!critical_temperature && temperature >= 75)) {
- props['color'] = 'red';
+ color = 'red';
} else if ((high_temperature && temperature >= high_temperature) || (!high_temperature && temperature >= 55)) {
- props['color'] = 'orange';
+ color = 'orange';
}
- return
+ return
}
function FanRPMStatistic({fan_rpm, ...props}) {
@@ -103,14 +95,15 @@ function FanRPMStatistic({fan_rpm, ...props}) {
function IOWaitStatistic({percent_iowait, ...props}) {
if (percent_iowait === null || percent_iowait === undefined) {
- return
+ return
}
+ let color;
if (percent_iowait >= 30) {
- props['color'] = 'red';
+ color = 'red';
} else if (percent_iowait >= 10) {
- props['color'] = 'orange';
+ color = 'orange';
}
- return
+ return
}
function formatUptime(seconds) {
@@ -140,13 +133,13 @@ export function BandwidthProgress({label = '', bytes, maxBytes, ...props}) {
label = `${label} (${humanBandwidth(bytes)})`;
const percent = (bytes / maxBytes) * 100;
+ let color;
if (percent > 70) {
- props['color'] = 'yellow';
+ color = 'yellow';
} else if (percent > 90) {
- props['color'] = 'red';
+ color = 'red';
}
- const disabled = percent === 0;
- return
+ return
}
export function BandwidthProgressGroup({bandwidth, ...props}) {
@@ -157,7 +150,6 @@ export function BandwidthProgressGroup({bandwidth, ...props}) {
bytes={bandwidth['bytes_recv_ps']}
label={`${bandwidth['name']} In`}
maxBytes={maxBytes}
- size='small'
{...props}
/>;
@@ -165,19 +157,12 @@ export function BandwidthProgressGroup({bandwidth, ...props}) {
bytes={bandwidth['bytes_sent_ps']}
label={`${bandwidth['name']} Out`}
maxBytes={maxBytes}
- size='small'
{...props}
/>;
- return
-
-
- {recv}
-
-
- {sent}
-
-
+ return
+ {recv}
+ {sent}
}
@@ -189,10 +174,10 @@ export function BandwidthProgressCombined({bandwidth, ...props}) {
export function CPUUsageProgress({percent, label}) {
if (percent === null) {
- return
+ return
}
- let color = 'black';
+ let color = undefined;
if (percent >= 90) {
color = 'red';
} else if (percent >= 70) {
@@ -200,44 +185,37 @@ export function CPUUsageProgress({percent, label}) {
} else if (percent >= 50) {
color = 'orange';
}
- return
+ return
}
export function MemoryUsageProgress({percent, label}) {
if (percent === null) {
- return
+ return
}
- let color = 'black';
- let size = 'small';
+ let color = undefined;
if (percent >= 90) {
color = 'red';
- size = 'large';
} else if (percent >= 70) {
color = 'orange';
- size = 'large';
}
- return
+ return
}
function ProcessInfoRow({pid, command, percent_cpu, percent_mem}) {
- const color = percent_cpu >= 80 ? 'orange' : null;
- return
- {command}
- {percent_cpu}
- {percent_mem}
- {pid}
-
+ return = 80}>
+ {command}
+ {percent_cpu}
+ {percent_mem}
+ {pid}
+
}
export function StatusPage() {
useTitle('Status');
- const [activeIndex, setActiveIndex] = React.useState(null);
-
const {status} = useContext(StatusContext);
const {settings} = useContext(SettingsContext);
- const {s} = useContext(ThemeContext);
let percent;
let cores;
@@ -299,23 +277,19 @@ export function StatusPage() {
const cpuProgress = ;
const memoryUsageProgress = ;
- const handleAccordionClick = (e, {index}) => {
- setActiveIndex(activeIndex === index ? -1 : index);
- }
+ const noProcessesRow =
+ No top processes
+
- const noProcessesRow =
- No top processes
-
-
- return
+ return <>
-
+
{cpuProgress}
{memoryUsageProgress}
-
-
-
-
+
+
+
+
-
-
+
+
-
+
{cpuProgress}
{memoryUsageProgress}
-
+
@@ -341,16 +315,15 @@ export function StatusPage() {
temperature={temperature}
high_temperature={high_temperature}
critical_temperature={critical_temperature}
- style={{marginRight: 0}}
/>
-
-
+
+
-
+
)
: }
-
+
-
+
Network Bandwidth
{!_.isEmpty(nicBandwidthStats) ?
Object.entries(nicBandwidthStats).map(([name, stats]) => )
: }
-
+
-
+
Top Processes
-
-
-
- Command
- CPU %
- Mem %
- PID
-
-
-
+
+
+
+ Command
+ CPU %
+ Mem %
+ PID
+
+
+
{processesStats && processesStats.length === 0 ?
noProcessesRow :
processesStats && processesStats.length > 0 ?
processesStats.map(i => )
- :
+ :
}
-
+
-
+
-
+
Drive Usage
{drivesStats && drivesStats.length > 0 ? drivesStats.map((drive) => )
: }
-
+
-
+
Developer
-
-
- Status Details
-
-
-
- {JSON.stringify(status, undefined, 1)}
-
-
+
+ Status Details
+
+
+ {JSON.stringify(status, undefined, 1)}
+
+
+
-
-
- Settings Details
-
-
-
- {JSON.stringify(settings, undefined, 1)}
-
-
+
+ Settings Details
+
+
+ {JSON.stringify(settings, undefined, 1)}
+
+
+
-
-
+
+ >
}
diff --git a/app/src/components/calculators/DriveCalculator.js b/app/src/components/calculators/DriveCalculator.js
index 6094deb3..ea9fcc29 100644
--- a/app/src/components/calculators/DriveCalculator.js
+++ b/app/src/components/calculators/DriveCalculator.js
@@ -1,10 +1,9 @@
import React from "react";
-import {Form, Header, Segment, Table} from "../Theme";
-import {Button, GridColumn, GridRow, Input, Label, TableBody, TableCell, TableRow} from "semantic-ui-react";
-import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
+import {Button, ButtonGroup, Grid, Group, Header, IconButton, NumberInput, Panel, Table} from "../ui";
import {ColoredInput} from "../Apps";
import {InfoPopup, roundDigits, Toggle, useLocalStorage} from "../Common";
import {Media} from "../../contexts/contexts";
+import {IconSettings, IconSettings2} from "@tabler/icons-react";
// Pure calculation functions for the Drive Ratio calculator (pulleys, gears, sprockets).
//
@@ -206,13 +205,15 @@ export function gearPoints(cx, cy, rOuter, rInner, teeth, tipFrac = 0.5) {
// Per-mode wording. All three drive types share the same math; only the size
// label (and the diagram) differ.
const MODES = {
- pulley: {button: 'Pulley', icon: 'circle outline', sizeLabel: 'Diameter', toothed: false},
- gear: {button: 'Gear', icon: 'cogs', sizeLabel: 'Teeth', toothed: true},
- sprocket: {button: 'Sprocket', icon: 'cog', sizeLabel: 'Teeth', toothed: true},
+ pulley: {button: 'Pulley', icon: 'circle', sizeLabel: 'Diameter', toothed: false},
+ gear: {button: 'Gear', icon: IconSettings, sizeLabel: 'Teeth', toothed: true},
+ sprocket: {button: 'Sprocket', icon: IconSettings2, sizeLabel: 'Teeth', toothed: true},
};
// Distinct, colorblind-friendly wheel colors (Paul Tol's bright palette):
// blue, red, green, yellow, cyan, purple. Grey is reserved as a neutral.
+// These are diagram/data-visualization colors, matched 1:1 to the drawn wheels
+// below; they are not themed UI chrome, so they stay literal hex.
const ELEMENT_COLORS = ['#4477aa', '#ee6677', '#228833', '#ccbb44', '#66ccee', '#aa3377'];
const MIN_ELEMENTS = 2;
@@ -393,6 +394,20 @@ function fmtSpeed(speed) {
return speed ? `${roundDigits(speed.value, 3)} ${speed.unit}` : '—';
}
+// One wheel's size/RPM input, its label tinted to match its diagram color. The
+// color is a literal hex (matched to the diagram, not a theme token), so this is
+// a small local composition rather than the token-driven `Label`.
+function WheelInput({hex, label, value, onChange, name, min, step}) {
+ return
+ {label}
+ e.target.select()} hideControls style={{flex: 1}}/>
+ ;
+}
+
export function DriveCalculator() {
const [mode, setMode] = React.useState('pulley');
const [metric, setMetric] = useLocalStorage('drive_calculator_metric', false);
@@ -433,20 +448,10 @@ export function DriveCalculator() {
const ratio = driveRatio(firstSize, lastSize);
const outTorque = torqueIn !== '' ? torqueOut(Number(torqueIn), firstSize, lastSize) : null;
- const inputProps = {
- fluid: true,
- type: 'number',
- onSelect: e => e.target.select(),
- autoComplete: 'off',
- };
- // Each wheel's inputs use its diagram color so the form and the picture line up.
- const coloredField = (hex, labelText, value, onChange, extraProps = {}, name) =>
-
- {labelText}}/>;
// Auxiliary (non-solver) numeric input.
const auxInput = (label, value, setter, name) =>
- e.target.select()} autoComplete='off'
+ name={name} label={label} value={value}
onChange={e => setter(e.target.value)}/>;
// Pitch diameter of a wheel given the mode's extra parameter. Pulley size IS the
@@ -468,43 +473,47 @@ export function DriveCalculator() {
? surfaceSpeedDisplay(pitchDiameter(Number(speedEl.size)), Number(speedEl.rpm), metric)
: null;
- const modeButtons =
+ const modeButtons =
{Object.entries(MODES).map(([key, m]) =>
- setMode(key)}/>)}
- ;
+ setMode(key)}>{m.button} )}
+ ;
// One row of inputs per wheel: size, RPM, and a remove button (above the minimum).
const elementRows = elements.map((el, i) => {
const hex = ELEMENT_COLORS[i % ELEMENT_COLORS.length];
- const onField = (field) => (e) => {
- dispatch({type: 'field', index: i, field, value: e.target.value});
+ const onField = (field) => (value) => {
+ dispatch({type: 'field', index: i, field, value});
scheduleSolve();
};
// Teeth are whole numbers; pulley diameters stay continuous.
const onSize = toothed
- ? (e) => {
- dispatch({type: 'field', index: i, field: 'size',
- value: e.target.value === '' ? '' : String(Math.round(Math.abs(Number(e.target.value))))});
+ ? (value) => {
+ dispatch({
+ type: 'field', index: i, field: 'size',
+ value: value === '' ? '' : String(Math.round(Math.abs(Number(value)))),
+ });
scheduleSolve();
}
: onField('size');
return {
hex,
- sizeField: coloredField(hex, `#${i + 1} ${sizeLabel}${sizeUnit}`, el.size, onSize,
- toothed ? {step: 1, min: 1} : {}, `size-${i}`),
- rpmField: coloredField(hex, `#${i + 1} RPM`, el.rpm, onField('rpm'), {}, `rpm-${i}`),
+ sizeField: ,
+ rpmField: ,
removeBtn: elements.length > MIN_ELEMENTS
- ? dispatch({type: 'remove', index: i})}/>
+ ? dispatch({type: 'remove', index: i})}/>
: null,
};
});
- const addButton = = MAX_ELEMENTS}
onClick={() => dispatch({type: 'add'})}
- style={{marginTop: '0.5em'}}/>;
+ style={{marginTop: '0.5em'}}>Add {button} ;
// Small colored swatch matching a wheel, used to label its detail rows.
const swatch = (hex) =>
-
- {swatch(ELEMENT_COLORS[i % ELEMENT_COLORS.length])}#{i + 1} Pitch Diameter
- {fmt(pitchDiameter(Number(el.size)), lengthUnit)}
- ) : null;
+
+ {swatch(ELEMENT_COLORS[i % ELEMENT_COLORS.length])}#{i + 1} Pitch Diameter
+ {fmt(pitchDiameter(Number(el.size)), lengthUnit)}
+ ) : null;
// Pairwise belt/chain length and center distance only make sense for a two-wheel
// drive; with 3+ wheels they depend on the physical layout, so they are omitted.
@@ -526,38 +535,38 @@ export function DriveCalculator() {
const belt = twoElements ? beltLength(firstSize, lastSize, Number(centerDistance)) : null;
detailRows = <>
{twoElements && <>
-
- Center Distance
- {auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")}
-
-
- Belt Length
- {fmt(belt, lengthUnit)}
-
+
+ Center Distance
+ {auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")}
+
+
+ Belt Length
+ {fmt(belt, lengthUnit)}
+
>}
-
- Belt Speed
- {fmtSpeed(trainSpeed)}
-
+
+ Belt Speed
+ {fmtSpeed(trainSpeed)}
+
>;
} else if (mode === 'gear') {
const paramLabel = metric ? 'Module (mm)' : 'Diametral Pitch (1/in)';
const pd1 = pitchDiameter(firstSize), pd2 = pitchDiameter(lastSize);
const cd = twoElements && pd1 > 0 && pd2 > 0 ? centerDistanceFromPitchDiameters(pd1, pd2) : null;
detailRows = <>
-
- {paramLabel}
- {auxInput(paramLabel, gearTooth, setGearTooth, "gearParam")}
-
+
+ {paramLabel}
+ {auxInput(paramLabel, gearTooth, setGearTooth, "gearParam")}
+
{pitchDiameterRows}
- {twoElements &&
- Center Distance
- {fmt(cd, lengthUnit)}
- }
-
- Pitch-Line Velocity
- {fmtSpeed(trainSpeed)}
-
+ {twoElements &&
+ Center Distance
+ {fmt(cd, lengthUnit)}
+ }
+
+ Pitch-Line Velocity
+ {fmtSpeed(trainSpeed)}
+
>;
} else { // sprocket
const pitch = Number(chainPitch);
@@ -565,27 +574,27 @@ export function DriveCalculator() {
? chainLengthPitchesEven(firstSize, lastSize, Number(centerDistance), pitch)
: null;
detailRows = <>
-
- Chain Pitch
- {auxInput(`Chain Pitch (${lengthUnit})`, chainPitch, setChainPitch, "chainPitch")}
-
- {twoElements &&
- Center Distance
- {auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")}
- }
+
+ Chain Pitch
+ {auxInput(`Chain Pitch (${lengthUnit})`, chainPitch, setChainPitch, "chainPitch")}
+
+ {twoElements &&
+ Center Distance
+ {auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")}
+ }
{pitchDiameterRows}
- {twoElements &&
- Chain Length
- {links === null ? '—' : `${links} pitches`}
- }
-
- Chain Speed
- {fmtSpeed(trainSpeed)}
-
+ {twoElements &&
+ Chain Length
+ {links === null ? '—' : `${links} pitches`}
+ }
+
+ Chain Speed
+ {fmtSpeed(trainSpeed)}
+
>;
}
- return
+ return
{modeButtons}
@@ -605,53 +614,52 @@ export function DriveCalculator() {
-
- {elementRows.map(({sizeField, rpmField, removeBtn}, i) =>
-
- {sizeField}
- {rpmField}
- {removeBtn}
- )}
-
+ {elementRows.map(({sizeField, rpmField, removeBtn}, i) =>
+
+ {sizeField}
+ {rpmField}
+ {removeBtn}
+ )}
{addButton}
-
+
el.size)}/>
-
+
-
-
-
- Drive Ratio{' '}
+
+
+
+ Drive Ratio{' '}
(#1 → #{elements.length})
-
- {formatRatio(ratio)}
-
-
- Torque Multiplier
- {ratio === null ? '—' : `${roundDigits(ratio, 3)}×`}
-
-
- Output Torque
-
-
+ {formatRatio(ratio)}
+
+
+ Torque Multiplier
+ {ratio === null ? '—' : `${roundDigits(ratio, 3)}×`}
+
+
+ Output Torque
+
+ e.target.select()} autoComplete='off'
+ name="torqueIn" label={`Input Torque (${torqueUnit})`}
value={torqueIn}
onChange={e => setTorqueIn(e.target.value)}/>
{outTorque !== null &&
= {roundDigits(outTorque, 3)} {torqueUnit}
}
-
-
-
+
+
+
-
- {detailRows}
+
- ;
+ ;
}
// Exported for testing
diff --git a/app/src/components/calculators/ElectricalCalculators.js b/app/src/components/calculators/ElectricalCalculators.js
index ba223e68..0adf483b 100644
--- a/app/src/components/calculators/ElectricalCalculators.js
+++ b/app/src/components/calculators/ElectricalCalculators.js
@@ -1,23 +1,8 @@
import {InfoPopup, roundDigits, Toggle, useLocalStorage, useLocalStorageInt} from "../Common";
import React, {useState} from "react";
-import {Form, Header, Segment, Table} from "../Theme";
-import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
-import {
- Container,
- Dropdown,
- GridColumn,
- GridRow,
- Icon,
- Input,
- TableBody,
- TableCell,
- TableHeader,
- TableHeaderCell,
- TableRow,
-} from "semantic-ui-react";
+import {Grid, Header, Message, Panel, Select, Stack, Table} from "../ui";
import {ColoredInput} from "../Apps";
-import Message from "semantic-ui-react/dist/commonjs/collections/Message";
-import {Media, ThemeContext} from "../../contexts/contexts";
+import {Media} from "../../contexts/contexts";
const initialState = {
volts: '',
@@ -119,7 +104,6 @@ export function OhmsLawCalculator({setVolts}) {
const inputProps = {
fluid: true,
type: 'number',
- labelPosition: 'right',
onSelect: e => e.target.select(), // Select the contents of the Input when a user selects it.
autoComplete: 'off',
};
@@ -133,43 +117,39 @@ export function OhmsLawCalculator({setVolts}) {
return
-
-
-
- dispatch({type: 'volts', value: e.target.value})}
- label='Volts'
- color={calculateColor('volts')}
- />
-
-
- dispatch({type: 'amps', value: e.target.value})}
- label='Amps'
- color={calculateColor('amps')}
- />
-
-
-
-
- dispatch({type: 'ohms', value: e.target.value})}
- label='Ohms'
- color={calculateColor('ohms')}
- />
-
-
- dispatch({type: 'watts', value: e.target.value})}
- label='Watts'
- color={calculateColor('watts')}
- />
-
-
+
+
+ dispatch({type: 'volts', value: e.target.value})}
+ label='Volts'
+ color={calculateColor('volts')}
+ />
+
+
+ dispatch({type: 'amps', value: e.target.value})}
+ label='Amps'
+ color={calculateColor('amps')}
+ />
+
+
+ dispatch({type: 'ohms', value: e.target.value})}
+ label='Ohms'
+ color={calculateColor('ohms')}
+ />
+
+
+ dispatch({type: 'watts', value: e.target.value})}
+ label='Watts'
+ color={calculateColor('watts')}
+ />
+
}
@@ -301,10 +281,10 @@ export const resistancesPerKm = {
// Dropdown options for wire types
const wireTypeOptions = [
- {key: 'solid', text: 'Solid Copper', value: 'solid'},
- {key: 'stranded', text: 'Stranded Copper', value: 'stranded'},
- {key: 'solid_aluminum', text: 'Solid Aluminum', value: 'solid_aluminum'},
- {key: 'stranded_aluminum', text: 'Stranded Aluminum', value: 'stranded_aluminum'},
+ {value: 'solid', label: 'Solid Copper'},
+ {value: 'stranded', label: 'Stranded Copper'},
+ {value: 'solid_aluminum', label: 'Solid Aluminum'},
+ {value: 'stranded_aluminum', label: 'Stranded Aluminum'},
];
// Helper function to calculate power loss
@@ -325,18 +305,15 @@ export const calcPowerLossPercent = (isSAE, volts, amps, resistancePerKiloLength
const PowerLossCalculator = ({volts, setVolts, wireType, setWireType, isSAE, setIsSAE, length, setLength}) => {
const [ampsRange] = useState([1, 5, 10, 20, 40, 100]);
- const {inverted} = React.useContext(ThemeContext);
- const warningBackgroundColor = inverted === 'inverted' ? '#332020' : '#ffbebe';
-
const resistances = isSAE ? resistancesPerKFeet[wireType]
: resistancesPerKm[wireType];
- const voltsInput = setVolts(parseInt(value))}
+ onChange={e => setVolts(parseInt(e.target.value))}
autoComplete="off"
/>;
@@ -346,91 +323,79 @@ const PowerLossCalculator = ({volts, setVolts, wireType, setWireType, isSAE, set
onChange={() => setIsSAE(!isSAE)}
/>;
- const lengthInput =
-
-
- setLength(parseInt(value))}
- autoComplete="off"
- />
-
-
-
-
-
+ const lengthInput =
+
+ setLength(parseInt(e.target.value))}
+ autoComplete="off"
+ />
+
+
+
+
;
- const wireTypeDropdown = setWireType(value)}
+ onChange={value => setWireType(value)}
/>;
- return
+ return
-
-
- {voltsInput}
- {lengthInput}
-
-
- {wireTypeDropdown}
- {saeToggle}
-
+
+ {voltsInput}
+ {lengthInput}
+ {wireTypeDropdown}
+ {saeToggle}
-
-
- {voltsInput}
- {saeToggle}
-
-
- {lengthInput}
- {wireTypeDropdown}
-
+
+ {voltsInput}
+ {saeToggle}
+ {lengthInput}
+ {wireTypeDropdown}
-
-
-
-
- {ampsRange.map(amp => {amp}A )}
-
-
-
+
+
+
+
+ {ampsRange.map(amp => {amp}A )}
+
+
+
{Object.entries(resistances).map(([size, resistence]) => (
-
- {size}
+
+ {size}
{ampsRange.map(amp => {
const lossPercentage = calcPowerLossPercent(isSAE, volts, amp, resistence, length);
- const style = lossPercentage > 2 ? {backgroundColor: warningBackgroundColor} : null;
- return
+ // The warning tint blends the amber token into the panel background, so
+ // it stays a token-driven color in every theme rather than a fixed hex.
+ const style = lossPercentage > 2
+ ? {backgroundColor: 'color-mix(in srgb, var(--amber) 30%, var(--panel))'}
+ : null;
+ return
{lossPercentage >= 100 ? '-' : roundDigits(lossPercentage, 1) + '%'}
-
+
})}
-
+
))}
-
+
-
-
-
- Warning
- These numbers are only an estimate and are only applicable for bare wires.
-
+
+ These numbers are only an estimate and are only applicable for bare wires.
-
+
};
// Resistance changes with temperature; the resistance tables are referenced at 20°C.
@@ -479,7 +444,7 @@ const FourWireKelvinCalculator = ({wireType, isSAE, length}) => {
setTemperatureC(isSAE ? (temperature - 32) * 5 / 9 : temperature);
};
- return
+ return
Expected millivolt drop across {length} {isSAE ? 'feet' : 'meters'} of wire when driving a known
@@ -488,48 +453,46 @@ const FourWireKelvinCalculator = ({wireType, isSAE, length}) => {
content='Uses the length and wire type from the Power Loss calculator above. The length is NOT doubled because the sense leads measure across a single conductor.'/>
-
-
-
- handleTemperatureChange(value)}
- autoComplete="off"
- />
-
-
-
-
-
+
+
+ handleTemperatureChange(e.target.value)}
+ autoComplete="off"
+ />
+
+
+
+
-
-
-
-
- {ampsRange.map(amp => {amp}A )}
-
-
-
+
+
+
+
+ {ampsRange.map(amp => {amp}A )}
+
+
+
{Object.entries(resistances).map(([size, resistance]) => (
-
- {size}
+
+ {size}
{ampsRange.map(amp => {
const milliVolts = calcKelvinMilliVoltDrop(
amp, resistance, length, temperatureC, tempCoefficientForWireType(wireType));
- return
+ return
{roundDigits(milliVolts, 1)} mV
-
+
})}
-
+
))}
-
+
-
+
};
export const ElectricalCalculators = () => {
@@ -537,12 +500,12 @@ export const ElectricalCalculators = () => {
const [wireType, setWireType] = useLocalStorage('calculators.power_loss.wire_type', 'solid');
const [isSAE, setIsSAE] = useLocalStorage('calculators.power_loss.is_sae', true);
const [length, setLength] = useLocalStorageInt('calculators.power_loss.length', 100);
- return
-
-
+
+
-
-
+ length={length} setLength={setLength}/>
+
+
}
diff --git a/app/src/components/calculators/FoodStorageCalculator.js b/app/src/components/calculators/FoodStorageCalculator.js
index 0c1c2df1..471bbf59 100644
--- a/app/src/components/calculators/FoodStorageCalculator.js
+++ b/app/src/components/calculators/FoodStorageCalculator.js
@@ -1,9 +1,7 @@
import React from "react";
-import {GridColumn, Input, Label, TableBody, TableCell, TableFooter, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
-import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
-import {Form, Header, Segment, Table} from "../Theme";
-import {InfoPopup, roundDigits, Toggle, useLocalStorage} from "../Common";
-import {ThemeContext} from "../../contexts/contexts";
+import {Grid, Header, Panel, Table, TextInput, Toggle} from "../ui";
+import {ColoredInput} from "../Apps";
+import {InfoPopup, roundDigits, useLocalStorage} from "../Common";
// Generic food-storage planner: given a household, estimate how much of each food category to store for a chosen
// duration. Independent of any inventory (like the Water calculator). Defaults are a common one-year-per-adult
@@ -97,19 +95,14 @@ const SHARES_INFO = 'Each person\'s share of one adult man\'s ration. Defaults f
+ 'Americans 2020-2025 moderate-activity calorie needs (man ~2,600, woman ~2,000, child ~1,800 kcal/day) — '
+ 'food storage scales with calories. Children vary widely by age, so adjust as needed.';
+// Category colors are theme tokens (not hex) so the tags stay legible — and become outlines instead of fills — in
+// night mode.
const PEOPLE_META = [
- {key: 'men', label: 'Men', color: '#4477aa'},
- {key: 'women', label: 'Women', color: '#aa3377'},
- {key: 'children', label: 'Children', color: '#228833'},
+ {key: 'men', label: 'Men', color: 'blue'},
+ {key: 'women', label: 'Women', color: 'pink'},
+ {key: 'children', label: 'Children', color: 'green'},
];
-function textColorFor(hex) {
- const r = parseInt(hex.slice(1, 3), 16);
- const g = parseInt(hex.slice(3, 5), 16);
- const b = parseInt(hex.slice(5, 7), 16);
- return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6 ? '#000000' : '#ffffff';
-}
-
function fmt(value, unit) {
return Number.isFinite(value) && value > 0 ? `${roundDigits(value, 1)} ${unit}` : '—';
}
@@ -120,7 +113,6 @@ export function FoodStorageCalculator() {
const [monthsIndex, setMonthsIndex] = useLocalStorage('food_storage_months_index', MONTH_STOPS.indexOf(12));
const [amounts, setAmounts] = useLocalStorage('food_storage_amounts', DEFAULT_AMOUNTS);
const [metric, setMetric] = useLocalStorage('food_storage_metric', false);
- const {t} = React.useContext(ThemeContext);
const unit = metric ? 'kg' : 'lb';
// Convert the editable amounts when switching units so the user keeps their numbers.
@@ -135,18 +127,13 @@ export function FoodStorageCalculator() {
const inputProps = {fluid: true, type: 'number', min: 0, onSelect: e => e.target.select(), autoComplete: 'off'};
- const labelStyle = (color) =>
- ({backgroundColor: color, color: textColorFor(color), borderColor: color});
-
const countInput = ({key, label, color}) =>
- setCounts({...counts, [key]: e.target.value})}
- label={{label} }/>;
+ setCounts({...counts, [key]: e.target.value})}/>;
const shareInput = ({key, label, color}) =>
- setShares({...shares, [key]: e.target.value})}
- label={{label} }/>;
+ setShares({...shares, [key]: e.target.value})}/>;
const rows = FOOD_CATEGORIES.map(c => {
const perYear = amounts[c.key] ?? '';
@@ -155,26 +142,26 @@ export function FoodStorageCalculator() {
});
const grandTotal = rows.reduce((s, r) => s + r.total, 0);
- return
+ return
-
Estimate how much food to store for your household over time.
+
Estimate how much food to store for your household over time.
-
- {PEOPLE_META.map(meta => {countInput(meta)} )}
+
+ {PEOPLE_META.map(meta => {countInput(meta)} )}
Ration share (% of an adult man)
-
- {PEOPLE_META.map(meta => {shareInput(meta)} )}
+
+ {PEOPLE_META.map(meta => {shareInput(meta)} )}
-
+
≈ {roundDigits(equivalents, 2)} adult-equivalent{equivalents === 1 ? '' : 's'}
@@ -183,40 +170,40 @@ export function FoodStorageCalculator() {
setMonthsIndex(Number(e.target.value))}
style={{width: '100%'}}/>
-
+
Amounts ({unit} per person per year)
-
-
-
-
- Category
- {unit === 'kg' ? 'Kg' : 'Lbs'} / person / year
- Total needed
-
-
-
- {rows.map(r =>
- {r.label}
-
- setAmounts({...amounts, [r.key]: e.target.value})}/>
-
- {fmt(r.total, unit)}
- )}
-
-
-
- Total
-
- {fmt(grandTotal, unit)}
-
-
+
+
+
+
+ Category
+ {unit === 'kg' ? 'Kg' : 'Lbs'} / person / year
+ Total needed
+
+
+
+ {rows.map(r =>
+ {r.label}
+
+ setAmounts({...amounts, [r.key]: e.target.value})}/>
+
+ {fmt(r.total, unit)}
+ )}
+
+
+
+ Total
+
+ {fmt(grandTotal, unit)}
+
+
-
- ;
+
+ ;
}
diff --git a/app/src/components/calculators/HamCalculators.js b/app/src/components/calculators/HamCalculators.js
index f327d05b..65ddb937 100644
--- a/app/src/components/calculators/HamCalculators.js
+++ b/app/src/components/calculators/HamCalculators.js
@@ -1,9 +1,7 @@
import React from "react";
-import {TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
-import {Header, Table} from "../Theme";
-import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
-import {roundDigits} from "../Common";
+import {Grid, Header, Table} from "../ui";
import {ColoredInput} from "../Apps";
+import {roundDigits} from "../Common";
// ---------------------------------------------------------------------------
// Pure antenna math
@@ -212,9 +210,10 @@ export function DipoleAntennaCalculator() {
setMhzInputValue(storageMhzValue);
}
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- const handleMhzChange = (e, {value}) => {
+ const handleMhzChange = (value) => {
setMhzInputValue(value);
const parsed = parseFloat(value);
// Require a finite positive frequency so overflow (e.g. 1e999 → Infinity) does not stick.
@@ -222,7 +221,7 @@ export function DipoleAntennaCalculator() {
localStorage.setItem(mhzStorageKey, value);
}
- const handleWaveChange = (e, {value}) => {
+ const handleWaveChange = (value) => {
setWaveInputValue(value);
const parsed = parseFloat(value);
if (value && Number.isFinite(parsed) && parsed > 0) {
@@ -257,76 +256,74 @@ export function DipoleAntennaCalculator() {
return <>
-
-
-
- handleClick(e, 'mhz')}
- color={mhzSelected ? null : 'grey'}
- />
-
-
- handleClick(e, 'wave')}
- color={mhzSelected ? 'grey' : null}
- />
-
-
+
+
+ handleMhzChange(e.target.value)}
+ onSelect={e => handleClick(e, 'mhz')}
+ color={mhzSelected ? null : 'grey'}
+ />
+
+
+ handleWaveChange(e.target.value)}
+ onSelect={e => handleClick(e, 'wave')}
+ color={mhzSelected ? 'grey' : null}
+ />
+
{ANTENNA_FRACTIONS.map(i => {
const [feet_, inches_] = convertFeetToFeetAndInches(feet * i.multiplier);
const [half_feet, half_inches] = convertFeetToFeetAndInches(feet * i.multiplier / 2);
- return
-
-
- {i.name}
- Leg Length
-
-
+ return
+
+
+ {i.name}
+ Leg Length
+
+
-
-
-
+
+
+
{feet_} ft. {inches_} in.
-
-
+
+
{half_feet} ft. {half_inches} in.
-
-
-
-
+
+
+
+
{roundDigits(inches * i.multiplier)} inches
-
-
+
+
{roundDigits(inches * i.multiplier / 2)} inches
-
-
-
-
+
+
+
+
{roundDigits(meters * i.multiplier)} meters
-
-
+
+
{roundDigits(meters * i.multiplier / 2)} meters
-
-
-
-
+
+
+
+
{roundDigits(cm * i.multiplier)} cm.
-
-
+
+
{roundDigits(cm * i.multiplier / 2)} cm.
-
-
-
+
+
+
})}
>
diff --git a/app/src/components/calculators/OneTimePadCalculator.js b/app/src/components/calculators/OneTimePadCalculator.js
index 65dfcc64..dccd87b5 100644
--- a/app/src/components/calculators/OneTimePadCalculator.js
+++ b/app/src/components/calculators/OneTimePadCalculator.js
@@ -1,5 +1,4 @@
-import React, {useContext, useState} from 'react';
-import {Checkbox, Divider, Icon, Input, Radio, SegmentGroup} from "semantic-ui-react";
+import React, {useState} from 'react';
import {
appendChecksum,
decryptOTP,
@@ -12,22 +11,13 @@ import {
validateCharset
} from "../otp";
import {ErrorMessage, SimpleAccordion} from "../Common";
-import {ThemeContext} from "../../contexts/contexts";
-import {Button, Form, Header, Segment, TextArea} from "../Theme";
+import {Button, Checkbox, Divider, Header, Panel, Radio, Stack, Status, Textarea, TextInput} from "../ui";
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) {
@@ -42,34 +32,34 @@ function Encrypt({chars, checksum}) {
return <>
-
-
+
+
Key
- setOtp(e.currentTarget.value)}
placeholder='The random letters from your One-Time Pad'
/>
-
-
+
+
Plaintext
- setPlaintext(e.currentTarget.value)}
placeholder='The message you want to send'
/>
-
-
+
+
Ciphertext
{error
? {error}
: {ciphertext || (chars ? 'Enter your message above' : 'Set valid characters in Advanced options')} }
-
-
+
+
>
}
@@ -78,14 +68,6 @@ function Decrypt({chars, checksum}) {
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)
@@ -107,44 +89,42 @@ function Decrypt({chars, checksum}) {
return <>
-
-
+
+
Key
- setOtp(e.currentTarget.value)}
placeholder='The random letters from your One-Time Pad'
/>
-
-
+
+
Ciphertext
- setCiphertext(e.currentTarget.value)}
placeholder='The message you received'
/>
-
-
+
+
Plaintext
- {checksumValid !== null &&
-
- {checksumValid ? 'Checksum is valid' : 'Checksum is invalid'}
-
}
+ {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('');
@@ -174,32 +154,28 @@ export function OneTimePadCalculator() {
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
+ These messages are never stored and cannot be retrieved.
+
+ Generate New Pad
+
+ Cheat Sheet PDF
-
-
- setCharsetKind('alpha')}/>
-
-
- setCharsetKind('alphanumeric')}/>
-
-
- setCharsetKind('custom')}/>
-
-
+
+ setCharsetKind('alpha')}/>
+ setCharsetKind('alphanumeric')}/>
+ setCharsetKind('custom')}/>
+
{charsetKind === 'custom' && <>
- setCustomChars(value)}
+ onChange={e => setCustomChars(e.currentTarget.value)}
/>
{charsetError && {charsetError} }
>}
diff --git a/app/src/components/calculators/RatioCalculator.js b/app/src/components/calculators/RatioCalculator.js
index 1689b7a3..055d0f3a 100644
--- a/app/src/components/calculators/RatioCalculator.js
+++ b/app/src/components/calculators/RatioCalculator.js
@@ -1,5 +1,4 @@
-import {Dropdown, MessageHeader, TableBody, TableCell, TableRow} from "semantic-ui-react";
-import {Form, Header, Table} from "../Theme";
+import {Group, Header, Label, NumberInput, Select, Table} from "../ui";
import React from "react";
import {HandPointMessage, roundDigits, useLocalStorage} from "../Common";
import {createUnit, multiply, unit} from "mathjs";
@@ -91,65 +90,65 @@ function ratioReducer(prevState, e) {
const baseToUnitsMap = {
'length': [
- {key: 'centimeter', value: 'centimeter', text: 'centimeter'},
- {key: 'feet', value: 'feet', text: 'feet'},
- {key: 'inch', value: 'inch', text: 'inch'},
- {key: 'kilometer', value: 'kilometer', text: 'kilometer'},
- {key: 'meter', value: 'meter', text: 'meter'},
- {key: 'mile', value: 'mile', text: 'mile'},
+ {value: 'centimeter', text: 'centimeter'},
+ {value: 'feet', text: 'feet'},
+ {value: 'inch', text: 'inch'},
+ {value: 'kilometer', text: 'kilometer'},
+ {value: 'meter', text: 'meter'},
+ {value: 'mile', text: 'mile'},
],
'area': [
- {key: 'm2', value: 'm2', text: 'm²'},
- {key: 'sqin', value: 'sqin', text: 'square inch'},
- {key: 'sqyd', value: 'sqyd', text: 'square yard'},
- {key: 'sqmi', value: 'sqmi', text: 'square mile'},
- {key: 'acre', value: 'acre', text: 'acre'},
- {key: 'hectare', value: 'hectare', text: 'hectare'},
+ {value: 'm2', text: 'm²'},
+ {value: 'sqin', text: 'square inch'},
+ {value: 'sqyd', text: 'square yard'},
+ {value: 'sqmi', text: 'square mile'},
+ {value: 'acre', text: 'acre'},
+ {value: 'hectare', text: 'hectare'},
],
'volume': [
- {key: 'cc', value: 'cc', text: 'cc'},
- {key: 'liter', value: 'liter', text: 'liter'},
- {key: 'm3', value: 'm3', text: 'meters³'},
- {key: 'cup', value: 'cup', text: 'cup'},
- {key: 'fluidounce', value: 'fluidounce', text: 'fl.oz'},
- {key: 'gallon', value: 'gallon', text: 'gallon'},
- {key: 'quart', value: 'quart', text: 'quart'},
- {key: 'milliliter', value: 'milliliter', text: 'ml'},
- {key: 'tablespoon', value: 'tablespoon', text: 'tablespoon'},
- {key: 'teaspoon', value: 'teaspoon', text: 'teaspoon'},
- {key: 'cuin', value: 'cuin', text: 'cuin'},
+ {value: 'cc', text: 'cc'},
+ {value: 'liter', text: 'liter'},
+ {value: 'm3', text: 'meters³'},
+ {value: 'cup', text: 'cup'},
+ {value: 'fluidounce', text: 'fl.oz'},
+ {value: 'gallon', text: 'gallon'},
+ {value: 'quart', text: 'quart'},
+ {value: 'milliliter', text: 'ml'},
+ {value: 'tablespoon', text: 'tablespoon'},
+ {value: 'teaspoon', text: 'teaspoon'},
+ {value: 'cuin', text: 'cuin'},
],
'mass': [
- {key: 'grain', value: 'grain', text: 'grain'},
- {key: 'gram', value: 'gram', text: 'gram'},
- {key: 'lbs', value: 'lbs', text: 'pound'},
- {key: 'stone', value: 'stone', text: 'stone'},
- {key: 'ton', value: 'ton', text: 'ton'},
+ {value: 'grain', text: 'grain'},
+ {value: 'gram', text: 'gram'},
+ {value: 'lbs', text: 'pound'},
+ {value: 'stone', text: 'stone'},
+ {value: 'ton', text: 'ton'},
],
'energy': [
- {key: 'joule', value: 'joule', text: 'joule'},
- {key: 'Wh', value: 'Wh', text: 'Wh'},
- {key: 'BTU', value: 'BTU', text: 'BTU'},
- {key: 'watt', value: 'watt', text: 'watt'},
- {key: 'hp', value: 'hp', text: 'hp'},
+ {value: 'joule', text: 'joule'},
+ {value: 'Wh', text: 'Wh'},
+ {value: 'BTU', text: 'BTU'},
+ {value: 'watt', text: 'watt'},
+ {value: 'hp', text: 'hp'},
],
};
// Used to get from unit like `BTU` back to the base `energy`.
let unitsToBaseMap = {};
for (const [base, units] of Object.entries(baseToUnitsMap)) {
- for (const {key} of units) {
- unitsToBaseMap[key] = base;
+ for (const {value} of units) {
+ unitsToBaseMap[value] = base;
}
}
const defaultUnits = {
// These will be replaced using local storage.
- length: baseToUnitsMap.length[0]['key'], // centimeter
- area: baseToUnitsMap.area[0]['key'], // m2
- volume: baseToUnitsMap.volume[0]['key'], // cc
- mass: baseToUnitsMap.mass[0]['key'], // grain
- energy: baseToUnitsMap.energy[0]['key'], // joule
+ length: baseToUnitsMap.length[0]['value'], // centimeter
+ area: baseToUnitsMap.area[0]['value'], // m2
+ volume: baseToUnitsMap.volume[0]['value'], // cc
+ mass: baseToUnitsMap.mass[0]['value'], // grain
+ energy: baseToUnitsMap.energy[0]['value'], // joule
[null]: null, // nullUnit
};
@@ -175,37 +174,40 @@ const unitToInputValue = (u) => {
return roundDigits(num, 3)
}
+// "None" is represented by an empty string in the Select (which needs a string value);
+// the reducer still receives/produces `null` for the base, same as before migration.
const baseOptions = [
- {key: null, value: null, text: 'None'},
- {key: 'length', value: 'length', label: 'meter, feet, inch, etc.', text: 'Length'},
- {key: 'area', value: 'area', label: 'inch², m², etc.', text: 'Area'},
- {key: 'volume', value: 'volume', label: 'liter, m³, cup, etc.', text: 'Volume'},
- {key: 'mass', value: 'mass', label: 'gram, pound, etc.', text: 'Mass'},
- {key: 'energy', value: 'energy', label: 'joule, watt, etc.', text: 'Energy'},
+ {value: '', label: 'None'},
+ {value: 'length', label: 'Length — meter, feet, inch, etc.'},
+ {value: 'area', label: 'Area — inch², m², etc.'},
+ {value: 'volume', label: 'Volume — liter, m³, cup, etc.'},
+ {value: 'mass', label: 'Mass — gram, pound, etc.'},
+ {value: 'energy', label: 'Energy — joule, watt, etc.'},
];
const RatioInput = React.forwardRef(({name, label, value, unitValue, unitName, unitOptions, color, dispatch, onKeyDown}, ref) => (
-
-
{label}
-
+
{label}
+
dispatch(e)}
+ onChange={val => dispatch({name, value: val})}
onKeyDown={onKeyDown}
onFocus={e => e.target.select()}
+ hideControls
+ style={{flex: 1}}
/>
{unitOptions &&
- dispatch(data)}
+ ({value: o.value, label: o.text}))}
+ onChange={val => dispatch({name: unitName, value: val})}
name={unitName}
value={unitValue}
+ style={{minWidth: 150}}
/>}
-
+
));
const RatioCalculator = () => {
@@ -239,16 +241,17 @@ const RatioCalculator = () => {
const unitOptions = baseToUnitsMap[state.base];
- const getColor = (name) => state.lastUpdated.includes(name) ? '' : 'grey';
+ // Recently-updated inputs (the ones the user is actively typing) are highlighted;
+ // the others read as inactive/grey.
+ const getColor = (name) => state.lastUpdated.includes(name) ? 'blue' : 'grey';
- const baseDropdown = dispatch(data)}
- style={{marginBottom: '1em'}}
+ const baseDropdown = dispatch({name: 'base', value: val || null})}
+ style={{marginBottom: '1em'}}
/>;
const handleInputChange = (e) => {
@@ -318,7 +321,7 @@ const RatioCalculator = () => {
}
}, []);
- return
+ return
@@ -335,37 +338,36 @@ const RatioCalculator = () => {
-
-
-
- {inputA}
-
- {inputC}
-
-
-
+
+
+
+ {inputA}
+
+ {inputC}
+
+
+
-
-
+
+
-
-
+
+
-
-
-
- {inputB}
-
- {inputD}
-
-
+
+
+
+ {inputB}
+
+ {inputD}
+
+
- Tip
- You can change inputs using the a , b , c , and d keys.
+ Tip You can change inputs using the a , b , c , and d keys.
-
+
}
export const RatioCalculators = () => {
diff --git a/app/src/components/calculators/RationCalculator.js b/app/src/components/calculators/RationCalculator.js
index 5360f478..9991bf92 100644
--- a/app/src/components/calculators/RationCalculator.js
+++ b/app/src/components/calculators/RationCalculator.js
@@ -1,12 +1,10 @@
import React from "react";
-import {GridColumn, Input, Label, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
-import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
-import {Button, Form, Header, Icon, Table} from "../Theme";
+import {Button, Grid, Header, Table} from "../ui";
+import {ColoredInput} from "../Apps";
import {InfoPopup, roundDigits, useLocalStorage} from "../Common";
import {formatDuration} from "./WaterCalculator";
import {findNameKey, planSupplyPurchase} from "../inventory/summarize";
import {downloadCSV, inventoryExportFilename, shoppingListCSV} from "../inventory/inventoryExport";
-import {ThemeContext} from "../../contexts/contexts";
// Field-role detection lives in inventory/summarize.js (single source of truth); re-exported here for back-compat.
export {findCaloriesKey, findCountKey} from "../inventory/summarize";
@@ -81,10 +79,12 @@ export const RATION_PRESETS = {
const DEFAULT_PRESET = 'moderate';
+// Category colors are theme tokens (not hex) so the tags stay legible — and become outlines instead of fills — in
+// night mode.
const CATEGORY_META = [
- {key: 'men', label: 'Men', color: '#4477aa'},
- {key: 'women', label: 'Women', color: '#aa3377'},
- {key: 'children', label: 'Children', color: '#228833'},
+ {key: 'men', label: 'Men', color: 'blue'},
+ {key: 'women', label: 'Women', color: 'pink'},
+ {key: 'children', label: 'Children', color: 'green'},
];
const DEMAND_INFO = 'Per-person daily calorie needs come from the Dietary Guidelines for Americans 2020-2025 '
@@ -93,13 +93,6 @@ const DEMAND_INFO = 'Per-person daily calorie needs come from the Dietary Guidel
+ 'The Survival preset is a short-term emergency ration floor, below maintenance. All values are editable — '
+ 'adjust the household and activity level to see how long the stored food lasts.';
-function textColorFor(hex) {
- const r = parseInt(hex.slice(1, 3), 16);
- const g = parseInt(hex.slice(3, 5), 16);
- const b = parseInt(hex.slice(5, 7), 16);
- return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6 ? '#000000' : '#ffffff';
-}
-
function fmt(value, unit) {
if (!Number.isFinite(value) || value <= 0) {
return '—';
@@ -130,61 +123,55 @@ export function RationEstimatePanel({name, items, fields, caloriesKey, countKey}
const inputProps = {fluid: true, type: 'number', onSelect: e => e.target.select(), autoComplete: 'off'};
const countInput = ({key, label, color}) =>
- setCounts({...counts, [key]: e.target.value})}
- label={
- {label} }/>;
+ setCounts({...counts, [key]: e.target.value})}/>;
const rateInput = ({key, label, color}) =>
- setRates({...rates, [key]: e.target.value})}
- label={
- {label} }/>;
+ setRates({...rates, [key]: e.target.value})}/>;
- return
+ return
-
- {CATEGORY_META.map(meta => {countInput(meta)} )}
+
+ {CATEGORY_META.map(meta =>
+ {countInput(meta)} )}
Daily Calories (per person)
-
+
{Object.entries(RATION_PRESETS).map(([key, p]) => (
- setPreset(key)} style={{
- marginRight: '0.5em', padding: '0.4em 0.8em',
- border: preset === key ? '2px solid #2185d0' : '1px solid #ccc',
- background: preset === key ? '#e8f4fc' : 'white', borderRadius: '4px', cursor: 'pointer',
- }}>
+ setPreset(key)}>
{p.label} — {p.description}
-
+
))}
-
- {CATEGORY_META.map(meta => {rateInput(meta)} )}
+
+ {CATEGORY_META.map(meta =>
+ {rateInput(meta)} )}
-
-
-
- Total Stored Calories
- {fmt(total, 'kcal')}
-
-
- Daily Demand
- {fmt(daily, 'kcal/day')}
-
-
- Food Lasts
- {days ? formatDuration(days) : '—'}
-
-
+
+
+
+ Total Stored Calories
+ {fmt(total, 'kcal')}
+
+
+ Daily Demand
+ {fmt(daily, 'kcal/day')}
+
+
+ Food Lasts
+ {days ? formatDuration(days) : '—'}
+
+
- ;
+ ;
}
// Print only the supply-plan shopping list (not the whole-inventory print block): toggle a body class that the
@@ -218,7 +205,6 @@ const PLAN_INFO = 'Drag the slider to a target duration longer than your current
* the additional packages to buy to reach it (the inventory scaled up proportionally — see planSupplyPurchase).
*/
function SupplyPlan({name, items, fields, caloriesKey, countKey, currentDays, total, daily}) {
- const {t} = React.useContext(ThemeContext);
const nameKey = findNameKey(fields);
const currentMonths = (currentDays || 0) / PLAN_MONTH_DAYS;
@@ -252,62 +238,60 @@ function SupplyPlan({name, items, fields, caloriesKey, countKey, currentDays, to
});
const sortDir = (key) => sort.key === key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined;
const headerCell = (key, label) =>
- toggleSort(key)} style={{cursor: 'pointer'}}>
- {label}
- ;
+ toggleSort(key)}>{label} ;
return
{!countKey
- ?
Add a Count field to this inventory to plan purchases.
+ ?
Add a Count field to this inventory to plan purchases.
: <>
{rows.length === 0
- ?
Drag the slider above your current estimate to see a shopping list.
+ ?
Drag the slider above your current estimate to see a shopping list.
: <>
-
-
-
+
+
+
{headerCell('name', 'Item')}
{headerCell('current', 'Have')}
{headerCell('additional', 'Buy')}
{headerCell('target', 'New Total')}
-
-
-
- {sortedRows.map((r, i) =>
- {r.name}
- {r.current}
- +{r.additional}
- {r.target}
- )}
-
+
+
+
+ {sortedRows.map((r, i) =>
+ {r.name}
+ {r.current}
+ +{r.additional}
+ {r.target}
+ )}
+
-
+
Buy {totalToBuy.toLocaleString()} additional package{totalToBuy === 1 ? '' : 's'}
{' '}across {rows.length} item{rows.length === 1 ? '' : 's'}
{projectedDays ? <> to reach about {formatDuration(projectedDays)} of food.> : '.'}
- downloadCSV(
inventoryExportFilename(`${name || 'inventory'} shopping list`, 'csv'),
shoppingListCSV(sortedRows))}>
- Download CSV
+ Download CSV
-
- Print / Save as PDF
+
+ Print / Save as PDF
{/* Hidden printable block — `printShoppingList` makes this the only thing printed. */}
diff --git a/app/src/components/calculators/RationCalculator.test.js b/app/src/components/calculators/RationCalculator.test.js
index 80f9de99..0c5567a5 100644
--- a/app/src/components/calculators/RationCalculator.test.js
+++ b/app/src/components/calculators/RationCalculator.test.js
@@ -102,7 +102,7 @@ describe('RationEstimatePanel supply plan', () => {
fireEvent.change(screen.getByLabelText(/target duration/i), {target: {value: '2'}});
// Scope to the on-screen table (the hidden print block duplicates these texts in the DOM).
- const plan = within(document.querySelector('table.sortable'));
+ const plan = within(document.querySelector('table.shopping-list-table'));
expect(plan.getByText('Beans')).toBeTruthy();
expect(plan.getByText('+100')).toBeTruthy();
// Summary line reports the total to buy.
@@ -118,13 +118,13 @@ describe('RationEstimatePanel supply plan', () => {
fireEvent.change(screen.getByLabelText(/target duration/i), {target: {value: '2'}});
const itemNames = () =>
- [...document.querySelectorAll('table.sortable tbody tr td:first-child')].map(td => td.textContent);
+ [...document.querySelectorAll('table.shopping-list-table tbody tr td:first-child')].map(td => td.textContent);
// Default sort is largest purchase first (Beans buys far more than Apples).
expect(itemNames()).toEqual(['Beans', 'Apples']);
// Sorting by Item ascending flips to alphabetical (scope to the sortable table; the print block also has an
// "Item" header).
- fireEvent.click(within(document.querySelector('table.sortable')).getByText('Item'));
+ fireEvent.click(within(document.querySelector('table.shopping-list-table')).getByText('Item'));
expect(itemNames()).toEqual(['Apples', 'Beans']);
});
diff --git a/app/src/components/calculators/WaterCalculator.js b/app/src/components/calculators/WaterCalculator.js
index b91ce9a7..53020081 100644
--- a/app/src/components/calculators/WaterCalculator.js
+++ b/app/src/components/calculators/WaterCalculator.js
@@ -1,7 +1,5 @@
import React from "react";
-import {GridColumn, Input, Label, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
-import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
-import {Form, Header, Segment, Table} from "../Theme";
+import {Button, Grid, Group, Header, NumberInput, Panel, Table} from "../ui";
import {InfoPopup, roundDigits, Toggle, useLocalStorage} from "../Common";
// Pure calculation functions for the Water Storage calculator.
@@ -171,11 +169,13 @@ const DEMAND_INFO = 'Per-person daily water includes drinking plus basic hygiene
+ 'livestock, or other needs not covered by the per-person categories.';
// Per-category colors (Paul Tol bright palette) so the count inputs read at a glance.
+// These are diagram/legend colors matched to a fixed visual identity per category —
+// not themed UI chrome — so they stay literal hex.
const CATEGORY_META = [
- {key: 'men', label: 'Men', icon: 'man', color: '#4477aa'},
- {key: 'women', label: 'Women', icon: 'woman', color: '#aa3377'},
- {key: 'children', label: 'Children', icon: 'child', color: '#228833'},
- {key: 'pregnant', label: 'Pregnant/Nursing', icon: 'heart', color: '#ee6677'},
+ {key: 'men', label: 'Men', color: '#4477aa'},
+ {key: 'women', label: 'Women', color: '#aa3377'},
+ {key: 'children', label: 'Children', color: '#228833'},
+ {key: 'pregnant', label: 'Pregnant/Nursing', color: '#ee6677'},
];
// Pick black or white label text for legibility on a given background color.
@@ -202,7 +202,21 @@ export function clampNonNegative(value) {
if (value === '') {
return '';
}
- return value.startsWith('-') ? value.slice(1) : value;
+ return `${value}`.startsWith('-') ? `${value}`.slice(1) : value;
+}
+
+// A labeled numeric input whose label is tinted to match a category's diagram color.
+// The color is a literal hex from CATEGORY_META, not a theme token, so this is a small
+// local composition rather than the token-driven `Label`.
+function SwatchInput({hex, label, value, onChange, name, min, step}) {
+ return
+ {label}
+ e.target.select()} hideControls style={{flex: 1}}/>
+ ;
}
export function WaterCalculator() {
@@ -236,69 +250,51 @@ export function WaterCalculator() {
const total = totalWater(daily, days);
const weight = waterWeight(total, metric);
- const inputProps = {fluid: true, type: 'number', onSelect: e => e.target.select(), autoComplete: 'off'};
-
// One count input per category, label colored to match.
- const countInput = ({key, label, icon, color}) =>
- setCounts({...counts, [key]: e.target.value === '' ? '' : String(Math.max(0, Math.round(Number(e.target.value))))})}
- label={
- {label} }/>;
+ const countInput = ({key, label, color}) =>
+ setCounts({
+ ...counts,
+ [key]: value === '' ? '' : String(Math.max(0, Math.round(Number(value)))),
+ })}/>;
// One editable rate input per category, sharing the category color.
const rateInput = ({key, label, color}) =>
- setRates({...rates, [key]: clampNonNegative(e.target.value)})}
- label={
- {label} }/>;
+ setRates({...rates, [key]: clampNonNegative(value)})}/>;
- const presetButtons = (
-
- {Object.entries(WATER_PRESETS).map(([key, p]) => (
- setPreset(key)}
- style={{
- marginRight: '0.5em',
- padding: '0.4em 0.8em',
- border: preset === key ? '2px solid #2185d0' : '1px solid #ccc',
- background: preset === key ? '#e8f4fc' : 'white',
- borderRadius: '4px',
- cursor: 'pointer'
- }}
- >
- {p.label} — {p.description}
-
- ))}
-
- (switching resets rates to preset defaults)
-
-
- );
+ const presetButtons =
+ {Object.entries(WATER_PRESETS).map(([key, p]) => (
+ setPreset(key)}>
+ {p.label} — {p.description}
+
+ ))}
+
+ (switching resets rates to preset defaults)
+
+ ;
// One row per container: how many of it the total water would fill, and the spare
// capacity in the last one. Shown for every container so the user can compare.
const containerRows = CONTAINERS.map(c => {
const capacity = metric ? c.liters : c.gallons;
const need = total !== null ? containersNeeded(total, capacity) : null;
- return
- {c.label}
- {capacity} {volumeUnit}
-
+ return
+ {c.label}
+ {capacity} {volumeUnit}
+
{need === null
? '—'
: {need.count}
{need.leftover > 0 &&
({fmt(need.leftover, volumeUnit)} spare) }
}
-
- ;
+
+ ;
});
- return
+ return
@@ -308,25 +304,23 @@ export function WaterCalculator() {
{presetButtons}
-
+
{CATEGORY_META.map(meta =>
- {countInput(meta)} )}
+ {countInput(meta)} )}
Daily Use ({rateUnit} per person)
-
+
{CATEGORY_META.map(meta =>
- {rateInput(meta)} )}
+ {rateInput(meta)} )}
-
setExtra(clampNonNegative(e.target.value))}
- label={
Extra {rateUnit} }/>
+
setExtra(clampNonNegative(value))}/>
Pets, livestock, guests, sick household members, or anything else not covered above.
@@ -338,40 +332,40 @@ export function WaterCalculator() {
value={durationStop} aria-label='Duration'
onChange={e => setDurationIndex(Number(e.target.value))}
style={{width: '100%'}}/>
-
-
-
-
-
- Daily Use
- {fmt(daily, `${volumeUnit}/day`)}
-
-
- Total Water for {formatDuration(days)}
- {fmt(total, volumeUnit)}
-
-
- Water Weight
- {fmt(weight, weightUnit)}
-
-
+
+
+
+
+ Daily Use
+ {fmt(daily, `${volumeUnit}/day`)}
+
+
+ Total Water for {formatDuration(days)}
+ {fmt(total, volumeUnit)}
+
+
+ Water Weight
+ {fmt(weight, weightUnit)}
+
+
-
-
-
- Container
- Capacity
- Needed
-
-
- {containerRows}
+
+
+
+ Container
+ Capacity
+ Needed
+
+
+ {containerRows}
-
- ;
+
+ ;
}
diff --git a/app/src/components/collections/BatchReorganizeModal.js b/app/src/components/collections/BatchReorganizeModal.js
index f7bef805..325334b2 100644
--- a/app/src/components/collections/BatchReorganizeModal.js
+++ b/app/src/components/collections/BatchReorganizeModal.js
@@ -1,8 +1,6 @@
import React, {useCallback, useEffect, useState} from 'react';
import {Link} from 'react-router';
-import {Grid, Icon, Label, Progress, Button as SButton} from 'semantic-ui-react';
-import Message from 'semantic-ui-react/dist/commonjs/collections/Message';
-import {Button, Modal, Table} from '../Theme';
+import {Button, Icon, Label, Loading, Message, Modal, Progress, Stack, Table} from '../ui';
import {APIButton} from '../Common';
import {
executeBatchReorganization,
@@ -200,72 +198,63 @@ export function BatchReorganizeModal({
if (!preview) return null;
return (
-
-
-
-
- {preview.total_collections} {kindLabelPlural.toLowerCase()} need reorganization,
- with {preview.total_files_needing_move} total files to move.
-
-
- New format: {preview.new_file_format}
-
-
-
+
+
+
+ {preview.total_collections} {kindLabelPlural.toLowerCase()} need reorganization,
+ with {preview.total_files_needing_move} total files to move.
+
+
+ New format: {preview.new_file_format}
+
+
{preview.collections && preview.collections.length > 0 && (
-
-
- {kindLabelPlural} to reorganize:
-
-
-
- {kindLabel}
- Files to Move
- Sample
+
+
{kindLabelPlural} to reorganize:
+
+
+
+ {kindLabel}
+ Files to Move
+ Sample
+
+
+
+ {preview.collections.slice(0, 10).map((collection, idx) => (
+
+ {collection.collection_name}
+
+ {collection.total_files}
+
+
+ {collection.sample_move ? (
+ <>
+ {collection.sample_move.old_path}
+
+
+ {collection.sample_move.new_path}
+ >
+ ) : '-'}
+
-
-
- {preview.collections.slice(0, 10).map((collection, idx) => (
-
- {collection.collection_name}
-
- {collection.total_files}
-
-
- {collection.sample_move ? (
- <>
- {collection.sample_move.old_path}
-
-
- {collection.sample_move.new_path}
- >
- ) : '-'}
-
-
- ))}
-
-
- {preview.collections.length > 10 && (
-
- ...and {preview.collections.length - 10} more {kindLabelPlural.toLowerCase()}
-
- )}
-
-
+ ))}
+
+
+ {preview.collections.length > 10 && (
+
+ ...and {preview.collections.length - 10} more {kindLabelPlural.toLowerCase()}
+
+ )}
+
)}
{preview.total_collections === 0 && (
-
-
-
- No {kindLabelPlural} Need Reorganization
- All {kindLabelPlural.toLowerCase()} are already organized according to the current format.
-
-
-
+
+ All {kindLabelPlural.toLowerCase()} are already organized according to the current format.
+
)}
-
+
);
};
@@ -273,83 +262,60 @@ export function BatchReorganizeModal({
if (!status) return null;
const currentCollection = status.current_collection;
+ const overallColor = status.status === 'complete' ? 'green' : status.status === 'failed' ? 'red' : 'blue';
return (
-
-
-
-
- Overall Status: {status.status}
-
- Overall Progress:
-
-
- {status.completed_collections || 0} of {status.total_collections || 0} {kindLabelPlural.toLowerCase()} completed
-
-
-
+
+
+
+ Overall Status: {status.status}
+
+
Overall Progress:
+
+
+ {status.completed_collections || 0} of {status.total_collections || 0} {kindLabelPlural.toLowerCase()} completed
+
+
{currentCollection && (
-
-
- Currently Processing: {currentCollection.name}
-
-
- {currentCollection.completed || 0} of {currentCollection.total || 0} files
-
-
-
+
+
Currently Processing: {currentCollection.name}
+
+
+ {currentCollection.completed || 0} of {currentCollection.total || 0} files
+
+
)}
{status.status === 'complete' && (
-
-
-
- Batch Reorganization Complete
- All {kindLabelPlural.toLowerCase()} have been reorganized successfully.
-
-
-
+
+ All {kindLabelPlural.toLowerCase()} have been reorganized successfully.
+
)}
{status.status === 'failed' && status.failed_collection && (
-
-
-
- Reorganization Failed
-
- Failed on {kindLabel.toLowerCase()}: {status.failed_collection.name}
-
-
- View {kindLabel}
-
- {status.error && (
- Error: {status.error}
- )}
-
-
-
+
+
+ Failed on {kindLabel.toLowerCase()}: {status.failed_collection.name}
+
+
+ View {kindLabel}
+
+ {status.error && (
+ Error: {status.error}
+ )}
+
)}
-
+
);
};
@@ -361,7 +327,7 @@ export function BatchReorganizeModal({
open={open}
onClose={handleClose}
closeIcon={true}
- closeOnDimmerClick={!isInProgress}
+ closeOnClickOutside={!isInProgress}
closeOnEscape={!isInProgress}
size='large'
>
@@ -369,19 +335,10 @@ export function BatchReorganizeModal({
Reorganize All {kindLabelPlural}
- {loading && (
-
-
-
- Loading Preview
- Analyzing {kindLabelPlural.toLowerCase()}...
-
-
- )}
+ {loading && Analyzing {kindLabelPlural.toLowerCase()}... }
{error && !status?.failed_collection && (
-
- Error
+
{error}
)}
@@ -390,12 +347,12 @@ export function BatchReorganizeModal({
{batchJobId && renderProgress()}
-
+
{status?.status === 'complete' ? 'Close' : isInProgress ? 'Hide' : 'Cancel'}
{canReorganize && (
diff --git a/app/src/components/collections/BatchReorganizeModal.test.js b/app/src/components/collections/BatchReorganizeModal.test.js
index df344796..f434eae4 100644
--- a/app/src/components/collections/BatchReorganizeModal.test.js
+++ b/app/src/components/collections/BatchReorganizeModal.test.js
@@ -68,7 +68,7 @@ describe('BatchReorganizeModal', () => {
renderWithProviders( );
// Should show loading initially
- expect(screen.getByText(/Loading Preview/i)).toBeInTheDocument();
+ expect(screen.getByText(/Analyzing channels/i)).toBeInTheDocument();
// Wait for preview to load
await waitFor(() => {
diff --git a/app/src/components/collections/CollectionReorganizeModal.js b/app/src/components/collections/CollectionReorganizeModal.js
index 77a4fa1f..b989761e 100644
--- a/app/src/components/collections/CollectionReorganizeModal.js
+++ b/app/src/components/collections/CollectionReorganizeModal.js
@@ -1,7 +1,5 @@
import React, {useCallback, useEffect, useState} from 'react';
-import {Button as SButton, Grid, Icon, Label, Progress} from 'semantic-ui-react';
-import Message from 'semantic-ui-react/dist/commonjs/collections/Message';
-import {Button, Modal, Table} from '../Theme';
+import {Button, Icon, Label, Loading, Message, Modal, Progress, Stack, Table} from '../ui';
import {APIButton} from '../Common';
import {
executeCollectionReorganization,
@@ -271,154 +269,124 @@ export function CollectionReorganizeModal({
if (!preview) return null;
return (
-
+
{/* Warning when collection appears organized but has files to move */}
{preview.files_needing_move > 0 && !needsReorganization && (
-
-
-
- Collection Appears Organized
-
- This collection's format matches the current configuration, but
- {' '}{preview.files_needing_move} files don't match the expected
- layout.
- This can happen if a previous reorganization was interrupted.
-
-
-
-
+
+
+ This collection's format matches the current configuration, but
+ {' '}{preview.files_needing_move} files don't match the expected
+ layout.
+ This can happen if a previous reorganization was interrupted.
+
+
)}
-
-
+
+
+ {preview.files_needing_move} of {preview.total_files} files
+ will be reorganized.
+
+ {preview.current_file_format && (
- {preview.files_needing_move} of {preview.total_files} files
- will be reorganized.
+ Current format: {preview.current_file_format}
- {preview.current_file_format && (
-
- Current format: {preview.current_file_format}
-
- )}
-
- New format: {preview.new_file_format}
-
-
-
+ )}
+
+ New format: {preview.new_file_format}
+
+
{/* Conflict warning */}
{preview.has_conflicts && (
-
-
-
-
- Conflicts Detected
-
-
- {preview.conflicts.length} destination{' '}
- {preview.conflicts.length === 1 ? 'path has' : 'paths have'}{' '}
- multiple files that would collide.
- You must resolve these conflicts before reorganizing.
-
-
- Resolve Conflicts
-
-
-
-
+
+
+ {preview.conflicts.length} destination{' '}
+ {preview.conflicts.length === 1 ? 'path has' : 'paths have'}{' '}
+ multiple files that would collide.
+ You must resolve these conflicts before reorganizing.
+
+
+ Resolve Conflicts
+
+
)}
{preview.sample_moves && preview.sample_moves.length > 0 && (
-
-
- Sample moves:
-
-
-
- Current Path
-
- New Path
+
+
Sample moves:
+
+
+
+ Current Path
+
+ New Path
+
+
+
+ {preview.sample_moves.map((move, idx) => (
+
+
+ {move.old_path}
+
+
+
+
+
+ {move.new_path}
+
-
-
- {preview.sample_moves.map((move, idx) => (
-
-
- {move.old_path}
-
-
-
-
-
- {move.new_path}
-
-
- ))}
-
-
- {preview.files_needing_move > preview.sample_moves.length && (
-
- ...and {preview.files_needing_move - preview.sample_moves.length} more files
-
- )}
-
-
+ ))}
+
+
+ {preview.files_needing_move > preview.sample_moves.length && (
+
+ ...and {preview.files_needing_move - preview.sample_moves.length} more files
+
+ )}
+
)}
{preview.files_needing_move === 0 && (
-
-
-
- No Files Need Reorganization
-
- All files are already organized according to the current format.
- {needsReorganization && ' Click "Mark as Organized" to dismiss the warning.'}
-
-
-
-
+
+
+ All files are already organized according to the current format.
+ {needsReorganization && ' Click "Mark as Organized" to dismiss the warning.'}
+
+
)}
-
+
);
};
const renderProgress = () => {
if (!status) return null;
+ const progressColor = status.status === 'complete' ? 'green' : status.status === 'error' ? 'red' : 'blue';
+
return (
-
-
-
-
- Status: {status.status}
-
-
-
- {status.completed || 0} of {status.total || 0} files processed
-
-
-
+
+
+
+ Status: {status.status}
+
+
+
+ {status.completed || 0} of {status.total || 0} files processed
+
+
{status.status === 'complete' && (
-
-
-
- Reorganization Complete
- All files have been reorganized successfully.
-
-
-
+
+ All files have been reorganized successfully.
+
)}
-
+
);
};
@@ -433,7 +401,7 @@ export function CollectionReorganizeModal({
open={open}
onClose={handleClose}
closeIcon={true}
- closeOnDimmerClick={!isInProgress}
+ closeOnClickOutside={!isInProgress}
closeOnEscape={!isInProgress}
size='fullscreen'
>
@@ -441,19 +409,10 @@ export function CollectionReorganizeModal({
Reorganize Files: {collectionName}
- {loading && (
-
-
-
- Loading Preview
- Analyzing files...
-
-
- )}
+ {loading && Analyzing files... }
{error && (
-
- Error
+
{error}
)}
@@ -462,12 +421,12 @@ export function CollectionReorganizeModal({
{status && renderProgress()}
-
+
{status?.status === 'complete' ? 'Close' : isInProgress ? 'Hide' : 'Cancel'}
{canReorganize && (
@@ -476,7 +435,7 @@ export function CollectionReorganizeModal({
)}
{canMarkAsOrganized && (
diff --git a/app/src/components/collections/CollectionReorganizeModal.test.js b/app/src/components/collections/CollectionReorganizeModal.test.js
index 3e6e825b..1e5aab9e 100644
--- a/app/src/components/collections/CollectionReorganizeModal.test.js
+++ b/app/src/components/collections/CollectionReorganizeModal.test.js
@@ -65,7 +65,7 @@ describe('CollectionReorganizeModal', () => {
renderWithProviders( );
// Should show loading initially
- expect(screen.getByText(/Loading Preview/i)).toBeInTheDocument();
+ expect(screen.getByText(/Analyzing files/i)).toBeInTheDocument();
// Wait for preview to load
await waitFor(() => {
diff --git a/app/src/components/collections/CollectionTable.js b/app/src/components/collections/CollectionTable.js
index 48d660ef..b65281a1 100644
--- a/app/src/components/collections/CollectionTable.js
+++ b/app/src/components/collections/CollectionTable.js
@@ -1,11 +1,10 @@
import React, {useContext} from 'react';
import {Link} from 'react-router';
-import {Message, PlaceholderHeader, PlaceholderLine, TableCell, TableRow} from 'semantic-ui-react';
-import {Placeholder, Table} from '../Theme';
+import {Button, Message, Placeholder, Table} from '../ui';
import _ from 'lodash';
import {SortableTable} from '../SortableTable';
import {formatFrequency, humanFileSize} from '../Common';
-import {Media, ThemeContext} from '../../contexts/contexts';
+import {Media} from '../../contexts/contexts';
import {TagsContext} from '../../Tags';
/**
@@ -46,17 +45,25 @@ function getCollectionSearchLink(collection, routes, primaryKey) {
return null;
}
+/**
+ * Convert a Semantic-style relative column width (1-16) into a CSS percentage.
+ * @param {number} width - Relative width, 1-16
+ * @returns {Object|undefined} A style object, or undefined when no width is configured
+ */
+function widthStyle(width) {
+ return width ? {width: `${(width / 16) * 100}%`} : undefined;
+}
+
/**
* Render a single table row for desktop view.
*
* @param {Object} collection - The collection data
* @param {Array} columns - Column configurations
* @param {Object} routes - Routes configuration
- * @param {string} inverted - Inverted theme class
* @param {Function} SingleTag - Tag component from context
* @param {Function} onRowClick - Optional click handler
*/
-function renderRow(collection, columns, routes, inverted, SingleTag, onRowClick) {
+function renderRow(collection, columns, routes, SingleTag, onRowClick) {
const cells = columns.map((col) => {
let value = collection[col.key];
@@ -64,9 +71,8 @@ function renderRow(collection, columns, routes, inverted, SingleTag, onRowClick)
if (col.type === 'actions') {
const idField = getIdField(routes);
const editRoute = routes?.edit?.replace(':id', collection[idField]);
- const buttonClass = `ui button secondary ${inverted}`;
- return
- {editRoute && Edit}
+ return
+ {editRoute && Edit }
;
}
@@ -90,7 +96,7 @@ function renderRow(collection, columns, routes, inverted, SingleTag, onRowClick)
}
}
- return
+ return
{value || '-'}
;
});
@@ -114,8 +120,8 @@ function MobileCollectionRow({collection, mobileColumns, routes}) {
const editRoute = routes?.edit?.replace(':id', collection[idField]);
const searchLink = getCollectionSearchLink(collection, routes, primaryColumn.key);
- return
-
+ return
+
{searchLink ? (
{collection[primaryColumn.key]}
@@ -140,11 +146,11 @@ function MobileCollectionRow({collection, mobileColumns, routes}) {
);
})
}
-
-
- {editRoute && Edit}
-
- ;
+
+
+ {editRoute && Edit }
+
+ ;
}
/**
@@ -165,38 +171,26 @@ export function CollectionTable({
onRowClick,
emptyMessage = 'No items yet'
}) {
- const {inverted} = useContext(ThemeContext);
const {SingleTag} = useContext(TagsContext);
// Loading state
if (collections === null) {
- return
-
-
-
-
- ;
+ return ;
}
// Error state
if (collections === undefined) {
- return
- Could not fetch collections
- ;
+ return ;
}
// Empty state
if (collections && collections.length === 0) {
- return
- {emptyMessage}
- ;
+ return ;
}
// No columns configured
if (!columns || columns.length === 0) {
- return
- No columns configured
- ;
+ return ;
}
// Filter collections by search string
@@ -244,7 +238,7 @@ export function CollectionTable({
return <>
}
@@ -255,9 +249,9 @@ export function CollectionTable({
renderRow(collection, columns, routes, inverted, SingleTag, onRowClick)}
+ rowFunc={(collection) => renderRow(collection, columns, routes, SingleTag, onRowClick)}
rowKey='id'
tableHeaders={headers}
defaultSortColumn={defaultSortColumn}
diff --git a/app/src/components/collections/CollectionTable.test.js b/app/src/components/collections/CollectionTable.test.js
index e813c25e..cb3fa24d 100644
--- a/app/src/components/collections/CollectionTable.test.js
+++ b/app/src/components/collections/CollectionTable.test.js
@@ -26,23 +26,28 @@ jest.mock('../../contexts/contexts', () => ({
},
}));
-// Mock SortableTable to simplify testing
-jest.mock('../SortableTable', () => ({
- SortableTable: ({data, rowFunc, tableHeaders}) => (
-
-
-
- {tableHeaders.map(h => (
- {h.text}
- ))}
-
-
-
- {data.map(item => rowFunc(item))}
-
-
- ),
-}));
+// Mock SortableTable to simplify testing. Uses the real `Table` compound
+// components (rather than raw //) because `rowFunc` returns
+// `Table.Row`/`Table.Cell` elements that read Mantine's table context.
+jest.mock('../SortableTable', () => {
+ const {Table} = jest.requireActual('../ui');
+ return {
+ SortableTable: ({data, rowFunc, tableHeaders}) => (
+
+
+
+ {tableHeaders.map(h => (
+ {h.text}
+ ))}
+
+
+
+ {data.map(item => rowFunc(item))}
+
+
+ ),
+ };
+});
// Test column and routes configurations
const DOMAIN_COLUMNS = [
@@ -73,7 +78,7 @@ describe('CollectionTable', () => {
/>
);
- expect(container.querySelector('.ui.placeholder')).toBeInTheDocument();
+ expect(container.querySelector('.mantine-Skeleton-root')).toBeInTheDocument();
});
it('renders error message when collections is undefined', () => {
diff --git a/app/src/components/collections/ConflictResolutionModal.js b/app/src/components/collections/ConflictResolutionModal.js
index ee8fac74..a3a303f8 100644
--- a/app/src/components/collections/ConflictResolutionModal.js
+++ b/app/src/components/collections/ConflictResolutionModal.js
@@ -1,7 +1,6 @@
import React, {useEffect, useState} from 'react';
-import {Card, Grid, Icon, Image, Label, Segment, Button as SButton} from 'semantic-ui-react';
-import Message from 'semantic-ui-react/dist/commonjs/collections/Message';
-import {Button, Modal} from '../Theme';
+import {IconVideo} from '@tabler/icons-react';
+import {Button, Icon, IconButton, Label, Message, Modal, Panel, Stack} from '../ui';
import {deleteFileGroups} from '../../api';
import {humanFileSize, isoDatetimeToString} from '../Common';
import {TaggedDeleteConfirmModal} from '../TaggedDeleteConfirmModal';
@@ -57,92 +56,91 @@ function ConflictFileCard({file, onDelete, isRecommended}) {
};
return (
-
-
- {localError && (
-
- {localError}
+
+ {localError && (
+
+
+ {localError}
- )}
-
-
- {/* Poster/Thumbnail column */}
-
- {file.poster_path ? (
-
- ) : (
-
-
-
- )}
-
+
+ )}
+
+ {/* Poster/Thumbnail column */}
+
+ {file.poster_path ? (
+
+ ) : (
+
+ {file.model_type === 'video'
+ ?
+ : }
+
+ )}
+
- {/* File info column */}
-
-
- {file.title || 'Untitled'}
- {isRecommended && (
-
- Recommended to Keep
-
- )}
-
-
- {file.current_path}
-
-
-
- {file.size > 0 && (
-
- {humanFileSize(file.size)}
-
- )}
- {file.published_datetime && (
-
- {isoDatetimeToString(file.published_datetime)}
-
- )}
- {file.source_id && (
-
- {file.source_id}
-
- )}
- {file.quality_rank !== null && file.quality_rank !== undefined && (
-
- {file.quality_rank}
-
- )}
-
- {file.model_type}
-
-
-
-
+ {/* File info column */}
+
+
+ {file.title || 'Untitled'}
+ {isRecommended && (
+
+ Recommended to Keep
+
+ )}
+
+
+ {file.current_path}
+
+
+ {file.size > 0 && (
+
+ {humanFileSize(file.size)}
+
+ )}
+ {file.published_datetime && (
+
+ {isoDatetimeToString(file.published_datetime)}
+
+ )}
+ {file.source_id && (
+
+ {file.source_id}
+
+ )}
+ {file.quality_rank !== null && file.quality_rank !== undefined && (
+
+ {file.quality_rank}
+
+ )}
+
+ {file.model_type}
+
+
+
- {/* Delete button column */}
-
- handleDelete(false)}
- title='Delete this file'
- >
-
-
-
-
-
-
+ {/* Delete button column */}
+
+ handleDelete(false)}
+ />
+
+
-
+
);
}
@@ -212,7 +210,6 @@ export function ConflictResolutionModal({
};
const totalConflicts = localConflicts.length;
- const totalFiles = localConflicts.reduce((sum, c) => sum + c.conflicting_files.length, 0);
return (
- Resolve Conflicts Before Reorganizing
+ Resolve Conflicts Before Reorganizing
-
- Destination Path Conflicts Detected
+
{totalConflicts} destination {totalConflicts === 1 ? 'path has' : 'paths have'}{' '}
multiple files that would be moved there. This typically happens when the same{' '}
@@ -238,56 +234,58 @@ export function ConflictResolutionModal({
- {localConflicts.map((conflict, idx) => {
- // Find the highest quality rank in this conflict (for video recommendations)
- const hasRanks = conflict.conflicting_files.some(
- f => f.quality_rank !== null && f.quality_rank !== undefined
- );
- const highestRank = hasRanks
- ? Math.max(...conflict.conflicting_files.map(f => f.quality_rank || 0))
- : null;
- // Check if there are multiple files with the same highest rank
- const highestRankCount = hasRanks
- ? conflict.conflicting_files.filter(f => f.quality_rank === highestRank).length
- : 0;
+
+ {localConflicts.map((conflict, idx) => {
+ // Find the highest quality rank in this conflict (for video recommendations)
+ const hasRanks = conflict.conflicting_files.some(
+ f => f.quality_rank !== null && f.quality_rank !== undefined
+ );
+ const highestRank = hasRanks
+ ? Math.max(...conflict.conflicting_files.map(f => f.quality_rank || 0))
+ : null;
+ // Check if there are multiple files with the same highest rank
+ const highestRankCount = hasRanks
+ ? conflict.conflicting_files.filter(f => f.quality_rank === highestRank).length
+ : 0;
- return (
-
-
- Destination: {conflict.destination_path}
- {conflict.conflicting_files.length} files
-
+ return (
+
+
+
+ Destination: {conflict.destination_path}
+ {conflict.conflicting_files.length} files
+
-
- {conflict.conflicting_files.map((file, fileIdx) => {
- // Only show "Recommended" if there's a clear winner (one file with highest rank)
- const isRecommended = hasRanks &&
- highestRankCount === 1 &&
- file.quality_rank === highestRank;
+
+ {conflict.conflicting_files.map((file, fileIdx) => {
+ // Only show "Recommended" if there's a clear winner (one file with highest rank)
+ const isRecommended = hasRanks &&
+ highestRankCount === 1 &&
+ file.quality_rank === highestRank;
- return (
-
- );
- })}
-
-
- );
- })}
+ return (
+
+ );
+ })}
+
+
+ );
+ })}
+
{localConflicts.length === 0 && (
-
- All Conflicts Resolved
+
You can now proceed with the reorganization.
)}
-
+
{localConflicts.length === 0 ? 'Done' : 'Close'}
diff --git a/app/src/components/inventory/FieldCell.js b/app/src/components/inventory/FieldCell.js
index c3493499..0d3abdf5 100644
--- a/app/src/components/inventory/FieldCell.js
+++ b/app/src/components/inventory/FieldCell.js
@@ -1,5 +1,5 @@
import React from "react";
-import {Input, Select} from "semantic-ui-react";
+import {Select, TextInput} from "../ui";
import {ALL_UNITS, evaluateExpression, NUMERIC_TYPES, UNIT_GROUPS} from "./units";
// Renders the correct editor for a single field/value, wiring up keyboard navigation. The `inputRef` is attached
@@ -31,17 +31,14 @@ export function FieldCell({field, value, unitValue, onChange, onUnitChange, onEn
if (!autoFocus) {
return;
}
+ // Our inputs forward their ref straight to the underlying DOM node, so focus + select work
+ // directly on it (unlike Semantic's Input, which wrapped the node behind `.inputRef`).
const node = localRef.current;
- // Semantic's Input exposes the DOM via `inputRef`; selecting its contents lets the user type over
- // the value immediately. Other editors (e.g. Select) just take focus.
- const dom = node && node.inputRef && node.inputRef.current;
- if (dom) {
- dom.focus();
- if (dom.select) {
- dom.select();
- }
- } else if (node && node.focus) {
+ if (node && node.focus) {
node.focus();
+ if (node.select) {
+ node.select();
+ }
}
}, [autoFocus]);
@@ -81,71 +78,74 @@ export function FieldCell({field, value, unitValue, onChange, onUnitChange, onEn
const common = {
value: value ?? '',
- onChange: (e, data) => onChange(data ? data.value : e.target.value),
+ onChange: (e) => onChange(e.currentTarget.value),
onKeyDown: handleKeyDown,
'aria-label': field.label,
name: field.key,
};
if (field.type === 'select') {
- const options = (field.options || []).map(o => ({key: o, value: o, text: o}));
+ const options = (field.options || []).map(o => ({value: o, label: o}));
// Allow clearing.
return onChange(data.value)}
+ onChange={value => onChange(value || '')}
onKeyDown={handleKeyDown}
aria-label={field.label}
/>;
}
if (field.type === 'date') {
- return ;
+ return ;
}
if (field.type === 'number' || field.type === 'calories') {
// `calories` is a number (kcal per unit) that the Summary's ration estimate detects by type. Text (not
// number) input so arithmetic expressions can be typed; evaluated on blur/Enter.
- return ;
+ return ;
}
if (field.type === 'quantity') {
- const unitOptions = ALL_UNITS.map(u => ({key: u, value: u, text: u}));
- return onChange(e.target.value)}
- onKeyDown={handleKeyDown}
- onBlur={onBlur}
- aria-label={field.label}
- label={ ({value: u, label: u}));
+ // Value + unit share one outline (the same layout `ActionInput` builds), but we need a real ref on the
+ // value input to support the parent's focus/select behavior, which a plain-function `ActionInput` cannot
+ // forward.
+ return
+ onChange(e.currentTarget.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={onBlur}
+ aria-label={field.label}
+ />
+ onUnitChange(data.value)}
+ onChange={value => onUnitChange(value)}
aria-label={`${field.label} unit`}
- />}
- labelPosition='right'
- />;
+ style={{width: '5.5em'}}
+ />
+
;
}
if (field.type === 'location') {
// Native datalist autocomplete fed by all inventories' locations (basement, attic, ...). Using a datalist
// (rather than a Dropdown) keeps it a real text input, so the Tab/Enter row-entry flow still works.
- return ;
+ return ;
}
// text / fallback (supports a datalist via listId, e.g. catalog name suggestions on the Name field).
- return ;
+ return ;
}
// The list of selectable unit groups, re-exported for the field editor.
diff --git a/app/src/components/inventory/FieldSchemaEditor.js b/app/src/components/inventory/FieldSchemaEditor.js
index c48b3d20..a6218835 100644
--- a/app/src/components/inventory/FieldSchemaEditor.js
+++ b/app/src/components/inventory/FieldSchemaEditor.js
@@ -1,15 +1,12 @@
import React, {useState} from "react";
-import {
- Checkbox, Input, Select, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow
-} from "semantic-ui-react";
-import {Button, Icon, Modal, Table} from "../Theme";
+import {Button, Icon, IconButton, Modal, Select, Table, TextInput, Toggle} from "../ui";
import {ALL_UNITS} from "./units";
import {addCountByWeightFields, isCountByWeight} from "./computeFields";
const FIELD_TYPE_OPTIONS = ['text', 'number', 'quantity', 'date', 'select', 'location', 'calories']
- .map(t => ({key: t, value: t, text: t}));
+ .map(t => ({value: t, label: t}));
-const UNIT_OPTIONS = ALL_UNITS.map(u => ({key: u, value: u, text: u}));
+const UNIT_OPTIONS = ALL_UNITS.map(u => ({value: u, label: u}));
function slugifyKey(label) {
return (label || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'field';
@@ -67,74 +64,72 @@ export function FieldSchemaEditor({fields, open, onClose, onSave}) {
return
Customize Fields
- {/* NOTE: do NOT use `fixed` here — semantic gives fixed-table cells `overflow:hidden`, which clips the
- Type/Unit dropdown menus to the cell height. The `width` props below size the columns without it. */}
-
-
-
- Order
- Label
- Type
- Unit / Options
-
+
+
+
+ Order
+ Label
+ Type
+ Unit / Options
+
Mobile
-
-
-
-
-
- {draft.map((f, index) =>
-
- move(index, -1)} disabled={index === 0}
- aria-label='Move up'>
- move(index, 1)}
- disabled={index === draft.length - 1}
- aria-label='Move down'>
-
-
- update(index, {label: e.target.value})}/>
-
-
- update(index, {type: data.value})}/>
-
-
+
+
+
+
+
+ {draft.map((f, index) =>
+
+ move(index, -1)}
+ disabled={index === 0} label='Move up'/>
+ move(index, 1)}
+ disabled={index === draft.length - 1} label='Move down'/>
+
+
+ update(index, {label: e.currentTarget.value})}/>
+
+
+ update(index, {type: value})}/>
+
+
{f.type === 'quantity' &&
- update(index, {unit: data.value})}/>}
+ onChange={value => update(index, {unit: value})}/>}
{f.type === 'select' &&
/* Keep the raw split (no trim/filter) so typing a comma to start the next option
isn't swallowed mid-edit; options are trimmed/cleaned on save(). */
- update(index, {options: e.target.value.split(',')})}/>}
+ update(index, {options: e.currentTarget.value.split(',')})}/>}
{isCountByWeight(f) &&
Auto-counted: Total Weight ÷ Unit Weight
}
-
-
- update(index, {mobile: data.checked})}/>
-
-
- remove(index)}
- aria-label='Remove field'>
-
- )}
-
+
+
+ update(index, {mobile: e.currentTarget.checked})}/>
+
+
+ remove(index)}
+ label='Remove field'/>
+
+ )}
+
- Add Field
-
- Count by Weight
+ Add Field
+
+ Count by Weight
- Cancel
- Save Fields
+ Cancel
+ Save Fields
;
}
diff --git a/app/src/components/inventory/FieldSchemaEditor.test.js b/app/src/components/inventory/FieldSchemaEditor.test.js
index 4646b926..a65b2ce5 100644
--- a/app/src/components/inventory/FieldSchemaEditor.test.js
+++ b/app/src/components/inventory/FieldSchemaEditor.test.js
@@ -1,5 +1,5 @@
import React from "react";
-import {fireEvent, render, screen} from "@testing-library/react";
+import {fireEvent, render, screen} from "../../test-utils";
import {FieldSchemaEditor} from "./FieldSchemaEditor";
const FIELDS = [
diff --git a/app/src/components/inventory/InventoryImportModal.js b/app/src/components/inventory/InventoryImportModal.js
index 57b59242..7f7aa18d 100644
--- a/app/src/components/inventory/InventoryImportModal.js
+++ b/app/src/components/inventory/InventoryImportModal.js
@@ -1,11 +1,8 @@
-import React, {useContext, useEffect, useState} from "react";
-import {toast} from "react-semantic-toasts-2";
-import {TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
-import {Button, Divider, Header, Icon, Loader, Modal, Table} from "../Theme";
+import React, {useEffect, useState} from "react";
+import {Button, Divider, Header, Icon, Loading, Modal, Table, toast} from "../ui";
import {
getInventoryBackups, postInventoryRestore, postInventoryRestorePreview, reimportInventories,
} from "../../api";
-import {ThemeContext} from "../../contexts/contexts";
const formatDate = (d) => d && d.length === 8 ? `${d.substring(0, 4)}-${d.substring(4, 6)}-${d.substring(6, 8)}` : d;
@@ -13,12 +10,11 @@ const itemName = (item) => (item && (item.name || `Item ${item.id}`)) || 'Item';
// Compact list of item names from a preview's add/remove arrays.
function ItemNameList({items}) {
- const {t} = useContext(ThemeContext);
if (!items || items.length === 0) {
return null;
}
return
- {items.map((i, idx) => {itemName(i)} )}
+ {items.map((i, idx) => {itemName(i)} )}
;
}
@@ -29,7 +25,6 @@ function ItemNameList({items}) {
* `onChanged` refreshes the page's inventory state after a successful action.
*/
export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
- const {t} = useContext(ThemeContext);
const [dates, setDates] = useState(null); // null while loading
const [reimporting, setReimporting] = useState(false);
const [selected, setSelected] = useState(null); // {date, mode, preview} confirmation view
@@ -96,38 +91,42 @@ export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
{modeLabel} backup from {formatDate(date)}
{applying
- ? Restoring…
+ ? Restoring…
: <>
{mode === 'overwrite'
- ?
+ ?
Replace {name} entirely with the {formatDate(date)} backup
({preview.backup_count} item{preview.backup_count === 1 ? '' : 's'}).
- :
+ :
Add items from the {formatDate(date)} backup that aren't already present; your
current items and fields are kept.
}
{preview.add.length > 0 && <>
- {preview.add.length} to add
+
+ {preview.add.length} to add
+
>}
{preview.remove.length > 0 && <>
- {preview.remove.length} to remove
+
+ {preview.remove.length} to remove
+
>}
{preview.fields_change &&
- The field columns will also change.
}
+ The field columns will also change.
}
{preview.add.length === 0 && preview.remove.length === 0 && !preview.fields_change &&
- No changes — this backup matches the current inventory.
}
- {preview.unchanged} unchanged
+
No changes — this backup matches the current inventory.
}
+ {preview.unchanged} unchanged
item{preview.unchanged === 1 ? '' : 's'}
>}
{!applying && <>
- setSelected(null)}>Back
- Apply
+ setSelected(null)}>Back
+ Apply
>}
;
@@ -137,57 +136,58 @@ export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
Import / Restore: {name}
-
+
Reload every inventory from its config file. Use this after editing a file by hand, or after copying
an inventory file in from another WROLPi.
-
- Re-import from disk
+
+ Re-import from disk
-
+
Merge adds items from the backup that aren't already present.
{' '}Overwrite replaces this inventory entirely with the backup.
{dates === null
- ? Loading backups…
+ ? Loading backups…
: dates.length === 0
- ? No backups yet. A backup is saved automatically whenever this inventory changes.
+ ? No backups yet. A backup is saved automatically whenever this inventory changes.
:
-
-
-
- Date
- Merge
- Overwrite
-
-
-
- {dates.map(date =>
- {formatDate(date)}
-
-
+
+
+ Date
+ Merge
+ Overwrite
+
+
+
+ {dates.map(date =>
+ {formatDate(date)}
+
+ selectAction(date, 'merge')}>
- Merge
+ Merge
-
-
-
+
+ selectAction(date, 'overwrite')}>
- Overwrite
+ Overwrite
-
- )}
-
+
+ )}
+
}
- Close
+ Close
;
}
diff --git a/app/src/components/inventory/InventoryImportModal.test.js b/app/src/components/inventory/InventoryImportModal.test.js
index a51d661d..6ec7d604 100644
--- a/app/src/components/inventory/InventoryImportModal.test.js
+++ b/app/src/components/inventory/InventoryImportModal.test.js
@@ -1,7 +1,6 @@
import React from "react";
-import {fireEvent, render, screen, waitFor, within} from "@testing-library/react";
+import {fireEvent, render, screen, waitFor, within} from "../../test-utils";
-jest.mock("react-semantic-toasts-2", () => ({toast: jest.fn()}));
jest.mock("../../api", () => ({
getInventoryBackups: jest.fn(),
postInventoryRestorePreview: jest.fn(),
diff --git a/app/src/components/inventory/InventoryRoute.js b/app/src/components/inventory/InventoryRoute.js
index b7b9f72f..405ce824 100644
--- a/app/src/components/inventory/InventoryRoute.js
+++ b/app/src/components/inventory/InventoryRoute.js
@@ -1,6 +1,8 @@
import React, {useMemo, useState} from "react";
-import {Dropdown, Input, Select} from "semantic-ui-react";
-import {Button, Confirm, Header, Icon, Loader, Menu, Modal, Segment} from "../Theme";
+import {
+ Button, Confirm, Header, IconButton, Loading, Modal, Panel, SearchBox, Select, TabBar, tabClassName, Text,
+ TextInput,
+} from "../ui";
import {PageContainer, useTitle} from "../Common";
import {collectLocations, useCatalog, useInventories} from "../../hooks/customHooks";
import {filterItems, InventoryTable} from "./InventoryTable";
@@ -13,18 +15,17 @@ import {defaultGroupKey, defaultSumKey, findCaloriesKey, findCountKey} from "./s
import {FieldSchemaEditor} from "./FieldSchemaEditor";
import {CatalogEditor} from "./CatalogEditor";
import {InventoryImportModal} from "./InventoryImportModal";
-import {Media, ThemeContext} from "../../contexts/contexts";
+import {Media} from "../../contexts/contexts";
const INVENTORY_TYPES = [
- {key: 'food', value: 'food', text: 'Food Storage'},
- {key: 'fuel', value: 'fuel', text: 'Fuel'},
- {key: 'tool', value: 'tool', text: 'Tools'},
+ {value: 'food', label: 'Food Storage'},
+ {value: 'fuel', label: 'Fuel'},
+ {value: 'tool', label: 'Tools'},
];
function NewInventoryModal({open, onClose, onCreate}) {
const [name, setName] = useState('');
const [type, setType] = useState('food');
- const {t} = React.useContext(ThemeContext);
const create = async () => {
const inventory = await onCreate(name, type);
@@ -38,19 +39,19 @@ function NewInventoryModal({open, onClose, onCreate}) {
return
New Inventory
- setName(e.target.value)}
- placeholder='Food Storage' style={{marginBottom: '1em'}}/>
+ setName(e.currentTarget.value)}
+ placeholder='Food Storage' mb='1em'/>
Type:{' '}
-
setType(data.value)}/>
-
+
+
The type seeds a starting set of fields — you can customize them afterward.
-
+
- Cancel
- Create
+ Cancel
+ Create
;
}
@@ -76,8 +77,6 @@ export function InventoryRoute() {
// Free-text filter applied to the active inventory's items across every column.
const [search, setSearch] = useState('');
- const {t} = React.useContext(ThemeContext);
-
// Default to the first inventory once loaded.
React.useEffect(() => {
if (slug == null && inventories && inventories.length > 0) {
@@ -134,62 +133,61 @@ export function InventoryRoute() {
};
if (inventories === null) {
- return ;
+ return ;
}
- const inventoryOptions = inventories.map(i => ({key: i.slug, value: i.slug, text: i.name}));
+ const inventoryOptions = inventories.map(i => ({value: i.slug, label: i.name}));
+
+ const tabs = [
+ {key: 'items', label: 'Items'},
+ {key: 'summary', label: 'Summary'},
+ ...(caloriesKey ? [{key: 'ration', label: 'Ration'}] : []),
+ {key: 'export', label: 'Export'},
+ ];
return
-
+
- setSlug(data.value)}
+ onChange={value => setSlug(value)}
style={{minWidth: '14em'}}
/>
- setNewOpen(true)} aria-label='New inventory'>
-
-
- setCatalogOpen(true)} aria-label='Food catalog'>
- Catalog
-
+ setNewOpen(true)} label='New inventory'/>
+ setCatalogOpen(true)}>Catalog
{current && <>
- {
+ {
setRenameValue(current.name);
setRenaming(true);
- }} aria-label='Rename inventory'>
- setEditFieldsOpen(true)} aria-label='Customize fields'>
- Fields
-
- setImportOpen(true)} aria-label='Import or restore inventory'>
- Restore
-
- setConfirmDelete(true)} aria-label='Delete inventory'>
-
-
+ }} label='Rename inventory'/>
+ setEditFieldsOpen(true)}>Fields
+ setImportOpen(true)}>Restore
+ setConfirmDelete(true)}
+ label='Delete inventory'/>
>}
-
+
{current ? <>
-
- setTab('items')}/>
- setTab('summary')}/>
- {caloriesKey &&
- setTab('ration')}/>}
- setTab('export')}/>
-
+
+ {tabs.map(({key, label}) => setTab(key)}
+ >{label} )}
+
{tab === 'items' && <>
{/* The search filter lives here so it clearly applies to the Items tab only, not Summary/Ration/Export. */}
- setSearch(data.value)}/>
+
+
+
{/* Portrait mobile: condensed, read-only. Rotate to landscape (tablet+) for the full editor. */}
@@ -221,7 +219,7 @@ export function InventoryRoute() {
setImportOpen(false)}
slug={slug} name={current.name} onChanged={fetchInventories}/>
- > : Create an inventory to get started.
}
+ > : Create an inventory to get started. }
setNewOpen(false)} onCreate={onCreate}/>
@@ -231,21 +229,23 @@ export function InventoryRoute() {
setRenaming(false)} closeIcon size='tiny'>
Rename Inventory
- setRenameValue(e.target.value)}/>
+ setRenameValue(e.currentTarget.value)}/>
- setRenaming(false)}>Cancel
- Save
+ setRenaming(false)}>Cancel
+ Save
setConfirmDelete(false)}
onConfirm={doDelete}
- />
+ >
+ {`Delete "${current?.name}" and all its items? This cannot be undone.`}
+
;
}
diff --git a/app/src/components/inventory/InventoryTable.js b/app/src/components/inventory/InventoryTable.js
index 5d4a6c02..2cb82052 100644
--- a/app/src/components/inventory/InventoryTable.js
+++ b/app/src/components/inventory/InventoryTable.js
@@ -1,6 +1,5 @@
import React, {useMemo, useRef, useState} from "react";
-import {Checkbox, TableBody, TableCell, TableFooter, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
-import {Button, Icon, Table} from "../Theme";
+import {Button, Checkbox, Icon, Table} from "../ui";
import {FieldCell} from "./FieldCell";
import {applyComputedCounts, computedCountConfigs} from "./computeFields";
import {NUMERIC_TYPES} from "./units";
@@ -129,11 +128,11 @@ export function InventoryTable({slug, fields, items, locations, catalog, search,
const visibleItems = useMemo(() => filterItems(displayItems, fields, search), [displayItems, fields, search]);
const focusFirst = () => {
+ // Our inputs forward their ref straight to the underlying DOM node (unlike Semantic's Input,
+ // which wrapped it behind `.inputRef`).
const node = firstInputRef.current;
if (node && node.focus) {
node.focus();
- } else if (node && node.inputRef && node.inputRef.current) {
- node.inputRef.current.focus();
}
};
@@ -213,33 +212,32 @@ export function InventoryTable({slug, fields, items, locations, catalog, search,
{catalog.map(e => )}
}
{selected.size > 0 &&
-
+
Delete {selected.size} selected
}
-
-
-
-
- {sortedFields.map(f =>
+
+
+
+ {sortedFields.map(f => toggleSort(f.key)}
- style={{cursor: 'pointer'}}>
+ onSort={() => toggleSort(f.key)}>
{f.label}
- )}
-
-
-
+ )}
+
+
+
{visibleItems.map(item => {
const editing = edits[item.id];
const expired = isExpired(item);
- return
-
+ return
+
toggleSelected(item.id)}/>
- {expired && }
-
- {sortedFields.map(f =>
+ {expired && }
+
+ {sortedFields.map(f =>
{editing
? startEdit(item, f.key)} style={{cursor: 'pointer'}}>
{formatValue(item, f)}
}
- )}
- ;
+ )}
+ ;
})}
{search && visibleItems.length === 0 && (items || []).length > 0 &&
-
-
+
+
No items match "{search}".
-
- }
-
-
+
+ }
+
+
{/* The persistent new-item entry row. */}
-
-
- +
-
- {sortedFields.map((f, index) =>
+
+
+ +
+
+ {sortedFields.map((f, index) =>
- )}
-
-
+ )}
+
+
>;
}
diff --git a/app/src/components/inventory/InventoryTable.test.js b/app/src/components/inventory/InventoryTable.test.js
index cc52f146..e28c1963 100644
--- a/app/src/components/inventory/InventoryTable.test.js
+++ b/app/src/components/inventory/InventoryTable.test.js
@@ -1,5 +1,5 @@
import React from "react";
-import {fireEvent, render, screen, within} from "@testing-library/react";
+import {fireEvent, render, screen, within} from "../../test-utils";
import {filterItems, InventoryTable} from "./InventoryTable";
const FIELDS = [
@@ -252,14 +252,14 @@ describe('InventoryTable expired highlighting', () => {
{id: 1, name: 'Old', expiration_date: '2000-01-01'},
{id: 2, name: 'Fresh', expiration_date: '2999-01-01'},
];
- const {container} = render(
+ render(
);
const rowOf = (name) => screen.getByText(name).closest('tr');
- expect(rowOf('Old').className).toContain('negative');
- expect(rowOf('Fresh').className).not.toContain('negative');
+ expect(rowOf('Old').className).toContain('wrolpi-row-failed');
+ expect(rowOf('Fresh').className).not.toContain('wrolpi-row-failed');
// One expired row -> one warning icon.
- expect(container.querySelectorAll('i.warning.sign.icon').length).toBe(1);
+ expect(screen.getAllByLabelText('Expired').length).toBe(1);
});
});
diff --git a/app/src/components/ui/Inputs.tsx b/app/src/components/ui/Inputs.tsx
index 22f04602..330f56a7 100644
--- a/app/src/components/ui/Inputs.tsx
+++ b/app/src/components/ui/Inputs.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, {forwardRef} from 'react';
import {
Checkbox as MCheckbox,
MultiSelect,
@@ -71,13 +71,18 @@ export interface ActionInputProps extends React.ComponentProps
+export const ActionInput = forwardRef((
+ {action, className, ...props}, ref
+) => {
+ // The ref reaches the input, not the wrapper: callers use it to focus or select the
+ // field, and a ref to the surrounding div would do neither.
+ if (!action) return
return
-
+
{action}
-}
+});
+ActionInput.displayName = 'ActionInput';
export {
MTextInput as TextInput,
diff --git a/app/src/components/ui/Surfaces.tsx b/app/src/components/ui/Surfaces.tsx
index a5fd2d28..87bef8fb 100644
--- a/app/src/components/ui/Surfaces.tsx
+++ b/app/src/components/ui/Surfaces.tsx
@@ -21,6 +21,12 @@ export interface HeaderProps extends Omit
-
+
{icon && (typeof icon === 'string'
?
: )}
diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js
index 2812a15a..0e4a605a 100644
--- a/app/src/hooks/customHooks.js
+++ b/app/src/hooks/customHooks.js
@@ -58,7 +58,7 @@ import {
import {createSearchParams, useLocation, useSearchParams} from "react-router";
import {enumerate, filterToMimetypes, humanFileSize, secondsToFullDuration} from "../components/Common";
import {QueryContext, SettingsContext, StatusContext} from "../contexts/contexts";
-import {toast} from "react-semantic-toasts-2";
+import {toast} from "../components/ui";
import {useSearch} from "../components/Search";
import _ from "lodash";
import {TagsSelector} from "../Tags";
diff --git a/app/src/hooks/useForm.js b/app/src/hooks/useForm.js
index 0d205a84..3d6197bb 100644
--- a/app/src/hooks/useForm.js
+++ b/app/src/hooks/useForm.js
@@ -2,7 +2,7 @@ import React from "react";
import _ from "lodash";
import {InfoPopup, RequiredAsterisk, Toggle, validURL, validURLs} from "../components/Common";
import {Form, Icon, TextArea} from "../components/Theme";
-import Message from "semantic-ui-react/dist/commonjs/collections/Message";
+import {Message} from "../components/ui";
async function asyncNoOp() {
@@ -341,13 +341,20 @@ export function InputForm({
const [inputProps, inputAttrs] = form.getInputProps({name, path, validator, type, required, onChange});
inputProps.disabled = disabled || inputProps.disabled;
+ /*
+ * Callers build these with Semantic's shape -- {content, header, positive|negative} --
+ * and the pages that build them are migrating separately, so both spellings are
+ * accepted until they have all moved to {kind, title}.
+ */
let messageElm = null;
- if (message && message.positive) {
- messageElm = ;
- } else if (message && message.negative) {
- messageElm = ;
- } else if (message) {
- messageElm = ;
+ if (message) {
+ const kind = message.kind
+ || (message.positive && 'success')
+ || (message.negative && 'error')
+ || 'info';
+ messageElm =
+ {message.children ?? message.content}
+ ;
}
return <>
diff --git a/app/src/semantic-baseline.json b/app/src/semantic-baseline.json
index fb5ae591..8f52ca63 100644
--- a/app/src/semantic-baseline.json
+++ b/app/src/semantic-baseline.json
@@ -1,52 +1,17 @@
[
- "src/App.js",
- "src/DashboardPage.js",
- "src/Events.js",
- "src/Tags.js",
- "src/api.js",
"src/components/Archive.js",
"src/components/BulkTagModal.js",
"src/components/Channels.js",
- "src/components/DatesSelector.js",
"src/components/Docs.js",
- "src/components/Download.js",
"src/components/FileBrowser.js",
"src/components/FilePreview.js",
- "src/components/Files.js",
"src/components/Flasher.js",
- "src/components/HelpModal.js",
- "src/components/Map.js",
- "src/components/MapSearchView.js",
- "src/components/MapViewer.js",
"src/components/Nav.js",
"src/components/Playlists.js",
- "src/components/Search.js",
"src/components/Theme.tsx",
"src/components/Upload.js",
"src/components/VideoPlayer.js",
- "src/components/Videos.js",
"src/components/Zim.js",
"src/components/admin/ControllerPage.js",
- "src/components/admin/Downloads.js",
- "src/components/admin/Settings.js",
- "src/components/admin/Status.js",
- "src/components/calculators/DriveCalculator.js",
- "src/components/calculators/ElectricalCalculators.js",
- "src/components/calculators/FoodStorageCalculator.js",
- "src/components/calculators/HamCalculators.js",
- "src/components/calculators/OneTimePadCalculator.js",
- "src/components/calculators/RatioCalculator.js",
- "src/components/calculators/RationCalculator.js",
- "src/components/calculators/WaterCalculator.js",
- "src/components/collections/BatchReorganizeModal.js",
- "src/components/collections/CollectionReorganizeModal.js",
- "src/components/collections/CollectionTable.js",
- "src/components/collections/ConflictResolutionModal.js",
- "src/components/inventory/FieldCell.js",
- "src/components/inventory/FieldSchemaEditor.js",
- "src/components/inventory/InventoryImportModal.js",
- "src/components/inventory/InventoryRoute.js",
- "src/components/inventory/InventoryTable.js",
- "src/hooks/customHooks.js",
- "src/hooks/useForm.js"
+ "src/components/admin/Downloads.js"
]