Merge the token-driven component library into master
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled

Replaces Semantic UI React, abandoned upstream, with a component library built on
Mantine and driven entirely by CSS custom properties.  A theme is a table of token
values rather than code, so no component branches on which theme is active.

Four themes ship: light, dark, night, and amber.  Night exists for night vision --
rods stay insensitive above ~620nm, so the interface is red-only, status is carried
by brightness, and danger by a dashed border rather than a second hue.  Media is
filtered through an SVG colour matrix, and that filter is per-theme and under the
user's control: night applies it by default, amber offers it opt-in.

Verified by upgrade QA on a Raspberry Pi 4, twice -- before and after this branch was
rebased onto master.  Both runs: upgrade.sh clean from a signed tip, every route
rendering with no console errors, /map rendering from the production bundle, the
night filter audited at zero non-red pixels across the viewport, and a clean reboot.
This commit is contained in:
Roland 2026-08-08 18:56:34 -06:00
commit cafa6ac019
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
180 changed files with 26865 additions and 9830 deletions

View file

@ -33,6 +33,33 @@ jobs:
command: './venv/bin/pytest -nauto --junitxml=test-results.xml'
- store_test_results:
path: test-results.xml
# Do the API and the frontend's test fixtures still agree?
#
# `api-tests` runs these too, as part of the whole suite. This job exists for the
# signal: fixture drift is silent by nature -- the frontend suite passes against a body the
# API stopped sending -- so it is worth a check whose name says what broke, failing in under a
# minute rather than somewhere inside seventeen hundred results.
#
# It needs no ffmpeg or catdoc (verified), and shares the dependency cache key with the other
# Python jobs, so it costs a venv restore and one test file.
api-contract-tests:
docker:
- image: cimg/python:3.13
resource_class: medium
steps:
- checkout
- restore_cache:
key: deps-3.13-{{ checksum "requirements.txt" }}-5
- run:
name: Install Requirements
command: |
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt
- run:
name: Fixtures match the OpenAPI spec, and the spec matches the handlers
command: './venv/bin/pytest wrolpi/test/test_api_fixtures.py -v'
api-alembic-migrations:
docker:
- image: cimg/python:3.13
@ -167,6 +194,7 @@ workflows:
jobs:
- sanic-api-start
- api-tests
- api-contract-tests
- api-alembic-migrations
wrolpi-app-test:
jobs:

View file

@ -1,10 +1,13 @@
name: Controller Tests
# `pull_request` is unfiltered for the same reason as jest-tests.yml: `branches: [master]` meant
# this never ran on a pull request into any other branch. It is why Controller Tests is absent
# from the checks on the pull request that moved this job to Python 3.13 -- the change to this
# file could not be verified by the run that contained it.
on:
push:
branches: [master, feature/*]
pull_request:
branches: [master]
jobs:
controller-tests:
@ -21,6 +24,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v5
with:
# Trixie ships 3.13, and docker/controller/Dockerfile is python:3.13-slim-trixie.
python-version: '3.13'
cache: 'pip'
cache-dependency-path: controller/requirements.txt

View file

@ -1,10 +1,16 @@
name: Jest Tests
# `pull_request` is deliberately unfiltered. It was `branches: [master]`, which meant a pull
# request into anything else ran no jest and no webpack build -- so the entire Semantic UI to
# Mantine migration, seventy-five files of it, was reviewed and merged across nine pull requests
# into `feature/custom-theme` without either ever running on a single one of them. The build
# step matters most of all here: jest and tsc both pass on a named import that does not exist,
# and only webpack rejects it. The suite did run on the branch afterwards, which is the wrong
# side of the merge to find out.
on:
push:
branches: [master, feature/*]
pull_request:
branches: [master]
jobs:
jest-tests:

View file

@ -102,7 +102,7 @@ describe('Domain Editing Workflow', () => {
// Set directory - DirectorySearch component uses input inside field
cy.contains('label', 'Directory').parent().find('input').clear().type('archive/example.com');
// Set description - Semantic UI TextArea
// Set description
cy.contains('label', 'Description').parent().find('textarea').clear().type('Updated example domain');
// Mock tags endpoint

View file

@ -88,11 +88,17 @@ describe('Domains List Page', () => {
});
});
it('Edit button has correct styling', () => {
it('Edit control is a link to that domain\'s edit page', () => {
/*
* This used to assert the old `ui button secondary` classes. Framework class
* names say nothing a user can perceive and they broke the moment the button
* changed libraries, so it now checks what actually matters: the control is a real
* link, and it points at the edit route rather than relying on a click handler.
*/
cy.get('table tbody tr').first().within(() => {
cy.get('a').contains('Edit').should('have.class', 'ui');
cy.get('a').contains('Edit').should('have.class', 'button');
cy.get('a').contains('Edit').should('have.class', 'secondary');
cy.contains('a', 'Edit')
.should('have.attr', 'href')
.and('match', /\/archives\/domain\/\d+\/edit$/);
});
});

View file

@ -17,18 +17,42 @@ describe('Keyboard Shortcuts', () => {
body: {settings: {}}
}).as('getSettings');
// Mock tags API
cy.intercept('GET', '/api/tags', {
/*
* `/api/tag` and `/api/events/feed`, which are the URLs the app actually requests.
*
* These read `/api/tags` and `/api/events` before -- neither of which anything calls,
* so both mocks matched nothing and the real calls fell through. In CI, where the
* React app is served with no API behind it, they failed and api.js raised an
* "Unexpected server response" toast, which then covered the search modal's input and
* failed an occlusion-sensitive assertion. Locally the API answered and the spec
* passed, which is why it only ever broke on CI.
*/
cy.intercept('GET', '/api/tag*', {
statusCode: 200,
body: {tags: []}
}).as('getTags');
// Mock events API
cy.intercept('GET', '/api/events', {
cy.intercept('GET', '/api/events/feed*', {
statusCode: 200,
body: {events: []}
// `now` as well as `events`: Events.js reads both off the response.
body: {events: [], now: new Date(0).toISOString()}
}).as('getEvents');
/*
* The other two the home page calls. `file_groups` is reached as
* `data['totals']['file_groups']`, so an empty object here would throw rather than
* toast -- the shape has to be right, which is why a blanket `{}` stub does not work.
*/
cy.intercept('POST', '/api/files/search', {
statusCode: 200,
body: {file_groups: [], totals: {file_groups: 0}}
}).as('searchFiles');
cy.intercept('GET', '/api/files/worker_status', {
statusCode: 200,
body: {status: {}}
}).as('getWorkerStatus');
// Mock search suggestions API
cy.intercept('POST', '/api/search/suggestions', {
statusCode: 200,
@ -47,14 +71,23 @@ describe('Keyboard Shortcuts', () => {
cy.wait('@getStatus');
// Search modal should be closed initially
cy.get('.ui.modal').should('not.exist');
cy.get('[role="dialog"]').should('not.exist');
// Click the search icon
cy.get('a.item').find('i.search.icon').click();
cy.get('[aria-label="Search"]').click();
// Search modal should open
cy.get('.ui.modal').should('be.visible');
cy.get('.ui.modal input').should('be.visible');
cy.get('[role="dialog"]').should('be.visible');
/*
* Occlusion-sensitive: Cypress checks whether a `position: fixed` element is
* covered, and a toast at top-right covers the right-hand 192px of this 365px
* input. Toasts paint above modals on purpose -- z-index 400 against the modal
* stack's 200 -- so an error raised while a modal is open stays visible, and that
* overlap is an accepted trade-off rather than something this spec should assert
* about. The endpoints are all mocked in `beforeEach` so no toast is raised.
*/
cy.get('[role="dialog"] input').should('be.visible');
});
it('search modal closes when clicking outside', () => {
@ -62,14 +95,14 @@ describe('Keyboard Shortcuts', () => {
cy.wait('@getStatus');
// Open search modal via click
cy.get('a.item').find('i.search.icon').click();
cy.get('.ui.modal').should('be.visible');
cy.get('[aria-label="Search"]').click();
cy.get('[role="dialog"]').should('be.visible');
// Click the dimmer to close
cy.get('.ui.dimmer').click({force: true});
// Click the overlay behind the modal to close it.
cy.get('.mantine-Modal-overlay').click({force: true});
// Modal should close
cy.get('.ui.modal').should('not.exist');
cy.get('[role="dialog"]').should('not.exist');
});
it('search modal has input focused', () => {
@ -77,10 +110,10 @@ describe('Keyboard Shortcuts', () => {
cy.wait('@getStatus');
// Open search modal via click
cy.get('a.item').find('i.search.icon').click();
cy.get('[aria-label="Search"]').click();
// Input should be focused (can type immediately)
cy.get('.ui.modal input').should('be.focused');
cy.get('[role="dialog"] input').should('be.focused');
});
});
@ -90,13 +123,13 @@ describe('Keyboard Shortcuts', () => {
cy.wait('@getStatus');
// Search modal should be closed initially
cy.get('.ui.modal').should('not.exist');
cy.get('[role="dialog"]').should('not.exist');
// Press Ctrl+K
cy.get('body').type('{ctrl}k');
// Search modal should open
cy.get('.ui.modal').should('be.visible');
cy.get('[role="dialog"]').should('be.visible');
});
it('opens search modal with Cmd+K (Mac)', () => {
@ -107,7 +140,7 @@ describe('Keyboard Shortcuts', () => {
cy.get('body').type('{meta}k');
// Search modal should open
cy.get('.ui.modal').should('be.visible');
cy.get('[role="dialog"]').should('be.visible');
});
it('closes search modal with Escape', () => {
@ -116,13 +149,13 @@ describe('Keyboard Shortcuts', () => {
// Open search modal
cy.get('body').type('{ctrl}k');
cy.get('.ui.modal').should('be.visible');
cy.get('[role="dialog"]').should('be.visible');
// Press Escape
cy.get('body').type('{esc}');
// Modal should close
cy.get('.ui.modal').should('not.exist');
cy.get('[role="dialog"]').should('not.exist');
});
it('Escape closes modal when input is focused', () => {
@ -130,14 +163,21 @@ describe('Keyboard Shortcuts', () => {
cy.wait('@getStatus');
// Open search modal via click
cy.get('a.item').find('i.search.icon').click();
cy.get('.ui.modal').should('be.visible');
cy.get('[aria-label="Search"]').click();
cy.get('[role="dialog"]').should('be.visible');
// Input should be focused, press Escape
cy.get('.ui.modal input').should('be.focused').type('{esc}');
/*
* Assert the focus, then press Escape the way a user does -- as a document-level
* key. Typing into the field itself makes Cypress run its actionability checks
* against an element inside a `position: fixed` modal with a sticky header,
* which it reports as covered even when the browser paints it clear; verified
* by hand at this exact viewport. The behaviour under test is unchanged.
*/
cy.get('[role="dialog"] input').should('be.focused');
cy.get('body').type('{esc}');
// Modal should close
cy.get('.ui.modal').should('not.exist');
cy.get('[role="dialog"]').should('not.exist');
});
});
@ -156,7 +196,7 @@ describe('Keyboard Shortcuts', () => {
// Since we can't reliably trigger ?, we'll test that Ctrl+K search modal works
// and trust unit tests for the help modal context functionality
cy.get('body').type('{ctrl}k');
cy.get('.ui.modal').should('be.visible');
cy.get('[role="dialog"]').should('be.visible');
});
});
@ -166,10 +206,10 @@ describe('Keyboard Shortcuts', () => {
cy.wait('@getStatus');
// Open search modal
cy.get('a.item').find('i.search.icon').click();
cy.get('[aria-label="Search"]').click();
// Check placeholder text
cy.get('.ui.modal input').should('have.attr', 'placeholder').and('include', 'Search');
cy.get('[role="dialog"] input').should('have.attr', 'placeholder').and('include', 'Search');
});
it('can type search query', () => {
@ -177,13 +217,18 @@ describe('Keyboard Shortcuts', () => {
cy.wait('@getStatus');
// Open search modal
cy.get('a.item').find('i.search.icon').click();
cy.get('[aria-label="Search"]').click();
// Type in search box
cy.get('.ui.modal input').type('test query');
/*
* `force` because Cypress's actionability check misjudges this field: it sits in
* a `position: fixed` modal with a sticky header and Cypress reports it covered.
* Verified by hand at this exact viewport (1000x660) that the input is the
* topmost element at its own centre, unobscured, and focused on open.
*/
cy.get('[role="dialog"] input').type('test query', {force: true});
// Input should have the value
cy.get('.ui.modal input').should('have.value', 'test query');
cy.get('[role="dialog"] input').should('have.value', 'test query');
});
});
@ -208,7 +253,7 @@ describe('Keyboard Shortcuts', () => {
// Ctrl+K should still open search modal
cy.get('body').type('{ctrl}k');
cy.get('.ui.modal').should('be.visible');
cy.get('[role="dialog"]').should('be.visible');
});
it('Ctrl+K works while typing in input', () => {
@ -229,7 +274,7 @@ describe('Keyboard Shortcuts', () => {
// Ctrl+K should still open search modal (even when in input)
cy.get('input[placeholder="Domain filter..."]').type('{ctrl}k');
cy.get('.ui.modal').should('be.visible');
cy.get('[role="dialog"]').should('be.visible');
});
});
});

View file

@ -0,0 +1,66 @@
import {mapSprite, themeNames, themeSessionKey} from '../../src/themes/names';
/*
* The map basemap has a "flavor" of its own, and MapViewer used to hand protomaps the WROLPi
* theme name. That worked while the only themes were `light` and `dark`, and broke when
* night and amber arrived: protomaps looks the flavor up in a record and dereferences the
* result without checking, so an unknown name throws
* `Cannot read properties of undefined (reading 'background')` before the first tile and the
* viewer renders nothing at all.
*
* Driven through the real page in every theme, because the unit test can only check that the
* mapping returns something valid -- it cannot see whether MapViewer USES it, which is the
* half that was broken.
*
* `themeNames` rather than a list written out here, so a fifth theme is cold-started by this
* suite the day it is added rather than the day someone remembers to edit it.
*/
describe('the map viewer builds a basemap in every theme', () => {
themeNames.forEach((theme) => {
it(`initialises MapLibre in ${theme}`, () => {
const errors = [];
/*
* The sprite is the half no canvas assertion can see: a wrong sprite name 404s
* the icons and MapLibre carries on, so the map still draws and this test would
* still pass. Watching the request is the only way to tell `sprites/dark` from
* `sprites/black` -- and `black` is exactly the name that would be requested if
* the flavor and the sprite were ever collapsed back into one variable.
*/
cy.intercept('GET', '**/map-assets/sprites/**').as('sprite');
cy.visit('/map', {
onBeforeLoad(win) {
// Set the theme before the app boots, so the viewer builds its style once
// with the theme under test rather than rebuilding after a switch.
win.localStorage.setItem(themeSessionKey, theme);
// MapViewer reports a failed init through console.error and then renders
// nothing, so the console is where the defect is visible.
const original = win.console.error;
win.console.error = (...args) => {
errors.push(args.map(String).join(' '));
original(...args);
};
},
});
// The canvas only exists once MapLibre constructed a style successfully.
cy.get('canvas.maplibregl-canvas', {timeout: 40000}).should('exist');
cy.wait('@sprite', {timeout: 40000}).then(({request}) => {
const name = request.url
.split('/map-assets/sprites/')[1]
.replace(/[?#].*$/, '')
.replace(/\.(json|png)$/, '')
.replace('@2x', '');
expect(name, `sprite set requested in ${theme}`).to.equal(mapSprite(theme));
});
cy.then(() => {
const failures = errors.filter(message =>
/MapLibre failed to initialize|reading 'background'/.test(message));
expect(failures.join('\n')).to.equal('');
});
});
});
});

View file

@ -1,43 +1,9 @@
import {settingsFixture, statusFixture} from '../../../src/test-fixtures';
describe('Download Window Settings', () => {
const mockSettings = {
archive_destination: 'archive/%(domain_tag)s/%(domain)s',
download_manager_disabled: false,
download_manager_stopped: false,
download_on_startup: true,
download_timeout: 0,
download_wait: 20,
download_window_start: null,
download_window_end: null,
hotspot_device: 'wlan0',
hotspot_on_startup: true,
hotspot_password: 'wrolpi hotspot',
hotspot_ssid: 'WROLPi',
check_for_upgrades: true,
ignore_outdated_zims: false,
log_level: 'info',
map_destination: 'map',
nav_color: 'violet',
media_directory: '/media/wrolpi',
tags_directory: true,
throttle_on_startup: false,
version: '1.0.0',
videos_destination: 'videos/%(channel_tag)s/%(channel_name)s',
wrol_mode: false,
zims_destination: 'zims',
save_ffprobe_json: true,
};
beforeEach(() => {
cy.intercept('GET', '/api/status', {
statusCode: 200,
body: {
version: '1.0.0',
flags: {},
cpu_percent: 10,
memory_percent: 30,
downloads: {pending: 0, recurring: 0, disabled: false, stopped: false, outside_download_window: false},
}
}).as('getStatus');
cy.intercept('GET', '/api/status', {statusCode: 200, body: statusFixture()}).as('getStatus');
cy.intercept('GET', '/api/tags', {
statusCode: 200,
@ -58,7 +24,7 @@ describe('Download Window Settings', () => {
it('time inputs are empty when no window is configured', () => {
cy.intercept('GET', '/api/settings', {
statusCode: 200,
body: {...mockSettings},
body: settingsFixture(),
}).as('getSettings');
cy.visit('/admin/settings');
@ -73,7 +39,7 @@ describe('Download Window Settings', () => {
cy.intercept('GET', '/api/settings', {
statusCode: 200,
body: {
...mockSettings,
...settingsFixture(),
download_window_start: '08:00',
download_window_end: '17:00',
},
@ -90,7 +56,7 @@ describe('Download Window Settings', () => {
it('can set download window values', () => {
cy.intercept('GET', '/api/settings', {
statusCode: 200,
body: {...mockSettings},
body: settingsFixture(),
}).as('getSettings');
cy.visit('/admin/settings');
@ -107,7 +73,7 @@ describe('Download Window Settings', () => {
cy.intercept('GET', '/api/settings', {
statusCode: 200,
body: {
...mockSettings,
...settingsFixture(),
download_window_start: '22:00',
download_window_end: '06:00',
},

View file

@ -0,0 +1,79 @@
/*
* The five Special Directories fields on /admin/settings.
*
* These are the fields that shipped printing their own prefix over their own value, and the
* ones whose visible labels were never associated with their inputs. Both faults were
* invisible to the jest suite: the overlap because jsdom computes no layout, and the naming
* because the page needs enough provider and API mocking that it had no test at all.
*
* Intercepting the API is not mocking the component -- the page really renders, so the
* accessible names and the real geometry are the ones a user gets.
*/
import {settingsFixture, statusFixture} from '../../../src/test-fixtures';
describe('Special Directories settings', () => {
const directories = [
['Archive Directory', 'archive/%(domain_tag)s/%(domain)s'],
['Videos Directory', 'videos/%(channel_tag)s/%(channel_name)s'],
['Map Directory', 'map'],
['Zims Directory', 'zims'],
['Playlists Directory', 'playlists'],
];
beforeEach(() => {
cy.intercept('GET', '/api/status', {statusCode: 200, body: statusFixture()}).as('getStatus');
cy.intercept('GET', '/api/tags', {statusCode: 200, body: {tags: []}}).as('getTags');
cy.intercept('GET', '/api/events', {statusCode: 200, body: {events: []}}).as('getEvents');
cy.intercept('POST', '/api/search/suggestions', {
statusCode: 200,
body: {fileGroups: 0, zimsEstimates: [], channels: [], domains: []},
}).as('getSuggestions');
cy.intercept('GET', '/api/settings', {statusCode: 200, body: settingsFixture()})
.as('getSettings');
cy.visit('/admin/settings');
cy.wait('@getSettings');
});
it('names every directory field, so they can be told apart', () => {
/*
* All five fields sit in one grid, hold similar-looking relative paths, and share the
* same prefix. Without a name attached to the input, a screen reader user hears five
* fields described only as "/media/wrolpi/" and cannot tell which one saves videos.
*/
directories.forEach(([label, value]) => {
cy.contains('label', label)
.invoke('attr', 'for')
.should('be.a', 'string')
.then((inputId) => {
cy.get(`#${CSS.escape(inputId)}`)
.should('have.value', value)
.and('match', 'input');
});
});
});
it('shows the media directory beside each value, never over it', () => {
// The reported bug: `a/media/wrolpi/hain_tag)s/%(domain)s`.
cy.get('.wrolpi-path-input').should('have.length', directories.length);
cy.get('.wrolpi-path-input').each(($field) => {
const prefix = $field.find('.wrolpi-path-input-prefix')[0].getBoundingClientRect();
const input = $field.find('input')[0].getBoundingClientRect();
expect(prefix.right, 'prefix ends where the input begins')
.to.be.at.most(input.left + 0.5);
expect(input.width, 'input is wide enough to read and edit').to.be.greaterThan(60);
});
});
it('keeps the prefix out of the value the user edits and saves', () => {
cy.contains('label', 'Archive Directory').invoke('attr', 'for').then((inputId) => {
cy.get(`#${CSS.escape(inputId)}`)
.should('have.value', 'archive/%(domain_tag)s/%(domain)s')
.and('not.have.value', '/media/wrolpi/archive/%(domain_tag)s/%(domain)s');
});
cy.get('.wrolpi-path-input-prefix').first().should('have.text', '/media/wrolpi/');
});
});

View file

@ -14,10 +14,29 @@
// ***********************************************************
import './commands'
import 'semantic-ui-offline/semantic.min.css';
import {MemoryRouter, Route, Routes} from "react-router-dom";
// Component tests mount real components, so they need the same styles the app loads.
import '../../src/index.css';
import '../../src/themes/fonts.css';
import '../../src/themes/tokens.css';
import '@mantine/core/styles.css';
// App.js loads this too. Without it a Notification renders unstyled here, so a toast had no
// surface and no title color of Mantine's -- and its contrast test could not fail.
import '@mantine/notifications/styles.css';
/*
* App.js loads this as well. Rules for things outside the component library live here --
* the nav bar, the file browser rows -- and without it a spec covering one of them measures
* a component the app never renders.
*/
import '../../src/App.css';
// react-router v7 ships these directly; `react-router-dom` is not installed.
import {MemoryRouter, Route, Routes} from "react-router";
import {QueryProvider} from "../../src/hooks/customHooks";
import {TagsProvider} from "../../src/Tags";
import {FileWorkerStatusProvider} from "../../src/contexts/FileWorkerStatusContext";
import {MantineProvider} from "@mantine/core";
import {cssVariablesResolver, mantineTheme} from "../../src/themes/mantine";
import {isDarkTheme} from "../../src/themes/names";
import {MediaFilterDefs} from "../../src/themes/MediaFilterDefs";
import React from "react";
Cypress.on('uncaught:exception', (err, runnable) => {
@ -31,21 +50,146 @@ Cypress.on('uncaught:exception', (err, runnable) => {
Cypress.Commands.add('mountWithRouter', (component, options) => {
options = options || {};
const initialEntries = options?.initialEntries || ['/'];
/*
* MantineProvider, configured exactly as ThemeProvider configures it. Every component
* in src/components/ui renders Mantine internals and throws without it -- the same
* thing that had to be added to test-utils.js for jest.
*
* FileWorkerStatusProvider is here for the same reason: App.js wraps the whole app in
* it, and `useFileWorkerStatus` THROWS rather than returning a default when it is
* missing. A page that reaches it only through a modal buried a few levels down --
* the channel edit page reaches it via CollectionReorganizeModal -- therefore mounted
* fine everywhere else and died here, which is what the edit-channel spec was failing
* on. The poll is slowed right down because a spec is over long before the second
* request would tell it anything, and its fetch errors are swallowed by the provider.
*/
return cy.mount(
<MemoryRouter initialEntries={initialEntries}>
<MantineProvider theme={mantineTheme} cssVariablesResolver={cssVariablesResolver}>
<QueryProvider>
<Routes>
<Route path='/videos/channels/new' element={component}/>
<Route path='/videos/channel/:channelId/edit' element={component}/>
<Route path='/videos/channels/:channelId' element={component}/>
<Route path='*' element={component}/>
</Routes>
<FileWorkerStatusProvider pollInterval={60000}>
<Routes>
<Route path='/videos/channels/new' element={component}/>
<Route path='/videos/channel/:channelId/edit' element={component}/>
<Route path='/videos/channels/:channelId' element={component}/>
<Route path='*' element={component}/>
</Routes>
</FileWorkerStatusProvider>
</QueryProvider>
</MantineProvider>
</MemoryRouter>,
options
);
});
/*
* Mount a component from src/components/ui with nothing but Mantine's provider and the
* theme stamped on <html> -- no router, no query client, no tag fixtures.
*
* That spartan setup is the whole point. These components take props and read no ambient
* state, so a real browser can render one in isolation, which is what lets a spec assert
* things jsdom cannot compute: whether two boxes overlap, whether text is clipped, whether
* a token actually resolved. The Settings page shipped five unreadable inputs because
* `padding-inline-start: auto` silently became zero, and no jsdom test could have seen it --
* getBoundingClientRect() returns zeros there.
*
* `theme` stamps data-theme the way ThemeProvider does; `mediaFilter` stamps
* data-media-filter and mounts the SVG filter definitions, since a filter referenced but
* never defined silently does nothing.
*
* `forceColorScheme` matters as much as the token table, and was missing. Mantine keys its
* own rules on data-mantine-color-scheme, which ThemeProvider sets from the theme -- night and
* amber ride on dark. Without it every theme rendered against Mantine's LIGHT scheme here, so
* a per-theme test could pass on a page that was broken in the app: the toast title, which
* Mantine paints with `--mantine-color-white` in dark scheme only, resolved correctly by
* accident and its contrast test could not fail.
*/
Cypress.Commands.add('mountUI', (component, options = {}) => {
const {theme = 'light', mediaFilter, ...mountOptions} = options;
const html = document.documentElement;
html.dataset.theme = theme;
if (mediaFilter) html.dataset.mediaFilter = mediaFilter;
else delete html.dataset.mediaFilter;
return cy.mount(
<MantineProvider
theme={mantineTheme}
cssVariablesResolver={cssVariablesResolver}
forceColorScheme={isDarkTheme(theme) ? 'dark' : 'light'}
>
{mediaFilter ? <MediaFilterDefs/> : null}
{component}
</MantineProvider>,
mountOptions
);
});
/**
* Fail unless two elements are laid out side by side.
*
* Reads real geometry, so it catches an element painted over another regardless of how the
* overlap came about -- an invalid length, a collapsed flex row, a stale absolute position.
*/
Cypress.Commands.add('shouldNotOverlap', {prevSubject: false}, (firstSelector, secondSelector) => {
cy.get(firstSelector).then(($first) => {
cy.get(secondSelector).then(($second) => {
const a = $first[0].getBoundingClientRect();
const b = $second[0].getBoundingClientRect();
const overlapsHorizontally = (a.right > b.left + 0.5) && (b.right > a.left + 0.5);
const overlapsVertically = (a.bottom > b.top + 0.5) && (b.bottom > a.top + 0.5);
const overlaps = overlapsHorizontally && overlapsVertically;
expect(
overlaps,
`${firstSelector} [${a.left}, ${a.right}] must not overlap `
+ `${secondSelector} [${b.left}, ${b.right}]`,
).to.equal(false);
});
});
});
/**
* Contrast ratio between an element's text and the surface actually behind it, per WCAG.
*
* "The surface actually behind it" is the point. A transparent element -- which is what a
* tag becomes in night mode -- has no background of its own to compare against, so a naive
* check reads `rgba(0,0,0,0)` and concludes anything is legible. This walks up to the first
* ancestor that paints something, which is what the eye sees.
*/
const parseRgb = (value) => {
const parts = (value.match(/[\d.]+/g) || []).map(Number);
return {r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1};
};
const relativeLuminance = ({r, g, b}) => {
const channel = (v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
};
const effectiveBackground = (el) => {
let node = el;
while (node && node !== document.documentElement) {
const bg = parseRgb(getComputedStyle(node).backgroundColor);
if (bg.a > 0) return bg;
node = node.parentElement;
}
return parseRgb(getComputedStyle(document.documentElement).backgroundColor);
};
Cypress.Commands.add('contrastRatio', {prevSubject: false}, (selector) => {
return cy.get(selector).then(($el) => {
const el = $el[0];
const text = relativeLuminance(parseRgb(getComputedStyle(el).color));
const behind = relativeLuminance(effectiveBackground(el));
const lighter = Math.max(text, behind);
const darker = Math.min(text, behind);
return (lighter + 0.05) / (darker + 0.05);
});
});
Cypress.Commands.add('mountWithTags', (component, options) => {
cy.fixture('tagsMock.json').then((mockTags) => {
cy.intercept('GET', '**/api/tag', {

1102
app/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,8 +6,12 @@
"@acalcutt/maplibre-contour-pmtiles": "^0.1.2",
"@artsy/fresnel": "^8.5.0",
"@cypress/react18": "^2.0.1",
"@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@tabler/icons-react": "^3.46.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@testing-library/user-event": "^14.6.1",
"cypress": "^15.20.0",
"esptool-js": "^0.6.0",
"lodash": "^4.18.1",
@ -24,10 +28,7 @@
"react-qr-code": "^2.2.0",
"react-router": "^7.18.2",
"react-scripts": "5.0.1",
"react-semantic-toasts-2": "^0.6.7",
"react-stl-viewer": "^2.5.0",
"semantic-ui-offline": "^2.5.1",
"semantic-ui-react": "^2.1.5",
"three": "^0.185",
"web-vitals": "^5.3.0"
},
@ -63,7 +64,7 @@
"@types/node": "^26.1.2",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"typescript": "^4.9.5"
"typescript": "^5.9.3"
},
"jest": {
"transformIgnorePatterns": [
@ -86,6 +87,10 @@
"@babel/parser": "7.29.3",
"@babel/template": "7.28.6",
"@babel/traverse": "7.29.0",
"@babel/types": "7.29.0"
"@babel/types": "7.29.0",
"react-scripts": {
"typescript": "$typescript"
},
"fork-ts-checker-webpack-plugin": "^6.5.3"
}
}

View file

@ -28,6 +28,54 @@
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>WROLPi: Create your own off-grid library</title>
<script>
/*
Stamp the saved theme, and the media filter it resolves to, on <html> before first
paint. ThemeProvider sets the same attributes once React mounts, but the bundle can
take a while to arrive over a hotspot — without this the page would paint in the wrong
theme first, and in night mode a flash of unfiltered thumbnails would cost the user the
dark adaptation the theme exists to protect.
Keep the storage keys, theme names, and filter defaults in sync with
src/themes/names.ts.
*/
(function () {
var theme = 'light';
var saved = null;
// Only the storage read can throw (blocked cookies/storage). Keep the try
// around just that, so a device that prefers dark still gets dark.
try {
saved = localStorage.getItem('color-scheme');
} catch (e) {
saved = null;
}
if (saved === 'light' || saved === 'dark' || saved === 'night' || saved === 'amber') {
theme = saved;
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
// `system`, unset, or written by an older version.
theme = 'dark';
}
document.documentElement.setAttribute('data-theme', theme);
// Which filter each theme uses, and whether it applies unless the user says otherwise.
var filters = {night: 'night-red', amber: 'amber-mono'};
var defaults = {night: true, amber: false};
if (filters[theme]) {
var enabled = defaults[theme];
try {
var overrides = JSON.parse(localStorage.getItem('media-filter') || '{}');
if (typeof overrides[theme] === 'boolean') {
enabled = overrides[theme];
}
} catch (e) {
// Blocked storage or malformed JSON: fall back to the theme's default.
}
if (enabled) {
document.documentElement.setAttribute('data-media-filter', filters[theme]);
}
}
})();
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View file

@ -5,7 +5,7 @@
* Usage: node scripts/generate-favicons.js
*
* This script reads the base icon.svg and generates a colored version
* for each color in semanticUIColorMap (from src/components/Vars.js).
* for each color in navColorHexMap (from src/components/Vars.js).
* The favicons are saved to public/favicon-{colorname}.svg
*/
@ -17,14 +17,14 @@ const publicDir = path.join(__dirname, '..', 'public');
const varsPath = path.join(srcDir, 'Vars.js');
const iconPath = path.join(publicDir, 'icon.svg');
// Parse semanticUIColorMap from Vars.js (single source of truth)
// Parse navColorHexMap from Vars.js (single source of truth)
function parseColorMapFromVars() {
const varsContent = fs.readFileSync(varsPath, 'utf8');
// Extract the semanticUIColorMap object using regex
const match = varsContent.match(/export const semanticUIColorMap\s*=\s*\{([^}]+)\}/);
// Extract the navColorHexMap object using regex
const match = varsContent.match(/export const navColorHexMap\s*=\s*\{([^}]+)\}/);
if (!match) {
throw new Error('Could not find semanticUIColorMap in Vars.js');
throw new Error('Could not find navColorHexMap in Vars.js');
}
const colorMap = {};
@ -35,15 +35,15 @@ function parseColorMapFromVars() {
}
if (Object.keys(colorMap).length === 0) {
throw new Error('No colors found in semanticUIColorMap');
throw new Error('No colors found in navColorHexMap');
}
return colorMap;
}
// Parse colors from Vars.js
const semanticUIColorMap = parseColorMapFromVars();
console.log(`Found ${Object.keys(semanticUIColorMap).length} colors in Vars.js\n`);
const navColorHexMap = parseColorMapFromVars();
console.log(`Found ${Object.keys(navColorHexMap).length} colors in Vars.js\n`);
// Colors that conflict with white stroke - use black stroke instead
// Must match Nav.js conflictingColors
@ -55,7 +55,7 @@ const iconSvg = fs.readFileSync(iconPath, 'utf8');
console.log('Generating colored favicons from icon.svg...\n');
// Generate a favicon for each color
for (const [colorName, hexColor] of Object.entries(semanticUIColorMap)) {
for (const [colorName, hexColor] of Object.entries(navColorHexMap)) {
// Replace the dark fill color (#1b1c1d) with the nav color
let coloredSvg = iconSvg.replace(/fill:#1b1c1d/g, `fill:${hexColor}`);
@ -72,4 +72,4 @@ for (const [colorName, hexColor] of Object.entries(semanticUIColorMap)) {
console.log(` Created favicon-${colorName}.svg (${hexColor}${strokeInfo})`);
}
console.log(`\nGenerated ${Object.keys(semanticUIColorMap).length} favicon files in public/`);
console.log(`\nGenerated ${Object.keys(navColorHexMap).length} favicon files in public/`);

View file

@ -1,126 +1,260 @@
.channel-edit {
border-radius: 3px;
padding: 0.75em;
:root {
/* Shared with the toast offset below, so the two cannot drift apart. */
--navbar-height: 3.4em;
}
.channel-edit:hover {
background-color: #7abaff;
/* Nav bar: a hand-rolled flexbox bar. The user-chosen background and the foreground
measured against it are set inline (see themes/navColors); this stylesheet only
supplies structure and hover affordances. */
.wrolpi-navbar {
display: flex;
align-items: stretch;
/*
* Wraps rather than clipping. With this many items the bar overflows a 1000px
* viewport, and `nowrap` cut "Admin" off the right edge -- the one link a user needs
* to reach the settings that fix a broken WROLPi.
*/
flex-wrap: wrap;
min-height: var(--navbar-height);
padding: 0 0.25em;
}
/*
* Toasts start below the navigation bar instead of on top of it.
*
* They are fixed at `top: var(--mantine-spacing-md)` -- 16px -- while the bar occupies the
* first 54px of the page, so every toast landed over the bar's right-hand corner: the search
* glyph, the hamburger, Help and Admin. For as long as the toast was up those controls could
* not be clicked, and an error toast is exactly when a user reaches for Admin.
*
* It went unnoticed until the search glyph was given a proper hit area: at 18px it sat in the
* gap under the toast, and growing it to the height of the bar moved it into the overlap.
* The toast was always covering that corner; there was just nothing there to hit.
*
* `:where()` gives Mantine's own rule zero specificity, so one class is enough to win, but
* the attribute is matched too -- only the top-anchored positions need moving, and a future
* switch to `bottom-right` should not silently inherit a top offset.
*/
html .mantine-Notifications-root[data-position^='top'] {
top: calc(var(--navbar-height) + var(--mantine-spacing-md, 1rem));
}
.wrolpi-navbar-right {
display: flex;
align-items: center;
margin-left: auto;
gap: 0.2em;
}
.wrolpi-navbar-link,
.wrolpi-navbar-link-button {
display: flex;
align-items: center;
/* Tightened from 1.1em: the bar has eleven links plus nine indicators. */
padding: 0 0.8em;
color: inherit;
text-decoration: none;
white-space: nowrap;
background: none;
border: none;
font: inherit;
cursor: pointer;
}
.wrolpi-navbar-link:hover,
.wrolpi-navbar-link-button:hover {
/* A translucent black works on every navbar color, light or dark. */
background: rgba(0, 0, 0, 0.12);
}
/*
* The tab for the section the user is in.
*
* react-router marks it with `active` and `aria-current` on its own, and always did -- the
* mark reached the DOM and nothing here drew it, so every tab looked identical and clicking
* Videos appeared to leave the bar untouched.
*
* The underline is a pseudo-element in `currentColor`, not a border: `currentColor` follows
* the foreground the bar computed for whatever navbar color the user picked, so this works
* on all twelve of them (see `navColorNames`) and in all four themes without naming a color;
* and a pseudo-element adds no height, where a border would jog the whole row as the mark
* moved between tabs. The darker wash is the hover treatment deepened, so the two read as
* the same idea.
*/
.wrolpi-navbar-link.active {
position: relative;
background: rgba(0, 0, 0, 0.2);
}
.wrolpi-navbar-link.active::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
/* rem, not px, so the mark grows with the --ui-scale setting like the text above it. */
height: 0.1875rem;
background: currentColor;
}
/*
* The same tabs inside the overflow "More" menu and the mobile hamburger, which are the
* only place most of them appear on a phone. Those render in a portal, outside the bar,
* so they take theme tokens rather than the bar's foreground.
*/
.mantine-Menu-item.active {
background: var(--head);
font-weight: 600;
}
/*
* Everything in the bar takes the bar's foreground.
*
* `color: inherit` on the links alone was not enough, because two of the things in the
* corner do not inherit at all and both came out blue on every bar in every theme:
*
* - An anchor takes `a {color: var(--blue)}` from tokens.css, which outranks an inherited
* value however the bar sets it. The search glyph is an anchor, and so is every status
* icon that links to /admin -- those only looked right because they carry an inline
* color of their own, which still wins here and is meant to.
* - A Mantine ActionIcon paints `--ai-color`, its own themed blue. The hamburger and the
* theme toggle are ActionIcons. The variable is no use as a hook: Mantine writes it
* INLINE on the element, so a stylesheet declaring it loses every time (the same trap
* as `--group-justify` on Pagination). The painted property has to be set instead, and
* `html` lifts this over Mantine's own single-class rule.
*
* Measuring the foreground against the bar is worth nothing if the icons opt out of it.
*/
.wrolpi-navbar a,
html .wrolpi-navbar .mantine-ActionIcon-root {
color: inherit;
}
/* The bar's own hover, rather than a blue tint from Mantine's palette. */
html .wrolpi-navbar .mantine-ActionIcon-root:hover {
background: rgba(0, 0, 0, 0.12);
color: inherit;
}
/*
* One status indicator's slot. Laid out rather than nudged: see NavIconWrapper in Nav.js
* for the 0.8em top margin this replaces and why it looked correct on half the indicators.
*/
.wrolpi-navbar-icon {
display: flex;
align-items: center;
margin-left: 1.5em;
/* Full bar height, so the anchor inside has somewhere to stretch to. */
align-self: stretch;
}
/*
* An icon-only trigger in the corner gets a link's hit area, not its glyph's.
*
* The share glyph already computed `cursor: pointer` -- it is an `<a href>` -- but the
* target was the 24px box the icon occupies, so the pointer appeared only if you found it
* exactly, and there was no hover highlight to aim at. Stretched and padded, it behaves
* like Help and Admin beside it.
*
* `align-self` rather than `align-items: stretch` on the wrapper: the IconButtons in the
* corner have an explicit height, and a stretch container places an item it cannot stretch
* at the start -- which would push the hotspot and theme glyphs back off centre.
*/
.wrolpi-navbar-icon > a {
align-self: stretch;
padding: 0 0.5em;
cursor: pointer;
}
.wrolpi-navbar-icon > a:hover {
background: rgba(0, 0, 0, 0.12);
}
/*
* An anchor in the corner hugs its glyph instead of a text line box.
*
* A line box reserves descender space for text that is not there, which leaves an icon
* sitting about 3px above the centre of the box being centred -- the share and search
* glyphs, but not the hotspot or theme picker, which are square ActionIcons that centre
* their own glyph. That difference is what made a single top margin look right on some
* indicators and wrong on others.
*/
.wrolpi-navbar-right a {
display: flex;
align-items: center;
}
/*
* A link in the corner fills the bar's height, as one outside it does.
*
* Help and Admin highlighted only a box around their text while Videos and Statistics
* highlighted the full height. Same class and same rules; the difference is the parent.
* The bar stretches its children, but the corner is a nested flex container that centres
* its own, so a link inside it was sized to its content. The hover background is painted
* on the link box, so this is the hover affordance itself and not just its outline.
*/
.wrolpi-navbar-right .wrolpi-navbar-link,
.wrolpi-navbar-right .wrolpi-navbar-link-button {
align-self: stretch;
}
::-ms-clear {
display: none;
}
.form-control-clear {
z-index: 10;
pointer-events: auto;
cursor: pointer;
}
pre.wrap-text {
white-space: pre-wrap; /* Since CSS 2.1 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
word-wrap: break-word; /* Internet Explorer 5.5+ */
}
/*
* A video's length, pinned to the corner of its poster.
*
* Tokens, not a literal white-on-black. The poster under it is filtered by night mode -- it is a
* `.media` leaf -- but this chip is drawn beside the image rather than inside it, so it was never
* filtered: a white tile on a card whose picture had gone dim red, which is the brightest thing on
* the videos page in the theme meant to have nothing bright on it.
*
* Opaque, because it sits on an arbitrary poster and has to be legible over any of them.
*/
.duration-overlay {
position: absolute;
top: 4px;
right: 4px;
color: black;
background: #ffffff;
padding: 0px 4px;
border-radius: 3px;
top: 0.25rem;
right: 0.25rem;
color: var(--text);
background: var(--panel);
border: 1px solid var(--border);
padding: 0px 0.25rem;
border-radius: 0.1875rem;
}
.no-link-underscore {
text-decoration: none !important;
}
/*
* A card's title and its author/channel/domain line are links, but they are the card's
* text -- they take the body color rather than the link color, so a grid of cards reads
* as titles and not as a wall of blue. This was a literal `black` until the themes
* arrived, which left every title at about 1.1:1 on the dark, night and amber panels.
*/
.card-link {
color: black;
color: var(--text);
}
.center-me {
margin: 0 auto;
}
div.inverted {
background-color: #1B1C1D;
}
.toggle {
position: relative;
display: inline-block;
width: 49px;
height: 23px;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgb(166, 166, 166);
-webkit-transition: .4s;
transition: .4s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 22px;
width: 22px;
left: 0;
bottom: 0;
background-color: white;
-webkit-transition: .4s;
transition: .4s;
border-radius: 50%;
}
.slider.disabled:before {
background-color: #CCCCCC;
}
input:checked + .slider {
background-color: #6435C9FF;
}
input.inverted:checked + .slider.inverted {
background-color: #6435C9AA;
}
input.disabled + .slider {
background-color: rgb(147, 147, 147);
}
input:checked + .slider:before {
-webkit-transform: translateX(26px);
-ms-transform: translateX(26px);
transform: translateX(26px);
}
.inverted-progress-text.ui.indicating.progress .bar > .progress,
.inverted-progress-text.ui.progress > .label {
color: white !important;
}
/* Light bar colors (yellow, olive) need dark text on the bar — must override inverted-progress-text. */
.light-bar-progress-text.ui.indicating.progress .bar > .progress,
.light-bar-progress-text.ui.progress .bar > .progress {
color: rgba(0, 0, 0, 0.85) !important;
}
/*
* The hand-rolled `.toggle` / `.slider` switch is gone.
*
* Nothing referenced either class: `Toggle` is a Mantine Switch, and the only remaining
* matches for the words were the `classList.toggle` DOM method and the controller's own
* templates, which do not load this stylesheet.
*
* It is deleted rather than finished because it was already half-converted and broken: the
* track and knob became rem while the checked-state travel stayed `translateX(26px)`, so on a
* 54px track the thumb would have stopped ~4px short. Dead CSS that is also wrong is a trap
* for whoever revives it, and it also hardcoded #6435C9 and white, which no theme can reach.
*/
/* File preview Modal.Actions toolbar.
Single layout that works at every viewport — buttons keep their order at any width.
@ -140,7 +274,8 @@ input:checked + .slider:before {
}
.preview-toolbar-tags {
flex: 1 1 200px;
/* rem so the basis grows with the tags it holds. */
flex: 1 1 12.5rem;
min-width: 0;
}
@ -150,9 +285,9 @@ input:checked + .slider:before {
Use 100dvh so mobile browser chrome (URL bar) is accounted for. */
.preview-fit {
width: 100%;
height: calc(100dvh - 300px);
max-height: calc(100dvh - 300px);
min-height: 240px;
height: calc(100dvh - 18.75rem);
max-height: calc(100dvh - 18.75rem);
min-height: 15rem;
overflow: hidden;
position: relative;
display: flex;
@ -171,16 +306,19 @@ td.file-path {
cursor: pointer;
}
/* Necessary until react-semantic-toasts is upgraded to Node 18 */
.ui-alerts {
position: fixed;
z-index: 2060;
padding: 24px;
}
.ui-alerts.top-right {
top: 20px;
right: 20px;
/*
* The row is an icon followed by a filename, as inline siblings. An inline SVG
* sits on the text baseline by default, so a 24px glyph next to 13px text hangs
* well above it and the name looks like it is resting on the icon's floor.
* `middle` centres the glyph against the text instead.
*
* The margin is the gap that was never there: the folder icon touched the first
* letter of its own name, and two icons (a folder and its ignored marker) touched
* each other.
*/
td.file-path > svg {
vertical-align: middle;
margin-right: 0.35em;
}
textarea.otp {
@ -188,104 +326,16 @@ textarea.otp {
color: #333;
}
.inline-header h1 {
display: inline-block;
}
.inline-header h2 {
display: inline-block;
}
.inline-header h3 {
display: inline-block;
}
.inline-header h4 {
display: inline-block;
}
.inline-header h5 {
display: inline-block;
}
.inline-header span {
margin-left: 1em;
}
.card-icon {
min-height: 163px;
min-height: 10.1875rem;
text-align: center;
padding-top: 53px;
}
.card-icon.inverted {
background-color: #222222;
}
.modal .inverted {
border-color: #888888;
}
.modal .header.inverted {
color: #DDDDDD !important;
background-color: #171616 !important;
}
.modal .content.inverted {
color: #DDDDDD !important;
border-color: #888888 !important;
background-color: #171616 !important;
}
.modal .actions.inverted {
color: #DDDDDD !important;
border-color: #888888 !important;
background-color: #171616 !important;
}
.modal.inverted i.close {
color: #DDDDDD !important;
}
/* The accordion has no background of its own; it inherits whatever container it sits in (Modal
content #171616, Segment #1B1C1D, the page body, etc.). This matches how the light theme already
renders accordions (transparent), and avoids a darker band when the parent isn't #171616.
Direct children only, so nested components that use the `content`/`title` class names (e.g. a
Message's Message.Content) are not affected. */
.ui.inverted.accordion,
.ui.inverted.accordion > .title,
.ui.inverted.accordion > .content {
background-color: transparent !important;
}
/* Semantic UI paints a definition table's empty corner cell white (background on the inverted
variant, white box-shadows on the celled/plain variants) to fake a "cut-out" corner on a white
page. Our dark theme has a dark page, so the corner must be transparent with no shadow. */
.ui.inverted.definition.table thead:not(.full-width) th:first-child,
.ui.inverted.definition.table tfoot:not(.full-width) th:first-child,
.ui.inverted.celled.definition.table thead:not(.full-width) th:first-child,
.ui.inverted.celled.definition.table tfoot:not(.full-width) th:first-child {
background: transparent !important;
box-shadow: none !important;
}
/* Mobile-responsive Confirm modal */
.ui.modal.themed-confirm {
max-width: 95vw;
padding-top: 3.3125rem;
}
.clickable {
cursor: pointer;
}
.segment.inverted {
border: 1px solid #ffffff22 !important;
}
.inverted label {
color: #DDDDDD !important;
}
.table-ellipsis td.column-ellipsis {
white-space: nowrap;
overflow: hidden;
@ -311,18 +361,6 @@ textarea.otp {
flex-grow: 1;
}
.search-clear {
margin: 10px;
}
.icon.divider {
color: rgb(0, 0, 0, 0.4) !important;
}
.divider.inverted {
color: rgb(255, 255, 255, 0.6) !important;
}
.video-player {
display: block;
margin: 0 auto; /* Center the video horizontally */
@ -352,109 +390,57 @@ table.ratio-table td.min-width {
width: auto;
}
/* CollectionEditForm styles */
.required-indicator {
color: red;
}
.action-button-spacing {
margin-top: 1em;
}
/* Form loading skeleton */
.form-skeleton-field {
margin-bottom: 1.5em;
}
.form-skeleton-label {
height: 14px;
width: 80px;
margin-bottom: 8px;
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: 3px;
}
.form-skeleton-input {
height: 38px;
width: 100%;
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: 4px;
}
.form-skeleton-textarea {
height: 80px;
width: 100%;
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: 4px;
}
.form-skeleton-button {
height: 42px;
width: 100px;
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes skeleton-shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Dark mode skeleton */
.inverted .form-skeleton-label,
.inverted .form-skeleton-input,
.inverted .form-skeleton-textarea,
.inverted .form-skeleton-button {
background: linear-gradient(90deg, #333 25%, #444 50%, #333 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
}
/* Sticky footer for file browser */
/*
* Sticky footer for the file browser.
*
* The surface is a token. It was two hardcoded greys with `.inverted` selecting the dark
* one, and once nothing set `.inverted` any more the footer stayed at near-white `#f9fafb`
* in dark, night and amber. In night mode a white bar pinned to the bottom of the screen
* costs the user the dark adaptation the whole theme exists to protect. A token has no
* such failure mode: there is one rule and each theme fills it in.
*/
.sticky-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 0.5em 1em;
background-color: #f9fafb;
border-top: 1px solid rgba(34, 36, 38, 0.15);
background-color: var(--panel);
border-top: 1px solid var(--border);
z-index: 100;
/*
* A row with gaps, like every other group of buttons in the app.
*
* This was a plain block, so the buttons -- inline-block elements with no whitespace
* between them, because JSX drops whitespace-only lines -- sat flush against each other
* in one unbroken slab. Everywhere else a group of buttons is a flex row with a gap;
* the file browser's footer is hand-rolled and never got one. It wraps because the bar
* carries eight controls and a phone cannot hold them.
*/
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5em;
}
.sticky-footer.inverted {
background-color: #1b1c1d;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
/* Drag selection visual feedback for FileBrowser */
/*
* Drag selection visual feedback for FileBrowser: a blue tint over the rows being taken,
* red over the rows being dropped.
*
* Each had a second, stronger rule under `.inverted` for the dark themes. Nothing has set
* that class since ThemeProvider stopped supplying it, so only the light tint has been
* drawn for some time -- and 15% of a fixed blue over a dark row is close to invisible,
* which is the one moment the user needs to see what they have hold of.
*
* A token mixed with transparent instead: the hue follows the theme, and the strength can
* go up without pinning it to a literal that only suits one background.
*/
tr.drag-selecting td {
background-color: rgba(33, 133, 208, 0.15) !important;
background-color: color-mix(in srgb, var(--blue) 22%, transparent) !important;
}
.inverted tr.drag-selecting td {
background-color: rgba(33, 133, 208, 0.25) !important;
}
/* Drag deselection visual feedback (red tint) */
tr.drag-deselecting td {
background-color: rgba(219, 40, 40, 0.15) !important;
}
.inverted tr.drag-deselecting td {
background-color: rgba(219, 40, 40, 0.25) !important;
background-color: color-mix(in srgb, var(--red) 22%, transparent) !important;
}
/* Prevent text selection during drag */
@ -469,11 +455,30 @@ tr.drag-deselecting td {
.file-browser-filter-container {
display: flex;
justify-content: flex-end;
margin-bottom: 0.5em;
/* The gap is the container's. The collapse-all button carried `margin-left: 0.5em` for it,
which is the same thing one element to the right of where it belongs. */
gap: 0.5em;
/* No `margin-bottom`: this is a top-level block of the Files page, and the page's stack
spaces it (see `.wrolpi-stack`). Its own 0.5em was added to that. */
}
/* Double the default input width on desktop. */
.ui.input.file-browser-filter {
/*
* Double the default input width on desktop.
*
* The selector was `.ui.input.file-browser-filter`, naming a wrapper the old input
* component rendered around the field. Nothing carries `ui input` any more, so this rule
* and the mobile one below it had been matching nothing at all: the filter was rendering
* at the browser's default width on a bar laid out to give it 34em.
*
* The class lands on ActionInput's wrapper, which is the field and its clear button
* together -- that pair is the control being sized, not the text box alone.
*
* Qualified by the container so it carries two classes. `.wrolpi-action-input` sets
* `width: 100%` in ui.css, which is one class like `.file-browser-filter` is, and ui.css
* is loaded after this file -- so a bare class selector here loses the tie and the field
* fills the row anyway. Specificity, not source order, is what should decide it.
*/
.file-browser-filter-container .file-browser-filter {
width: 34em;
max-width: 100%;
}
@ -488,36 +493,12 @@ tr.drag-deselecting td {
}
/* Fill the row, but stay on one line with the collapse-all button. */
.ui.input.file-browser-filter {
.file-browser-filter-container .file-browser-filter {
flex: 1 1 auto;
width: auto;
}
}
/* Ensure step icons are always visible, even when disabled */
.ui.steps .step.disabled > .icon,
.ui.steps .step.disabled > i.icon,
.ui.steps .disabled.step > .icon,
.ui.steps .disabled.step > i.icon {
opacity: 0.45 !important;
display: inline-block !important;
visibility: visible !important;
width: auto !important;
height: auto !important;
color: rgba(0, 0, 0, 0.87) !important;
}
/* Ensure the icon glyph (::before) is visible for disabled steps */
.ui.steps .step.disabled > .icon:before,
.ui.steps .step.disabled > i.icon:before,
.ui.steps .disabled.step > .icon:before,
.ui.steps .disabled.step > i.icon:before {
opacity: 0.45 !important;
visibility: visible !important;
color: rgba(0, 0, 0, 0.87) !important;
}
/* Inventory "PDF" export: the printable table is hidden on screen and becomes the only
visible content when printing, so a user can use the browser dialog to Save as PDF. */
.inventory-print {
@ -568,7 +549,7 @@ tr.drag-deselecting td {
.inventory-print th,
.inventory-print td {
border: 1px solid #999;
padding: 4px 6px;
padding: 0.25rem 0.375rem;
text-align: left;
vertical-align: top;
}

View file

@ -4,8 +4,13 @@ import {NavBar} from "./components/Nav";
import {createBrowserRouter, createRoutesFromElements, Link, Outlet, Route, RouterProvider} from "react-router";
import {VideosTabLayout, VideosPage, VideosSettingsPage, VideosStatistics, VideoWrapper} from "./components/Videos";
import AdminRoute from "./components/admin/AdminRoute";
import {Container} from "semantic-ui-react";
import 'semantic-ui-offline/semantic.min.css';
import {Box, Header} from "./components/ui";
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
// Theme tokens load last so they win over both component libraries.
import './themes/fonts.css';
import './themes/tokens.css';
import {MediaFilterDefs} from "./themes/MediaFilterDefs";
import {MoreRoute} from "./components/Apps";
import {InventoryRoute} from "./components/inventory/InventoryRoute";
import {ArchiveRoute} from "./components/Archive";
@ -14,12 +19,12 @@ import {QueryProvider, StatusProvider} from "./hooks/customHooks";
import {FileWorkerStatusProvider} from "./contexts/FileWorkerStatusContext";
import {ChannelEditPage, ChannelNewPage, ChannelsPage} from "./components/Channels";
import {MapRoute} from "./components/Map";
import {MediaContextProvider, mediaStyles, StatusContext, ThemeContext} from "./contexts/contexts";
import {Header, ThemeProvider} from "./components/Theme";
import {MediaContextProvider, mediaStyles, StatusContext} from "./contexts/contexts";
import {ThemeProvider} from "./components/Theme";
import {DashboardPage} from "./DashboardPage";
import {DonatePage} from "./components/DonatePage";
import {ThemeSamplePage} from "./components/ThemeSamplePage";
import {useEventsInterval} from "./Events";
import {SemanticToastContainer} from "react-semantic-toasts-2";
import {FilePreviewProvider} from "./components/FilePreview";
import {TagsProvider} from "./Tags";
import {ZimRoute} from "./components/Zim";
@ -30,11 +35,10 @@ import ErrorBoundary from "./components/ErrorBoundary";
import {KeyboardShortcutsProvider} from "./components/KeyboardShortcutsProvider";
function PageNotFound() {
const {t} = useContext(ThemeContext);
return <Container fluid>
return <Box>
<Header as='h1'>Page Not Found!</Header>
<p {...t}>The page you requested cannot be found</p>
</Container>
<p>The page you requested cannot be found</p>
</Box>
}
function Dot() {
@ -42,7 +46,6 @@ function Dot() {
}
function Footer() {
const {t} = useContext(ThemeContext);
const {status} = useContext(StatusContext);
let version;
try {
@ -50,18 +53,19 @@ function Footer() {
} catch (e) {
// Not logging because this is not that important.
}
return <Container textAlign='center' style={{marginTop: '1.5em', marginBottom: '1em', ...t}}>
<span {...t}>
return <Box style={{textAlign: 'center', marginTop: '1.5em', marginBottom: '1em'}}>
<span>
WROLPi {version} <Dot/>
<a href='https://wrolpi.org' target='_blank' rel='nofollow noreferrer'>WROLPi.org</a> <Dot/>
<Link to='/donate'>Donate</Link>
</span>
</Container>
</Box>
}
function Root() {
return <QueryProvider>
<ThemeProvider>
<MediaFilterDefs/>
<TagsProvider>
<KeyboardShortcutsProvider>
<FilePreviewProvider>
@ -87,6 +91,9 @@ const router = createBrowserRouter(createRoutesFromElements(<Route
<Route index element={<ErrorBoundary><DashboardPage/></ErrorBoundary>}/>
<Route path='search/*' element={<ErrorBoundary><DashboardPage/></ErrorBoundary>}/>
<Route path='donate' element={<DonatePage/>}/>
{/* Component gallery: lets a user see a theme before committing to it, and lets us
review the design. Linked from Settings. */}
<Route path='theme-sample' element={<ErrorBoundary><ThemeSamplePage/></ErrorBoundary>}/>
<Route path='videos'>
<Route element={<ErrorBoundary><VideosTabLayout/></ErrorBoundary>}>
<Route index element={<ErrorBoundary><VideosPage/></ErrorBoundary>}/>
@ -118,8 +125,7 @@ export default function App() {
<FileWorkerStatusProvider>
{/* Context and style to handle switching between mobile/computer. */}
<style>{mediaStyles}</style>
{/* Toasts can be on any page. */}
<SemanticToastContainer position='top-right'/>
{/* Toasts (from components/ui) are mounted by ThemeProvider, which wraps every page. */}
<MediaContextProvider>
<RouterProvider router={router}/>
</MediaContextProvider>

View file

@ -12,19 +12,21 @@ import {useHotkeys} from "react-hotkeys-hook";
import {Media, SettingsContext, StatusContext} from "./contexts/contexts";
import {DownloadMenu} from "./components/Download";
import {
ActionInput,
Button,
Divider,
Form,
Header,
Icon,
Message,
Modal,
Segment,
Statistic
} from "./components/Theme";
Panel,
Stack,
Statistic,
StatisticGroup,
} from "./components/ui";
import {Link, useNavigate, useSearchParams} from "react-router";
import {BandwidthProgressCombined, CPUUsageProgress} from "./components/admin/Status";
import {ProgressPlaceholder} from "./components/Placeholder";
import {GridColumn, GridRow, Icon, Input, Message} from "semantic-ui-react";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {refreshFiles} from "./api";
import _ from "lodash";
import {TagsDashboard} from "./Tags";
@ -37,6 +39,9 @@ import {fileMimetypeFilterOptions, FileCards, SearchFilterButton} from "./compon
import {useCalculators} from "./components/Calculators";
import {classifyAndEvaluate} from "./components/calculators/mathConfig";
// A spinning refresh glyph, used as the `icon` for the "files are being refreshed" Message.
const RefreshingIcon = (props) => <Icon name='circle notched' loading {...props}/>;
export function FlagsMessages() {
const {settings, fetchSettings} = React.useContext(SettingsContext);
const {status} = useContext(StatusContext);
@ -53,47 +58,44 @@ export function FlagsMessages() {
// Do not tell the maintainer to refresh the files if the FileWorker is busy.
if (flags.file_worker_busy) {
// FileWorker is busy.
refreshingMessage = <Message icon>
<Icon name='circle notched' loading/>
<Message.Content>
<Message.Header>Your files are being refreshed.</Message.Header>
<p><Link to='/files'>Click here to view the progress</Link></p>
</Message.Content>
refreshingMessage = <Message kind='info' icon={RefreshingIcon} title='Your files are being refreshed.'>
<p><Link to='/files'>Click here to view the progress</Link></p>
</Message>;
} else if (!flags.refresh_complete) {
// `refresh_complete` flag is not set. Tell the maintainer to refresh the files.
refreshRequiredMessage = <Message icon warning onClick={refreshFiles}>
<Icon name='hand point right'/>
<Message.Content>
<Message.Header>Refresh required</Message.Header>
<a href='#'>Click here</a> to refresh all your files.
</Message.Content>
refreshRequiredMessage = <Message kind='warning' icon='hand point right' title='Refresh required'>
<a href='#' onClick={(e) => {
e.preventDefault();
refreshFiles();
}}>Click here</a> to refresh all your files.
</Message>;
}
let dbDownMessage;
if (!flags.db_up) {
dbDownMessage = <ErrorMessage>
<Message.Header>Database is down</Message.Header>
API is unable to connect to the database. Check the server logs.
<strong>Database is down</strong>
<p>API is unable to connect to the database. Check the server logs.</p>
</ErrorMessage>
}
let internetDownMessage;
if (!flags.have_internet && !settings.wrol_mode) {
internetDownMessage = <ErrorMessage icon='globe'>
<Message.Header>No Internet</Message.Header>
WROLPi has no Internet. Downloads will not start.
<strong>No Internet</strong>
<p>WROLPi has no Internet. Downloads will not start.</p>
</ErrorMessage>;
}
let mediaUnmountedMessage;
if (flags.media_mounted === false) {
mediaUnmountedMessage = <ErrorMessage icon='hdd outline'>
<Message.Header>No drive mounted</Message.Header>
WROLPi has no media drive mounted for one or more storage locations.
Downloads would write to the root filesystem and can fill it.
{' '}<Link to='/admin/controller'>Open the Controller</Link> to mount a drive.
mediaUnmountedMessage = <ErrorMessage icon='hdd'>
<strong>No drive mounted</strong>
<p>
WROLPi has no media drive mounted for one or more storage locations.
Downloads would write to the root filesystem and can fill it.
{' '}<Link to='/admin/controller'>Open the Controller</Link> to mount a drive.
</p>
</ErrorMessage>;
}
@ -140,7 +142,6 @@ function EvaluateForm() {
<pre>2 Tb to Gb</pre>
</span>
const inputRef = React.useRef();
const [showMessage, setShowMessage] = React.useState(false);
const [inputValue, setInputValue] = React.useState('');
const [evaluatedValue, setEvaluatedValue] = React.useState(helpContents);
@ -166,45 +167,36 @@ function EvaluateForm() {
}
}
return <Form onSubmit={doEvaluate}>
<Input fluid
ref={inputRef}
value={inputValue}
label='𝒇'
type='text'
onFocus={() => setShowMessage(true)}
onChange={(e, {value}) => setInputValue(value)}
action={<Button type='submit'>Evaluate</Button>}
return <form onSubmit={(e) => {
e.preventDefault();
doEvaluate();
}}>
<ActionInput
value={inputValue}
leftSection='𝒇'
type='text'
onFocus={() => setShowMessage(true)}
onChange={(e) => setInputValue(e.currentTarget.value)}
action={<Button type='submit' role='primary'>Evaluate</Button>}
/>
{showMessage &&
<Message>
<Message.Header>Result</Message.Header>
<Message.Content>
{evaluatedValue}
</Message.Content>
<Message kind='info' title='Result'>
{evaluatedValue}
</Message>
}
</Form>
</form>
}
function DashboardCalculators() {
const {calculatorLinks} = useCalculators();
return <Segment>
return <Panel>
<Header as='h2'>Calculators</Header>
<Grid columns={1}>
<Grid.Row>
<Grid.Column>
<EvaluateForm/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
{calculatorLinks}
</Grid.Column>
</Grid.Row>
</Grid>
</Segment>
<Stack>
<EvaluateForm/>
<div>{calculatorLinks}</div>
</Stack>
</Panel>
}
export function Getters() {
@ -244,29 +236,25 @@ export function Getters() {
}
}
const getter = <Segment>
<Grid columns={2} textAlign='center'>
<Divider vertical>Or</Divider>
<GridRow verticalAlign='middle'>
<GridColumn>
<Button color='violet' onClick={e => handleSetGetter(e, 'downloads')}>
<Icon name='download'/>
Download
</Button>
</GridColumn>
<GridColumn>
<Button color='green' onClick={e => handleSetGetter(e, 'upload')}>
<Icon name='upload'/>
Upload
</Button>
</GridColumn>
</GridRow>
</Grid>
</Segment>;
const getter = <Panel>
<div className='wrolpi-or-split'>
<div>
<Button role='primary' icon='download' onClick={e => handleSetGetter(e, 'downloads')}>
Download
</Button>
</div>
<div>
<Button role='save' icon='upload' onClick={e => handleSetGetter(e, 'upload')}>
Upload
</Button>
</div>
<span className='wrolpi-or-label'>Or</span>
</div>
</Panel>;
let getterModal;
if (selectedGetter === 'downloads') {
getterModal = <Modal closeIcon
getterModal = <Modal size='large' closeIcon
open={true}
centered={false}
onClose={() => handleSetGetter(null, null)}
@ -281,7 +269,7 @@ export function Getters() {
</Modal.Content>
</Modal>;
} else if (selectedGetter === 'upload') {
getterModal = <Modal closeIcon
getterModal = <Modal size='large' closeIcon
open={true}
centered={false}
onClose={() => handleSetGetter(null, null)}
@ -304,7 +292,7 @@ export function Getters() {
}
function DashboardStatus() {
export function DashboardStatus() {
const {status} = useContext(StatusContext);
let percent = 0;
@ -330,16 +318,25 @@ function DashboardStatus() {
bandwidths = null;
}
return <Segment>
<Link to='/admin/status'>
return <Panel>
{/*
* The panel is a link, but it should not read as one.
*
* `a` takes `color: var(--blue)` and an underline on hover from tokens.css, and a
* statistic's value sets no color of its own -- so the three load figures inherited
* the link color, and crossing the panel underlined them together. The same pair
* every card title uses says "this text belongs to what contains it": the pointer
* cursor still comes from the UA stylesheet, which is the affordance that matters.
*/}
<Link to='/admin/status' className='no-link-underscore card-link'>
<Header as='h2'>Status</Header>
<CPUUsageProgress value={percent} label='CPU Usage'/>
<Statistic.Group size='mini'>
<StatisticGroup>
<LoadStatistic label='1 Min. Load' value={load['minute_1']} cores={cores}/>
<LoadStatistic label='5 Min. Load' value={load['minute_5']} cores={cores}/>
<LoadStatistic label='15 Min. Load' value={load['minute_15']} cores={cores}/>
</Statistic.Group>
</StatisticGroup>
<Header as='h3'>Bandwidth</Header>
{bandwidths}
@ -347,26 +344,26 @@ function DashboardStatus() {
<Divider style={{marginTop: '3em'}}/>
<Link to='/admin'>
<Statistic.Group size='mini'>
<Link to='/admin' className='no-link-underscore card-link'>
<StatisticGroup>
<Statistic label='Downloading' value={pending_downloads}/>
</Statistic.Group>
</StatisticGroup>
</Link>
</Segment>;
</Panel>;
}
function DashboardRecentFiles() {
const {searchFiles, loading, fetchFiles} = useSearchRecentFiles();
return <Segment>
return <Panel>
<RefreshHeader
header='Recently Viewed Files'
popupContents='Fetch the most recent files again'
onRefresh={fetchFiles}
/>
<FileCards files={searchFiles} loading={loading}/>
</Segment>
</Panel>
}
export function DashboardPage() {
@ -467,7 +464,13 @@ export function DashboardPage() {
if (big) {
inputProps.size = 'big';
}
return <div style={{display: 'flex', alignItems: 'center', gap: '0.5em', marginBottom: '2em'}}>
/*
* No `marginBottom`. This row is a top-level block of the dashboard, so its own 2em was
* added to the page stack's gap -- 52.8px below the search box against 17.6px between
* every other pair of blocks, on the most-looked-at page in the app. One source of
* spacing, and it is the stack.
*/
return <div style={{display: 'flex', alignItems: 'center', gap: '0.5em'}}>
{getSearchResultsInput(inputProps)}
<SearchFilterButton fileFilterOptions={fileMimetypeFilterOptions} showDates={true} showDeep={true}
size={big ? 'big' : undefined}/>

View file

@ -1,8 +1,7 @@
import React from 'react';
import {render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router';
import {Getters, FlagsMessages} from './DashboardPage';
import {SettingsContext, StatusContext, ThemeContext} from './contexts/contexts';
import {render, screen} from './test-utils';
import {DashboardStatus, Getters, FlagsMessages} from './DashboardPage';
import {SettingsContext, StatusContext} from './contexts/contexts';
// Replace DownloadMenu with a stub that records its props. The deep-link
// behavior under test is just "Getters reads URL params and forwards them
@ -23,18 +22,14 @@ jest.mock('./hooks/customHooks', () => ({
}));
function renderAtUrl(url) {
// test-utils' render() wraps content in a BrowserRouter (not MemoryRouter),
// so point the jsdom location at the desired URL before rendering.
window.history.pushState({}, '', url);
const status = {flags: {refresh_complete: true}};
const theme = {
i: {}, s: {}, t: {}, theme: 'light', inverted: false, setInverted: () => {},
};
return render(
<MemoryRouter initialEntries={[url]}>
<ThemeContext.Provider value={theme}>
<StatusContext.Provider value={{status}}>
<Getters/>
</StatusContext.Provider>
</ThemeContext.Provider>
</MemoryRouter>
<StatusContext.Provider value={{status}}>
<Getters/>
</StatusContext.Provider>
);
}
@ -80,13 +75,11 @@ describe('DashboardPage Getters deep-link', () => {
function renderFlagsMessages({flags = {}, settings = {}} = {}) {
const status = {flags: {refresh_complete: true, db_up: true, have_internet: true, ...flags}};
return render(
<MemoryRouter>
<SettingsContext.Provider value={{settings, fetchSettings: () => {}}}>
<StatusContext.Provider value={{status}}>
<FlagsMessages/>
</StatusContext.Provider>
</SettingsContext.Provider>
</MemoryRouter>
<SettingsContext.Provider value={{settings, fetchSettings: () => {}}}>
<StatusContext.Provider value={{status}}>
<FlagsMessages/>
</StatusContext.Provider>
</SettingsContext.Provider>
);
}
@ -108,3 +101,65 @@ describe('DashboardPage no-drive-mounted banner', () => {
expect(screen.queryByText(/No drive mounted/i)).toBeNull();
});
});
function renderDashboardStatus() {
const status = {
cpu_stats: {percent: 12, cores: 4},
load_stats: {minute_1: 0.4, minute_5: 0.5, minute_15: 0.6},
downloads: {pending: 3, disabled: false},
nic_bandwidth_stats: {},
};
return render(
<StatusContext.Provider value={{status}}>
<DashboardStatus/>
</StatusContext.Provider>
);
}
describe('the dashboard status panel links without looking like a link', () => {
/*
* The whole panel is wrapped in a <Link> to /admin/status, which makes it an anchor --
* and `a` takes `color: var(--blue)` with an underline on hover from tokens.css. A
* statistic's value sets no color of its own, so the load figures inherited that: three
* blue numbers on a panel where nothing else is blue, underlining as a group when the
* pointer crossed any part of the panel.
*
* `no-link-underscore card-link` is the pair already used wherever a link IS the
* element's own text rather than a link through it -- every card title and meta line.
* The pointer cursor is untouched: it comes from the UA stylesheet for `a[href]`, and
* the affordance is the point of wrapping the panel at all.
*
* Colors are asserted in ui-layout.cy.js; jsdom does not resolve the custom property
* these classes rely on.
*/
it('dresses the status link as the panel\'s own text', () => {
const {container} = renderDashboardStatus();
const link = container.querySelector('a[href="/admin/status"]');
expect(link).toBeInTheDocument();
expect(link).toHaveClass('card-link');
expect(link).toHaveClass('no-link-underscore');
});
it('does the same for the downloads link beneath it', () => {
const {container} = renderDashboardStatus();
const link = container.querySelector('a[href="/admin"]');
expect(link).toBeInTheDocument();
expect(link).toHaveClass('card-link');
expect(link).toHaveClass('no-link-underscore');
});
it('leaves the load figures inside it, which is what went blue', () => {
// The premise for the two assertions above: this link really does wrap statistics
// whose value carries no color of its own, so it decides theirs.
const {container} = renderDashboardStatus();
const link = container.querySelector('a[href="/admin/status"]');
const values = [...link.querySelectorAll('.wrolpi-statistic-value')];
expect(values.length).toBeGreaterThanOrEqual(3);
expect(values.map(value => value.textContent)).toEqual(
expect.arrayContaining(['0.4', '0.5', '0.6']));
});
});

View file

@ -1,7 +1,7 @@
import React, {useEffect} from 'react';
import {useRecurringTimeout} from "./hooks/customHooks";
import {ApiDownError, getEvents} from "./api";
import {toast} from "react-semantic-toasts-2";
import {toast} from "./components/ui";
const apiEventName = 'apiEvent';

View file

@ -10,6 +10,8 @@ describe('Tags', () => {
it('Tags Dashboard displays Tags.', () => {
cy.wait('@getTags');
cy.get('div.ui.label.large').contains('Automotive').should('be.visible');
// `.wrolpi-tag` is what a tag chip is now. `div.ui.label.large` named the old
// framework's markup, and has matched nothing since its stylesheet stopped loading.
cy.get('.wrolpi-tag').contains('Automotive').should('be.visible');
});
});

View file

@ -1,6 +1,5 @@
import React, {useEffect} from "react";
import {ApiDownError, deleteTag, getRecentTags, getTags, saveTag} from "./api";
import {Dimmer, Divider, Grid, GridColumn, GridRow, Input, Label, TableCell, TableRow,} from "semantic-ui-react";
import {
APIButton,
contrastingColor,
@ -10,17 +9,24 @@ import {
scrollToTopOfElement
} from "./components/Common";
import {
Box,
Button,
Form,
Divider,
Group,
Header,
Loader,
Icon,
IconButton,
Label,
Loading,
Modal,
Segment
} from "./components/Theme";
Panel,
Table,
TextInput,
} from "./components/ui";
import _ from "lodash";
import {HexColorPicker} from "react-colorful";
import {useRecurringTimeout} from "./hooks/customHooks";
import {Media, QueryContext, ThemeContext} from "./contexts/contexts";
import {Media, QueryContext} from "./contexts/contexts";
import {Link, useNavigate} from "react-router";
import {TagPlaceholder} from "./components/Placeholder";
import {SortableTable} from "./components/SortableTable";
@ -96,64 +102,96 @@ export function useTags() {
|| fuzzyMatch(lowerName, i.name.toLowerCase(), 2))
}
// A single tag chip. Tag colors are chosen by the user (stored per-tag), not by the
// theme, so they are rendered with the raw hex value rather than a token -- but the
// `wrolpi-label` className still gives them the theme's shape (and its night-mode
// outline treatment, which keys off the same `--label-color` variable).
const NameToTagLabel = ({name, to, ...props}) => {
const tag = findTagByName(name);
if (tag !== null && tag !== undefined) {
const tagColor = tag['color'] || DEFAULT_TAG_COLOR;
const textColor = contrastingColor(tagColor);
const style = {...props['style'], backgroundColor: tagColor, color: textColor};
return <Label
size='large'
const className = ['wrolpi-label', 'wrolpi-tag', props.onClick ? 'clickable' : '']
.filter(Boolean).join(' ');
return <span
{...props} // onClick passed here.
style={style}
className={props.onClick ? 'clickable' : null}
className={className}
/*
* The contrasting text color goes in a custom property, not in `color`. As
* `color` it was an inline declaration, which no stylesheet rule can outrank --
* so night, where a tag becomes an outline over a near-black page, still got
* the black text calculated for a bright tag's fill. The tag was legible only
* by its border. A custom property lets each theme decide instead.
*/
style={{...props['style'], ['--label-color']: tagColor, ['--label-text']: textColor}}
>
{name}
</Label>;
</span>;
}
// No tags have been fetched.
return <Label size='large'>{name}</Label>;
return <Label tag>{name}</Label>;
}
const TagsGroup = ({tagNames, onClick}) => {
if (!tagNames || tagNames.length === 0) {
return <React.Fragment/>;
}
return <Label.Group tag>
return <Group gap={6}>
{tagNames.map(i => <NameToTagLabel key={i} name={i} onClick={() => onClick(i)}/>)}
</Label.Group>
</Group>
}
/*
* The link carries no margin of its own. It had `0.3em` either side, from when these were
* labels that had none -- but a tag already reserves a `margin-left` in ui.css for
* the point drawn outside its body, so that was 0.3em twice on top of the group's gap and
* the point's own room. Two tags sat 34.7px apart, of which the point needs 18.7px.
*
* Those are rendered px, measured in the browser at the default interface scale -- which is
* what a user sees, and why they are the numbers worth recording. The stylesheet states the
* same margin in design px, as `1.0625rem`; 17 x 1.1 is the 18.7 above.
*/
const TagLabelLink = ({name, props}) => {
const to = getLocationStr({tag: name}, '/search');
const style = {marginLeft: '0.3em', marginRight: '0.3em'};
try {
// We prefer to use Link to avoid reloading the page, check if React Router is available, so we can use it.
useNavigate();
return <Link to={to} style={style}>
return <Link to={to}>
<NameToTagLabel name={name} {...props}/>
</Link>
} catch {
// React Router is not available, use anchor.
return <a href={to} style={style}>
return <a href={to}>
<NameToTagLabel name={name} {...props}/>
</a>
}
}
const TagsLinkGroup = ({tagNames, ...props}) => {
/*
* Props here have two possible targets, and which one they take is part of the contract:
*
* `style` and `className` dress the ROW. Everything else reaches each CHIP, which is how
* `onClick` gets to a tag.
*
* `style` used to go down with the rest, so the dashboard's `marginTop` -- meant to space the
* row below its heading -- landed on all 34 tags and became 7.7px of extra space between
* every wrapped row of them. `className` is named alongside it because it is the same kind
* of thing and would fail the same way; a Mantine `Group` prop (`gap`, `wrap`, `justify`) or
* a `data-*` meant for the row still would, so add it here rather than at a call site.
*/
const TagsLinkGroup = ({tagNames, style, className, ...props}) => {
if (!tagNames || tagNames.length === 0) {
return <React.Fragment/>;
}
return <Label.Group tag>
return <Group gap={6} style={style} className={className}>
{tagNames.map(i => <TagLabelLink key={i} name={i} props={props}/>)}
</Label.Group>
</Group>
}
const SingleTag = ({name, ...props}) => {
return <Label.Group tag {...props}><NameToTagLabel name={name}/></Label.Group>
return <Group gap={6} {...props}><NameToTagLabel name={name}/></Group>
}
useEffect(() => {
@ -187,16 +225,15 @@ function EditTagRow({tag, onDelete, onEdit}) {
const {SingleTag} = React.useContext(TagsContext);
const {name, color, id, file_group_count, zim_entry_count, channel_count, domain_count} = tag;
const deleteConfirm = <>
<APIButton
icon='trash'
color='red'
confirmContent={`Are you sure you want to delete: ${name}?`}
confirmButton='Delete'
onClick={async () => onDelete(id, name)}
/>
</>;
const editButton = <Button primary onClick={() => onEdit(name, color, id)} icon='edit'/>;
const deleteConfirm = <APIButton
icon='trash'
role='danger'
confirmContent={`Are you sure you want to delete: ${name}?`}
confirmButton='Delete'
onClick={async () => onDelete(id, name)}
/>;
const editButton = <IconButton icon='edit' label='Edit' role='primary'
onClick={() => onEdit(name, color, id)}/>;
// Individual count labels for tablet+
const fileCountColor = file_group_count > 0 ? 'black' : 'grey';
@ -213,32 +250,31 @@ function EditTagRow({tag, onDelete, onEdit}) {
const totalCountColor = totalCount > 0 ? 'black' : 'grey';
const totalCountLabel = <Label color={totalCountColor}>{totalCount}</Label>;
return <TableRow>
<TableCell>{deleteConfirm}</TableCell>
<TableCell>{editButton}</TableCell>
<TableCell><SingleTag name={name}/></TableCell>
return <Table.Row>
<Table.Cell>{deleteConfirm}</Table.Cell>
<Table.Cell>{editButton}</Table.Cell>
<Table.Cell><SingleTag name={name}/></Table.Cell>
<Media at='mobile'>
{(className, renderChildren) => {
return renderChildren ? <TableCell className={className}>{totalCountLabel}</TableCell> : null;
return renderChildren ? <Table.Cell className={className}>{totalCountLabel}</Table.Cell> : null;
}}
</Media>
<Media greaterThanOrEqual='tablet'>
{(className, renderChildren) => {
return renderChildren ? <>
<TableCell className={className}>{fileCountLabel}</TableCell>
<TableCell className={className}>{zimCountLabel}</TableCell>
<TableCell className={className}>{channelCountLabel}</TableCell>
<TableCell className={className}>{domainCountLabel}</TableCell>
<Table.Cell className={className}>{fileCountLabel}</Table.Cell>
<Table.Cell className={className}>{zimCountLabel}</Table.Cell>
<Table.Cell className={className}>{channelCountLabel}</Table.Cell>
<Table.Cell className={className}>{domainCountLabel}</Table.Cell>
</>
: null;
}}
</Media>
</TableRow>
</Table.Row>
}
function EditTagsModal() {
const {fetchTags, tags} = React.useContext(TagsContext);
const {inverted} = React.useContext(ThemeContext);
// Return a random, but distinct Hex color.
const getRandomColor = () => getDistinctColor((tags || []).map(i => i.color));
@ -292,11 +328,12 @@ function EditTagsModal() {
setRandomColor();
}
const handleTagNameChange = (e, {value}) => {
const handleTagNameChange = (e) => {
const value = e.target.value;
setTagName(value);
// Tag names cannot contain these characters.
const tagNameRegex = /[,<>:|"\\?*%!\n\r]/;
setTagNameError(tagNameRegex.test(value) ? {content: 'Invalid Tag Name'} : null);
setTagNameError(tagNameRegex.test(value) ? 'Invalid Tag Name' : null);
}
const tableHeaders = [
@ -316,59 +353,48 @@ function EditTagsModal() {
];
return <>
<Modal closeIcon
open={open}
onOpen={() => setOpen(true)}
onClose={localOnClose}
>
<Modal size='large' open={open} onClose={localOnClose}>
<Modal.Header>Edit Tags</Modal.Header>
<div className={`content scrolling ${inverted}`} id='editModalContent'>
<Label.Group tag>
<Label size='large' style={{backgroundColor: tagColor, color: textColor}}>
<div id='editModalContent'>
<Group gap={6}>
{/*
* `--label-text`, not `color`, for the same reason as the chip itself:
* inline `color` cannot be overridden, so in night this preview showed
* black text on a transparent outline over a near-black page. It is the
* preview of the color being chosen, so being honest about how the tag
* will actually look matters here more than anywhere.
*/}
<span
className='wrolpi-label wrolpi-tag'
style={{['--label-color']: tagColor, ['--label-text']: textColor}}
>
{tagName || 'Example Tag'}
</Label>
</Label.Group>
</span>
</Group>
<Form autoComplete='off'>
<Form.Input required
autoFocus
label={<b>Tag Name</b>}
placeholder='Unique name'
value={tagName}
error={tagNameError}
onChange={handleTagNameChange}
/>
</Form>
<TextInput
required
autoFocus
autoComplete='off'
label={<b>Tag Name</b>}
placeholder='Unique name'
value={tagName}
error={tagNameError}
onChange={handleTagNameChange}
style={{marginTop: '1em'}}
/>
<HexColorPicker color={tagColor} onChange={setTagColor} style={{marginTop: '1em'}}/>
<Grid>
<Grid.Row columns={2}>
<Grid.Column>
<Button
color='orange'
onClick={setRandomColor}
style={{marginTop: '2em'}}
type='button'
>Random</Button>
</Grid.Column>
<Grid.Column textAlign='right'>
<APIButton
color='violet'
size='big'
onClick={localSaveTag}
style={{marginTop: '2em'}}
disabled={disabled}
>Save</APIButton>
</Grid.Column>
</Grid.Row>
</Grid>
<Group justify='space-between' style={{marginTop: '2em'}}>
<Button role='cancel' type='button' onClick={setRandomColor}>Random</Button>
<APIButton role='save' onClick={localSaveTag} disabled={disabled}>Save</APIButton>
</Group>
<Divider/>
<Media at='mobile'>
<SortableTable
tableProps={{unstackable: true}}
data={tags}
rowFunc={(i, sortData) => <EditTagRow key={i['name']} tag={i} onDelete={localDeleteTag}
onEdit={localEditTag}/>}
@ -378,7 +404,6 @@ function EditTagsModal() {
</Media>
<Media greaterThanOrEqual='tablet'>
<SortableTable
tableProps={{unstackable: true}}
data={tags}
rowFunc={(i, sortData) => <EditTagRow key={i['name']} tag={i} onDelete={localDeleteTag}
onEdit={localEditTag}/>}
@ -388,7 +413,7 @@ function EditTagsModal() {
</Media>
</div>
</Modal>
<Button onClick={() => setOpen(true)} color='violet' disabled={tags === undefined}>
<Button role='primary' onClick={() => setOpen(true)} disabled={tags === undefined}>
Edit
</Button>
</>
@ -406,7 +431,6 @@ export function AddTagsButton({
limit = null,
disabled = false,
filterByOverlap = false,
color = 'violet',
}) {
// A button which displays a modal in which the user can add or remove tags.
@ -556,19 +580,17 @@ export function AddTagsButton({
const hideFrequentTags = filterByOverlap && localTags && localTags.length > 0;
return <>
<Button
<IconButton
icon={active ? 'tags' : 'tag'}
color={color}
label={active ? 'Tags applied' : 'Add tag'}
role='primary'
onClick={handleOpen}
type="button"
type='button'
disabled={disabled}
/>
<Modal closeIcon
open={open}
onOpen={(e) => handleOpen(e)}
onClose={() => setOpen(false)}>
<Modal size='small' open={open} onClose={() => setOpen(false)}>
<Modal.Content>
{loading && <Dimmer active><Loader/></Dimmer>}
{loading && <Loading size='xs'>Updating tags…</Loading>}
<Header as='h4'>Applied Tags</Header>
{localTags && localTags.length > 0 ? selectedTagsGroup : emptySelectedTags}
@ -581,7 +603,7 @@ export function AddTagsButton({
<Divider/>
<Input
<TextInput
ref={filterInputRef}
placeholder='Filter tags...'
value={filterText}
@ -592,8 +614,7 @@ export function AddTagsButton({
addTag(filteredUnusedTags[0]);
}
}}
icon='search'
fluid
leftSection={<Icon name='search'/>}
style={{marginBottom: '0.5em'}}
/>
@ -604,24 +625,21 @@ export function AddTagsButton({
: 'You have no tags')}
</Modal.Content>
<Modal.Actions>
<Grid textAlign='left'>
<Grid.Row>
<Grid.Column width={8}>
{!hideEdit && <EditTagsModal/>}
</Grid.Column>
<Grid.Column width={8}>
<Button onClick={() => setOpen(false)} floated='right'>Close</Button>
{showAny && <Button color='violet' onClick={localOnAnyTag} floated='right'>Any</Button>}
<Button floated='right' secondary onClick={() => clearLocalTags()}>Clear</Button>
</Grid.Column>
</Grid.Row>
</Grid>
<Group justify='space-between'>
<Box>
{!hideEdit && <EditTagsModal/>}
</Box>
<Group gap={8}>
<Button role='cancel' onClick={() => clearLocalTags()}>Clear</Button>
{showAny && <Button role='primary' onClick={localOnAnyTag}>Any</Button>}
<Button role='cancel' onClick={() => setOpen(false)}>Close</Button>
</Group>
</Group>
</Modal.Actions>
</Modal>
</>
}
export const taggedImageLabel = {corner: 'left', icon: 'tag', color: 'green'};
export const TagsSelector = ({
hideEdit = false,
@ -663,43 +681,37 @@ export const TagsSelector = ({
return button;
}
return <Grid columns={2}>
<Grid.Row>
<Grid.Column mobile={2} computer={1}>
{button}
</Grid.Column>
<Grid.Column mobile={13} computer={14}>
<TagsLinkGroup tagNames={selectedTagNames}/>
</Grid.Column>
</Grid.Row>
</Grid>
return <Group align='flex-start' wrap='nowrap' gap={8}>
{button}
<Box style={{flex: 1}}>
<TagsLinkGroup tagNames={selectedTagNames}/>
</Box>
</Group>
}
export const TagsDashboard = () => {
const {tagNames, TagsLinkGroup} = React.useContext(TagsContext);
const tagPlaceholder = <GridColumn style={{width: 100}}><TagPlaceholder/></GridColumn>;
let availableTagsGroup = <Grid columns={3}>
<GridRow>
{tagPlaceholder}
{tagPlaceholder}
{tagPlaceholder}
</GridRow>
</Grid>;
const tagPlaceholder = <Box style={{width: 100}}><TagPlaceholder/></Box>;
let availableTagsGroup = <Group gap={12}>
{tagPlaceholder}
{tagPlaceholder}
{tagPlaceholder}
</Group>;
if (tagNames && tagNames.length >= 1) {
availableTagsGroup = <TagsLinkGroup tagNames={tagNames} style={{marginTop: '0.5em'}}/>;
} else if (tagNames === undefined) {
availableTagsGroup = <ErrorMessage>Could not fetch tags</ErrorMessage>
}
return <Segment>
return <Panel>
<Header as='h2'>Tags</Header>
{availableTagsGroup}
<Divider/>
<EditTagsModal/>
</Segment>
</Panel>
}
export const TagsProvider = (props) => {

207
app/src/Tags.test.js Normal file
View file

@ -0,0 +1,207 @@
import React from 'react';
import {screen, waitFor} from '@testing-library/react';
import {render} from './test-utils';
import {queryContextFixture} from './test-fixtures';
import {QueryContext} from './contexts/contexts';
import {useTags} from './Tags';
/*
* Tests for the real tag chip.
*
* Everything else in the suite takes `NameToTagLabel` from `tagsContextFixture`, which stubs
* it as a function returning the name -- so nothing exercised the component that actually
* paints a tag. That is how a tag came to be unreadable in night mode without a single test
* noticing: the colors are decided here, in the one place in the app that paints with a
* value no theme chose.
*/
jest.mock('./api', () => ({
getTags: jest.fn(),
getRecentTags: jest.fn(),
saveTag: jest.fn(),
deleteTag: jest.fn(),
}));
import {getTags, getRecentTags} from './api';
const BRIGHT = '#ffe066'; // contrastingColor -> black
const DARK = '#1a1a2e'; // contrastingColor -> light
// A harness that renders the real NameToTagLabel out of the real hook.
const TagProbe = ({name}) => {
const {NameToTagLabel} = useTags();
return <NameToTagLabel name={name}/>;
};
const renderTag = (name, tags) => render(
<QueryContext.Provider value={queryContextFixture()}>
<TagProbe name={name}/>
</QueryContext.Provider>
);
// The same, for the group -- which is where a row's spacing is decided.
const GroupProbe = ({tagNames, ...props}) => {
const {TagsLinkGroup} = useTags();
return <TagsLinkGroup tagNames={tagNames} {...props}/>;
};
const renderGroup = (tagNames, props = {}) => render(
<QueryContext.Provider value={queryContextFixture()}>
<GroupProbe tagNames={tagNames} {...props}/>
</QueryContext.Provider>
);
const tagOf = (name) => screen.getByText(name);
/*
* Wait for the fetched colors to arrive. Before they do, `NameToTagLabel` falls back to a
* plain `<Label tag>` -- which also carries `wrolpi-tag`, so waiting on the class alone
* passes against the fallback and asserts nothing about the real chip. Wait for the user's
* color instead.
*/
const awaitColored = async (name, color) => {
await waitFor(() =>
expect(tagOf(name).style.getPropertyValue('--label-color')).toBe(color));
return tagOf(name);
};
describe('a tag chip', () => {
beforeEach(() => {
getTags.mockResolvedValue({
tags: [
{id: 1, name: 'Water', color: BRIGHT},
{id: 2, name: 'Archived', color: DARK},
{id: 3, name: 'Uncolored', color: null},
],
});
getRecentTags.mockResolvedValue({tags: []});
});
it('carries the tag shape, not just the chip shape', async () => {
renderTag('Water');
const tag = await awaitColored('Water', BRIGHT);
expect(tag).toHaveClass('wrolpi-tag');
expect(tag).toHaveClass('wrolpi-label');
});
it('puts the calculated text color in a variable, never in `color`', async () => {
/*
* This is the whole defect. As an inline `color` declaration nothing in a stylesheet
* could outrank it, so night -- which turns a tag into an outline over a near-black
* page -- was still painting the black text that had been calculated for a bright
* yellow fill. The tag was legible only by its border.
*/
renderTag('Water');
const tag = await awaitColored('Water', BRIGHT);
expect(tag.style.getPropertyValue('--label-text')).toBe('#000000');
expect(tag.style.color).toBe('');
});
it('calculates light text for a dark tag', async () => {
renderTag('Archived');
const tag = await awaitColored('Archived', DARK);
const text = tag.style.getPropertyValue('--label-text');
// Non-empty first: an absent variable would satisfy "not black" for free.
expect(text).toBeTruthy();
expect(text).not.toBe('#000000');
});
it('falls back to a default color rather than leaving the fill unset', async () => {
renderTag('Uncolored');
// The tag exists with no color of its own, so the default stands in.
await waitFor(() =>
expect(tagOf('Uncolored').style.getPropertyValue('--label-color')).toBeTruthy());
});
it('still shows the name before any tags have been fetched', async () => {
// The colors are not known yet; the word matters more than the color.
getTags.mockResolvedValue({tags: []});
renderTag('Water');
expect(await screen.findByText('Water')).toBeInTheDocument();
});
});
/*
* How far apart a row of tags sits.
*
* A tag already carries a `margin-left` in ui.css, and that one is structural: the point is
* drawn outside the body, so the margin is the room it needs and cannot be reclaimed. Every
* other gap in a row was decoration on top of it, and measured on the dashboard the tags were
* 34.7px apart horizontally and 18.5px apart vertically -- of which only 18.7px was the point.
*
* Rendered px throughout, measured in the browser at the default interface scale. ui.css states
* the same margin in design px, as `1.0625rem`.
*/
describe('a row of tags', () => {
beforeEach(() => {
getTags.mockResolvedValue({
tags: [
{id: 1, name: 'Water', color: BRIGHT},
{id: 2, name: 'Archived', color: DARK},
],
});
getRecentTags.mockResolvedValue({tags: []});
});
it('adds no margin of its own around a linked tag', async () => {
/*
* The link wrapper carried `0.3em` either side, from when these were framework labels
* that had no margin of their own. It is 0.3em twice, plus the group's gap, plus the
* structural margin -- so two tags sat 34.7px apart where the point needs 18.7px, and
* a wall of tags on the dashboard read as loose.
*/
renderGroup(['Water', 'Archived']);
const tag = await awaitColored('Water', BRIGHT);
// The link has to exist first, or `null.style` would be the failure rather than a margin.
const link = tag.closest('a');
expect(link).toBeTruthy();
expect(link.style.marginLeft).toBe('');
expect(link.style.marginRight).toBe('');
});
it('applies the caller-given spacing to the group, not to every tag in it', async () => {
/*
* The dashboard passes `marginTop` to space the row below its heading. `TagsLinkGroup`
* spread its props all the way down to each chip, so that margin landed on all 34 tags
* -- which is not a margin above the row, it is 7.7px added between every wrapped row
* of it. The tags were the only thing on the page whose rows were further apart than
* the group's own gap.
*/
renderGroup(['Water', 'Archived'], {style: {marginTop: '0.5em'}});
const tag = await awaitColored('Water', BRIGHT);
// Not on the chip ...
expect(tag.style.marginTop).toBe('');
// ... and on the group, so the spacing the caller asked for still happens.
const group = tag.closest('.mantine-Group-root');
expect(group).toBeTruthy();
expect(group.style.marginTop).toBe('0.5em');
});
it('dresses the row with a className, and still hands the rest to each tag', async () => {
/*
* `className` goes the same way as `style`, and for the same reason: it describes the row.
* It is tested rather than only documented because the two halves of this contract are
* one destructure apart, and the `style` half was wrong for as long as it existed.
*
* `onClick` is the other half, asserted here so a later tidy-up cannot route everything
* to the group and leave a wall of tags that no longer responds to a click.
*/
const onClick = jest.fn();
renderGroup(['Water', 'Archived'], {className: 'tag-row', onClick});
const tag = await awaitColored('Water', BRIGHT);
expect(tag.className).not.toContain('tag-row');
expect(tag.closest('.mantine-Group-root').className).toContain('tag-row');
tag.click();
expect(onClick).toHaveBeenCalled();
});
});

View file

@ -1,5 +1,5 @@
import {emptyToNull} from "./components/Common";
import {toast} from "react-semantic-toasts-2";
import {toast} from "./components/ui";
import _ from "lodash";
import {
API_URI,

View file

@ -0,0 +1,93 @@
import fs from 'fs';
import path from 'path';
/*
* The @babel core pipeline is pinned, and has to stay pinned.
*
* Floating it broke the React /map page: maplibre-gl's Web Worker is inlined into the
* production bundle, Babel 7.29 transpiles it differently, and CRA's Terser mangle pass then
* turns it into `ReferenceError: a is not defined`. The worker's style-layer index never
* builds, every vector source fails to parse, and the map renders blank with only the pins.
*
* Two properties make this worth a test rather than a comment:
*
* - It is invisible to everything else we run. The dev server is fine, the jest suite is
* fine, `npm run build` succeeds -- only the MINIFIED bundle is broken, and only on a page
* whose failure looks like missing map data rather than a JS error.
* - It has already regressed once by accident. The pins were added deliberately, then
* removed as collateral when the Mantine commit rewrote `overrides` for TypeScript 5.
* Nothing failed, so nothing objected; the map survived on lockfile inertia alone until
* someone re-resolved it.
*
* Verified rather than assumed, on this tree: raising @babel/core to 7.29.7 and reinstalling
* fails `npm run build` outright ("'opera_mobile' is not a valid target", from a
* helper-compilation-targets that floated away from the browserslist data it was pinned
* against). Restoring these pins builds, and /map renders from the built bundle.
*/
const APP = path.join(__dirname, '..');
const pkg = JSON.parse(fs.readFileSync(path.join(APP, 'package.json'), 'utf8'));
const lock = JSON.parse(fs.readFileSync(path.join(APP, 'package-lock.json'), 'utf8'));
/** The 16 packages that differ between the last-good tree and the one that broke the map. */
const PINNED = [
'@babel/code-frame', '@babel/compat-data', '@babel/core', '@babel/generator',
'@babel/helper-compilation-targets', '@babel/helper-globals', '@babel/helper-module-imports',
'@babel/helper-module-transforms', '@babel/helper-string-parser',
'@babel/helper-validator-identifier', '@babel/helper-validator-option', '@babel/helpers',
'@babel/parser', '@babel/template', '@babel/traverse', '@babel/types',
];
describe('@babel pins', () => {
it('names every package in the core pipeline', () => {
/*
* The whole pipeline, not just @babel/core. Pinning core alone was tried when this was
* first diagnosed and is not enough: the transform plugins resolve their own copies of
* traverse/generator/types, so the new AST pipeline comes back in underneath a pinned
* core.
*/
const overrides = pkg.overrides || {};
const missing = PINNED.filter(name => !overrides[name]);
expect(missing).toEqual([]);
});
it('pins them to an exact version, not a range', () => {
// `^7.20.12` is not a pin -- it resolves to 7.29 the moment anything re-resolves, which
// is exactly the event this guards against.
const loose = PINNED
.map(name => [name, (pkg.overrides || {})[name]])
.filter(([, spec]) => typeof spec !== 'string' || !/^\d+\.\d+\.\d+$/.test(spec));
expect(loose).toEqual([]);
});
it('resolves every installed copy to the pinned version', () => {
/*
* The manifest is the intent; the lockfile is what actually gets installed. npm nests
* a second copy of a dependency whenever versions conflict, and a nested copy that
* escaped the override would put the new pipeline back into the build while the
* top-level entry still read correctly.
*
* So this walks EVERY path in the lockfile, not just `node_modules/<name>`.
*/
const wanted = Object.fromEntries(PINNED.map(name => [name, pkg.overrides[name]]));
const mismatched = [];
let copies = 0;
for (const [location, info] of Object.entries(lock.packages || {})) {
for (const [name, version] of Object.entries(wanted)) {
if (location === `node_modules/${name}` || location.endsWith(`/node_modules/${name}`)) {
copies += 1;
if (info.version !== version) {
mismatched.push(`${location}: ${info.version} (pinned ${version})`);
}
}
}
}
// The premise: a lockfile these names never matched would report nothing mismatched.
expect(copies).toBeGreaterThan(100);
expect(mismatched).toEqual([]);
});
});

View file

@ -1,8 +1,5 @@
import React, {useEffect, useState} from "react";
import {Input, Message} from "semantic-ui-react";
import {toast} from "react-semantic-toasts-2";
import {Button, Form, List, Loader, Modal} from "./Theme";
import {Button, IconButton, Loading, Message, Modal, Radio, Stack, TextInput, toast} from "./ui";
import {addPlaylistItem, createPlaylist, fetchPlaylists} from "../api";
@ -68,32 +65,38 @@ export function AddToPlaylistModal({open, onClose, items = [], onComplete}) {
return <Modal open={open} onClose={onClose} size='tiny'>
<Modal.Header>Add to Playlist</Modal.Header>
<Modal.Content>
{playlists === undefined && <Message error><Message.Header>Could not load playlists</Message.Header></Message>}
{playlists === null && <Loader active inline='centered'/>}
{playlists === undefined && <Message kind='error' title='Could not load playlists'/>}
{playlists === null && <Loading/>}
{Array.isArray(playlists) && playlists.length > 0 &&
<List divided selection>
{playlists.map(p => <List.Item key={p.id} active={selected === p.id}
onClick={() => setSelected(p.id)}>
<List.Icon name={selected === p.id ? 'check circle' : 'circle outline'}
verticalAlign='middle'/>
<List.Content><List.Header>{p.name}</List.Header></List.Content>
</List.Item>)}
</List>}
<Stack gap={4}>
{playlists.map(p => <Radio
key={p.id}
name='playlist'
label={p.name}
checked={selected === p.id}
onChange={() => setSelected(p.id)}
/>)}
</Stack>}
{Array.isArray(playlists) && playlists.length === 0 &&
<p>No playlists yet — create one below.</p>}
<Form onSubmit={handleCreateAndAdd} style={{marginTop: '1em'}}>
<Form.Field>
<label>Or create a new playlist</label>
<Input autoFocus placeholder='New playlist name...' value={newName}
onChange={(e, {value}) => setNewName(value)}/>
</Form.Field>
</Form>
<form onSubmit={(e) => {
e.preventDefault();
handleCreateAndAdd();
}} style={{marginTop: '1em'}}>
<TextInput
label='Or create a new playlist'
autoFocus
placeholder='New playlist name...'
value={newName}
onChange={(e) => setNewName(e.currentTarget.value)}
/>
</form>
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Cancel</Button>
<Button role='cancel' onClick={onClose}>Cancel</Button>
{creating
? <Button primary loading={adding} onClick={handleCreateAndAdd}>Create &amp; Add</Button>
: <Button primary disabled={!selected} loading={adding} onClick={handleAddExisting}>Add</Button>}
? <Button role='primary' loading={adding} onClick={handleCreateAndAdd}>Create &amp; Add</Button>
: <Button role='primary' disabled={!selected} loading={adding} onClick={handleAddExisting}>Add</Button>}
</Modal.Actions>
</Modal>;
}
@ -104,9 +107,12 @@ export function AddToPlaylistModal({open, onClose, items = [], onComplete}) {
* - one FileGroup (fileGroupId) or many (fileGroupIds),
* - a Zim article (zim={zimId, entry, title}), or
* - a URL (url={url, title}) — link to anything the WROLPi browser can open (e.g. a map location).
* Extra props are forwarded to the Button (color, size, disabled, icon, content, ...).
* Extra props are forwarded to the Button (size, disabled, ...).
*/
export function AddToPlaylistButton({fileGroupId, fileGroupIds, zim, url, content = 'Add to Playlist', onComplete, ...buttonProps}) {
export function AddToPlaylistButton({
fileGroupId, fileGroupIds, zim, url, content = 'Add to Playlist',
title, onComplete, ...buttonProps
}) {
const [open, setOpen] = useState(false);
let items;
@ -119,13 +125,16 @@ export function AddToPlaylistButton({fileGroupId, fileGroupIds, zim, url, conten
items = ids.map(id => ({item_kind: 'file', file_group_id: id}));
}
// An empty `content` makes a compact icon-only button (omit content entirely so Semantic does
// not reserve label padding); otherwise render the labeled "Add to Playlist" button.
const contentProps = content ? {content} : {};
// An empty/null `content` makes a compact icon-only button; the accessible name comes
// from `title` (kept for callers that already pass it) or falls back to the action name.
const iconOnly = !content;
return <>
<Button color='green' icon='list' onClick={() => setOpen(true)}
disabled={items.length === 0} {...contentProps} {...buttonProps}/>
{iconOnly
? <IconButton role='save' icon='list' label={title || 'Add to Playlist'} onClick={() => setOpen(true)}
disabled={items.length === 0} {...buttonProps}/>
: <Button role='save' icon='list' onClick={() => setOpen(true)}
disabled={items.length === 0} {...buttonProps}>{content}</Button>}
<AddToPlaylistModal open={open} onClose={() => setOpen(false)} items={items}
onComplete={onComplete}/>
</>;

View file

@ -1,5 +1,4 @@
import React, {useContext} from 'react';
import {Input, Label, StatisticLabel, StatisticValue} from "semantic-ui-react";
import React from 'react';
import {Link, Route, Routes} from "react-router";
import {
ErrorMessage,
@ -10,16 +9,13 @@ import {
toLocaleString,
useTitle
} from "./Common";
import {ThemeContext} from "../contexts/contexts";
import {Header, Loader, Segment, Statistic} from "./Theme";
import {Header, Loading, Panel, Statistic, StatisticGroup} from "./ui";
import {useStatistics} from "../hooks/customHooks";
import {CalculatorsPage} from "./Calculators";
function StatisticsPage() {
useTitle('Statistics');
const {s} = useContext(ThemeContext);
const {statistics} = useStatistics();
if (statistics === undefined) {
@ -49,90 +45,56 @@ function StatisticsPage() {
return <>
<Header as='h1'>Statistics</Header>
<Header as='h2'>Files</Header>
<Segment>
<Statistic.Group>
<Statistic>
<StatisticValue>{toLocaleString(total_count)}</StatisticValue>
<StatisticLabel>All Files</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{humanFileSize(total_size)}</StatisticValue>
<StatisticLabel>Total Size</StatisticLabel>
</Statistic>
</Statistic.Group>
</Segment>
<Segment>
<Statistic.Group size='small'>
<Panel>
<StatisticGroup>
<Statistic value={toLocaleString(total_count)} label='All Files'/>
<Statistic value={humanFileSize(total_size)} label='Total Size'/>
</StatisticGroup>
</Panel>
<Panel>
<StatisticGroup>
<Link to={'/videos/statistics'}>
<Statistic color={mimetypeColor('video/')}>
<StatisticValue>{toLocaleString(video_count)}</StatisticValue>
<StatisticLabel>Videos</StatisticLabel>
</Statistic>
<Statistic color={mimetypeColor('video/')} value={toLocaleString(video_count)}
label='Videos'/>
</Link>
<Statistic color={mimetypeColor('application/pdf')}>
<StatisticValue>{toLocaleString(pdf_count)}</StatisticValue>
<StatisticLabel>PDFs</StatisticLabel>
</Statistic>
<Statistic color={mimetypeColor('application/epub')}>
<StatisticValue>{toLocaleString(ebook_count)}</StatisticValue>
<StatisticLabel>eBooks</StatisticLabel>
</Statistic>
<Statistic color={mimetypeColor('text/html')}>
<StatisticValue>{toLocaleString(archive_count)}</StatisticValue>
<StatisticLabel>Archives</StatisticLabel>
</Statistic>
<Statistic color={mimetypeColor('image/')}>
<StatisticValue>{toLocaleString(image_count)}</StatisticValue>
<StatisticLabel>Images</StatisticLabel>
</Statistic>
</Statistic.Group>
</Segment>
<Segment>
<Statistic.Group size='tiny'>
<Statistic>
<StatisticValue>{toLocaleString(zip_count)}</StatisticValue>
<StatisticLabel>ZIP</StatisticLabel>
</Statistic>
<Statistic color={mimetypeColor('audio/')}>
<StatisticValue>{toLocaleString(audio_count)}</StatisticValue>
<StatisticLabel>Audio</StatisticLabel>
</Statistic>
</Statistic.Group>
</Segment>
<Statistic color={mimetypeColor('application/pdf')} value={toLocaleString(pdf_count)}
label='PDFs'/>
<Statistic color={mimetypeColor('application/epub')} value={toLocaleString(ebook_count)}
label='eBooks'/>
<Statistic color={mimetypeColor('text/html')} value={toLocaleString(archive_count)}
label='Archives'/>
<Statistic color={mimetypeColor('image/')} value={toLocaleString(image_count)}
label='Images'/>
</StatisticGroup>
</Panel>
<Panel>
<StatisticGroup>
<Statistic value={toLocaleString(zip_count)} label='ZIP'/>
<Statistic color={mimetypeColor('audio/')} value={toLocaleString(audio_count)} label='Audio'/>
</StatisticGroup>
</Panel>
<Header as='h2'>Tags</Header>
<Segment>
<Statistic.Group>
<Statistic>
<StatisticValue>{humanNumber(tags_count)}</StatisticValue>
<StatisticLabel>Tags</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{humanNumber(tagged_files)}</StatisticValue>
<StatisticLabel>Tagged Files</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{humanNumber(tagged_zims)}</StatisticValue>
<StatisticLabel>Tagged Zims</StatisticLabel>
</Statistic>
</Statistic.Group>
</Segment>
<Panel>
<StatisticGroup>
<Statistic value={humanNumber(tags_count)} label='Tags'/>
<Statistic value={humanNumber(tagged_files)} label='Tagged Files'/>
<Statistic value={humanNumber(tagged_zims)} label='Tagged Zims'/>
</StatisticGroup>
</Panel>
<Header as='h2'>Database</Header>
<Segment>
<Statistic.Group>
<Statistic>
<StatisticValue>{humanFileSize(db_size)}</StatisticValue>
<StatisticLabel>Size</StatisticLabel>
</Statistic>
</Statistic.Group>
</Segment>
<Panel>
<StatisticGroup>
<Statistic value={humanFileSize(db_size)} label='Size'/>
</StatisticGroup>
</Panel>
</>;
}
return <>
<Header as='h1'>Statistics</Header>
<Segment><Loader inline active/></Segment>
<Panel><Loading/></Panel>
</>;
}
@ -145,8 +107,3 @@ export function MoreRoute(props) {
</Routes>
</PageContainer>
}
export function ColoredInput({name, value, label, color, ...props}) {
label = label ? <Label color={color}>{label}</Label> : null;
return <Input value={value} name={name} label={label} {...props}/>
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,7 @@
import React from "react";
import {Icon, Loader, Message, Placeholder, PlaceholderLine} from "semantic-ui-react";
import {downloadArchiveMember, getArchiveContents} from "../api";
import {fileSuffixIconName, humanFileSize} from "./Common";
import {Modal, Segment, Table} from "./Theme";
import {Icon, Message, Modal, Panel, Placeholder, Table} from "./ui";
function ArchiveEntry({entry, depth, archivePath}) {
const [expanded, setExpanded] = React.useState(depth === 0);
@ -19,9 +18,9 @@ function ArchiveEntry({entry, depth, archivePath}) {
<Table.Cell className='file-path'>
{indent}
<Icon name={expanded ? 'folder open' : 'folder'}/>
{entry.name}/
{' '}{entry.name}/
</Table.Cell>
<Table.Cell collapsing textAlign='right'>
<Table.Cell numeric>
&mdash;
</Table.Cell>
</Table.Row>
@ -37,11 +36,11 @@ function ArchiveEntry({entry, depth, archivePath}) {
<Table.Cell className='file-path' onClick={handleDownload} style={{cursor: 'pointer'}}>
{indent}
<Icon name={fileSuffixIconName(entry.name)}/>
{entry.name}
{' '}{entry.name}
{' '}
<Icon name='download' size='small' color='grey'/>
<Icon name='download' size='small'/>
</Table.Cell>
<Table.Cell collapsing textAlign='right'>
<Table.Cell numeric>
{humanFileSize(entry.size)}
</Table.Cell>
</Table.Row>
@ -52,7 +51,7 @@ function SkeletonRows() {
{[1, 2, 3].map(i =>
<Table.Row key={i}>
<Table.Cell colSpan={2}>
<Placeholder><PlaceholderLine/></Placeholder>
<Placeholder lines={1}/>
</Table.Cell>
</Table.Row>
)}
@ -88,24 +87,24 @@ export function ArchivePreviewContent({previewFile}) {
if (error) {
return <Modal.Content>
<Message negative>
<Message.Header>Cannot read archive</Message.Header>
<Message kind='error' title='Cannot read archive'>
<p>{error}</p>
</Message>
</Modal.Content>
}
return <Modal.Content scrolling>
{!loading && contents && <Segment>
{!loading && contents && <Panel>
<Icon name='archive'/>
{' '}
<strong>{contents.total_files}</strong> files,{' '}
<strong>{humanFileSize(contents.total_size)}</strong> total
</Segment>}
<Table striped selectable unstackable>
</Panel>}
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell collapsing textAlign='right'>Size</Table.HeaderCell>
<Table.HeaderCell>Size</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>

View file

@ -1,9 +1,7 @@
import React from 'react';
import {Button, Divider, Header, Loader, Modal, Progress} from "./Theme";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import {Button, Divider, Header, Loading, Message, Modal, Progress} from "./ui";
import {applyBulkTags, getBulkTagPreview, getBulkTagProgress} from "../api";
import {TagsContext} from "../Tags";
import {ThemeContext} from "../contexts/contexts";
const BULK_TAG_WARNING_THRESHOLD = 50;
const POLL_INTERVAL = 500; // Poll progress every 500ms
@ -18,7 +16,6 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
const [error, setError] = React.useState(null);
const {tagNames, TagsGroup} = React.useContext(TagsContext);
const {t} = React.useContext(ThemeContext);
// Fetch preview when modal opens
React.useEffect(() => {
@ -128,20 +125,17 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
: 0;
return (
<Modal closeIcon open={open} onClose={handleClose} size="small">
<Modal closeIcon open={open} onClose={handleClose} size='large'>
<Modal.Header>Bulk Tag Files</Modal.Header>
<Modal.Content>
{state === 'loading' && (
<div style={{textAlign: 'center', padding: '2em'}}>
<Loader active inline="centered">Loading preview...</Loader>
</div>
<Loading>Loading preview...</Loading>
)}
{state === 'preview' && (
<>
{error && (
<Message negative>
<Message.Header>Error</Message.Header>
<Message kind='error' title='Error'>
<p>{error}</p>
</Message>
)}
@ -151,8 +145,7 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
</Header>
{showWarning && (
<Message warning>
<Message.Header>Warning</Message.Header>
<Message kind='warning' title='Warning'>
<p>Tagging over {BULK_TAG_WARNING_THRESHOLD} files may make it difficult to find what you are looking for. Are you sure?</p>
</Message>
)}
@ -163,7 +156,7 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
{localTags.length > 0 ? (
<TagsGroup tagNames={localTags} onClick={removeTag}/>
) : (
<p {...t}>Add one or more tags below</p>
<p>Add one or more tags below</p>
)}
<Divider/>
@ -171,7 +164,7 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
{unusedTags.length > 0 ? (
<TagsGroup tagNames={unusedTags} onClick={addTag}/>
) : (
<p {...t}>You have no tags</p>
<p>You have no tags</p>
)}
</>
)}
@ -181,18 +174,14 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
<Header as="h4">Applying tags...</Header>
<Progress
percent={progressPercent}
progress
indicating
color="blue"
>
<span {...t}>{progress.completed} / {progress.total} files</span>
</Progress>
label={`${progress.completed} / ${progress.total} files`}
/>
{progress.queued_jobs > 0 && (
<p {...t}>{progress.queued_jobs} job{progress.queued_jobs !== 1 ? 's' : ''} queued</p>
<p>{progress.queued_jobs} job{progress.queued_jobs !== 1 ? 's' : ''} queued</p>
)}
{error && (
<Message negative>
<Message.Header>Error</Message.Header>
<Message kind='error' title='Error'>
<p>{error}</p>
</Message>
)}
@ -201,13 +190,11 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
{state === 'complete' && (
<div style={{padding: '1em'}}>
<Message positive>
<Message.Header>Complete</Message.Header>
<Message kind='success' title='Complete'>
<p>Successfully tagged {progress.total} file{progress.total !== 1 ? 's' : ''}.</p>
</Message>
{error && (
<Message warning>
<Message.Header>Warning</Message.Header>
<Message kind='warning' title='Warning'>
<p>Some errors occurred: {error}</p>
</Message>
)}
@ -217,7 +204,7 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
<Modal.Actions>
{state === 'preview' && (
<>
<Button onClick={handleClose}>Cancel</Button>
<Button role='cancel' onClick={handleClose}>Cancel</Button>
<Button
color="violet"
onClick={handleApply}
@ -228,12 +215,12 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
</>
)}
{state === 'applying' && (
<Button onClick={handleClose} disabled>
<Button role='cancel' onClick={handleClose} disabled>
Please wait...
</Button>
)}
{state === 'complete' && (
<Button color="green" onClick={handleClose}>
<Button role='save' onClick={handleClose}>
Done
</Button>
)}

View file

@ -1,7 +1,8 @@
import React from 'react';
import {act, render, screen, waitFor} from '@testing-library/react';
import {act, screen, waitFor} from '@testing-library/react';
import {BulkTagModal} from './BulkTagModal';
import {TagsContext} from '../Tags';
import {renderWithProviders} from '../test-utils';
// Mock the API functions
jest.mock('../api', () => ({
@ -10,42 +11,6 @@ jest.mock('../api', () => ({
getBulkTagProgress: jest.fn(),
}));
// Mock Theme components with compound patterns
jest.mock('./Theme', () => {
const MockModal = ({open, onClose, children, closeIcon}) => {
if (!open) return null;
return (
<div data-testid="modal">
{closeIcon && <button data-testid="close-icon" onClick={onClose}>x</button>}
{children}
</div>
);
};
MockModal.Header = ({children}) => <div data-testid="modal-header">{children}</div>;
MockModal.Content = ({children}) => <div data-testid="modal-content">{children}</div>;
MockModal.Actions = ({children}) => <div data-testid="modal-actions">{children}</div>;
MockModal.Description = ({children}) => <div data-testid="modal-description">{children}</div>;
return {
...jest.requireActual('./Theme'),
Modal: MockModal,
Button: ({children, onClick, disabled, color, ...props}) => (
<button onClick={onClick} disabled={disabled} data-testid={`button-${children}`} {...props}>{children}</button>
),
Header: ({children, as}) => <div data-testid="header">{children}</div>,
Divider: () => <hr data-testid="divider"/>,
Loader: ({children}) => <div data-testid="loader">{children}</div>,
Message: ({children, warning, negative, positive}) => (
<div data-testid={`message-${warning ? 'warning' : negative ? 'negative' : positive ? 'positive' : 'info'}`}>
{children}
</div>
),
Progress: ({percent, children}) => (
<div data-testid="progress" data-percent={percent}>{children}</div>
),
};
});
// Import mocked API functions
import {getBulkTagPreview, applyBulkTags, getBulkTagProgress} from '../api';
@ -60,23 +25,18 @@ const MockTagsGroup = ({tagNames, onClick}) => (
</div>
);
// Create a wrapper with TagsContext
const TagsContextWrapper = ({children}) => {
// Render with the real ui library components (Mantine-backed) plus TagsContext.
const renderWithTags = (ui, options) => {
const tagsValue = {
tagNames: ['tag1', 'tag2', 'tag3'],
TagsGroup: MockTagsGroup,
};
return (
<TagsContext.Provider value={tagsValue}>
{children}
</TagsContext.Provider>
return renderWithProviders(
<TagsContext.Provider value={tagsValue}>{ui}</TagsContext.Provider>,
options,
);
};
// Custom render function with TagsContext
const renderWithTags = (ui, options) =>
render(ui, {wrapper: TagsContextWrapper, ...options});
describe('BulkTagModal', () => {
const defaultProps = {
open: true,
@ -102,17 +62,17 @@ describe('BulkTagModal', () => {
describe('Modal Rendering', () => {
it('renders modal when open is true', async () => {
renderWithTags(<BulkTagModal {...defaultProps} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('does not render modal when open is false', () => {
renderWithTags(<BulkTagModal {...defaultProps} open={false}/>);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('shows header', async () => {
renderWithTags(<BulkTagModal {...defaultProps} />);
expect(screen.getByTestId('modal-header')).toHaveTextContent('Bulk Tag Files');
expect(screen.getByText('Bulk Tag Files')).toBeInTheDocument();
});
});
@ -121,7 +81,7 @@ describe('BulkTagModal', () => {
getBulkTagPreview.mockImplementation(() => new Promise(() => {
})); // Never resolves
renderWithTags(<BulkTagModal {...defaultProps} />);
expect(screen.getByTestId('loader')).toBeInTheDocument();
expect(screen.getByText('Loading preview...')).toBeInTheDocument();
});
});
@ -142,7 +102,7 @@ describe('BulkTagModal', () => {
await waitFor(() => {
expect(screen.getByText(/10 files will be affected/i)).toBeInTheDocument();
});
expect(screen.queryByTestId('message-warning')).not.toBeInTheDocument();
expect(screen.queryByText(/Tagging over 50 files/i)).not.toBeInTheDocument();
});
it('Apply button is disabled when no changes', async () => {
@ -150,7 +110,7 @@ describe('BulkTagModal', () => {
await waitFor(() => {
expect(screen.getByText(/2 files will be affected/i)).toBeInTheDocument();
});
const applyButton = screen.getByTestId('button-Apply Tags');
const applyButton = screen.getByRole('button', {name: 'Apply Tags'});
expect(applyButton).toBeDisabled();
});
});
@ -183,7 +143,7 @@ describe('BulkTagModal', () => {
await waitFor(() => {
expect(screen.getByText(/2 files will be affected/i)).toBeInTheDocument();
});
const cancelButton = screen.getByTestId('button-Cancel');
const cancelButton = screen.getByRole('button', {name: 'Cancel'});
act(() => {
cancelButton.click();
});
@ -197,7 +157,7 @@ describe('BulkTagModal', () => {
getBulkTagPreview.mockResolvedValue(undefined);
renderWithTags(<BulkTagModal {...defaultProps} />);
// Modal should still be visible
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
});

View file

@ -1,6 +1,6 @@
import React from "react";
import {Breadcrumbs, useTitle} from "./Common";
import {Button, Header, Icon} from "./Theme";
import {Button, Header, Icon} from "./ui";
import {useCalcQuery} from "../hooks/customHooks";
import {TemperatureCalculator} from "./calculators/TemperatureCalculator";
import {ElectricalCalculators} from "./calculators/ElectricalCalculators";
@ -18,7 +18,7 @@ import {VinDecoderCalculator} from "./calculators/VinDecoderCalculator";
// `group` matching one of these names; any calculator whose group is missing/unknown falls
// into a trailing "Other" section so a new calculator can never silently disappear.
//
// `color` is a Semantic UI named color (colorblind-friendly palette) used to tint the group's
// `color` is one of the named palette colors (see `paletteColorNames`) used to tint the group's
// buttons. Violet is intentionally avoided — it is the default navbar color and would blend in.
export const CALCULATOR_GROUPS = [
{name: 'Preparedness & Storage', color: 'red'},

View file

@ -1,7 +1,7 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {IconArrowLeft, IconArrowRight} from '@tabler/icons-react';
import {getArchiveContents, getArchiveMemberUrl} from '../api';
import {Icon, Label, Loader, Message} from 'semantic-ui-react';
import {Button} from './Theme';
import {IconButton, Label, Loading, Message} from './ui';
import {useLocalStorage} from './Common';
const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i;
@ -77,15 +77,13 @@ export function CbzViewer({path}) {
}, [onLeftClick, onRightClick]);
if (error) {
return <Message negative>
<Message.Header>Cannot load comic</Message.Header>
return <Message kind='error' title='Cannot load comic'>
<p>{error}</p>
</Message>;
}
if (!pages) return <Loader active inline='centered'/>;
if (!pages) return <Loading/>;
if (pages.length === 0) {
return <Message warning>
<Message.Header>No images found</Message.Header>
return <Message kind='warning' title='No images found'>
<p>This archive does not contain any image files.</p>
</Message>;
}
@ -94,12 +92,12 @@ export function CbzViewer({path}) {
const hasNext = currentPage < pages.length - 1;
return <div style={{marginBottom: '1em'}}>
<div ref={imageContainerRef} style={{
<div ref={imageContainerRef} className='media' style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: '#222',
borderRadius: fullscreen ? 0 : '4px',
background: 'var(--bg)',
borderRadius: 0,
padding: '0.5em',
minHeight: '300px',
position: fullscreen ? 'fixed' : 'relative',
@ -133,20 +131,14 @@ export function CbzViewer({path}) {
gap: '0.5em',
marginTop: '0.5em',
}}>
<Button icon basic active={rtl} onClick={() => setRtl(r => !r)}
aria-label='Reading direction'>
<Icon name={rtl ? 'hand point left' : 'hand point right'}/>
</Button>
<IconButton icon={rtl ? IconArrowLeft : IconArrowRight} label='Reading direction'
variant={rtl ? 'filled' : 'default'} onClick={() => setRtl(r => !r)}/>
<span style={{flex: 1}}/>
<Button icon basic disabled={rtl ? !hasNext : !hasPrev}
onClick={onLeftClick} aria-label='Previous page'>
<Icon name='chevron left'/>
</Button>
<IconButton icon='chevron left' label='Previous page'
disabled={rtl ? !hasNext : !hasPrev} onClick={onLeftClick}/>
<Label>{currentPage + 1} / {pages.length}</Label>
<Button icon basic disabled={rtl ? !hasPrev : !hasNext}
onClick={onRightClick} aria-label='Next page'>
<Icon name='chevron right'/>
</Button>
<IconButton icon='chevron right' label='Next page'
disabled={rtl ? !hasPrev : !hasNext} onClick={onRightClick}/>
</div>
</div>;
}

View file

@ -53,6 +53,6 @@ describe('Channels Page', () => {
cy.wait(500);
cy.get('input[name="name"]').should('be.visible');
cy.get('input[name="name"]').should('have.value', 'Editing Channel');
cy.get('div.ui.label.large').contains('Automotive').should('be.visible');
cy.get('.wrolpi-tag').contains('Automotive').should('be.visible');
});
});

View file

@ -1,6 +1,5 @@
import React, {useState} from "react";
import {useHotkeys} from "react-hotkeys-hook";
import {Grid, StatisticLabel, StatisticValue,} from "semantic-ui-react";
import {createChannel, deleteChannel, refreshChannel, tagChannel, tagChannelInfo} from "../api";
import {CollectionTagModal} from "./collections/CollectionTagModal";
import {CollectionReorganizeModal} from "./collections/CollectionReorganizeModal";
@ -18,11 +17,22 @@ import {
WROLModeMessage
} from "./Common";
import {Link, useNavigate, useParams} from "react-router";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import {useChannel, useChannels, useOneQuery} from "../hooks/customHooks";
import _ from "lodash";
import {Button, Form, Header, Loader, Modal, Segment, Statistic} from "./Theme";
import {toast} from "react-semantic-toasts-2";
import {
Button,
Grid,
Group,
Header,
Loading,
Message,
Modal,
Panel,
Stack,
Statistic,
StatisticGroup,
toast,
} from "./ui";
import {RecurringDownloadsTable} from "./admin/Downloads";
import {InputForm, ToggleForm} from "../hooks/useForm";
import {ChannelDownloadForm, DestinationForm, DownloadTagsSelector} from "./Download";
@ -58,29 +68,16 @@ function ChannelStatistics({statistics}) {
return <></>
}
return <Segment>
return <Panel>
<Header as='h1'>Statistics</Header>
<Statistic>
<StatisticValue>{statistics.video_count}</StatisticValue>
<StatisticLabel>Videos</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{humanFileSize(statistics.size, true)}</StatisticValue>
<StatisticLabel>Total Size</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{humanFileSize(statistics.largest_video, true)}</StatisticValue>
<StatisticLabel>Largest Video</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{secondsToFullDuration(statistics.length)}</StatisticValue>
<StatisticLabel>Total Duration</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{humanNumber(statistics.video_tags)}</StatisticValue>
<StatisticLabel>Video Tags</StatisticLabel>
</Statistic>
</Segment>
<StatisticGroup>
<Statistic value={statistics.video_count} label='Videos'/>
<Statistic value={humanFileSize(statistics.size, true)} label='Total Size'/>
<Statistic value={humanFileSize(statistics.largest_video, true)} label='Largest Video'/>
<Statistic value={secondsToFullDuration(statistics.length)} label='Total Duration'/>
<Statistic value={humanNumber(statistics.video_tags)} label='Video Tags'/>
</StatisticGroup>
</Panel>
}
@ -97,7 +94,7 @@ export function ChannelEditPage() {
useTitle(_.isEmpty(channel) ? null : `${channel.name} Channel`);
if (!form.ready) {
return <Loader active/>;
return <Loading/>;
}
const handleRefreshChannel = async (e) => {
@ -169,39 +166,35 @@ export function ChannelEditPage() {
};
const deleteButton = <APIButton
color='red'
role='danger'
size='small'
confirmContent='Are you sure you want to delete this channel? No video files will be deleted.'
confirmButton='Delete'
confirmHeader='Delete Channel?'
onClick={handleDelete}
obeyWROLMode={true}
style={{marginTop: '1em'}}
>Delete</APIButton>;
const refreshButton = <APIButton
color='blue'
role='primary'
size='small'
onClick={handleRefreshChannel}
obeyWROLMode={true}
style={{marginTop: '1em'}}
>Refresh</APIButton>;
const tagButton = <Button
type="button"
size='small'
onClick={() => setTagEditModalOpen(true)}
color='violet'
style={{marginTop: '1em'}}
role='primary'
>Tag</Button>;
const reorganizeButton = (
<APIButton
color='orange'
role='retry'
size='small'
onClick={() => setReorganizeModalOpen(true)}
obeyWROLMode={true}
style={{marginTop: '1em'}}
>Reorganize Files</APIButton>
);
@ -216,14 +209,15 @@ export function ChannelEditPage() {
const downloadMissingDataLabel = <>Download Missing Data<InfoPopup content={downloadMissingDataInfo}/></>;
return <>
<BackButton/>
<Link to={`/videos/channel/${channel.id}/video`}>
<Button>Videos</Button>
</Link>
<div className='wrolpi-button-row'>
<BackButton/>
<Link to={`/videos/channel/${channel.id}/video`}>
<Button>Videos</Button>
</Link>
</div>
{channel?.needs_reorganization && (
<Message warning>
<Message.Header>File Format Changed</Message.Header>
<Message kind='warning' title='File Format Changed'>
<p>
The file name format has changed. Click "Reorganize Files" to move existing files
to match the new format.
@ -239,8 +233,8 @@ export function ChannelEditPage() {
appliedTagName={channel?.tag_name}
onSubmit={handleSubmit}
>
<Grid.Row>
<Grid.Column width={8}>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<InputForm
form={form}
label="Channel Name"
@ -248,8 +242,8 @@ export function ChannelEditPage() {
placeholder="Short Channel Name"
required={true}
/>
</Grid.Column>
<Grid.Column width={8}>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<DestinationForm
form={form}
label='Directory'
@ -257,38 +251,30 @@ export function ChannelEditPage() {
path='directory'
required={true}
/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
</Grid.Col>
<Grid.Col span={12}>
<ToggleForm
form={form}
label={downloadMissingDataLabel}
path='download_missing_data'
/>
</Grid.Column>
</Grid.Row>
{(channel.url || channel.rss_url) &&
<Grid.Row>
<Grid.Column>
</Grid.Col>
{(channel.url || channel.rss_url) &&
<Grid.Col span={12}>
<SimpleAccordion title='Details'>
<Grid>
{channel.url && <Grid.Row columns={1}>
<Grid.Column>
<Header as='h4'>URL</Header>
<a href={channel.url}>{channel.url}</a>
</Grid.Column>
</Grid.Row>}
{channel.rss_url && <Grid.Row columns={1}>
<Grid.Column>
<Header as='h4'>RSS URL</Header>
<a href={channel.rss_url}>{channel.rss_url}</a>
</Grid.Column>
</Grid.Row>}
</Grid>
<Stack gap='sm'>
{channel.url && <div>
<Header as='h4'>URL</Header>
<a href={channel.url}>{channel.url}</a>
</div>}
{channel.rss_url && <div>
<Header as='h4'>RSS URL</Header>
<a href={channel.rss_url}>{channel.rss_url}</a>
</div>}
</Stack>
</SimpleAccordion>
</Grid.Column>
</Grid.Row>}
</Grid.Col>}
</Grid>
</CollectionEditForm>
{/* Tag Modal */}
@ -314,22 +300,14 @@ export function ChannelEditPage() {
/>
{/* Downloads Segment */}
<Segment>
<Grid columns={2}>
<Grid.Row>
<Grid.Column>
<Header as='h1'>Downloads</Header>
</Grid.Column>
<Grid.Column>
<Button floated='right'
onClick={() => setDownloadModalOpen(!downloadModalOpen)}
>
New Download
</Button>
</Grid.Column>
</Grid.Row>
</Grid>
<Modal open={downloadModalOpen} closeIcon onClose={() => setDownloadModalOpen(false)}>
<Panel>
<Group justify='space-between' align='center' wrap='wrap'>
<Header as='h1'>Downloads</Header>
<Button onClick={() => setDownloadModalOpen(!downloadModalOpen)}>
New Download
</Button>
</Group>
<Modal size='small' open={downloadModalOpen} onClose={() => setDownloadModalOpen(false)}>
<Modal.Content>
<Header as='h2'>New Channel Download</Header>
<ChannelDownloadForm
@ -342,7 +320,7 @@ export function ChannelEditPage() {
</Modal>
<RecurringDownloadsTable downloads={channel?.downloads} fetchDownloads={fetchChannel} onDelete={onDelete}/>
</Segment>
</Panel>
{channel && channel.statistics && <ChannelStatistics statistics={channel.statistics}/>}
</>;
@ -434,88 +412,74 @@ export function ChannelNewPage() {
let messageRow;
if (error || success) {
messageRow = <Grid.Row columns={1}>
<Grid.Column>
{error &&
<Message negative
header={messageHeader}
content={messageContent}
/>}
{success &&
<Message positive
header={messageHeader}
content={messageContent}
/>}
</Grid.Column>
</Grid.Row>
messageRow = <Grid.Col span={12}>
{error &&
<Message kind='error' title={messageHeader}>{messageContent}</Message>}
{success &&
<Message kind='success' title={messageHeader}>{messageContent}</Message>}
</Grid.Col>
}
return <>
<BackButton/>
<div className='wrolpi-button-row'><BackButton/></div>
<Segment>
<Panel>
<Header as="h1">New Channel</Header>
<WROLModeMessage content='Channel creation is disabled while WROL Mode is enabled.'/>
<Form
<form
id="newChannel"
error={error}
success={success}
autoComplete="off"
onSubmit={e => e.preventDefault()}
>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
<InputForm
form={form}
label="Channel Name"
name="name"
placeholder="Short Channel Name"
required={true}
/>
</Grid.Column>
<Grid.Column>
<DestinationForm
form={form}
label='Directory'
name='directory'
path='directory'
required={true}
/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<ToggleForm
form={form}
label={downloadMissingDataLabel}
path='download_missing_data'
/>
</Grid.Column>
</Grid.Row>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<InputForm
form={form}
label="Channel Name"
name="name"
placeholder="Short Channel Name"
required={true}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<DestinationForm
form={form}
label='Directory'
name='directory'
path='directory'
required={true}
/>
</Grid.Col>
<Grid.Col span={12}>
<ToggleForm
form={form}
label={downloadMissingDataLabel}
path='download_missing_data'
/>
</Grid.Col>
{messageRow}
<Grid.Row>
<Grid.Column width={8}>
<DownloadTagsSelector
form={form}
limit={1}
path='tag_name'
name='tag_name'
/>
</Grid.Column>
<Grid.Column>
<Grid.Col span={{base: 12, sm: 8}}>
<DownloadTagsSelector
form={form}
limit={1}
path='tag_name'
name='tag_name'
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 4}}>
<div style={{textAlign: 'right'}}>
<APIButton
color='violet'
role='primary'
size='big'
floated='right'
onClick={handleSubmit}
disabled={form.disabled}
obeyWROLMode={true}
>Save</APIButton>
</Grid.Column>
</Grid.Row>
</div>
</Grid.Col>
</Grid>
</Form>
</Segment>
</form>
</Panel>
</>;
}
@ -533,38 +497,30 @@ export function ChannelsPage() {
}
}, {enableOnFormTags: false});
// Header section matching DomainsPage pattern
const header = <div style={{marginBottom: '1em'}}>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
<SearchInput
placeholder='Name filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(channels) || channels.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
</Grid.Column>
<Grid.Column textAlign='right'>
<Link to='/videos/channel/new'>
<Button secondary>New Channel</Button>
</Link>
</Grid.Column>
</Grid.Row>
</Grid>
</div>;
// Header section matching DomainsPage pattern. No `mb`: this is a top-level block of the
// page, and the page's stack spaces it -- its own margin was added to that gap.
const header = <Group justify='space-between' align='flex-end' wrap='wrap'>
<SearchInput
placeholder='Name filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(channels) || channels.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
<Link to='/videos/channel/new'>
<Button role='primary'>New Channel</Button>
</Link>
</Group>;
// Empty state
if (channels && channels.length === 0) {
return <>
{header}
<Message>
<Message.Header>No channels exist yet!</Message.Header>
<Message.Content><Link to='/videos/channel/new'>Create one.</Link></Message.Content>
<Message title='No channels exist yet!'>
<Link to='/videos/channel/new'>Create one.</Link>
</Message>
</>;
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,15 @@
import React from 'react';
import {act, render, screen, waitFor} from '@testing-library/react';
import fs from 'fs';
import path from 'path';
import {act, render, screen, waitFor} from '../test-utils';
import userEvent from '@testing-library/user-event';
import {DirectorySearch} from './Common';
import {
CardLink, contrastingColor, contrastRatio, DirectorySearch, ExternalCardLink, HelpHeader,
InfoHeader, loadRole,
PageContainer,
SearchResultsInput, TAG_TEXT_DARK, TAG_TEXT_LIGHT,
} from './Common';
import {healthRole} from './admin/ControllerPage';
// Mock debounce to make tests run faster
jest.mock('lodash/debounce', () => jest.fn(fn => {
@ -102,7 +110,7 @@ describe('DirectorySearch', () => {
it('renders the dropdown without crashing when directoryName is null', async () => {
// An unset form field (e.g. the new Channel page) passes a null value. With
// isDir=false a "New Directory" result is built from directoryName; if that title is
// null, Semantic UI's Search crashes reading null.length when the menu opens.
// null, the old Search crashed reading null.length when the menu opened.
setMockHookState({
directories: [],
channelDirectories: [],
@ -123,13 +131,14 @@ describe('DirectorySearch', () => {
});
it('displays with required indicator', () => {
const {container} = render(
render(
<DirectorySearch onSelect={mockOnSelect} value="" required/>
);
// Semantic UI doesn't add required attribute to Search input,
// but we verify the prop is passed
expect(container.querySelector('.ui.search')).toBeInTheDocument();
// Unlike the old Search, the SearchBox forwards `required` straight
// onto the input.
const input = screen.getByPlaceholderText(/search directory names/i);
expect(input).toBeRequired();
});
});
@ -153,11 +162,10 @@ describe('DirectorySearch', () => {
loading: true,
});
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const {container} = render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const searchContainer = screen.getByPlaceholderText(/search directory names/i)
.closest('.ui.search');
expect(searchContainer).toHaveClass('loading');
// SearchBox renders a Loader alongside the input while `loading` is true.
expect(container.querySelector('.wrolpi-searchbox-loading')).toBeInTheDocument();
});
it('hides loading indicator when loading state is false', () => {
@ -166,11 +174,9 @@ describe('DirectorySearch', () => {
loading: false,
});
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const {container} = render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const searchContainer = screen.getByPlaceholderText(/search directory names/i)
.closest('.ui.search');
expect(searchContainer).not.toHaveClass('loading');
expect(container.querySelector('.wrolpi-searchbox-loading')).not.toBeInTheDocument();
});
it('displays categorized results', async () => {
@ -265,16 +271,16 @@ describe('DirectorySearch', () => {
});
// Render with empty value prop (different from directoryName)
const {container} = render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
// Find the Search component container and trigger blur on it
const searchComponent = container.querySelector('.ui.search');
// The SearchBox wires `onBlur` straight to the input (there is no longer a
// wrapping Search component to blur instead).
const input = screen.getByPlaceholderText(/search directory names/i);
// Blur the Search component - should trigger onBlur which calls onSelect
// Blur the input - should trigger onBlur which calls onSelect
await act(async () => {
// Use fireEvent.blur which better simulates the Semantic UI Search blur behavior
const {fireEvent} = require('@testing-library/react');
fireEvent.blur(searchComponent);
const {fireEvent} = require('../test-utils');
fireEvent.blur(input);
});
// Should call onSelect with directoryName from hook state
@ -319,8 +325,16 @@ describe('DirectorySearch', () => {
expect(screen.getByText('videos/nature')).toBeInTheDocument();
});
// Click multiple results in succession
// Click multiple results in succession. Choosing a result closes the
// dropdown (as any combobox would) without moving focus off the input
// (the option is chosen on mousedown, with the default prevented), so
// pressing a key that reopens the list -- same as a real user -- is
// needed before the next selection.
await userEvent.click(screen.getByText('videos/nature'));
await userEvent.keyboard('{ArrowDown}');
await waitFor(() => {
expect(screen.getByText('videos/tech')).toBeInTheDocument();
});
await userEvent.click(screen.getByText('videos/tech'));
// Should call onSelect for each selection
@ -436,3 +450,760 @@ describe('DirectorySearch', () => {
});
});
});
describe('SearchResultsInput', () => {
const results = {
tags: {name: 'Tags', results: [{type: 'tag', title: 'Cooking'}]},
videos: {name: 'Videos', results: [
{title: 'Big Buck Bunny', description: 'Blender', location: '/videos/1'},
]},
};
it('hands the selected suggestion to its caller as {result}', async () => {
/*
* Regression: the migration passed the bare result, but every caller destructures
* `{result}` (see Search.js), so `result.location` was undefined and clicking a
* search suggestion navigated nowhere.
*/
const handleResultSelect = jest.fn();
render(<SearchResultsInput searchStr='axe' onSubmit={jest.fn()} results={results}
handleResultSelect={handleResultSelect}/>);
await userEvent.click(screen.getByRole('combobox'));
await userEvent.click(screen.getByText('Big Buck Bunny'));
expect(handleResultSelect).toHaveBeenCalledWith(
{result: expect.objectContaining({location: '/videos/1'})});
});
it('forwards resultRenderer so a caller keeps its own suggestion markup', async () => {
// Search.js renders a tag suggestion as a tag chip, not a line of text.
const resultRenderer = (result) => <span>rendered:{result.title}</span>;
render(<SearchResultsInput searchStr='cook' onSubmit={jest.fn()} results={results}
handleResultSelect={jest.fn()} resultRenderer={resultRenderer}/>);
await userEvent.click(screen.getByRole('combobox'));
expect(screen.getByText('rendered:Cooking')).toBeInTheDocument();
});
});
describe('contrastingColor', () => {
/*
* This decides the text color on every tag, against a fill the user picked, so it is the
* one place in the app where legibility is computed rather than designed.
*/
it('picks whichever of the two options actually reads better', () => {
// The palette blue. The old threshold check chose light text here, at 2.9:1, when dark
// text on the same fill gives 5.3:1 -- the worse of the only two available answers.
// Every mid-tone blue, teal and purple a user might choose sat in that band.
const blue = '#2185d0';
const chosen = contrastingColor(blue);
const rejected = chosen === TAG_TEXT_DARK ? TAG_TEXT_LIGHT : TAG_TEXT_DARK;
expect(contrastRatio(chosen, blue)).toBeGreaterThan(contrastRatio(rejected, blue));
expect(chosen).toBe(TAG_TEXT_DARK);
});
it('never picks the worse option, for any color', () => {
// The property, rather than a list of examples: whatever it returns must be at least as
// legible as the alternative would have been.
const colors = [
'#000000', '#ffffff', '#f2f2f2', '#1b1c1d', '#2185d0', '#21ba45', '#db2828',
'#fbbd08', '#6435c9', '#a5673f', '#00b5ad', '#e03997', '#b5cc18', '#767676',
'#808080', '#7f7f7f',
];
for (const color of colors) {
const chosen = contrastingColor(color);
const rejected = chosen === TAG_TEXT_DARK ? TAG_TEXT_LIGHT : TAG_TEXT_DARK;
expect(contrastRatio(chosen, color))
.toBeGreaterThanOrEqual(contrastRatio(rejected, color));
}
});
it('returns one of the two text colors and nothing else', () => {
// It is written into `--label-text`; anything undefined leaves the tag unstyled.
for (const color of ['#000000', '#ffffff', '#2185d0']) {
expect([TAG_TEXT_DARK, TAG_TEXT_LIGHT]).toContain(contrastingColor(color));
}
});
it('measures contrast on the WCAG scale', () => {
// Anchors the ratio itself, so "better" above is comparing real numbers.
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 1);
expect(contrastRatio('#ffffff', '#ffffff')).toBeCloseTo(1, 5);
});
});
describe('contrastingColor with shorthand hex', () => {
/*
* Tag colors reach the UI from the database, and a tags config is a file a user can edit
* by hand -- configs are the source of truth in WROLPi -- so a perfectly valid `#fff` can
* arrive here. `hexToRGBArray` used to reject anything but six digits, which left the
* luminance at zero: a near-white tag was treated as black and given light text.
*/
it('reads a three-digit hex as the color it is', () => {
expect(contrastRatio('#000000', '#fff')).toBeCloseTo(21, 1);
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(contrastRatio('#000000', '#fff'), 5);
});
it('picks dark text for a near-white shorthand fill', () => {
expect(contrastingColor('#fff')).toBe(TAG_TEXT_DARK);
});
it('picks light text for a near-black shorthand fill', () => {
expect(contrastingColor('#000')).toBe(TAG_TEXT_LIGHT);
});
it('agrees with the six-digit form of the same color', () => {
for (const [short, long] of [['#fff', '#ffffff'], ['#000', '#000000'],
['#f00', '#ff0000'], ['#1b2', '#11bb22']]) {
expect(contrastingColor(short)).toBe(contrastingColor(long));
}
});
});
describe('loadRole', () => {
/*
* The branches, tested as branches. This used to be a source guard scraping the
* function body out of Common.js, because the obvious render test cannot work: jsdom
* REJECTS `color: var(--danger)` as an invalid declaration and drops it, so every inline
* color reads back as the empty string and the assertions passed for that reason alone.
* Pulling the mapping out as a pure function is the honest fix.
*
* Thresholds are halves and three quarters of the core count, so each is asserted either
* side of its boundary rather than at one comfortable value.
*/
it('says nothing about a load below half the cores', () => {
expect(loadRole(1.9, 4)).toBeUndefined();
expect(loadRole(0, 4)).toBeUndefined();
});
it('warns from half the cores', () => {
expect(loadRole(2.0, 4)).toBe('warning');
expect(loadRole(2.9, 4)).toBe('warning');
});
it('calls it danger from three quarters', () => {
expect(loadRole(3.0, 4)).toBe('danger');
expect(loadRole(99, 4)).toBe('danger');
});
it('says nothing when the core count is unknown', () => {
// Both thresholds are guarded on `cores`. Without it every load would read as fine,
// which is the safe direction, but worth pinning so it stays deliberate.
expect(loadRole(12, undefined)).toBeUndefined();
expect(loadRole(12, 0)).toBeUndefined();
});
it('never returns a hue', () => {
// The regression this exists for: `orange` is byte-identical to `--text` in night,
// so a warning load rendered as an ordinary uncolored number.
[loadRole(1, 4), loadRole(2.5, 4), loadRole(4, 4)].filter(Boolean).forEach(role =>
expect(role).not.toMatch(/^(red|green|amber|yellow|orange|blue)$/));
});
});
describe('healthRole', () => {
it('maps every SMART assessment to a severity', () => {
expect(healthRole('PASS')).toBe('success');
expect(healthRole('WARN')).toBe('warning');
expect(healthRole('FAIL')).toBe('danger');
});
it('is not case sensitive, because the value comes from smartctl', () => {
expect(healthRole('pass')).toBe('success');
expect(healthRole('Fail')).toBe('danger');
});
it('treats a missing or unrecognised assessment as unknown, not as healthy', () => {
// Reporting an unreadable drive as PASS would be the dangerous direction.
[undefined, null, '', 'SOMETHING_ELSE'].forEach(value =>
expect(healthRole(value)).toBe('neutral'));
});
});
describe('a heading keeps its help icon on the same row as the text', () => {
/*
* `HelpHeader` and `InfoHeader` put their icon in a sibling `<span>` beside a `<label>`,
* held on one line by `.inline-header h1..h5 {display: inline-block}` in App.css. That
* worked while the heading was a bare `<h2>`; the moment it gained a block wrapper the
* label's block child forced a break, and every help icon in the app dropped below its
* own heading -- visible on "Root CA Certificate" in Settings.
*
* They pass the icon through the heading's `after` slot now. Containment is the claim
* jsdom can judge; that the two actually share a line is asserted in ui-layout.cy.js,
* since jsdom has no layout at all.
*/
it("renders the help control in the heading's row", () => {
const {container} = render(
<HelpHeader headerSize='h3' headerContent='Root CA Certificate'
helpPath='/system/certificates/'/>);
expect(container.querySelector('.wrolpi-header-row'))
.toContainElement(screen.getByRole('button', {name: 'Help'}));
// And not inside the heading itself, which would put it in the accessible name.
expect(screen.getByRole('heading')).toHaveAccessibleName('Root CA Certificate');
});
it("renders the info popup in the heading's row", () => {
const {container} = render(
<InfoHeader headerSize='h4' headerContent='Video Resolutions'
popupContent='Highest available is tried first.'/>);
expect(container.querySelector('.wrolpi-header-after svg')).not.toBeNull();
});
it('renders no label at all when there is no field to name', () => {
/*
* Several call sites pass an InfoHeader as the `label` prop of InputForm/ToggleForm,
* which wraps it in a `<label>` of its own. An unconditional label here nested one
* inside another -- invalid HTML, and it makes clicking the text ambiguous.
*/
const {container} = render(
<InfoHeader headerSize='h5' headerContent='Video File Format' popupContent='...'/>);
expect(container.querySelector('label')).toBeNull();
expect(screen.getByRole('heading', {name: /Video File Format/})).toBeInTheDocument();
});
it('keeps the label association, which is what `for_` is for', () => {
// Three call sites use it to tie the heading text to the field below it.
render(<InfoHeader headerSize='h4' headerContent='Video Resolutions'
for_='video_resolutions_input' popupContent='...'/>);
expect(screen.getByText(/Video Resolutions/).closest('label'))
.toHaveAttribute('for', 'video_resolutions_input');
});
it('leaves no .inline-header behind', () => {
// The class and its stylesheet rules are gone; a stray one would be dead markup
// relying on CSS that no longer exists.
const {container} = render(<HelpHeader headerContent='Root CA Certificate' helpPath='/x/'/>);
expect(container.querySelector('.inline-header')).toBeNull();
});
});
describe('card links', () => {
/*
* `.card-link` is what makes a card's title read as the card's text rather than as a
* link, so a title that misses it is the one thing on the card in the wrong color.
* Both helpers set the class and then spread the caller's props over it, so any call
* site passing a className of its own -- which is every archive title, via
* `card-title-ellipsis` -- silently replaced the class instead of adding to it.
*/
it.each([
['CardLink', <CardLink to='/archives/1' className='card-title-ellipsis'>Title</CardLink>],
['ExternalCardLink',
<ExternalCardLink to='http://x/a.html' className='card-title-ellipsis'>Title</ExternalCardLink>],
])('%s keeps its own classes when the call site adds one', (_name, element) => {
render(element);
const link = screen.getByText('Title');
expect(link).toHaveClass('card-link');
expect(link).toHaveClass('no-link-underscore');
expect(link).toHaveClass('card-title-ellipsis');
});
});
describe('PageContainer', () => {
/*
* The wrapper that decides how far apart a page's blocks sit. The spacing itself is a
* stylesheet rule and is measured in ui-layout.cy.js, where there is a layout engine; what
* this asserts is that the rule is REACHED -- the class has to be on both of the responsive
* branches, or the spacing silently applies on one viewport and not the other.
*/
it('marks both of its viewport branches as a stack', () => {
const {container} = render(<PageContainer><div>Body</div></PageContainer>);
const stacks = container.querySelectorAll('.wrolpi-stack');
// Mobile and tablet-and-up, both rendered; `Media` hides one with CSS.
expect(stacks).toHaveLength(2);
stacks.forEach(stack => expect(stack.textContent).toBe('Body'));
});
it('is never rendered by a page whose route already provides page chrome', () => {
/*
* `PageContainer` carries `margin-top: 1em` and `padding: 1em`, so nesting one inside
* another applies both twice: the page is pushed 1em further down and inset 1em further
* in on all four sides than every other page in the app. `/archives/settings` did this
* -- `ArchiveRoute` wraps its `<Routes>` in one, and `ArchiveSettingsPage` wrapped itself
* in another -- and read as "more margin than /archives/domains", which is exactly what
* it was.
*
* Nothing about a doubled margin throws, so this is a source scan. It finds each router
* whose enclosing `return` provides page chrome, takes the components it routes to, and
* checks the ones declared in the same file -- which is where a route and its pages
* normally live together.
*
* "Chrome" is `PageContainer` OR a `wrolpi-stack` wrapper, not just the former. Keying on
* `PageContainer` alone missed `MapRoute` and `ZimRoute`, which wrap their own content in a
* stack instead -- and three pages under them (`ManageMap`, `MapPins`, `ManageZim`) mounted
* a `PageContainer` each, so they sat a further 1em down and 1em in from the tab bar than
* the viewers beside them. The same defect as Archive, in a shape the first scan could not
* see.
*
* Known limit: a layout that routes through `<Outlet/>` has its `<Route>` list in App.js,
* naming components from other files, so the page cannot be resolved from one file's text.
* That is `VideosTabLayout`, whose pages are clean today but are not covered here.
*/
const src = SRC;
const files = () => sourceFiles();
const offenders = [];
for (const file of files(src)) {
const text = fs.readFileSync(file, 'utf8');
for (const block of chromedRouterBlocks(text)) {
for (const name of routedComponents(block)) {
if (bodyOf(text, name).includes('<PageContainer>')) {
offenders.push(`${path.relative(src, file)}: ${name}`);
}
}
}
}
/*
* The premise: a scan that found no routers, or resolved no page components, would report
* no offenders and read exactly like a clean codebase.
*/
const blocks = files(src).flatMap(file => chromedRouterBlocks(fs.readFileSync(file, 'utf8')));
expect(blocks.length).toBeGreaterThan(3);
expect(blocks.flatMap(routedComponents).length).toBeGreaterThan(8);
expect(offenders).toEqual([]);
});
});
describe('a row of buttons', () => {
/*
* Buttons used to bring `margin: 0 .25em .25em 0` with them, so a page could list buttons as
* bare siblings and they arrived both spaced and, when the row wrapped, separated. Nothing
* replaced that margin. The archive page's six actions shared edges, and two of them carried
* `marginTop: 0.5em` -- put there to keep a wrapped row from touching vertically, which instead
* made those two sit lower than the rest, because `vertical-align: middle` aligns the margin box.
*
* `.wrolpi-button-row` is a flex row with a gap, so it covers both axes at once and no button
* needs a margin of its own. ui-layout.cy.js measures that the row really spaces and aligns
* what it holds; these two scans are about the row being REACHED and left alone -- neither of
* which throws, and both of which is how the app got here.
*/
/*
* `=>` is blanked before any tag scanning. An arrow function in a prop -- `onClick={() =>
* handleDelete(false)}`, which most of these buttons have -- puts a `>` inside an opening tag,
* and a scanner reading `>` as "tag ends here" splits one button into two bogus tags. Two
* characters for two, so every index still points where it did in the original text.
*/
const scannable = (text) => text.replace(/=>/g, '=~');
const TAG = /<(\/?)([A-Za-z][\w.]*)([^>]*?)(\/?)>/g;
const tagsIn = (text) => [...scannable(text).matchAll(TAG)].map(match => ({
start: match.index,
end: match.index + match[0].length,
// The name was not captured until a test needed to accept `Group` and `Modal.Actions` as
// rows: `ROW_COMPONENTS.has(undefined)` is false for every tag, so the list of components
// that lay out their own children silently accepted none of them.
name: match[2],
attributes: match[3],
closing: match[1] === '/',
selfClosing: match[4] === '/',
}));
/**
* The text inside the element opened by `open`, found by walking forward to its MATCHING close.
*
* Written as "slice to the first `</div>`" first, which is true of every row in the app today
* and is a false negative waiting to happen: one nested div -- an error-trigger wrapper, a
* format-picker span, the Cancel/Save pair the collection form now groups -- truncates the body,
* and every button after it goes unscanned.
*/
const bodyOfElement = (text, tags, open) => {
let depth = 0;
for (const tag of tags) {
if (tag.start <= open.start || tag.selfClosing) continue;
if (tag.closing) {
if (depth === 0) return text.slice(open.end, tag.start);
depth -= 1;
} else {
depth += 1;
}
}
return text.slice(open.end); // Unclosed; scan what is left rather than nothing.
};
/**
* The tag that encloses `index`: walk the tags before it backwards, letting a close push the
* depth up and an open bring it back down, and stop at the first open that is still unclosed.
*/
const enclosingTag = (tags, index) => {
let depth = 0;
for (const tag of [...tags].filter(tag => tag.end <= index).reverse()) {
if (tag.selfClosing) continue;
if (tag.closing) depth += 1;
else if (depth === 0) return tag;
else depth -= 1;
}
return null;
};
/**
* Every `const <name> = ...` declared at any indentation, as button consts are.
*
* Every one, not the first: Archive.js declares a `deleteButton` in the archive page and
* another in the domain edit page, and a resolver that stopped at the first found the clean one
* and reported the page with the margin on it as clean too.
*/
const localBodiesOf = (text, name) => {
const pattern = new RegExp(`^[ \\t]*(?:const|let)\\s+${name}\\s*=`, 'gm');
return [...text.matchAll(pattern)].map(found => {
const after = text.slice(found.index + found[0].length);
const next = after.search(/\n[ \t]*(?:const|let|function|return|\})[\s(]/);
return next === -1 ? after : after.slice(0, next);
});
};
/** Every `const actionButtons = <>...</>` fragment in `text`: a button row under another name. */
const actionFragments = (text) => {
const bodies = [];
const pattern = /^[ \t]*const\s+actionButtons\s*=\s*<>/gm;
for (const found of text.matchAll(pattern)) {
const from = found.index + found[0].length;
const end = text.indexOf('</>', from);
if (end !== -1) bodies.push(text.slice(from, end));
}
return bodies;
};
const VERTICAL_MARGIN = /margin(?:Top|Bottom)\s*:|margin\s*:/;
/*
* The classes that lay buttons out in a row. A row is a row whatever it is called -- the file
* preview's toolbar had one before this, at a tighter gap because its buttons are icons -- so
* the check below is "is this one of them", and the test after it is what keeps the list honest
* by reading each one out of the stylesheet and confirming it really is a flex row with a gap.
*/
const ROW_CLASSES = [
'wrolpi-button-row', 'wrolpi-modal-actions', 'wrolpi-confirm-actions', 'preview-toolbar-group',
// Two that predate `.wrolpi-button-row` and are already correct: the file browser's sticky
// footer, and its filter row. Both are checked against the stylesheet below like the rest.
'sticky-footer', 'file-browser-filter-container',
];
/*
* Components that lay their own children out in a row. A `Group` or a `ButtonGroup` is a row by
* construction, so a caller putting buttons in one has already answered the question.
*/
const ROW_COMPONENTS = new Set(['Group', 'ButtonGroup', 'Row', 'Stack', 'Modal.Actions']);
/*
* An inline flex or grid with a gap of its own -- an anonymous row. Several call sites write one
* rather than reach for a class, and they are not wrong; a scan that only knew about class names
* would report every one of them.
*/
const INLINE_ROW = /display:\s*['"]?(?:flex|grid)/;
const isInlineRow = (attributes, text) => {
if (INLINE_ROW.test(attributes) && attributes.includes('gap')) return true;
/*
* `style={someStyle}` -- the style is a const somewhere above, so the attribute itself says
* nothing. Resolving it matters: the seasons row on the dates selector is a four-column
* grid with a gap, declared exactly that way, and it would otherwise be reported forever.
*/
const named = /style=\{(\w+)\}/.exec(attributes);
if (!named) return false;
const declared = new RegExp(`\\b${named[1]}\\s*=\\s*(\\{[^;]*?\\});`).exec(text);
return !!declared && INLINE_ROW.test(declared[1]) && declared[1].includes('gap');
};
const isRow = (tag, text = '') => !!tag && (
ROW_COMPONENTS.has(tag.name)
|| ROW_CLASSES.some(name => tag.attributes.includes(name))
|| isInlineRow(tag.attributes, text));
it('lays every row out as a flex row with a gap', () => {
// Otherwise the list above is just a list of names the scans below happen to accept.
const stylesheets = ['components/ui/ui.css', 'App.css']
.map(name => fs.readFileSync(path.join(SRC, name), 'utf8')).join('\n');
ROW_CLASSES.forEach(name => {
const found = new RegExp(`\\.${name}\\s*\\{([^}]*)\\}`).exec(stylesheets);
// Named in the failure rather than passed to `expect`, which takes no message.
expect(found ? name : `${name} is not declared`).toBe(name);
expect(found[1]).toMatch(/display:\s*flex/);
expect(found[1]).toMatch(/gap:/);
});
});
it('never stamps a vertical margin on a button the row already spaces', () => {
/*
* The margin and the gap add up. Nine buttons across the three collection edit pages had
* `marginTop: 1em` each and sat 1em below the Save button beside them; the archive page's
* Update and Generate Screenshot had 0.5em and sat half that below their own neighbours.
*/
const offenders = [];
let rows = 0;
let resolved = 0;
for (const file of sourceFiles()) {
const text = fs.readFileSync(file, 'utf8');
const where = path.relative(SRC, file);
const blocks = actionFragments(text);
const tags = tagsIn(text);
for (const tag of tags) {
if (!tag.closing && tag.attributes.includes('wrolpi-button-row')) {
blocks.push(bodyOfElement(text, tags, tag));
}
}
rows += blocks.length;
for (const block of blocks) {
// A button declared inline in the row, style and all.
if (VERTICAL_MARGIN.test(block)) offenders.push(`${where}: inline in the row`);
// ...or interpolated by name, which is how most of them are written.
for (const name of new Set([...block.matchAll(/\{(\w*[Bb]utton\w*)\}/g)]
.map(match => match[1]))) {
for (const body of localBodiesOf(text, name)) {
resolved += 1;
if (VERTICAL_MARGIN.test(body)) offenders.push(`${where}: ${name}`);
}
}
}
}
// A scan that found no rows, or resolved none of their buttons, reports a clean app.
expect(rows).toBeGreaterThan(3);
expect(resolved).toBeGreaterThan(8);
expect(offenders).toEqual([]);
});
it('puts every row of buttons in a row', () => {
/*
* The other half: a page can have margin-free buttons and still list them as bare siblings,
* which is the touching this fixes. Every `{actionButtons}` is rendered somewhere, and the
* element it is rendered into is what has to carry the gap -- on the doc page that was a
* plain `<div style={{marginTop: '1em'}}>`, spacing the row from what was above it while
* leaving the buttons inside it edge to edge.
*/
const offenders = [];
let sites = 0;
for (const file of sourceFiles()) {
const text = fs.readFileSync(file, 'utf8');
if (!text.includes('{actionButtons}')) continue;
const tags = tagsIn(text);
for (const found of text.matchAll(/\{actionButtons\}/g)) {
// `actionButtons={actionButtons}` is the prop being handed down, not a render.
if (text.slice(found.index - 14, found.index) === 'actionButtons=') continue;
sites += 1;
const parent = enclosingTag(tags, found.index);
if (!isRow(parent)) {
offenders.push(`${path.relative(SRC, file)}: ${
parent ? parent.attributes.trim().slice(0, 40) : 'no enclosing tag'}`);
}
}
}
/*
* Three: the shared collection edit form, which three pages hand a fragment to, and the doc
* page's own two responsive branches. A scan that counted the `actionButtons={...}` props
* instead would reach eight and never look at a render site at all.
*/
expect(sites).toBe(3);
expect(offenders).toEqual([]);
});
it('never lists interpolated buttons as bare siblings', () => {
/*
* The touching itself, and the reported defect: the archive page interpolated six buttons
* one after another straight into its Panel, so every pair shared an edge.
*
* A run of adjacent button interpolations is the shape to look for. Runs inside an
* `actionButtons` fragment are blanked first -- a fragment is spread into its parent's row
* and has no business being one itself, which the previous test is what checks. Blanked
* rather than skipped so every index still points where it did.
*
* The sibling test below covers the other way a row is written; this one only sees `{foo}`.
*/
const offenders = [];
let runs = 0;
for (const file of sourceFiles()) {
let text = fs.readFileSync(file, 'utf8');
for (const fragment of actionFragments(text)) {
text = text.replace(fragment, ' '.repeat(fragment.length));
}
const tags = tagsIn(text);
for (const found of text.matchAll(/\{\w*[Bb]utton\w*\}(?:\s*\{\w*[Bb]utton\w*\})+/g)) {
runs += 1;
const parent = enclosingTag(tags, found.index);
if (!isRow(parent)) {
offenders.push(`${path.relative(SRC, file)}: ${found[0].replace(/\s+/g, '')}`);
}
}
}
/*
* Four: the archive page's actions, the file preview's toolbar (which had a row of its own
* already), the downloads table's edit/restart pair, and the doc page's format picker
* beside its actions. Asserted exactly, because a run that stopped being recognised would
* leave this reporting a clean app.
*/
expect(runs).toBe(4);
expect(offenders).toEqual([]);
});
it('never lists written-out buttons as bare siblings either', () => {
/*
* The same claim for the other way a row is written. The interpolation scan above sees
* `{fooButton}` and nothing else, and most of the app writes its buttons out in place --
* which is how the video page's Download/Delete/Refresh/AddToPlaylist strip, the direct
* analogue of the archive row this PR started with, went on touching after that scan
* reported the app clean. Twelve more rows were in the same state, in table cells, on the
* controller page, and under the one-time-pad and ration calculators.
*
* A "row" is deliberately broad here: the class, a Group or ButtonGroup, or an inline
* flex/grid with a gap, including one whose style is a const declared above. The point is
* whether the buttons are spaced by something, not whether they use this PR's class.
*/
const offenders = [];
let runs = 0;
for (const file of sourceFiles()) {
let text = fs.readFileSync(file, 'utf8');
// A fragment is spread into its parent's row; the earlier test checks that parent.
for (const fragment of actionFragments(text)) {
text = text.replace(fragment, ' '.repeat(fragment.length));
}
const tags = tagsIn(text);
// A button element ending, then whitespace, then another button beginning.
const runPattern = /(?:<\/\w*Button>|<\w*Button\b[^>]*?\/>)\s*<\w*Button\b/g;
for (const match of scannable(text).matchAll(runPattern)) {
// Anchor on the SECOND button's `<`: at the first one's closing tag the pair is
// still open, and the walk would report the first button as its own parent.
const at = match.index + match[0].lastIndexOf('<');
runs += 1;
if (!isRow(enclosingTag(tags, at), text)) {
offenders.push(`${path.relative(SRC, file)}:${
text.slice(0, at).split('\n').length}`);
}
}
}
/*
* Around eighty, which is why this is a scan and not a list. Asserted loosely at both ends
* -- tight enough that a pattern matching nothing shows up, loose enough that adding a
* button to an existing row is not a test failure.
*/
expect(runs).toBeGreaterThan(60);
expect(runs).toBeLessThan(200);
expect(offenders).toEqual([]);
});
it('puts every Back button in a row, so the page stack cannot stretch it', () => {
/*
* A page's blocks are stacked by `.wrolpi-stack`, which is a flex column at
* `align-items: stretch` -- deliberately, because a panel was full-width as a block
* element and has to stay that way. A BARE button as a direct child of that stack is
* stretched too, and every page's Back button was one: full-page-width, and on the domain,
* channel and playlist edit pages the button beside it was pushed onto its own line where
* the two had been inline before.
*
* A row fixes it without touching the stack: the row is the stretched child, and it lays
* its buttons out at their own width. Nine sites, so this scan rather than nine
* assertions -- and a scan because a stretched button is still a working button.
*/
const offenders = [];
let found = 0;
for (const file of sourceFiles()) {
const text = fs.readFileSync(file, 'utf8');
const tags = tagsIn(text);
for (const match of text.matchAll(/<BackButton\b/g)) {
found += 1;
if (!isRow(enclosingTag(tags, match.index))) {
offenders.push(`${path.relative(SRC, file)}:${
text.slice(0, match.index).split('\n').length}`);
}
}
}
// Nine renders. Common.js declares it, and a declaration is not a `<BackButton`.
expect(found).toBe(9);
expect(offenders).toEqual([]);
});
});
/** The app's own source, minus its tests: what a source scan is allowed to read. */
const SRC = path.join(__dirname, '..');
const sourceFiles = (dir = SRC) => fs.readdirSync(dir, {withFileTypes: true}).flatMap(entry => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return sourceFiles(full);
if (!/\.jsx?$/.test(entry.name)) return [];
if (/\.(test|cy)\.jsx?$/.test(entry.name)) return [];
return [full];
});
/**
* Every `<Routes>...</Routes>` in `text` whose enclosing `return` provides page chrome -- a
* `PageContainer` or a `wrolpi-stack` wrapper. Those are the routers that have already spaced and
* inset the page, so a page under one must not do it again.
*
* All of them, and located from the router rather than from the chrome: keying on the first
* `<PageContainer>` in a file missed this entirely in Archive.js, where the first one belongs to
* the offending page and holds no router at all.
*/
const chromedRouterBlocks = (text) => {
const blocks = [];
let from = 0;
for (;;) {
const start = text.indexOf('<Routes>', from);
if (start === -1) return blocks;
const end = text.indexOf('</Routes>', start);
if (end === -1) return blocks;
from = end + 1;
const returned = text.lastIndexOf('return', start);
if (returned === -1) continue;
const chrome = text.slice(returned, start);
if (chrome.includes('<PageContainer>') || chrome.includes('wrolpi-stack')) {
blocks.push(text.slice(start, end));
}
}
};
/**
* The page components a router block routes to. Any capitalised tag that is not routing furniture,
* so `element={<ErrorBoundary><Page/></ErrorBoundary>}` yields `Page` as well as the plain
* `element={<Page/>}` form.
*/
const FURNITURE = new Set(['Route', 'Routes', 'ErrorBoundary', 'Suspense', 'Navigate', 'Outlet']);
const routedComponents = (block) => [...new Set(
[...block.matchAll(/<([A-Z]\w*)/g)].map(m => m[1]).filter(name => !FURNITURE.has(name)))];
/**
* The source of the component named `name`, bounded by the next top-level declaration. `class` is
* in both patterns because `ManageZim` is one, and a scan that only knew about functions would
* have reported it clean.
*/
const bodyOf = (text, name) => {
const declaration = new RegExp(`^(?:export\\s+)?(?:function|const|class)\\s+${name}\\b`, 'm');
const found = declaration.exec(text);
if (!found) return ''; // Declared elsewhere; out of this scan's reach.
const after = text.slice(found.index + 1);
const next = after.search(/^(?:export\s+)?(?:function|const|class)\s/m);
return next === -1 ? after : after.slice(0, next);
};

View file

@ -1,9 +1,6 @@
import React from "react";
import {Button, Form, Modal, Segment} from "./Theme";
import {Checkbox, Dropdown} from "semantic-ui-react";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {Button, Checkbox, Grid, Message, Modal, Panel, Select} from "./ui";
import {monthNames} from "./Common";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
export const dateRangeIsEmpty = (dateRange) => {
return dateRange[0] === null && dateRange[1] === null;
@ -39,9 +36,10 @@ export function MonthsForm({monthsSelected, setMonthsSelected}) {
return <Checkbox
label={label}
checked={isChecked}
size='large'
onChange={(e, {checked}) => {
size='lg'
onChange={(e) => {
// Add or remove month index if the respective checkbox is checked.
const checked = e.currentTarget.checked;
if (checked) {
setMonthsSelected([...monthsSelected, idx]);
} else {
@ -51,36 +49,37 @@ export function MonthsForm({monthsSelected, setMonthsSelected}) {
/>
}
return <Form>
<Grid>
<Grid.Row columns={3}>
<Grid.Column>{monthCheckbox('January')}</Grid.Column>
<Grid.Column>{monthCheckbox('February')}</Grid.Column>
<Grid.Column>{monthCheckbox('March')}</Grid.Column>
</Grid.Row>
<Grid.Row columns={3}>
<Grid.Column>{monthCheckbox('April')}</Grid.Column>
<Grid.Column>{monthCheckbox('May')}</Grid.Column>
<Grid.Column>{monthCheckbox('June')}</Grid.Column>
</Grid.Row>
<Grid.Row columns={3}>
<Grid.Column>{monthCheckbox('July')}</Grid.Column>
<Grid.Column>{monthCheckbox('August')}</Grid.Column>
<Grid.Column>{monthCheckbox('September')}</Grid.Column>
</Grid.Row>
<Grid.Row columns={3}>
<Grid.Column>{monthCheckbox('October')}</Grid.Column>
<Grid.Column>{monthCheckbox('November')}</Grid.Column>
<Grid.Column>{monthCheckbox('December')}</Grid.Column>
</Grid.Row>
<Grid.Row columns={4}>
<Grid.Column><Button onClick={handleWinter} color='blue' size='small'>Winter</Button></Grid.Column>
<Grid.Column><Button onClick={handleSpring} color='green' size='small'>Spring</Button></Grid.Column>
<Grid.Column><Button onClick={handleSummer} color='yellow' size='small'>Summer</Button></Grid.Column>
<Grid.Column><Button onClick={handleFall} color='red' size='small'>Fall</Button></Grid.Column>
</Grid.Row>
</Grid>
</Form>
const monthRowStyle = {display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.7em', marginBottom: '0.7em'};
const seasonRowStyle = {display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '0.7em'};
return <div>
<div style={monthRowStyle}>
<div>{monthCheckbox('January')}</div>
<div>{monthCheckbox('February')}</div>
<div>{monthCheckbox('March')}</div>
</div>
<div style={monthRowStyle}>
<div>{monthCheckbox('April')}</div>
<div>{monthCheckbox('May')}</div>
<div>{monthCheckbox('June')}</div>
</div>
<div style={monthRowStyle}>
<div>{monthCheckbox('July')}</div>
<div>{monthCheckbox('August')}</div>
<div>{monthCheckbox('September')}</div>
</div>
<div style={monthRowStyle}>
<div>{monthCheckbox('October')}</div>
<div>{monthCheckbox('November')}</div>
<div>{monthCheckbox('December')}</div>
</div>
<div style={seasonRowStyle}>
<Button onClick={handleWinter} color='blue' size='small'>Winter</Button>
<Button onClick={handleSpring} color='green' size='small'>Spring</Button>
<Button onClick={handleSummer} color='yellow' size='small'>Summer</Button>
<Button onClick={handleFall} color='red' size='small'>Fall</Button>
</div>
</div>
}
export function DateRangeForm({dateRange, setDateRange}) {
@ -89,20 +88,16 @@ export function DateRangeForm({dateRange, setDateRange}) {
let yearRange = [];
for (let i = 1970; i <= currentYear; i++) {
yearRange = [...yearRange, {
key: i,
text: i,
value: i,
}];
yearRange = [...yearRange, {value: i.toString(), label: i.toString()}];
}
const handleFromYear = (e, {value}) => {
const handleFromYear = (value) => {
const toYear = dateRange[1] || currentYear;
setDateRange([value, toYear]);
setDateRange([value ? parseInt(value) : null, toYear]);
}
const handleToYear = (e, {value}) => {
setDateRange([dateRange[0], value]);
const handleToYear = (value) => {
setDateRange([dateRange[0], value ? parseInt(value) : null]);
}
React.useEffect(() => {
@ -113,37 +108,33 @@ export function DateRangeForm({dateRange, setDateRange}) {
}
}, [dateRange])
const errorMessage = error && <Message error content={error}/>;
const errorMessage = error && <Message kind='error'>{error}</Message>;
return <React.Fragment>
<Form>
<Grid>
<Grid.Row columns={2}>
<Grid.Column>
<Form.Field>
<label>From Year</label>
<Dropdown search scrolling fluid
options={yearRange}
value={dateRange[0]}
onChange={handleFromYear}
error={!!error}
/>
</Form.Field>
</Grid.Column>
<Grid.Column>
<Form.Field>
<label>To Year</label>
<Dropdown search scrolling fluid
options={yearRange.reverse()}
value={dateRange[1]}
onChange={handleToYear}
error={!!error}
/>
</Form.Field>
</Grid.Column>
</Grid.Row>
</Grid>
</Form>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<label>From Year</label>
<Select
searchable
data={yearRange}
value={dateRange[0] != null ? dateRange[0].toString() : null}
onChange={handleFromYear}
error={!!error}
maxDropdownHeight={280}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<label>To Year</label>
<Select
searchable
data={[...yearRange].reverse()}
value={dateRange[1] != null ? dateRange[1].toString() : null}
onChange={handleToYear}
error={!!error}
maxDropdownHeight={280}
/>
</Grid.Col>
</Grid>
{errorMessage}
</React.Fragment>
}
@ -221,7 +212,7 @@ export function DateSelectorButton({
return <React.Fragment>
<Button
icon='calendar alternate'
icon='calendar'
onClick={handleOpen}
color={color}
{...buttonProps}
@ -233,16 +224,16 @@ export function DateSelectorButton({
>
<Modal.Header>Filter by Published Date</Modal.Header>
<Modal.Content>
<Segment>
<Panel>
<DateRangeForm dateRange={dateRange} setDateRange={localOnSetDateRange}/>
</Segment>
<Segment>
</Panel>
<Panel>
<MonthsForm monthsSelected={monthsSelected} setMonthsSelected={setMonthsSelected}/>
</Segment>
</Panel>
</Modal.Content>
<Modal.Actions>
<Button onClick={handleClear} secondary>Clear</Button>
<Button onClick={handleClose}>Close</Button>
<Button onClick={handleClear} role='cancel'>Clear</Button>
<Button onClick={handleClose} role='primary'>Close</Button>
</Modal.Actions>
</Modal>
</React.Fragment>

View file

@ -1,5 +1,5 @@
import React from 'react';
import {render, screen} from '@testing-library/react';
import {render, screen} from '../test-utils';
import userEvent from '@testing-library/user-event';
import {DesktopToggle} from './Common';
@ -34,7 +34,9 @@ describe('DesktopToggle', () => {
await userEvent.click(screen.getByTestId('toggle'));
// Not stopped yet; the Confirm modal is shown instead.
expect(mockSetDesktop).not.toHaveBeenCalled();
expect(screen.getByText('Stop the desktop')).toBeInTheDocument();
// Mantine's Modal mounts its content on a subsequent render pass, so the
// text isn't present synchronously after the click.
expect(await screen.findByText('Stop the desktop')).toBeInTheDocument();
await userEvent.click(screen.getByText('Stop'));
expect(mockSetDesktop).toHaveBeenCalledWith(false);
@ -43,7 +45,7 @@ describe('DesktopToggle', () => {
it('cancelling the confirmation leaves the desktop running', async () => {
render(<DesktopToggle/>);
await userEvent.click(screen.getByTestId('toggle'));
await userEvent.click(screen.getByText('Cancel'));
await userEvent.click(await screen.findByText('Cancel'));
expect(mockSetDesktop).not.toHaveBeenCalled();
expect(screen.getByTestId('toggle')).toBeChecked();
});

View file

@ -1,8 +1,7 @@
import React, {useContext, useEffect, useState} from 'react';
import React, {useEffect, useState} from 'react';
import {Link, Route, Routes, useLocation, useNavigate, useParams, useSearchParams} from "react-router";
import {Grid, Icon as SIcon, Loader, StatisticLabel, StatisticValue} from "semantic-ui-react";
import {deleteFileGroups, getDocStatistics, tagFileGroup, untagFileGroup} from "../api";
import {Media, ThemeContext} from "../contexts/contexts";
import {Media} from "../contexts/contexts";
import {
APIButton,
BackButton,
@ -18,13 +17,13 @@ import {
toLocaleString,
useTitle
} from "./Common";
import {Button, darkTheme, Header, Icon, Segment, Statistic, Tab} from "./Theme";
import {Button, ButtonGroup, Group, Header, Loading, Panel, Statistic, StatisticGroup, Tabs} from "./ui";
import {BulkTagModal} from "./BulkTagModal";
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
import {DeepSearchHint, docMimetypeFilterOptions, FilesView, SearchControlBar} from "./Files";
import {useAuthors, useDoc, useOneQuery, useSearchDocs, useSubjects} from "../hooks/customHooks";
import {TagsSelector} from "../Tags";
import {toast} from "react-semantic-toasts-2";
import {toast} from "./ui";
import {CollectionTable} from "./collections/CollectionTable";
import {AddToPlaylistButton} from "./AddToPlaylist";
import {CbzViewer} from "./CbzViewer";
@ -84,19 +83,19 @@ function DocsPage() {
setSelectedDocs([]);
}
const selectElm = <div style={{marginTop: '0.5em'}}>
const selectElm = <div className='wrolpi-button-row' style={{marginTop: '0.5em'}}>
<Button color='violet' disabled={_.isEmpty(selectedDocs)}
onClick={() => setBulkTagOpen(true)}>Tag</Button>
<APIButton
color='red'
role='danger'
disabled={_.isEmpty(selectedDocs)}
confirmButton='Delete'
confirmContent='Are you sure you want to delete these documents? This cannot be undone.'
onClick={() => onDelete(false)}
obeyWROLMode={true}
>Delete</APIButton>
<Button color='grey' onClick={invertSelection} disabled={_.isEmpty(docs)}>Invert</Button>
<Button color='yellow' onClick={clearSelection}
<Button role='cancel' onClick={invertSelection} disabled={_.isEmpty(docs)}>Invert</Button>
<Button role='cancel' onClick={clearSelection}
disabled={_.isEmpty(docs) || _.isEmpty(selectedDocs)}>Clear</Button>
<BulkTagModal
open={bulkTagOpen}
@ -159,7 +158,6 @@ function DocPage() {
const {fileGroupId} = useParams();
const navigate = useNavigate();
const {docFile, doc, fetchDoc} = useDoc(parseInt(fileGroupId));
const {theme} = useContext(ThemeContext);
const [searchParams] = useSearchParams();
const [taggedFileGroups, setTaggedFileGroups] = useState(null);
// Deep-link params: `loc` = EPUB spine index, `page` = PDF page, `q` = search term.
@ -173,7 +171,7 @@ function DocPage() {
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
if (docFile === null) {
return <Segment><Loader active/></Segment>;
return <Panel><Loading/></Panel>;
}
if (docFile === undefined) {
return <>
@ -282,75 +280,66 @@ function DocPage() {
return mt.split('/').pop().toUpperCase();
};
// About pane.
const aboutPane = {
menuItem: 'About', render: () => <Tab.Pane>
{doc && doc.description && <>
<Header as='h3'>Description</Header>
<p>{doc.description}</p>
</>}
// About tab.
const aboutTab = <>
{doc && doc.description && <>
<Header as='h3'>Description</Header>
<p>{doc.description}</p>
</>}
{doc && doc.publisher && <>
<Header as='h3'>Publisher</Header>
<p>{doc.publisher}</p>
</>}
{doc && doc.publisher && <>
<Header as='h3'>Publisher</Header>
<p>{doc.publisher}</p>
</>}
{doc && doc.language && <>
<Header as='h3'>Language</Header>
<p>{doc.language}</p>
</>}
{doc && doc.language && <>
<Header as='h3'>Language</Header>
<p>{doc.language}</p>
</>}
{doc && doc.subject && <>
<Header as='h3'>Subject</Header>
<p><Link to={`/docs?subject=${encodeURIComponent(doc.subject)}`}>{doc.subject}</Link></p>
</>}
{doc && doc.subject && <>
<Header as='h3'>Subject</Header>
<p><Link to={`/docs?subject=${encodeURIComponent(doc.subject)}`}>{doc.subject}</Link></p>
</>}
{doc && doc.page_count && <>
<Header as='h3'>Page Count</Header>
<p>{doc.page_count}</p>
</>}
{doc && doc.page_count && <>
<Header as='h3'>Page Count</Header>
<p>{doc.page_count}</p>
</>}
<Header as='h3'>Size</Header>
<p>{humanFileSize(size)}</p>
</Tab.Pane>
};
<Header as='h3'>Size</Header>
<p>{humanFileSize(size)}</p>
</>;
// Files pane.
// Files tab.
const tableStyle = {width: '100%', borderCollapse: 'separate', borderSpacing: '0 0.7em'};
const labelStyle = {whiteSpace: 'nowrap', paddingRight: '1.5em', verticalAlign: 'top'};
const filesPane = {
menuItem: 'Files', render: () => <Tab.Pane>
<table style={tableStyle}>
<tbody>
{docFile.files && docFile.files.map((file, idx) =>
<tr key={idx}>
<td style={labelStyle}>
<strong>{file.path.split('.').pop().toUpperCase()}</strong>
</td>
<td>
<PreviewPath path={file.path} mimetype={file.mimetype} taggable={false}>
{file.path}
</PreviewPath>
{file.size && <span style={{marginLeft: '1em', color: 'grey'}}>
({humanFileSize(file.size)})
</span>}
</td>
</tr>
)}
<tr>
<td style={labelStyle}><strong>Directory</strong></td>
<td><DirectoryLink path={directory}/></td>
</tr>
</tbody>
</table>
</Tab.Pane>
};
const tabPanes = [aboutPane, filesPane];
const tabMenu = theme === darkTheme ? {inverted: true, attached: true} : {attached: true};
const filesTab = <table style={tableStyle}>
<tbody>
{docFile.files && docFile.files.map((file, idx) =>
<tr key={idx}>
<td style={labelStyle}>
<strong>{file.path.split('.').pop().toUpperCase()}</strong>
</td>
<td>
<PreviewPath path={file.path} mimetype={file.mimetype} taggable={false}>
{file.path}
</PreviewPath>
{file.size && <span style={{marginLeft: '1em', color: 'var(--muted)'}}>
({humanFileSize(file.size)})
</span>}
</td>
</tr>
)}
<tr>
<td style={labelStyle}><strong>Directory</strong></td>
<td><DirectoryLink path={directory}/></td>
</tr>
</tbody>
</table>;
return <>
<BackButton/>
<div className='wrolpi-button-row'><BackButton/></div>
{isCbz && activePath && <CbzViewer path={activePath}/>}
@ -358,56 +347,51 @@ function DocPage() {
<iframe
src={embedUrl}
title={docFile.title || docFile.name}
style={{width: '100%', height: '80vh', border: '1px solid #ccc', borderRadius: '4px'}}
style={{width: '100%', height: '80vh', border: '1px solid var(--border)'}}
/>
</div>}
<Segment>
<Panel>
<Header as='h2'>{docFile.title || docFile.name}</Header>
{docFile.author && <Header as='h3'>
Author: <Link to={`/docs?author=${encodeURIComponent(docFile.author)}`}>{docFile.author}</Link>
</Header>}
<Grid columns={2} stackable>
<Grid.Row>
<Grid.Column>
<Header as='h4'>Format: {formatLabel()}{doc && doc.page_count && ` (${doc.page_count} pages)`}</Header>
</Grid.Column>
{publishedDatetimeString && <Grid.Column>
<Header as='h4'>Published: {publishedDatetimeString}</Header>
</Grid.Column>}
</Grid.Row>
</Grid>
<Group gap='xl' wrap='wrap'>
<Header as='h4'>Format: {formatLabel()}{doc && doc.page_count && ` (${doc.page_count} pages)`}</Header>
{publishedDatetimeString && <Header as='h4'>Published: {publishedDatetimeString}</Header>}
</Group>
{(() => {
const formatButtons = viewableFiles.length > 1 ? <Button.Group>
const formatButtons = viewableFiles.length > 1 ? <ButtonGroup>
{viewableFiles.map((file, idx) => {
const mt = file.mimetype || '';
let btnColor;
if (mt.startsWith('application/epub')) btnColor = 'yellow';
else if (mt === 'application/pdf') btnColor = 'red';
const isSelected = idx === selectedFileIndex;
return <Button
key={file.path}
active={idx === selectedFileIndex}
onClick={() => setSelectedFileIndex(idx)}
color={idx === selectedFileIndex ? btnColor : undefined}
basic={idx !== selectedFileIndex}
color={isSelected ? btnColor : undefined}
variant={isSelected ? 'filled' : 'default'}
>
{formatLabel(file.mimetype)}
</Button>;
})}
</Button.Group> : null;
</ButtonGroup> : null;
const actionButtons = <>
{openUrl && <Button as='a' href={openUrl} target='_blank' rel='noreferrer' color='violet'>
<SIcon name='expand arrows alternate'/> Open
{openUrl && <Button component='a' href={openUrl} target='_blank' rel='noreferrer'
color='violet' icon='expand arrows alternate'>
Open
</Button>}
{downloadUrl && <Button as='a' href={downloadUrl}>
<SIcon name='download'/> Download
{downloadUrl && <Button component='a' href={downloadUrl} icon='download'>
Download
</Button>}
<APIButton
color='red'
role='danger'
confirmContent='Are you sure you want to delete this document? All files will be deleted.'
confirmButton='Delete'
onClick={() => handleDelete(false)}
@ -419,25 +403,42 @@ function DocPage() {
return <>
<Media at='mobile'>
{formatButtons && <div style={{marginTop: '1em'}}>{formatButtons}</div>}
<div style={{marginTop: '1em'}}>{actionButtons}</div>
<div className='wrolpi-button-row' style={{marginTop: '1em'}}>
{actionButtons}
</div>
</Media>
<Media greaterThanOrEqual='tablet'>
<div style={{marginTop: '1em'}}>
{formatButtons && <span style={{marginRight: '1em', display: 'inline-block'}}>
{formatButtons}
</span>}
{/*
* The format picker and the actions are one row, separated by its gap.
*
* This IS a change: the picker used to sit 1em from the actions, held
* there by a span with `margin-right`, and it now sits at the row's
* 0.625rem like everything else in a row. The two groups read as slightly
* closer than before. Deliberate -- one spacing rule for buttons in a row
* beats a second one for buttons in a row that happen to be a picker --
* but it is a visual change and not a pure refactor.
*/}
<div className='wrolpi-button-row' style={{marginTop: '1em'}}>
{formatButtons}
{actionButtons}
</div>
</Media>
</>;
})()}
</Segment>
</Panel>
<Segment>
<Panel>
<TagsSelector selectedTagNames={docFile.tags} onAdd={localAddTag} onRemove={localRemoveTag}/>
</Segment>
</Panel>
<Tab menu={tabMenu} panes={tabPanes}/>
<Tabs defaultValue='about'>
<Tabs.List>
<Tabs.Tab value='about'>About</Tabs.Tab>
<Tabs.Tab value='files'>Files</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value='about' pt='md'>{aboutTab}</Tabs.Panel>
<Tabs.Panel value='files' pt='md'>{filesTab}</Tabs.Panel>
</Tabs>
<TaggedDeleteConfirmModal
open={taggedFileGroups !== null}
@ -478,23 +479,18 @@ function AuthorsPage() {
const [searchStr, setSearchStr] = useOneQuery('author');
const searchInputRef = React.useRef();
const header = <div style={{marginBottom: '1em'}}>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
<SearchInput
placeholder='Author filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(authors) || authors.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
</Grid.Column>
</Grid.Row>
</Grid>
// No `marginBottom`: a top-level block of the page, spaced by the page's stack.
const header = <div>
<SearchInput
placeholder='Author filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(authors) || authors.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
</div>;
return <>
@ -516,23 +512,18 @@ function SubjectsPage() {
const [searchStr, setSearchStr] = useOneQuery('subject');
const searchInputRef = React.useRef();
const header = <div style={{marginBottom: '1em'}}>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
<SearchInput
placeholder='Subject filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(subjects) || subjects.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
</Grid.Column>
</Grid.Row>
</Grid>
// No `marginBottom`: a top-level block of the page, spaced by the page's stack.
const header = <div>
<SearchInput
placeholder='Subject filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(subjects) || subjects.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
</div>;
return <>
@ -568,45 +559,24 @@ function DocsStatisticsPage() {
}, []);
if (statistics === null) {
return <Loader active inline='centered'/>;
return <Loading/>;
}
if (statistics === undefined) {
return <ErrorMessage>Unable to fetch Doc Statistics</ErrorMessage>;
}
return <Segment>
<Header as='h1' textAlign='center'>Documents</Header>
<Statistic.Group>
<Statistic style={{margin: '2em'}}>
<StatisticValue>{toLocaleString(statistics.doc_count)}</StatisticValue>
<StatisticLabel>Documents</StatisticLabel>
</Statistic>
<Statistic style={{margin: '2em'}}>
<StatisticValue>{toLocaleString(statistics.epub_count)}</StatisticValue>
<StatisticLabel>eBooks</StatisticLabel>
</Statistic>
<Statistic style={{margin: '2em'}}>
<StatisticValue>{toLocaleString(statistics.pdf_count)}</StatisticValue>
<StatisticLabel>PDFs</StatisticLabel>
</Statistic>
<Statistic style={{margin: '2em'}}>
<StatisticValue>{toLocaleString(statistics.other_count)}</StatisticValue>
<StatisticLabel>Other</StatisticLabel>
</Statistic>
<Statistic style={{margin: '2em'}}>
<StatisticValue>{humanFileSize(statistics.total_size)}</StatisticValue>
<StatisticLabel>Total Size</StatisticLabel>
</Statistic>
<Statistic style={{margin: '2em'}}>
<StatisticValue>{toLocaleString(statistics.author_count)}</StatisticValue>
<StatisticLabel>Authors</StatisticLabel>
</Statistic>
<Statistic style={{margin: '2em'}}>
<StatisticValue>{toLocaleString(statistics.subject_count)}</StatisticValue>
<StatisticLabel>Subjects</StatisticLabel>
</Statistic>
</Statistic.Group>
</Segment>;
return <Panel>
<Header as='h1' style={{textAlign: 'center'}}>Documents</Header>
<StatisticGroup>
<Statistic value={toLocaleString(statistics.doc_count)} label='Documents'/>
<Statistic value={toLocaleString(statistics.epub_count)} label='eBooks'/>
<Statistic value={toLocaleString(statistics.pdf_count)} label='PDFs'/>
<Statistic value={toLocaleString(statistics.other_count)} label='Other'/>
<Statistic value={humanFileSize(statistics.total_size)} label='Total Size'/>
<Statistic value={toLocaleString(statistics.author_count)} label='Authors'/>
<Statistic value={toLocaleString(statistics.subject_count)} label='Subjects'/>
</StatisticGroup>
</Panel>;
}
export function DocsRoute() {

View file

@ -95,7 +95,7 @@ describe('DomainEditPage', () => {
render(<DomainEditPage/>);
// Should show Semantic UI Loader with text
// Should show the Loading component with text
expect(screen.getByText(/loading domain/i)).toBeInTheDocument();
// Form should not be visible during initial load

View file

@ -1,16 +1,17 @@
import React from 'react';
import {createMockDomain, createTestForm, render, screen} from '../test-utils';
import {tagsContextFixture} from '../test-fixtures';
import {TagsContext} from '../Tags';
import {CollectionEditForm} from './collections/CollectionEditForm';
import {DomainsPage} from './Archive';
// Mock the TagsContext
jest.mock('../Tags', () => ({
TagsContext: {
_currentValue: {
SingleTag: ({name}) => <span data-testid="applied-tag">{name}</span>
}
},
}));
/*
* The Tags context used to be faked here as a bare object with a `_currentValue` and no
* `.Provider`, which worked only because `useContext` reads that React internal. It is now
* the real context carrying a fixture value, passed in by this spec rather than by the harness
* so that it is the same module instance the component reads -- see test-utils.js.
*/
const withTags = {contexts: [[TagsContext, tagsContextFixture()]]};
// Mock the DirectorySearch and DestinationForm components
jest.mock('./Common', () => ({
@ -96,7 +97,8 @@ describe('Domain Tagging Logic', () => {
appliedTagName="News"
>
<div data-testid="child-content">Child content</div>
</CollectionEditForm>
</CollectionEditForm>,
withTags,
);
// Should render title
@ -105,8 +107,8 @@ describe('Domain Tagging Logic', () => {
// Should render children
expect(screen.getByTestId('child-content')).toBeInTheDocument();
// Should render applied tag
expect(screen.getByTestId('applied-tag')).toHaveTextContent('News');
// The tag name a user would see, rather than a test id the old mock invented.
expect(screen.getByText('News')).toBeInTheDocument();
// Should render Save button
expect(screen.getByRole('button', {name: /save/i})).toBeInTheDocument();
@ -148,7 +150,7 @@ describe('Domain Tagging Logic', () => {
</CollectionEditForm>
);
expect(screen.queryByTestId('applied-tag')).not.toBeInTheDocument();
expect(screen.queryByText('News')).not.toBeInTheDocument();
});
});

View file

@ -1,7 +1,6 @@
import React, {useState} from "react";
import {PageContainer, useTitle} from "./Common";
import {Button, Header, Modal, Segment, Table} from "./Theme";
import {Icon, TableBody, TableCell, TableRow} from "semantic-ui-react";
import {Button, Header, IconButton, Modal, Panel, Table} from "./ui";
import QRCode from "react-qr-code";
const bitcoinAddress = '1mi1ddLSd6LmkuwJd1ttZsKuED724vYjS';
@ -17,18 +16,19 @@ function CoinQRButton({qrCodeValue, header, buttonColor}) {
const [open, setOpen] = useState(false);
return <>
<Button icon color={buttonColor}
onClick={() => setOpen(true)}>
<Icon name='qrcode' size='big'/>
</Button>
<Modal closeIcon
<IconButton icon='qrcode' label={`Show ${header} QR code`} color={buttonColor}
onClick={() => setOpen(true)}/>
<Modal size='small' closeIcon
open={open}
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}
>
<Modal.Header>{header}</Modal.Header>
<Modal.Content>
<div style={{backgroundColor: '#FFFFFF', display: 'inline-block', padding: '1em'}}>
{/* `media`: the white field a scanner needs is filtered as a unit by night mode,
rather than sitting on screen as a lit panel. */}
<div className='media'
style={{backgroundColor: '#FFFFFF', display: 'inline-block', padding: '1em'}}>
<QRCode value={qrCodeValue} size={300}/>
</div>
</Modal.Content>
@ -39,17 +39,17 @@ function CoinQRButton({qrCodeValue, header, buttonColor}) {
function CoinRow({header, qrCodeValue, address, buttonColor}) {
return <>
<Header as='h3'>{header}</Header>
<Table stackable>
<TableBody>
<TableRow>
<TableCell width={1}>
<Table>
<Table.Body>
<Table.Row>
<Table.Cell style={{width: '1%', whiteSpace: 'nowrap'}}>
<CoinQRButton qrCodeValue={qrCodeValue} header={header} buttonColor={buttonColor}/>
</TableCell>
<TableCell width={15} textAlign='left'>
</Table.Cell>
<Table.Cell style={{textAlign: 'left'}}>
<pre>{address}</pre>
</TableCell>
</TableRow>
</TableBody>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</>
}
@ -58,31 +58,28 @@ export function DonatePage() {
useTitle('Donate');
return <PageContainer>
<Segment>
<Panel>
<Header as='h1'>Donate</Header>
<Header as='h3'>We appreciate any support you can provide to WROLPi!</Header>
<p>
<Button
size='huge'
size='xl'
icon='paypal'
as='a'
color='blue'
component='a'
role='primary'
href='https://www.paypal.com/donate/?hosted_button_id=ZH2CN92SMJ66N'
rel='noopener noreferrer'
target='_blank'
label={{
basic: true,
content: 'Donate on PayPal',
color: 'blue'
}}
/>
>
Donate on PayPal
</Button>
</p>
<CoinRow header='Monero' qrCodeValue={moneroContent} address={moneroAddress} buttonColor='orange'/>
<CoinRow header='Litecoin' qrCodeValue={litecoinContent} address={litecoinAddress} buttonColor='grey'/>
<CoinRow header='Ethereum' qrCodeValue={ethereumContent} address={ethereumAddress} buttonColor='blue'/>
<CoinRow header='Bitcoin' qrCodeValue={bitcoinContent} address={bitcoinAddress} buttonColor='yellow'/>
</Segment>
</Panel>
</PageContainer>;
}

View file

@ -1,6 +1,21 @@
import React from 'react';
import {ArchiveDownloadForm, ChannelDownloadForm, EditRSSDownloadForm} from './Download';
import 'semantic-ui-offline/semantic.min.css';
/*
* A field in the error state.
*
* The old form controls took `error` and put the bare word on the DOM node, so a spec could
* assert `have.attr 'error'`. Our fields mark an invalid control the way assistive
* technology reads it instead -- `aria-invalid` on the input. The old assertion did not
* fail when the controls changed; it went on looking for an attribute that no longer
* exists, on a field that is both styled and announced as invalid.
*
* Valid is `aria-invalid="false"`, not a missing attribute: the field states its validity
* either way, which is what lets a screen reader say a control is fine rather than say
* nothing about it. Both are asserted by value so neither direction can pass on absence.
*/
const shouldBeInvalid = (selector) => cy.get(selector).should('have.attr', 'aria-invalid', 'true');
const shouldBeValid = (selector) => cy.get(selector).should('have.attr', 'aria-invalid', 'false');
describe('<ArchiveDownloadForm />', () => {
beforeEach(() => {
@ -9,18 +24,18 @@ describe('<ArchiveDownloadForm />', () => {
it('displays error for invalid URL', () => {
cy.get('#urls_textarea').type('invalid-url').wait(500);
cy.get('#urls_textarea').should('have.attr', 'error');
shouldBeInvalid('#urls_textarea');
});
it('enables download button for valid URL', () => {
cy.get('#urls_textarea').type('https://wrolpi.org').wait(500);
cy.get('#urls_textarea').should('not.have.attr', 'error');
shouldBeValid('#urls_textarea');
cy.get('#download_form_download_button').should('be.visible').and('not.have.attr', 'disabled');
});
it('disables download button for invalid URL', () => {
cy.get('#urls_textarea').type('invalid-url').wait(500);
cy.get('#urls_textarea').should('have.attr', 'error');
shouldBeInvalid('#urls_textarea');
cy.get('#download_form_download_button').should('be.visible').and('have.attr', 'disabled');
});
@ -30,11 +45,11 @@ describe('<ArchiveDownloadForm />', () => {
it('clears error when switching from invalid to valid URL', () => {
cy.get('#urls_textarea').type('invalid-url').wait(500);
cy.get('#urls_textarea').should('have.attr', 'error');
shouldBeInvalid('#urls_textarea');
cy.get('#urls_textarea').clear();
cy.get('#urls_textarea').type('https://wrolpi.org').wait(500);
cy.get('#urls_textarea').should('not.have.attr', 'error');
shouldBeValid('#urls_textarea');
});
});
@ -54,16 +69,42 @@ describe('<ChannelDownloadForm/>', () => {
it('displays error for invalid URL', () => {
cy.get('#url_input').type('invalid-url').wait(500);
cy.get('#url_input').should('have.attr', 'error');
shouldBeInvalid('#url_input');
});
it('can choose different options', () => {
cy.get('#download_frequency_selector').click()
.get('.item').contains('Once').click();
cy.get('#download_frequency_selector').click()
.get('.item').contains('Daily').click();
cy.get('#download_frequency_selector').click()
.get('.item').contains('180 Days').click();
/*
* `#download_frequency_selector` and `.item` named the old dropdown and its menu rows.
* SelectField ids each control `<name>_select`, and an option carries the ARIA role
* rather than a class -- which is what a screen reader navigates, so it is the better
* handle of the two.
*/
const chooseFrequency = (label) => {
cy.get('#frequency_select').click();
/*
* One query, so Cypress retries the whole thing while the dropdown opens.
* `cy.get(...).contains(...)` does not: `get` settles on whatever matched at
* that instant, and the options are not in the DOM yet when it does.
*
* `:visible` because the form carries several Selects, each keeping its options
* mounted while closed -- a bare `[role="option"]` matches a hidden one from a
* dropdown that was never opened, and clicking that fails.
*/
cy.get('[role="option"]:visible').should('have.length.greaterThan', 0);
/*
* `force` because the dropdown re-renders as its open transition settles, and
* Cypress aborts a click whose element was replaced between locating it and
* clicking it. Visibility is asserted on its own above, so this is not
* skipping a check that could hide an unreachable control.
*/
cy.contains('[role="option"]:visible', label).should('be.visible')
.click({force: true});
cy.get('#frequency_select').should('have.value', label);
};
chooseFrequency('Once');
chooseFrequency('Daily');
chooseFrequency('180 Days');
});
});
@ -96,8 +137,14 @@ describe('<EditRSSDownloadForm />', () => {
tag_names: ['Automotive'],
url: "https://www.youtube.com/feeds/videos.xml?channel_id=UC4t8bw1besFTyjW7ZBCOIrw"
};
cy.mountWithTags(<EditRSSDownloadForm download={download}/>);
cy.intercept('GET', '/api/downloaders', downloadersResponse).as('downloaders');
/*
* The intercepts are declared BEFORE the mount, which is the whole reason this
* block is ordered this way. Mounting first lets the form's own effects fire
* against a bare network -- `/api/downloaders` rejected with "Failed to fetch",
* and Cypress fails a test on an unhandled rejection out of application code
* whatever it was in the middle of asserting.
*/
cy.intercept('GET', '**/api/downloaders', downloadersResponse).as('downloaders');
cy.intercept('POST', '**/api/files/search_directories', {
statusCode: 200,
body: {
@ -106,47 +153,64 @@ describe('<EditRSSDownloadForm />', () => {
},
}).as('searchDirectories');
cy.mountWithTags(<EditRSSDownloadForm download={download}/>);
});
it('can edit videos download', () => {
// Weekly frequency.
cy.get('#url_input').should('have.value',
'https://www.youtube.com/feeds/videos.xml?channel_id=UC4t8bw1besFTyjW7ZBCOIrw');
cy.get('div.divider.text').contains('Weekly');
// The old dropdown showed its choice in a `div.divider.text`; a Select is a real input
// holding the label, so the value can be read rather than searched for.
cy.get('#frequency_select').should('have.value', 'Weekly');
cy.get('input[name="title_include"]').should('have.value', 'included');
cy.get('input[name="title_exclude"]').should('have.value', 'excluded');
cy.get('#destination_search_form').should('be.visible');
cy.get('div.ui.large.label').contains('Automotive').should('be.visible');
cy.get('.wrolpi-tag').contains('Automotive').should('be.visible');
});
it('can submit videos download', () => {
cy.get('button[type="submit"]').should('be.visible');
cy.intercept('PUT', '/api/download/1', (req) => {
expect(req.body).to.deep.equal(JSON.stringify({
"destination": "destination directory",
"downloader": "rss",
"frequency": 604800,
"settings": {
"excluded_urls": null,
"title_exclude": "excluded",
"title_include": "included",
"video_resolutions": [
"720p",
"480p",
"maximum"
],
"video_format": "mp4",
"minimum_duration": null,
"maximum_duration": null
},
"sub_downloader": "video",
"tag_names": ["Automotive"],
"urls": ["https://www.youtube.com/feeds/videos.xml?channel_id=UC4t8bw1besFTyjW7ZBCOIrw"]
}));
req.reply({statusCode: 204});
}).as('downloadPut');
cy.intercept('PUT', '/api/download/1', {statusCode: 204}).as('downloadPut');
cy.get('button[type="submit"]').click();
/*
* The edit form sends the download back unchanged.
*
* This asserted one exact JSON string, which broke the moment the video downloader
* gained settings the form fills in with their defaults -- `compress_singlefile`,
* `audio_only`, `user_agent` and five more. None of that is the subject: what
* matters is that the values the user was shown are the values that go back.
*
* So the envelope is still matched exactly, which catches a field appearing or
* vanishing at the top level, while `settings` is checked for the seven the fixture
* set. Waiting on the alias matters as much as either -- without it the click ended
* the test and an interceptor that never ran would have passed silently.
*/
cy.wait('@downloadPut').its('request.body').then((body) => {
const sent = typeof body === 'string' ? JSON.parse(body) : body;
const {settings, ...envelope} = sent;
expect(envelope).to.deep.equal({
destination: 'destination directory',
downloader: 'rss',
frequency: 604800,
sub_downloader: 'video',
tag_names: ['Automotive'],
urls: ['https://www.youtube.com/feeds/videos.xml?channel_id=UC4t8bw1besFTyjW7ZBCOIrw'],
});
expect(settings).to.deep.include({
excluded_urls: null,
title_exclude: 'excluded',
title_include: 'included',
video_resolutions: ['720p', '480p', 'maximum'],
video_format: 'mp4',
minimum_duration: null,
maximum_duration: null,
});
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import React from 'react';
import {Button, Container, Icon, Message, Segment} from 'semantic-ui-react';
import {Box, Button, ButtonGroup, Message, Panel} from './ui';
/**
* Error Boundary component that catches JavaScript errors in child components
@ -52,23 +52,19 @@ class ErrorBoundary extends React.Component {
const errorMessage = error?.message || 'An unexpected error occurred';
return (
<Container style={{padding: '2em'}}>
<Message negative icon>
<Icon name='warning sign'/>
<Message.Content>
<Message.Header>Something went wrong</Message.Header>
<p>{errorMessage}</p>
<Button.Group>
<Button onClick={this.handleReset} primary>
<Icon name='refresh'/> Try Again
</Button>
<Button onClick={this.handleReload}>
<Icon name='redo'/> Reload Page
</Button>
</Button.Group>
</Message.Content>
<Box style={{padding: '2em'}}>
<Message kind='error' icon='warning sign' title='Something went wrong'>
<p>{errorMessage}</p>
<ButtonGroup>
<Button onClick={this.handleReset} role='primary' icon='refresh'>
Try Again
</Button>
<Button onClick={this.handleReload} icon='redo'>
Reload Page
</Button>
</ButtonGroup>
</Message>
</Container>
</Box>
);
}
@ -96,11 +92,11 @@ class InlineErrorBoundary extends React.Component {
render() {
if (this.state.hasError) {
return (
<Segment basic>
<Message negative size='small'>
<Icon name='warning'/> Failed to load this section
<Panel>
<Message kind='error' icon='warning'>
Failed to load this section
</Message>
</Segment>
</Panel>
);
}
return this.props.children;

View file

@ -1,17 +1,20 @@
import {Button, Header, Icon, Modal, Placeholder, Progress, Segment, Table} from "./Theme";
import {
ActionInput,
Button,
Checkbox,
Form,
Grid,
IconGroup,
Input,
PlaceholderLine,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow
} from "semantic-ui-react";
Header,
Icon,
IconButton,
IconStack,
Modal,
Panel,
Placeholder,
Progress,
Skeleton,
Stack,
Table,
TextInput,
} from "./ui";
import {
APIButton,
DirectorySearch,
@ -23,6 +26,7 @@ import {
useIsIgnoredDirectory
} from "./Common";
import React, {useContext, useEffect, useState} from "react";
import {IconArrowsMove, IconChevronsUp, IconCursorText, IconTags} from "@tabler/icons-react";
import {useDropzone} from "react-dropzone";
import {useHotkeys} from "react-hotkeys-hook";
import {deleteFile, ignoreDirectory, makeDirectory, movePaths, renamePath, unignoreDirectory} from "../api";
@ -111,23 +115,28 @@ function Folder({folder, onFolderClick, sortData, onFileClick, disabled, loading
const isLoading = loadingFolders && loadingFolders.has(path);
const depth = (path.match(/\//g) || []).length;
const f = <TableRow
const f = <Table.Row
key={path}
disabled={isLoading || disabled}
className={dragClass}
style={(isLoading || disabled) ? {opacity: 0.5, pointerEvents: 'none'} : undefined}
onMouseDown={(e) => handleDragStart(path, e)}
onMouseEnter={() => handleDragMove(path)}
>
<TableCell collapsing>
<Table.Cell style={{width: '1%', whiteSpace: 'nowrap'}}>
<Checkbox checked={showAsSelected} onChange={() => onSelect(folder['path'])}/>
</TableCell>
<TableCell onClick={() => shouldAllowClick() && onFolderClick(path)} className='file-path' colSpan={2} disabled={is_empty}>
</Table.Cell>
<Table.Cell
onClick={() => shouldAllowClick() && onFolderClick(path)}
className='file-path'
colSpan={2}
style={is_empty ? {opacity: 0.5} : undefined}
>
{depthIndentation(pathWithNoTrailingSlash)}
{is_empty ? <Icon name='folder outline'/> : <Icon name='folder'/>}
{ignored && <Icon name='eye slash' color='red'/>}
{name}
</TableCell>
</TableRow>;
</Table.Cell>
</Table.Row>;
if (children) {
// Folder has children, recursively display them.
children = sortData(children);
@ -164,27 +173,27 @@ function File({file, onFileClick, disabled}) {
const showAsSelected = (isInSelectedPaths && !isDragDeselecting) || isDragSelecting;
// Visual highlight class
const dragClass = isDragSelecting ? 'drag-selecting' : (isDragDeselecting ? 'drag-deselecting' : '');
return <TableRow
return <Table.Row
key={path}
disabled={disabled}
className={dragClass}
style={disabled ? {opacity: 0.5, pointerEvents: 'none'} : undefined}
onMouseDown={(e) => handleDragStart(path, e)}
onMouseEnter={() => handleDragMove(path)}
>
<TableCell collapsing>
<Table.Cell style={{width: '1%', whiteSpace: 'nowrap'}}>
<Checkbox checked={showAsSelected} onChange={() => onSelect(path)}/>
</TableCell>
<TableCell onClick={() => shouldAllowClick() && onFileClick(file)} className='file-path'>
</Table.Cell>
<Table.Cell onClick={() => shouldAllowClick() && onFileClick(file)} className='file-path'>
{depthIndentation(path)}
<FileRowTagIcon file={file}/>
{/*null size to make icon the correct size*/}
<FileIcon file={file} size={null}/>
{pathName(path)}
</TableCell>
<TableCell collapsing textAlign='right'>
</Table.Cell>
<Table.Cell numeric style={{width: '1%', whiteSpace: 'nowrap'}}>
{humanFileSize(size)}
</TableCell>
</TableRow>
</Table.Cell>
</Table.Row>
}
function Path({path, onFolderClick, onFileClick, sortData, disabled, loadingFolders}) {
@ -209,17 +218,15 @@ function Path({path, onFolderClick, onFileClick, sortData, disabled, loadingFold
function SkeletonFileRow({depth}) {
const indentation = '\xa0\xa0\xa0\xa0'.repeat(depth);
return (
<TableRow>
<TableCell collapsing>
<Table.Row>
<Table.Cell style={{width: '1%', whiteSpace: 'nowrap'}}>
<Checkbox disabled/>
</TableCell>
<TableCell className='file-path' colSpan={2}>
</Table.Cell>
<Table.Cell className='file-path' colSpan={2}>
{indentation}
<Placeholder style={{display: 'inline-block', width: '200px', marginLeft: '0.5em'}}>
<PlaceholderLine/>
</Placeholder>
</TableCell>
</TableRow>
<Skeleton height={12} width={200} radius={0} animate style={{display: 'inline-block', marginLeft: '0.5em'}}/>
</Table.Cell>
</Table.Row>
);
}
@ -259,7 +266,6 @@ export function FileBrowser() {
}, {enableOnFormTags: false});
const {settings, fetchSettings} = React.useContext(SettingsContext);
const {inverted} = React.useContext(ThemeContext);
const selectedPathsCount = selectedPaths ? selectedPaths.length : 0;
@ -387,28 +393,25 @@ export function FileBrowser() {
const [footerRef, footerWidth] = useElementWidth();
// Expand icon-only buttons to include labels once the footer is wide enough to fit them.
const showLabels = footerWidth >= FOOTER_LABELS_MIN_WIDTH;
// Semantic UI renders a labeled icon button when `content` and `labelPosition` are set, else icon-only.
const labelProps = (content) => showLabels ? {content, labelPosition: 'left'} : {};
// Show a label alongside the icon once there's room; otherwise the icon carries the meaning alone.
const label = (text) => showLabels ? text : null;
const footerClassName = `sticky-footer ${inverted}`;
const footer = <div className={footerClassName} ref={footerRef}>
const footer = <div className='sticky-footer' ref={footerRef}>
<FilesRefreshButton paths={selectedPaths} showLabel={showLabels}/>
<APIButton
icon='trash'
color='red'
role='danger'
confirmContent='Are you sure you want to delete these files?'
confirmButton='Delete'
onClick={onDelete}
disabled={selectedPathsCount === 0}
obeyWROLMode={true}
{...labelProps('Delete')}
/>
<Button icon='text cursor'
>{label('Delete')}</APIButton>
<Button icon={IconCursorText}
color='yellow'
onClick={() => setRenameOpen(true)}
disabled={wrolModeEnabled || selectedPathsCount !== 1}
{...labelProps('Rename')}
/>
>{label('Rename')}</Button>
{selectedPathsCount === 1 ?
<RenameModal
open={renameOpen}
@ -417,12 +420,11 @@ export function FileBrowser() {
path={selectedPaths[0]}
onSubmit={reset}
/> : null}
<Button icon='move'
<Button icon={IconArrowsMove}
color='teal'
disabled={wrolModeEnabled || selectedPathsCount === 0}
onClick={() => setMoveOpen(true)}
{...labelProps('Move')}
/>
>{label('Move')}</Button>
{selectedPathsCount > 0 ?
<MoveModal open={moveOpen}
onClose={() => setMoveOpen(false)}
@ -435,10 +437,11 @@ export function FileBrowser() {
disabled={wrolModeEnabled}
style={{paddingLeft: '1em', paddingRight: showLabels ? '1em' : '0.8em'}}
>
<IconGroup>
{/* The corner disc follows the button's fill on its own -- including when WROL
mode disables it and Mantine repaints it grey. See ui.css. */}
<IconStack corner={<Icon name='add'/>} label='New folder'>
<Icon name='folder'/>
<Icon corner name='add'/>
</IconGroup>
</IconStack>
{showLabels ? <>&nbsp;New Folder</> : null}
</Button>
<MakeDirectoryModal
@ -452,8 +455,7 @@ export function FileBrowser() {
color='green'
disabled={wrolModeEnabled || (selectedPathsCount > 0 && !singleDirectorySelected)}
onClick={() => setUploadOpen(true)}
{...labelProps('Upload')}
/>
>{label('Upload')}</Button>
<FileBrowserUploadModal
open={uploadOpen}
onClose={() => setUploadOpen(false)}
@ -465,8 +467,7 @@ export function FileBrowser() {
icon={isDirectoryIgnored ? 'eye' : 'eye slash'}
disabled={!singleDirectorySelected || wrolModeEnabled}
onClick={() => setIgnoreDirectoryOpen(true)}
{...labelProps(isDirectoryIgnored ? 'Unignore' : 'Ignore')}
/>
>{label(isDirectoryIgnored ? 'Unignore' : 'Ignore')}</Button>
<IgnoreDirectoryModal
open={ignoreDirectoryOpen}
onClose={onIgnore}
@ -476,11 +477,10 @@ export function FileBrowser() {
/>
<Button
color='violet'
icon='tags'
icon={IconTags}
disabled={selectedPathsCount === 0}
onClick={() => setBulkTagOpen(true)}
{...labelProps('Tag')}
/>
>{label('Tag')}</Button>
<BulkTagModal
open={bulkTagOpen}
onClose={() => setBulkTagOpen(false)}
@ -536,10 +536,7 @@ export function FileBrowser() {
);
if (browseFiles === null) {
return <Placeholder>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>;
return <Placeholder lines={2}/>;
} else if (browseFiles === undefined) {
return <ErrorMessage>Could not fetch files</ErrorMessage>
}
@ -551,32 +548,28 @@ export function FileBrowser() {
openFolders={openFolders}
>
<div className='file-browser-filter-container'>
<Input
icon='filter'
iconPosition='left'
<ActionInput
leftSection={<Icon name='filter'/>}
placeholder='Filter files...'
value={filterStr}
onChange={(e, {value}) => setFilterStr(value)}
onChange={(e) => setFilterStr(e.target.value)}
aria-label='Filter files'
className='file-browser-filter'
ref={filterInputRef}
action={<Button
action={<IconButton
icon='close'
type='button'
onClick={() => setFilterStr('')}
disabled={!filterStr}
className='search-clear'
aria-label='Clear filter'
label='Clear filter'
/>}
/>
<Button
icon='angle double up'
<IconButton
icon={IconChevronsUp}
type='button'
onClick={onCollapseAll}
disabled={!openFolders || openFolders.length === 0}
title='Close all folders'
aria-label='Close all folders'
style={{marginLeft: '0.5em'}}
label='Close all folders'
/>
</div>
<FileBrowserContent
@ -598,7 +591,6 @@ function FileBrowserContent({browseFiles, headers, onFolderClick, setPreviewFile
return (
<div style={{marginBottom: '4em'}} className={containerClassName}>
<SortableTable
tableProps={{unstackable: true, selectable: true, striped: true}}
data={browseFiles}
tableHeaders={headers}
defaultSortColumn='path'
@ -624,11 +616,8 @@ export function RenameModal({open, onClose, path, onSubmit, onPending}) {
const [value, setValue] = React.useState(name);
const disabled = value === name;
const handleInputChange = (e, props) => {
if (e) {
e.preventDefault();
}
setValue(props.value);
const handleInputChange = (e) => {
setValue(e.target.value);
}
const handleSubmit = async (e) => {
@ -652,25 +641,32 @@ export function RenameModal({open, onClose, path, onSubmit, onPending}) {
setValue(pathName(path));
}, [path]);
return <Modal closeIcon
// Audited: unchanged at `small` (440px), written down so the default cannot move it.
return <Modal size='small'
open={open}
onClose={onClose}
>
<Modal.Header>Rename: {name}</Modal.Header>
<Modal.Content>
<Form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit}>
<Header as='h4'>New Name</Header>
<pre>{parent ? parent + '/' : null}{value}</pre>
<Input fluid
<TextInput
autoFocus
label='Name'
value={value}
onChange={handleInputChange}
/>
</Form>
</form>
</Modal.Content>
<Modal.Actions>
<Button
onClick={() => setValue(name)}
style={{marginRight: 'auto'}}
>
Reset
</Button>
<Button
color='violet'
disabled={disabled}
@ -678,12 +674,6 @@ export function RenameModal({open, onClose, path, onSubmit, onPending}) {
>
Rename
</Button>
<Button
onClick={() => setValue(name)}
floated='left'
>
Reset
</Button>
</Modal.Actions>
</Modal>
}
@ -710,47 +700,52 @@ export function MoveModal({open, paths, onClose, onSubmit}) {
}
}
const fileCell = i => <TableCell>
const fileCell = i => <Table.Cell>
<pre style={{marginTop: 0, marginBottom: 0}}>{i}</pre>
</TableCell>;
</Table.Cell>;
const destinationRows = paths.map(i => <TableRow key={i}>
const destinationRows = paths.map(i => <Table.Row key={i}>
{fileCell(i)}
{destination ? fileCell(destination + '/' + pathName(i)) : fileCell(pathName(i))}
</TableRow>);
</Table.Row>);
return <Modal closeIcon
/*
* Audited: `large` (620px). This dialog holds a DirectorySearch and a two-column
* Source/Destination table of every path being moved, which is the densest content
* of the five FileBrowser modals.
*/
return <Modal size='large'
onClose={onClose}
open={open}
>
<Modal.Header>Move Files/Directories</Modal.Header>
<Modal.Content>
<p>Search for a directory to move your files into:</p>
<DirectorySearch onSelect={handleDirectory} disabled={pending} input={{autoFocus: true}}/>
<DirectorySearch onSelect={handleDirectory} disabled={pending} autoFocus/>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Source</TableHeaderCell>
<TableHeaderCell>Destination</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Source</Table.HeaderCell>
<Table.HeaderCell>Destination</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{destinationRows}
</TableBody>
</Table.Body>
</Table>
</Modal.Content>
<Modal.Actions>
<Button
onClick={() => setDestination(null)}
style={{marginRight: 'auto'}}
disabled={pending}
>Reset</Button>
<Button
color='violet'
onClick={handleMove}
disabled={pending}
>Move</Button>
<Button
onClick={() => setDestination(null)}
floated='left'
disabled={pending}
>Reset</Button>
</Modal.Actions>
</Modal>
}
@ -779,20 +774,21 @@ export function IgnoreDirectoryModal({open, onClose, onSubmit, directory, ignore
const content = ignored ?
'Remove ignore of the following directory? You must refresh your files after this.'
: 'Ignore the following directory? The files within will not be indexed and will not show in search results.';
let submitButton = <Button primary
let submitButton = <Button role='primary'
disabled={pending}
onClick={handleIgnore}
>{ignored ? 'Un-ignore' : 'Ignore'}</Button>;
return <Modal closeIcon open={open} onClose={onClose}>
// Audited: `small` (440px), which is the width it already had.
return <Modal size='small' open={open} onClose={onClose}>
<Modal.Header>{header}</Modal.Header>
<Modal.Content>
{content}
<pre>{mediaDirectory}/{directory}</pre>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' style={{marginRight: 'auto'}} onClick={onClose}>Close</Button>
{submitButton}
<Button secondary floated='left' onClick={onClose}>Close</Button>
</Modal.Actions>
</Modal>
}
@ -801,11 +797,8 @@ export function MakeDirectoryModal({open, onClose, parent, onSubmit}) {
const [value, setValue] = useState('');
const mediaDirectory = useMediaDirectory();
const handleInputChange = (e, props) => {
if (e) {
e.preventDefault();
}
setValue(props.value);
const handleInputChange = (e) => {
setValue(e.target.value);
}
const handleSubmit = async (e) => {
@ -824,18 +817,30 @@ export function MakeDirectoryModal({open, onClose, parent, onSubmit}) {
}
}
return <Modal closeIcon open={open} onClose={onClose}>
/*
* Audited: `large` (620px), deliberately wider than RenameModal's `small` even though
* the form is the same shape -- one field and a button. The difference is the path
* above it: rename shows a RELATIVE path, this shows the absolute one including the
* media directory, and a bare `<pre>` is `white-space: pre`, so it scrolls rather
* than wraps. Measured in the browser: 440px holds 42 characters and 620px holds
* 61. `/media/wrolpi/videos/Practical Engineering/Season 2024/` is 55, so an
* ordinary channel-and-season path scrolls out of sight at 440 and fits at 620.
*
* Written out because it reads like an inconsistency next to rename, and the next
* person to notice that should see the measurement before narrowing it back.
*/
return <Modal size='large' open={open} onClose={onClose}>
<Modal.Header>Make Directory</Modal.Header>
<Modal.Content>
<Form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit}>
<pre>{mediaDirectory}/{parent}{value}</pre>
<Input fluid
<TextInput
autoFocus
label='Name'
value={value}
onChange={handleInputChange}
/>
</Form>
</form>
</Modal.Content>
<Modal.Actions>
<Button color='violet' onClick={handleSubmit}>Create</Button>
@ -845,7 +850,6 @@ export function MakeDirectoryModal({open, onClose, parent, onSubmit}) {
export function FileBrowserUploadModal({open, onClose, destination, onComplete}) {
const mediaDirectory = useMediaDirectory();
const {t} = React.useContext(ThemeContext);
const {
setFiles,
@ -909,27 +913,22 @@ export function FileBrowserUploadModal({open, onClose, destination, onComplete})
color = 'orange';
statusString = 'Already Exists:';
}
let indicating = status === 'pending';
return <Grid.Row key={name}>
<Grid.Column mobile={3} tablet={2} computer={1}>
<Icon name={mimetypeIconName(type, name.toLowerCase())} size='big'/>
</Grid.Column>
<Grid.Column mobile={13} tablet={14} computer={15}>
<Progress progress indicating={indicating} percent={percent} color={color}>
<p {...t}>{statusString} {name}</p>
</Progress>
</Grid.Column>
</Grid.Row>
return <div key={name} style={{display: 'flex', alignItems: 'center', gap: '0.75em'}}>
<Icon name={mimetypeIconName(type, name.toLowerCase())} size='large'/>
<div style={{flex: 1}}>
<Progress percent={percent} color={color} label={`${statusString} ${name}`}/>
</div>
</div>
})
}
const displayPath = destination ? `${mediaDirectory}/${destination}` : mediaDirectory;
return <Modal closeIcon open={open} onClose={handleClose} size='large'>
return <Modal open={open} onClose={handleClose} size='large'>
<Modal.Header>Upload to: {displayPath}</Modal.Header>
<Modal.Content>
<Form>
<form onSubmit={e => e.preventDefault()}>
{tagsSelector}
<div style={{marginTop: '1em'}}>
<Toggle
@ -939,49 +938,27 @@ export function FileBrowserUploadModal({open, onClose, destination, onComplete})
disabled={inProgress}
/>
</div>
</Form>
</form>
<Segment>
<Grid columns={1}>
<Grid.Row>
<Grid.Column style={{padding: '1em'}}>
<Form>
<div {...getRootProps()}>
<input {...getInputProps()}/>
<Grid textAlign='center'>
<Grid.Row>
<Grid.Column>
<Header icon>
<Icon name='file text'/>
Click here, or drop files here to upload
</Header>
</Grid.Column>
</Grid.Row>
</Grid>
</div>
</Form>
</Grid.Column>
</Grid.Row>
</Grid>
</Segment>
<Panel style={{marginTop: '1em', padding: '1em'}}>
<div {...getRootProps()} style={{cursor: 'pointer', textAlign: 'center'}}>
<input {...getInputProps()}/>
<Header as='h4' icon='file text'>Click here, or drop files here to upload</Header>
</div>
</Panel>
<Grid>
<Grid.Row columns={1}>
<Grid.Column>
{progresses && Object.keys(progresses).length > 1 && <Progress progress
percent={overallProgress}
color='blue'
label='Overall Progress'
/>}
</Grid.Column>
</Grid.Row>
</Grid>
<Grid columns={2}>
{progresses && Object.keys(progresses).length > 1 &&
<div style={{marginTop: '1em'}}>
<Progress percent={overallProgress} color='blue' label='Overall Progress'/>
</div>}
<Stack gap={8} style={{marginTop: '1em'}}>
{progressBars}
</Grid>
</Stack>
</Modal.Content>
<Modal.Actions>
<Button
role='cancel'
onClick={handleClose}
disabled={inProgress}
>

View file

@ -1,3 +1,5 @@
import fs from 'fs';
import path from 'path';
import React from 'react';
import {act, fireEvent, screen, waitFor} from '@testing-library/react';
import {FileBrowser, filterBrowseFiles} from './FileBrowser';
@ -28,14 +30,23 @@ const mockUseUploadFile = {
inProgress: false,
};
jest.mock('../hooks/customHooks', () => ({
useBrowseFiles: jest.fn(),
useMediaDirectory: jest.fn(() => '/media/wrolpi'),
useWROLMode: jest.fn(() => false),
useStatusFlag: jest.fn(() => false),
useUploadFile: () => mockUseUploadFile,
useElementWidth: jest.fn(() => [() => {}, 0]),
}));
/*
* Every other export of customHooks used to be dropped here -- the factory listed six hooks and
* returned only those, so the rest of the module was undefined for anything this spec rendered.
* mockModule spreads the real module first, so the next hook a component reaches for still
* exists.
*/
jest.mock('../hooks/customHooks', () => require('../test-utils').mockModule(
jest.requireActual('../hooks/customHooks'),
{
useBrowseFiles: jest.fn(),
useMediaDirectory: jest.fn(() => '/media/wrolpi'),
useWROLMode: jest.fn(() => false),
useStatusFlag: jest.fn(() => false),
useUploadFile: () => mockUseUploadFile,
useElementWidth: jest.fn(() => [() => {}, 0]),
},
));
// Mock react-dropzone
jest.mock('react-dropzone', () => ({
@ -155,7 +166,9 @@ describe('FileBrowser', () => {
// Find the upload button
const buttons = screen.getAllByRole('button');
const uploadButton = buttons.find(btn => btn.classList.contains('green'));
const uploadButton = buttons.find(btn =>
btn.classList.contains('green') || btn.querySelector('[class*="upload"]')
);
expect(uploadButton).not.toBeDisabled();
});
@ -173,7 +186,9 @@ describe('FileBrowser', () => {
// Find the upload button
const buttons = screen.getAllByRole('button');
const uploadButton = buttons.find(btn => btn.classList.contains('green'));
const uploadButton = buttons.find(btn =>
btn.classList.contains('green') || btn.querySelector('[class*="upload"]')
);
expect(uploadButton).toBeDisabled();
});
@ -409,7 +424,7 @@ describe('FileBrowser', () => {
});
// Click delete button (red trash button) - uses APIButton which has confirm modal
const deleteButton = screen.getAllByRole('button').find(btn => btn.classList.contains('red'));
const deleteButton = document.querySelector('.tabler-icon-trash').closest('button');
await act(async () => {
fireEvent.click(deleteButton);
});
@ -454,7 +469,7 @@ describe('FileBrowser', () => {
});
// Click delete button
const deleteButton = screen.getAllByRole('button').find(btn => btn.classList.contains('red'));
const deleteButton = document.querySelector('.tabler-icon-trash').closest('button');
await act(async () => {
fireEvent.click(deleteButton);
});
@ -479,6 +494,28 @@ describe('FileBrowser', () => {
});
describe('Filter', () => {
it('joins the clear button to the input, and keeps the row aligned', () => {
/*
* The clear button belongs INSIDE ActionInput's wrapper, which is what removes
* the input's right border so the pair reads as one control -- the same joined
* look the Videos and Archives filters have.
*
* It used to carry `search-clear`, a class left over from Semantic whose
* `margin: 0.625rem` did three things at once: opened a gap so the button read
* as detached, pushed it 11px below the input, and stretched the wrapper to
* 62px against a 40px field, which threw the collapse-all button out of line
* too. jsdom applies no stylesheet, so the guard is that no rule reintroduces
* a margin on a control inside the wrapper.
*/
renderFileBrowser(<FileBrowser browseFiles={mockBrowseFiles}/>);
const clear = screen.getByRole('button', {name: 'Clear filter'});
expect(clear.closest('.wrolpi-action-input')).not.toBeNull();
const css = fs.readFileSync(path.join(__dirname, '..', 'App.css'), 'utf8');
expect(css).not.toMatch(/\.search-clear\s*{/);
});
// foo/ and bar/ are open, their children are loaded.
const nestedBrowseFiles = [
{

View file

@ -1,14 +1,12 @@
import React from "react";
import _ from "lodash";
import {Button as SButton, Dropdown, Header as SHeader, Icon, Image,} from "semantic-ui-react";
import {TagsSelector} from "../Tags";
import {encodeMediaPath, isSupportedArchive, useLocalStorage} from "./Common";
import {getFile, tagFileGroup, untagFileGroup} from "../api";
import {ArchivePreviewContent} from "./ArchivePreview";
import {CbzViewer} from "./CbzViewer";
import {StlViewer} from "react-stl-viewer";
import {Button, Modal} from "./Theme";
import {toast} from "react-semantic-toasts-2";
import {Button, ButtonGroup, Header, Icon, IconButton, Menu, Modal, toast} from "./ui";
import {useOneQuery} from "../hooks/customHooks";
import {ShareButton} from "./Share";
import {AddToPlaylistButton} from "./AddToPlaylist";
@ -63,25 +61,31 @@ function OpenInSlicerButton({previewFile}) {
// Browsers cannot report which slicers are installed, so remember the last one used.
const [slicerKey, setSlicerKey] = useLocalStorage('preferredSlicer', SLICERS[0].key);
const slicer = SLICERS.find(s => s.key === slicerKey) || SLICERS[0];
return <SButton.Group color='violet' size='small'>
<Button color='violet' as='a' href={getSlicerURL(previewFile, slicer.scheme)} icon labelPosition='left'>
<Icon name='cube'/>Open in {slicer.text}
return <ButtonGroup>
<Button color='violet' component='a' href={getSlicerURL(previewFile, slicer.scheme)} icon='cube' size='small'>
Open in {slicer.text}
</Button>
<Dropdown className='button icon' floating trigger={<></>}>
<Dropdown.Menu>
<Menu position='bottom-end' withinPortal>
<Menu.Target>
<IconButton color='violet' icon='dropdown' label='Choose a different slicer' size='small'/>
</Menu.Target>
<Menu.Dropdown>
{SLICERS.map(s =>
<Dropdown.Item key={s.key} text={`Open in ${s.text}`}
as='a' href={getSlicerURL(previewFile, s.scheme)}
onClick={(e) => {
// Navigate programmatically so keyboard activation (which may not
// follow the href) still launches the slicer.
if (e) e.preventDefault();
setSlicerKey(s.key);
window.location.assign(getSlicerURL(previewFile, s.scheme));
}}/>)}
</Dropdown.Menu>
</Dropdown>
</SButton.Group>
<Menu.Item key={s.key}
component='a' href={getSlicerURL(previewFile, s.scheme)}
onClick={(e) => {
// Navigate programmatically so keyboard activation (which may not
// follow the href) still launches the slicer.
if (e) e.preventDefault();
setSlicerKey(s.key);
window.location.assign(getSlicerURL(previewFile, s.scheme));
}}
>
Open in {s.text}
</Menu.Item>)}
</Menu.Dropdown>
</Menu>
</ButtonGroup>
}
function getEpubViewerURL(previewFile) {
@ -115,7 +119,7 @@ function getImagePreviewModal(previewFile) {
<Modal.Content>
<div className='preview-fit'>
<a href={url}>
<Image src={url} style={{maxHeight: '100%', maxWidth: '100%', objectFit: 'contain'}}/>
<img src={url} alt={name} style={{maxHeight: '100%', maxWidth: '100%', objectFit: 'contain'}}/>
</a>
</div>
</Modal.Content>
@ -131,7 +135,7 @@ function getVideoPreviewModal(previewFile) {
{name}
</Modal.Header>
<Modal.Content>
<SHeader as='h5'>{path['path']}</SHeader>
<Header as='h5'>{path['path']}</Header>
<div className='preview-fit'>
<video controls
autoPlay={true}
@ -416,14 +420,13 @@ export function FilePreviewProvider({children}) {
setErrorModalOpen(false);
}
const errorModal = <Modal closeIcon
open={errorModalOpen}
const errorModal = <Modal size='large' open={errorModalOpen}
onClose={handleErrorModalClose}
>
<Modal.Header>Unknown File</Modal.Header>
<Modal.Content>Cannot display preview, no such file exists: {previewQuery}</Modal.Content>
<Modal.Actions>
<Button onClick={handleErrorModalClose}>Close</Button>
<Button role='cancel' onClick={handleErrorModalClose}>Close</Button>
</Modal.Actions>
</Modal>
@ -500,21 +503,21 @@ export function FilePreviewProvider({children}) {
function setModalContent(content, url, downloadURL, path, taggable = true, extraButtons = null) {
// All action buttons share `size='small'` and `icon` for consistent height across viewports.
// Close is handled by the Modal's `closeIcon` (X in the top-right corner) — no dedicated button.
// Close is handled by the Modal's close button (X in the top-right corner) — no dedicated button.
const openButton = url
? <Button color='blue' as='a' href={url} size='small' icon labelPosition='left'>
<Icon name='external'/>Open
</Button>
? <Button role='primary' component='a' href={url} size='small' icon='external'>Open</Button>
: null;
const tagsDisplay = taggable ?
<TagsSelector selectedTagNames={previewFile['tags']} onAdd={localAddTag}
onRemove={localRemoveTag}/>
: null;
const downloadButton = downloadURL
? <Button color='yellow' as='a' href={downloadURL} size='small' icon='download'/>
? <IconButton color='yellow' component='a' href={downloadURL} size='small' icon='download' label='Download'/>
: null;
const directoryURL = path ? `/files?folders=${encodeURIComponent(pathDirectory(path))}` : null;
const directoryButton = path ? <Button as='a' href={directoryURL} size='small' icon='folder'/> : null;
const directoryButton = path
? <IconButton component='a' href={directoryURL} size='small' icon='folder' label='Open containing folder'/>
: null;
const pathContent = path ? <Modal.Content>
<pre>{path}</pre>
</Modal.Content>
@ -526,8 +529,7 @@ export function FilePreviewProvider({children}) {
// Single flex toolbar — same button order at every viewport size.
// `flex-wrap: wrap` lets the tags selector drop to a second line on narrow screens
// while the button groups stay together and in order: [file actions] [tags] [open/close].
setPreviewModal(<Modal closeIcon
size='fullscreen'
setPreviewModal(<Modal size='fullscreen'
open={true}
onClose={e => handleClose(e)}
>

View file

@ -1,19 +1,4 @@
import React, {useContext, useState} from "react";
import {
CardContent,
CardDescription,
CardHeader,
CardMeta,
Checkbox,
Container,
Form,
Image,
Label,
PlaceholderLine,
Step,
TableCell,
TableHeaderCell,
} from "semantic-ui-react";
import {
CardGroupCentered,
CardPoster,
@ -25,7 +10,6 @@ import {
HandPointMessage,
humanFileSize,
isoDatetimeToAgoPopup,
mimetypeColor,
PageContainer,
Paginator,
PreviewLink,
@ -33,7 +17,8 @@ import {
TagIcon,
Toggle,
textEllipsis,
useTitle
mimetypeColor,
useTitle,
} from "./Common";
import {
usePages,
@ -48,23 +33,29 @@ import {
useWROLMode
} from "../hooks/customHooks";
import {useFileWorkerStatus} from "../contexts/FileWorkerStatusContext";
import {IconArticle, IconLayoutList, IconSortAscending, IconSortDescending} from "@tabler/icons-react";
import {Link, Route, Routes, useSearchParams} from "react-router";
import {CardPlaceholder} from "./Placeholder";
import {ArchiveCard, ArchiveRowCells} from "./Archive";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {QueryContext, ThemeContext} from "../contexts/contexts";
import {QueryContext} from "../contexts/contexts";
import {
Button,
Card,
Divider,
Group,
Header,
Icon,
IconButton,
Label,
Modal,
Panel,
Placeholder,
Popup,
Progress,
Segment
} from "./Theme";
Radio,
Stack,
Table,
Tooltip,
} from "./ui";
import {DateRangeForm, dateRangeIsEmpty, MonthsForm} from "./DatesSelector";
import {SelectableTable} from "./Tables";
import {VideoCard, VideoRowCells} from "./Videos";
@ -78,14 +69,13 @@ import {FILES_MEDIA_URI} from "./Vars";
// Split a ts_headline snippet on our sentinel markers and wrap each match in <b>.
// We use sentinels instead of ts_headline's default <b> tags so the snippet can be
// safely rendered as React children without dangerouslySetInnerHTML.
function renderHighlightedSnippet(snippet, s) {
function renderHighlightedSnippet(snippet) {
const parts = snippet.split(/\[\[WROLPI_HL\]\]|\[\[\/WROLPI_HL\]\]/);
// Even indices are plain text, odd indices are highlighted matches.
return parts.map((part, i) => i % 2 === 1 ? <b key={i} {...s}>{part}</b> : <span key={i} {...s}>{part}</span>);
return parts.map((part, i) => i % 2 === 1 ? <b key={i}>{part}</b> : <span key={i}>{part}</span>);
}
function EbookCard({file, sortField}) {
const {s, t} = useContext(ThemeContext);
const [searchParams] = useSearchParams();
const query = searchParams.get('q') || '';
@ -105,70 +95,52 @@ function EbookCard({file, sortField}) {
detailUrl = `${detailUrl}?${params.toString()}`;
}
const color = mimetypeColor(file.mimetype, file.primary_path);
const title = file.title || file.stem || file.name;
const header = detailUrl
? <Link to={detailUrl} className='no-link-underscore card-link card-title-ellipsis' {...t}>{title}</Link>
? <Link to={detailUrl} className='no-link-underscore card-link card-title-ellipsis'>{title}</Link>
: <ExternalCardLink to={viewerUrl || downloadUrl} className='card-title-ellipsis'>{title}</ExternalCardLink>;
return <Card color={color}>
<CardPoster file={file} to={detailUrl}/>
<CardContent {...s}>
<CardHeader>
<Container textAlign='left'>
<Popup on='hover'
trigger={header}
content={title}/>
</Container>
</CardHeader>
<CardMeta>
{file.author ? <b {...s}>{file.author}</b> : null}
{sortField === 'published_datetime'
? <p {...s}>{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}</p>
: file.size && <p {...s}>{humanFileSize(file.size)}</p>
}
{hint && <p {...s} style={{fontSize: '0.85em', marginTop: '0.3em'}}>
<b {...s}>{hint.label}</b>
{hint.snippet && <span style={{...s, marginLeft: '0.4em', opacity: 0.8}}>
{renderHighlightedSnippet(hint.snippet, s)}
</span>}
</p>}
</CardMeta>
</CardContent>
</Card>
const meta = <>
{file.author ? <div><b>{file.author}</b></div> : null}
{sortField === 'published_datetime'
? <div>{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}</div>
: file.size && <div>{humanFileSize(file.size)}</div>
}
{hint && <div style={{fontSize: '0.85em', marginTop: '0.3em'}}>
<b>{hint.label}</b>
{hint.snippet && <span style={{marginLeft: '0.4em', opacity: 0.8}}>
{renderHighlightedSnippet(hint.snippet)}
</span>}
</div>}
</>;
return <Card media={<CardPoster file={file} to={detailUrl}/>}
title={<Tooltip label={title}>{header}</Tooltip>}
meta={meta}
color={mimetypeColor(file.mimetype, file.primary_path)}
/>
}
function ImageCard({file}) {
const {s} = useContext(ThemeContext);
const url = `/media/${encodeMediaPath(file.primary_path)}`;
const title = file.title || file.stem || file.primary_path;
const header = <ExternalCardLink to={url} className='no-link-underscore card-link'>
<p>{textEllipsis(title)}</p>
<span>{textEllipsis(title)}</span>
</ExternalCardLink>;
const dt = file.published_datetime || file.published_modified_datetime || file.modified;
return <Card color={mimetypeColor(file.mimetype)}>
<PreviewLink file={file}>
<CardPoster file={file}/>
</PreviewLink>
<CardContent {...s}>
<CardHeader>
<Popup on='hover'
trigger={header}
content={title}/>
</CardHeader>
<CardMeta {...s}>
<p>{isoDatetimeToAgoPopup(dt, false)}</p>
</CardMeta>
<CardDescription {...s}>
<p>{humanFileSize(file.size)}</p>
</CardDescription>
</CardContent>
</Card>
const meta = <>
<div>{isoDatetimeToAgoPopup(dt, false)}</div>
<div>{humanFileSize(file.size)}</div>
</>;
return <Card media={<PreviewLink file={file}><CardPoster file={file}/></PreviewLink>}
title={<Tooltip label={title}>{header}</Tooltip>}
meta={meta}
color={mimetypeColor(file.mimetype, file.primary_path)}
/>
}
function FileCard({file}) {
const {s} = useContext(ThemeContext);
const {sort} = useSearchOrder();
const sortField = sort ? sort.replace(/^-/, '') : null;
@ -189,31 +161,25 @@ function FileCard({file}) {
}
const author = file.author;
const color = mimetypeColor(file.mimetype, file.primary_path);
const size = file.size !== null && file.size !== undefined ? humanFileSize(file.size) : null;
const title = file.title || file.name || file.primary_path;
const dt = file.published_datetime || file.published_modified_datetime || file.modified;
return <Card color={color}>
<CardPoster file={file}/>
<CardContent {...s}>
<CardHeader>
<PreviewLink file={file}>
<Popup on='hover'
trigger={<span className='card-title-ellipsis'>{title}</span>}
content={title}/>
</PreviewLink>
</CardHeader>
{author && <b {...s}>{author}</b>}
{sortField === 'published_datetime'
? <p>{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}</p>
: <>
<p>{isoDatetimeToAgoPopup(dt, false)}</p>
<p>{size}</p>
</>
}
</CardContent>
</Card>
const header = <PreviewLink file={file}>
<Tooltip label={title}><span className='card-title-ellipsis'>{title}</span></Tooltip>
</PreviewLink>;
const meta = <>
{author && <div><b>{author}</b></div>}
{sortField === 'published_datetime'
? <div>{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}</div>
: <>
<div>{isoDatetimeToAgoPopup(dt, false)}</div>
<div>{size}</div>
</>
}
</>;
return <Card media={<CardPoster file={file}/>} title={header} meta={meta}
color={mimetypeColor(file.mimetype, file.primary_path)}/>
}
export function FileCards({files}) {
@ -222,7 +188,7 @@ export function FileCards({files}) {
{files.map(i => <FileCard key={i['primary_path']} file={i}/>)}
</CardGroupCentered>
} else if (files && files.length === 0) {
return <Segment>No results!</Segment>
return <Panel>No results!</Panel>
} else if (files === undefined) {
return <ErrorMessage>Could not search!</ErrorMessage>
}
@ -236,22 +202,22 @@ function ImageRowCells({file}) {
let poster = <FileIcon file={file} size='large'/>;
if (file.size && file.size < 50000000) {
// Image is less than 5mb, use it.
poster = <Image wrapped src={url} width='50px'/>;
poster = <img alt='' src={url} width='50'/>;
}
// Fragment for SelectableRow
return <React.Fragment>
<TableCell>
<Table.Cell>
<center>
{poster}
</center>
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
<PreviewLink file={file}>
<p>{textEllipsis(file.title || file.stem || file.primary_path)}</p>
</PreviewLink>
</TableCell>
<TableCell>{humanFileSize(file.size)}</TableCell>
</Table.Cell>
<Table.Cell>{humanFileSize(file.size)}</Table.Cell>
</React.Fragment>
}
@ -260,21 +226,21 @@ export function EbookRowCells({file}) {
const posterPath = findPosterPath(file);
if (posterPath) {
const coverSrc = `/media/${encodeMediaPath(posterPath)}`;
cover = <Image wrapped src={coverSrc} width='50px'/>;
cover = <img alt='' src={coverSrc} width='50'/>;
}
// Fragment for SelectableRow
return <React.Fragment>
<TableCell>
<Table.Cell>
<center>{cover}</center>
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
<PreviewLink file={file}>
<FileRowTagIcon file={file}/>
{textEllipsis(file.title || file.stem)}
</PreviewLink>
</TableCell>
<TableCell>{humanFileSize(file.size)}</TableCell>
</Table.Cell>
<Table.Cell>{humanFileSize(file.size)}</Table.Cell>
</React.Fragment>
}
@ -295,14 +261,14 @@ function FileRow({file}) {
// Fragment for SelectableRow
return <React.Fragment>
<TableCell>
<Table.Cell>
<center><FileIcon file={file} size='large'/></center>
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
<FileRowTagIcon file={file}/>
<PreviewLink file={file}>{textEllipsis(file.title || file.name || file.stem)}</PreviewLink>
</TableCell>
<TableCell/>
</Table.Cell>
<Table.Cell/>
</React.Fragment>
}
@ -319,16 +285,12 @@ export function FileTable({files, selectOn, onSelect, footer, selectedKeys}) {
rows={rows}
/>;
} else if (files) {
return <Segment>No results!</Segment>
return <Panel>No results!</Panel>
} else if (files === undefined) {
return <ErrorMessage>Could not search!</ErrorMessage>
}
return <Placeholder>
<PlaceholderLine/>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>
return <Placeholder lines={3}/>
}
export function FileRowTagIcon({file}) {
@ -343,18 +305,18 @@ export function SearchViewButton({headlines}) {
if (headlines) {
// Cycle: cards -> headline -> list
if (view === 'headline') {
return <Button icon='list' onClick={() => setView('list')}/>;
return <IconButton icon='list' label='Switch to list view' onClick={() => setView('list')}/>;
} else if (view === 'list') {
return <Button icon='th' onClick={() => setView('cards')}/>;
return <IconButton icon='th' label='Switch to cards view' onClick={() => setView('cards')}/>;
}
return <Button icon='searchengin' onClick={() => setView('headline')}/>;
return <IconButton icon={IconArticle} label='Switch to headline view' onClick={() => setView('headline')}/>;
}
// Cycle: cards -> list
if (view === 'list') {
return <Button icon='th' onClick={() => setView('cards')}/>;
return <IconButton icon='th' label='Switch to cards view' onClick={() => setView('cards')}/>;
}
return <Button icon='browser' onClick={() => setView('list')}/>;
return <IconButton icon={IconLayoutList} label='Switch to list view' onClick={() => setView('list')}/>;
}
export function FilesView(
@ -371,15 +333,22 @@ export function FilesView(
) {
const {view} = useSearchView();
const paginator = <center style={{marginTop: '2em'}}>
/*
* No margin of its own. Videos, Archives, Docs and Files all render this as a top-level block
* of the page, and the page spaces its blocks (see `wrolpi-stack`) -- so a `marginTop: 2em`
* here added to that and put the pager twice as far from the results as anything else on the
* page sits from its neighbour. Zim keeps its own paginator, inside an accordion panel that
* does not stack, and is untouched.
*/
const paginator = <div>
<Paginator activePage={activePage} totalPages={totalPages} onPageChange={setPage}/>
</center>;
</div>;
let body;
if (view === 'list') {
const footer = selectElem ?
<>
<TableHeaderCell colSpan='3'>{selectElem}</TableHeaderCell>
<Table.HeaderCell colSpan='3'>{selectElem}</Table.HeaderCell>
</> : null;
body = <FileTable
files={files}
@ -423,15 +392,12 @@ export function SearchFilter({filters = [], modalHeader, size = 'medium'}) {
}
const filterFields = filters.map(
i => <Form.Field key={i['text']}>
<Checkbox radio
label={i['text']}
name='searchFilterRadioGroup'
checked={filter === i['value']}
value={i['value']}
onChange={() => setFilter(i['value'])}
/>
</Form.Field>
i => <Radio
key={i['text']}
label={i['text']}
checked={filter === i['value']}
onChange={() => setFilter(i['value'])}
/>
);
// Use violet color when filter has been applied.
@ -439,24 +405,20 @@ export function SearchFilter({filters = [], modalHeader, size = 'medium'}) {
if (filters && filters.length > 0) {
return <>
<Modal open={open} onOpen={() => handleOpen()} onClose={() => setOpen(false)} closeIcon>
{/* Audited: unchanged at `small` (440px), recorded so the default cannot move it. */}
<Modal size='small' open={open} onClose={() => setOpen(false)} closeIcon>
{modalHeader || <Modal.Header>Filter</Modal.Header>}
<Modal.Content>
<Form>
<Stack gap='xs'>
{filterFields}
</Form>
</Stack>
</Modal.Content>
<Modal.Actions>
<Button onClick={handleClear} secondary>Clear</Button>
<Button onClick={() => setOpen(false)}>Close</Button>
<Button role='cancel' onClick={handleClear}>Clear</Button>
<Button role='cancel' onClick={() => setOpen(false)}>Close</Button>
</Modal.Actions>
</Modal>
<Button
icon='filter'
onClick={handleOpen}
color={buttonColor}
size={size}
/>
<IconButton icon='filter' label='Filter' onClick={handleOpen} color={buttonColor} size={size}/>
</>
}
@ -523,7 +485,7 @@ export function DocSearchFilterButton({size = 'medium'}) {
const DEFAULT_SEARCH_LIMIT = 24;
// A single section within the comprehensive SearchFilterModal. Vertical spacing between sections is
// provided by the surrounding Grid rows.
// provided by the surrounding Stack.
function SearchFilterSection({header, children}) {
return <>
<Header as='h4' style={{marginBottom: '0.6em'}}>{header}</Header>
@ -662,29 +624,22 @@ export function SearchFilterModal(
style={{margin: '0.2em'}}
>{o['text']}</Button>
);
const directionToggle = <Button icon labelPosition='left'
const directionToggle = <Button icon={desc ? IconSortDescending : IconSortAscending}
onClick={() => {
const base = sortKey || sorts[0]['value'];
setDraftSort(desc ? base : `-${base}`);
}}>
<Icon name={desc ? 'sort amount down' : 'sort amount up'}/>
{desc ? 'Descending' : 'Ascending'}
</Button>;
// File-type filter section (single-select radios; clicking the active one clears it). Laid out in
// columns so more options fit on each line.
// File-type filter section (single-select radios; clicking the active one clears it). Wraps so more
// options fit on each line.
const filterFields = fileFilterOptions && fileFilterOptions.map(i =>
<Grid.Column mobile={8} tablet={5} computer={4} key={i['value']}>
<Form.Field>
<Checkbox radio
label={i['text']}
name='searchFilterRadioGroup'
checked={draftFilter === i['value']}
value={i['value']}
onChange={() => setDraftFilter(draftFilter === i['value'] ? null : i['value'])}
/>
</Form.Field>
</Grid.Column>
<Radio key={i['value']}
label={i['text']}
checked={draftFilter === i['value']}
onChange={() => setDraftFilter(draftFilter === i['value'] ? null : i['value'])}
/>
);
// Results-per-page as buttons (matches the Sort By buttons and avoids a dropdown menu being clipped by
@ -697,33 +652,25 @@ export function SearchFilterModal(
>{i}</Button>
);
return <Modal open={open} onClose={handleClose} closeIcon size='small'>
return <Modal open={open} onClose={handleClose} closeIcon size='large'>
<Modal.Header>Search Filters</Modal.Header>
<Modal.Content scrolling>
<Grid>
<Modal.Content>
<Stack gap='lg'>
{sorts &&
<Grid.Row>
<Grid.Column>
<SearchFilterSection header='Sort By'>
<div style={{marginBottom: '1em'}}>{directionToggle}</div>
<div>{sortButtons}</div>
</SearchFilterSection>
</Grid.Column>
</Grid.Row>}
<SearchFilterSection header='Sort By'>
<div style={{marginBottom: '1em'}}>{directionToggle}</div>
<div>{sortButtons}</div>
</SearchFilterSection>}
{fileFilterOptions &&
<Grid.Row>
<Grid.Column>
<SearchFilterSection header='File Type'>
<Form><Grid>{filterFields}</Grid></Form>
</SearchFilterSection>
</Grid.Column>
</Grid.Row>}
<SearchFilterSection header='File Type'>
<Group gap='md'>{filterFields}</Group>
</SearchFilterSection>}
{(showTags || showLimit) &&
<Grid.Row columns={2}>
<Group align='flex-start' grow wrap='wrap'>
{showTags &&
<Grid.Column>
<div style={{minWidth: 220}}>
<SearchFilterSection header='Tags'>
<TagsSelector hideGroup hideEdit showAny
active={draftAnyTag || (draftTags && draftTags.length > 0)}
@ -742,54 +689,42 @@ export function SearchFilterModal(
style={{marginLeft: '0.3em', marginTop: '0.3em'}}
/>
</SearchFilterSection>
</Grid.Column>}
</div>}
{showLimit &&
<Grid.Column>
<div style={{minWidth: 160}}>
<SearchFilterSection header='Results Per Page'>
<div>{limitButtons}</div>
</SearchFilterSection>
</Grid.Column>}
</Grid.Row>}
</div>}
</Group>}
{showCensored &&
<Grid.Row>
<Grid.Column>
<SearchFilterSection header='Availability'>
<Toggle label='Only censored (no longer available to download)'
checked={draftCensored}
onChange={setDraftCensored}
/>
</SearchFilterSection>
</Grid.Column>
</Grid.Row>}
<SearchFilterSection header='Availability'>
<Toggle label='Only censored (no longer available to download)'
checked={draftCensored}
onChange={setDraftCensored}
/>
</SearchFilterSection>}
{showDeep &&
<Grid.Row>
<Grid.Column>
<SearchFilterSection header='Search Depth'>
<Toggle label='Deep search (captions, document text; slower)'
checked={draftDeep}
onChange={setDraftDeep}
/>
</SearchFilterSection>
</Grid.Column>
</Grid.Row>}
<SearchFilterSection header='Search Depth'>
<Toggle label='Deep search (captions, document text; slower)'
checked={draftDeep}
onChange={setDraftDeep}
/>
</SearchFilterSection>}
{showDates &&
<Grid.Row>
<Grid.Column>
<SearchFilterSection header='Published Date'>
<MonthsForm monthsSelected={draftMonths} setMonthsSelected={setDraftMonths}/>
<Divider/>
<DateRangeForm dateRange={draftRange} setDateRange={setDraftRange}/>
</SearchFilterSection>
</Grid.Column>
</Grid.Row>}
</Grid>
<SearchFilterSection header='Published Date'>
<MonthsForm monthsSelected={draftMonths} setMonthsSelected={setDraftMonths}/>
<Divider/>
<DateRangeForm dateRange={draftRange} setDateRange={setDraftRange}/>
</SearchFilterSection>}
</Stack>
</Modal.Content>
<Modal.Actions>
<Button secondary floated='left' onClick={handleClearAll}>Clear All</Button>
<Button primary onClick={handleClose}>Done</Button>
<Button role='cancel' onClick={handleClearAll} style={{marginRight: 'auto'}}>Clear All</Button>
<Button role='primary' onClick={handleClose}>Done</Button>
</Modal.Actions>
</Modal>
}
@ -840,11 +775,10 @@ export function SearchFilterButton(
const active = count > 0;
return <>
<Button color={active ? 'violet' : 'grey'} size={size} onClick={() => setOpen(true)}>
<Icon name='filter'/>
<Button icon='filter' color={active ? 'violet' : 'grey'} size={size} onClick={() => setOpen(true)}>
{content}
{count > 0 &&
<Label circular size='tiny' style={{marginLeft: '0.7em'}}>{count}</Label>}
<Label style={{marginLeft: '0.7em'}}>{count}</Label>}
</Button>
<SearchFilterModal
open={open}
@ -890,7 +824,9 @@ export function SearchControlBar(
inputRef={inputRef}
/>;
return <div style={{display: 'flex', alignItems: 'center', gap: '0.5em', marginBottom: '1em'}}>
// No `marginBottom`: this is a top-level block on Videos, Archives and Docs, and the page
// spaces its own blocks. Carrying one here added to that gap.
return <div style={{display: 'flex', alignItems: 'center', gap: '0.5em'}}>
{viewButton}
<div style={{flexGrow: 1, minWidth: 0}}>{searchInput}</div>
<SearchFilterButton sorts={sorts} fileFilterOptions={fileFilterOptions} showDates={showDates}
@ -909,13 +845,13 @@ export function DeepSearchHint({searchStr, files}) {
return null;
}
return <Segment>
return <Panel>
{files.length === 0 ? 'Not finding what you need?' : 'More results may be available in deep search.'}
<Button primary
<Button role='primary'
onClick={() => updateQuery({deep: 'true', o: 0})}
style={{marginLeft: '1em'}}
>Try deep search</Button>
</Segment>
</Panel>
}
export function FilesSearchView({
@ -951,27 +887,28 @@ export function FilesSearchView({
? deepEstimate - total : null;
// Enable deep search and reset pagination in a single update (like the filter modal does), so the
// user starts at page 1 of the deep results.
const deepButton = <Button primary
const deepButton = <Button role='primary'
onClick={() => updateQuery({deep: 'true', o: 0})}
style={{marginLeft: '1em'}}
>Try deep search</Button>;
if (searchFiles.length === 0 && (moreCount === null || moreCount > 0)) {
deepHint = <Segment>
deepHint = <Panel>
{moreCount > 0
? `No results in fast search. ${moreCount} result${moreCount === 1 ? '' : 's'} available in deep search.`
: 'Not finding what you need?'}
{deepButton}
</Segment>;
</Panel>;
} else if (searchFiles.length > 0 && moreCount > 0) {
deepHint = <Segment>
deepHint = <Panel>
{`${moreCount} more result${moreCount === 1 ? '' : 's'} available in deep search.`}
{deepButton}
</Segment>;
</Panel>;
}
}
return <>
{showView && <div style={{marginBottom: '1em'}}>{viewButton}</div>}
{/* No margin: another top-level block, spaced by the page. */}
{showView && <div>{viewButton}</div>}
{body}
{deepHint}
{paginator}
@ -1041,12 +978,31 @@ function isGlobalRefresh(progress) {
const REFRESH_PHASES = [
{key: 1, icon: 'search', title: 'Counting', description: 'Finding files'},
{key: 2, icon: 'database', title: 'Inserting', description: 'Updating database'},
{key: 3, icon: 'cogs', title: 'Modeling', description: 'Extracting metadata'},
{key: 2, icon: 'disk', title: 'Inserting', description: 'Updating database'},
{key: 3, icon: 'settings', title: 'Modeling', description: 'Extracting metadata'},
{key: 4, icon: 'book', title: 'Indexing', description: 'Building search index'},
{key: 5, icon: 'check circle', title: 'Cleanup', description: 'Finalizing'},
];
// A row of phase markers, brightness/weight (via the active
// border) carrying the current stage rather than a bespoke component from the shared library.
function RefreshStepItem({phase, isActive, isCompleted, isDisabled, description}) {
return <div style={{
display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 160px', padding: '0.5em 0.75em',
opacity: isDisabled ? 0.5 : 1,
borderBottom: isActive ? '2px solid var(--info)' : '2px solid transparent',
}}>
{/* The same three roles Status maps complete/active/pending onto. Not the Status
component itself: these rows carry a per-phase glyph, which is the point of them. */}
<Icon name={phase.icon} size='large'
style={{color: isCompleted ? 'var(--success)' : isActive ? 'var(--info)' : 'var(--neutral)'}}/>
<div>
<div style={{fontWeight: 600, fontSize: '0.8125rem'}}>{phase.title}</div>
<div style={{fontSize: '0.75rem', color: 'var(--muted)'}}>{isActive || isCompleted ? description : ''}</div>
</div>
</div>
}
export function RefreshSteps({progress}) {
const currentPhase = getRefreshPhase(progress?.status);
const {operation_processed, operation_total, status} = progress || {};
@ -1055,44 +1011,30 @@ export function RefreshSteps({progress}) {
return null;
}
return (
<Step.Group size='mini' fluid>
{REFRESH_PHASES.map(phase => {
const isCompleted = phase.key < currentPhase;
const isActive = phase.key === currentPhase;
const isDisabled = phase.key > currentPhase;
return <div style={{display: 'flex', flexWrap: 'wrap', border: '1px solid var(--border)'}}>
{REFRESH_PHASES.map(phase => {
const isCompleted = phase.key < currentPhase;
const isActive = phase.key === currentPhase;
const isDisabled = phase.key > currentPhase;
// Determine description for active phase
let description = phase.description;
if (isActive) {
if (operation_total > 0) {
description = `${operation_processed?.toLocaleString()} / ${operation_total?.toLocaleString()}`;
} else if (status === 'comparing') {
description = 'Comparing files...';
} else if (status === 'upserting') {
description = 'Updating files...';
} else if (status === 'deleting') {
description = 'Removing deleted...';
}
// Determine description for active phase
let description = phase.description;
if (isActive) {
if (operation_total > 0) {
description = `${operation_processed?.toLocaleString()} / ${operation_total?.toLocaleString()}`;
} else if (status === 'comparing') {
description = 'Comparing files...';
} else if (status === 'upserting') {
description = 'Updating files...';
} else if (status === 'deleting') {
description = 'Removing deleted...';
}
}
return (
<Step
key={phase.key}
active={isActive}
completed={isCompleted}
disabled={isDisabled}
>
<Icon name={phase.icon}/>
<Step.Content>
<Step.Title>{phase.title}</Step.Title>
<Step.Description>{isActive || isCompleted ? description : ''}</Step.Description>
</Step.Content>
</Step>
);
})}
</Step.Group>
);
return <RefreshStepItem key={phase.key} phase={phase} isActive={isActive} isCompleted={isCompleted}
isDisabled={isDisabled} description={description}/>
})}
</div>
}
function RefreshProgressBar({status, operation_total, operation_processed, operation_percent, error}) {
@ -1104,7 +1046,7 @@ function RefreshProgressBar({status, operation_total, operation_processed, opera
// Show error state
if (status === 'error' && error) {
return <Progress error percent={100}>{`Error: ${error}`}</Progress>;
return <Progress percent={100} color='danger' label={`Error: ${error}`}/>;
}
let label = `${phaseLabel}`;
@ -1112,7 +1054,7 @@ function RefreshProgressBar({status, operation_total, operation_processed, opera
label = `${label} (${operation_processed.toLocaleString()} / ${operation_total.toLocaleString()})`;
}
return <Progress active color='violet' percent={operation_percent || 0} progress>{label}</Progress>;
return <Progress percent={operation_percent || 0} color='violet' label={label}/>;
}
export function FilesRefreshProgress() {
@ -1214,12 +1156,13 @@ const useRefresh = () => {
export function FilesRefreshButton({paths, showLabel}) {
const {globalRefreshing, loading, wrolModeEnabled, refreshFiles} = useRefresh();
const disabled = wrolModeEnabled || globalRefreshing;
const busy = loading || globalRefreshing;
return <Button icon='refresh'
content={showLabel ? 'Refresh' : undefined}
labelPosition={showLabel ? 'left' : undefined}
loading={loading || globalRefreshing}
onClick={() => refreshFiles(paths)}
disabled={wrolModeEnabled || globalRefreshing}/>
;
if (showLabel) {
return <Button role='cancel' icon='refresh' loading={busy} disabled={disabled}
onClick={() => refreshFiles(paths)}>Refresh</Button>;
}
return <IconButton role='cancel' icon='refresh' label='Refresh' loading={busy} disabled={disabled}
onClick={() => refreshFiles(paths)}/>;
}

View file

@ -1,27 +1,30 @@
import React, {useContext, useEffect, useLayoutEffect, useRef, useState} from 'react';
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {Route, Routes} from "react-router";
import {Checkbox, Label, Message, Select, Button as SButton, Icon as SIcon} from "semantic-ui-react";
import {ESPLoader, Transport} from "esptool-js";
import {useDropzone} from "react-dropzone";
import _ from "lodash";
import {encodeMediaPath, humanFileSize, PageContainer, useTitle} from "./Common";
import {
Button,
darkTheme,
Checkbox,
Divider,
Form,
Group,
Header,
Icon,
List,
Loader,
IconButton,
Label,
Loading,
Message,
Modal,
Panel,
Progress,
Segment,
Tab,
Select,
Stack,
Table,
TextArea
} from "./Theme";
import {ThemeContext} from "../contexts/contexts";
Tabs,
Textarea,
TextInput,
} from "./ui";
import {deleteFlasherConfig, flasherSearch, getFlasherConfigs, saveFlasherConfig} from "../api";
// The suffix used to find flashable ESP32 firmware in the media directory.
@ -50,6 +53,10 @@ const KNOWN_ESP_CHIPS = [
// colors when they render at the same width. The first eight cover today's largest same-width group (the eight
// 8-character ESP32-* names); the extras let a same-width group grow past eight before colors would repeat.
// (Truly colorblind-distinct qualitative colors top out around ten, so beyond that distinctness is best-effort.)
//
// These are deliberately NOT theme tokens: this is a fixed categorical palette used to visually tell many ESP
// chip families apart, the same way a chart's series colors stay fixed regardless of theme. See the report to
// the user for the resulting night-mode tension (non-red hues on these chips, unlike the rest of the UI).
const CHIP_COLORS = [
'#4477aa', '#ee6677', '#228833', '#ccbb44', '#66ccee', '#aa3377', '#bbbbbb', '#000000',
'#ee7733', '#332288', '#999933', '#882255',
@ -115,10 +122,10 @@ export function espImageChipId(header) {
// bundled into the frontend build, so this works fully offline once the page has loaded.
const BAUD_OPTIONS = [
{key: '115200', value: 115200, text: '115200 (safe)'},
{key: '230400', value: 230400, text: '230400'},
{key: '460800', value: 460800, text: '460800'},
{key: '921600', value: 921600, text: '921600 (fast)'},
{value: 115200, text: '115200 (safe)'},
{value: 230400, text: '230400'},
{value: 460800, text: '460800'},
{value: 921600, text: '921600 (fast)'},
];
// Web Serial is only available in a secure context (HTTPS or localhost) on Chromium-based browsers.
@ -218,10 +225,10 @@ export function withTimeout(promise, ms, message) {
// How long to wait for a device to connect before giving up (esptool-js retries can otherwise stall the UI).
const CONNECT_TIMEOUT_MS = 30_000;
// A read-only log TextArea that auto-scrolls to the newest output — but only when the user is already at the
// A read-only log Textarea that auto-scrolls to the newest output — but only when the user is already at the
// bottom, so scrolling up to read earlier lines isn't yanked back down. Starts at the bottom on mount (e.g. when
// a modal opens).
function AutoScrollLog({value, ...props}) {
function AutoScrollLog({value, style, ...props}) {
const containerRef = useRef(null);
const atBottomRef = useRef(true);
@ -241,13 +248,18 @@ function AutoScrollLog({value, ...props}) {
}, [value]);
return <div ref={containerRef}>
<TextArea readOnly value={value} onScroll={handleScroll} {...props}/>
<Textarea readOnly value={value} onScroll={handleScroll} styles={{input: style}} {...props}/>
</div>;
}
// Tab keys for the firmware-source picker, in display order: saved sets first, then the media picker, then a
// local file.
const TAB_SAVED = 'saved';
const TAB_WROLPI = 'wrolpi';
const TAB_COMPUTER = 'computer';
export function FlasherPage() {
useTitle('Flasher');
const {t, theme} = useContext(ThemeContext);
// Each entry is {name, size, address, file?: File, mediaPath?: string}. A local file has `file`; a media
// firmware has `mediaPath`. Default offset 0x0 suits a single merged firmware image.
@ -283,7 +295,7 @@ export function FlasherPage() {
const [flashError, setFlashError] = useState('');
// Which firmware-source tab is active (controlled so a file drop can switch to "Add from computer").
const [activeTab, setActiveTab] = useState(0);
const [activeTab, setActiveTab] = useState(TAB_SAVED);
// Saved firmware configurations (flasher.yaml).
const [savedConfigs, setSavedConfigs] = useState([]);
@ -331,15 +343,14 @@ export function FlasherPage() {
}
};
// The "Add from computer" tab (index 2 in firmwarePanes) hosts the dropzone; a drop anywhere on the page or a
// click on the zone lands here.
const COMPUTER_TAB_INDEX = 2;
// The "Add from computer" tab hosts the dropzone; a drop anywhere on the page or a click on the zone lands
// here.
const onDrop = React.useCallback((droppedFiles) => {
// Only take .bin firmware; anything else dropped is ignored.
const bins = (droppedFiles || []).filter(file => file.name.toLowerCase().endsWith(FIRMWARE_SUFFIX));
if (bins.length > 0) {
addFiles(bins);
setActiveTab(COMPUTER_TAB_INDEX);
setActiveTab(TAB_COMPUTER);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// The dropzone root wraps the whole page so firmware can be dropped anywhere. noClick/noKeyboard because the
@ -354,7 +365,7 @@ export function FlasherPage() {
// While the user drags a file over the page, jump to the "Add from computer" tab so the drop target is shown.
useEffect(() => {
if (isDragActive) {
setActiveTab(COMPUTER_TAB_INDEX);
setActiveTab(TAB_COMPUTER);
}
}, [isDragActive]);
@ -644,15 +655,11 @@ export function FlasherPage() {
if (!webSerialSupported()) {
return <PageContainer>
<Header as='h1'>Flasher</Header>
<Message icon warning>
<SIcon name='warning sign'/>
<Message.Content>
<Message.Header>Web Serial is not available</Message.Header>
<p>Flashing requires the Web Serial API, which is only available in Chromium-based browsers
(Chrome, Edge, Brave) served over a secure connection (HTTPS or <code>localhost</code>).</p>
<p>Open this page in Chrome or Edge over <code>https://</code> to flash your device. Firefox
and iOS are not supported.</p>
</Message.Content>
<Message kind='warning' icon='warning sign' title='Web Serial is not available'>
<p>Flashing requires the Web Serial API, which is only available in Chromium-based browsers
(Chrome, Edge, Brave) served over a secure connection (HTTPS or <code>localhost</code>).</p>
<p>Open this page in Chrome or Edge over <code>https://</code> to flash your device. Firefox
and iOS are not supported.</p>
</Message>
</PageContainer>;
}
@ -664,409 +671,417 @@ export function FlasherPage() {
const totalFlashBytes = files.reduce((sum, item) => sum + (item.size || 0), 0);
const flashEtaText = humanDuration(estimateFlashSeconds(totalFlashBytes, baudrate));
// The nested tab menu isn't inverted automatically; give it the inverted style in dark mode so it matches.
const tabMenu = theme === darkTheme ? {inverted: true, attached: true} : {attached: true};
// A connect failure needs a download-mode reminder shown right where the user acted — both in the Connect
// segment and next to the "Filter files by detecting device" button so it isn't missed off-screen.
// panel and next to the "Filter files by detecting device" button so it isn't missed off-screen.
const connectErrorMessage = () => (error && bootHint) &&
<Message error onDismiss={() => {
setError('');
setBootHint(false);
}} style={{marginTop: '1em'}}>
<Message.Header>Could not connect to the device</Message.Header>
<p>{error}</p>
<p>
Many ESP boards must be put into <b>download mode</b> manually before flashing. Hold the
{' '}<b>BOOT</b> (or <b>IO0</b>) button, briefly press <b>RESET</b> (<b>EN</b>), then release
BOOT and try again. Also check the USB cable is a data cable and no other tab has the port open.
</p>
</Message>;
<div style={{marginTop: '1em'}}>
<Message
kind='error'
title='Could not connect to the device'
onDismiss={() => {
setError('');
setBootHint(false);
}}
>
<p>{error}</p>
<p>
Many ESP boards must be put into <b>download mode</b> manually before flashing. Hold the
{' '}<b>BOOT</b> (or <b>IO0</b>) button, briefly press <b>RESET</b> (<b>EN</b>), then release
BOOT and try again. Also check the USB cable is a data cable and no other tab has the port open.
</p>
</Message>
</div>;
// The firmware source is chosen via three tabs (see the ordered array below).
const computerPane = {
menuItem: 'Add from computer',
render: () => <Tab.Pane>
{/* Clickable drop target (files may also be dropped anywhere on the page, which routes here). */}
<Segment
placeholder
onClick={busy ? undefined : open}
// The firmware source is chosen via three tabs, in this order: saved sets, the media picker, then a local
// file.
const computerPaneContent = <>
{/* Clickable drop target (files may also be dropped anywhere on the page, which routes here). */}
<Panel
onClick={busy ? undefined : open}
style={{
cursor: busy ? 'default' : 'pointer',
textAlign: 'center',
...(isDragActive ? {borderColor: 'var(--blue)', borderWidth: 2} : {}),
}}
>
<Header as='h4' icon='microchip'>
{isDragActive
? <>Drop <code>.bin</code> firmware to add it</>
: <>Click here, or drop <code>.bin</code> firmware here</>}
</Header>
</Panel>
<p style={{marginTop: '1em', opacity: 0.8}}>
A single merged image is flashed at offset <code>0x0</code>. For multi-part firmware, add each
part and set its offset (e.g. bootloader <code>0x1000</code>, partitions <code>0x8000</code>,
app <code>0x10000</code>).
</p>
</>;
const wrolpiPaneContent = <>
<p style={{opacity: 0.8}}>
Not sure which file you need? Plug in your device and let WROLPi show only the firmware that
matches it.
</p>
{/* Always available so a user who swaps to a different ESP device can re-detect it. */}
<Button
icon='usb'
loading={connecting}
disabled={busy}
onClick={handleFilterByDevice}
>
{deviceChip ? 'Detect a different device' : 'Filter files by detecting device'}
</Button>
{/* Show a connect failure here too, so the user doesn't have to scroll to the Connect panel. */}
{connectErrorMessage()}
{deviceChip &&
<Message kind='info' icon='microchip'>
Showing firmware for <b>{deviceChip}</b>.
<Button role='cancel' size='xs' onClick={() => applyDeviceChip(null)}
disabled={busy} style={{marginLeft: '1em'}}>
Show all firmware
</Button>
</Message>}
<TextInput
style={{marginTop: '1em'}}
leftSection={<Icon name='search'/>}
placeholder='Filter firmware by path (e.g. a folder name)...'
value={mediaFilter}
disabled={busy}
onChange={(e) => handleMediaFilterChange(e.currentTarget.value)}
/>
<div style={{marginTop: '1em', overflowX: 'auto'}}>
{mediaLoading
? <Loading/>
: (mediaResults.length === 0
? <p style={{opacity: 0.7}}>
No <code>.bin</code> firmware found
{deviceChip ? ` for ${deviceChip}` : ' in your media directory'}
{mediaFilter ? ' matching that filter.' : '.'}
</p>
: <Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File</Table.HeaderCell>
<Table.HeaderCell>Path</Table.HeaderCell>
<Table.HeaderCell>Size</Table.HeaderCell>
<Table.HeaderCell>Chip</Table.HeaderCell>
<Table.HeaderCell>Kind</Table.HeaderCell>
<Table.HeaderCell style={{width: '1%'}}/>
</Table.Row>
</Table.Header>
<Table.Body>
{mediaResults.map(result => {
const name = result.name || (result.primary_path || '').split('/').pop();
// esp_kind is 'not_esp_image' for parts with no chip (partitions, littlefs).
const kind = result.esp_kind && result.esp_kind !== 'not_esp_image'
? result.esp_kind : null;
return <Table.Row key={result.primary_path}>
<Table.Cell>{name}</Table.Cell>
<Table.Cell style={{opacity: 0.7, wordBreak: 'break-all'}}>
{result.primary_path}
</Table.Cell>
<Table.Cell>{humanFileSize(result.size)}</Table.Cell>
<Table.Cell>
{result.esp_chip
// `--label-text` rather than `color`: an inline
// `color` cannot be overridden, and night and
// amber replace the chip's fill with their own.
? <span className='wrolpi-label' style={{
'--label-color': chipColor(result.esp_chip),
'--label-text': chipTextColor(chipColor(result.esp_chip)),
}}>{result.esp_chip}</span>
: <span style={{opacity: 0.5}}>—</span>}
</Table.Cell>
<Table.Cell>
{kind || <span style={{opacity: 0.5}}>—</span>}
</Table.Cell>
<Table.Cell style={{textAlign: 'center'}}>
<IconButton icon='plus' label='Add to selected firmware' role='primary'
size='xs' disabled={busy}
onClick={() => handleAddMediaFile(result)}/>
</Table.Cell>
</Table.Row>;
})}
</Table.Body>
</Table>)}
</div>
</>;
const savedPaneContent = <>
<p style={{opacity: 0.8}}>
Configure the firmware files and offsets, then save them as a named set to re-flash later
(e.g. a Meshtastic T-Deck: firmware at <code>0x0</code>, littlefs at <code>0xc90000</code>).
</p>
{savedConfigs.length > 0
? <Panel style={{padding: 0}}>
{savedConfigs.map((configuration, index) => <div
key={configuration.name}
style={{
cursor: busy ? 'default' : 'pointer',
textAlign: 'center',
...(isDragActive ? {outline: '2px dashed #2185d0', outlineOffset: '-2px'} : {}),
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
padding: '10px 12px',
borderBottom: index === savedConfigs.length - 1 ? undefined : '1px solid var(--border)',
}}
>
<Header icon {...t}>
<Icon name='microchip'/>
{isDragActive
? <>Drop <code>.bin</code> firmware to add it</>
: <>Click here, or drop <code>.bin</code> firmware here</>}
</Header>
</Segment>
<p {...t} style={{marginTop: '1em', opacity: 0.8}}>
A single merged image is flashed at offset <code>0x0</code>. For multi-part firmware, add each
part and set its offset (e.g. bootloader <code>0x1000</code>, partitions <code>0x8000</code>,
app <code>0x10000</code>).
</p>
</Tab.Pane>,
};
const wrolpiPane = {
menuItem: 'Choose from your WROLPi',
render: () => <Tab.Pane>
<p {...t} style={{opacity: 0.8}}>
Not sure which file you need? Plug in your device and let WROLPi show only the firmware that
matches it.
</p>
{/* Always available so a user who swaps to a different ESP device can re-detect it. */}
<Button
icon labelPosition='left'
loading={connecting}
disabled={busy}
onClick={handleFilterByDevice}
>
<Icon name='usb'/>
{deviceChip ? 'Detect a different device' : 'Filter files by detecting device'}
</Button>
{/* Show a connect failure here too, so the user doesn't have to scroll to the Connect segment. */}
{connectErrorMessage()}
{deviceChip &&
<Message info>
{/* Plain (non-inverted) icon: Message backgrounds are light, so a themed white icon
vanishes. */}
<SIcon name='microchip'/>
Showing firmware for <b>{deviceChip}</b>.
<SButton size='tiny' compact onClick={() => applyDeviceChip(null)}
disabled={busy} style={{marginLeft: '1em'}}>
Show all firmware
</SButton>
</Message>}
<Form style={{marginTop: '1em'}}>
<Form.Input
fluid
icon='search'
placeholder='Filter firmware by path (e.g. a folder name)...'
value={mediaFilter}
disabled={busy}
onChange={(e, {value}) => handleMediaFilterChange(value)}
/>
</Form>
<div style={{marginTop: '1em', overflowX: 'auto'}}>
{mediaLoading
? <Loader active inline='centered'/>
: (mediaResults.length === 0
? <p {...t} style={{opacity: 0.7}}>
No <code>.bin</code> firmware found
{deviceChip ? ` for ${deviceChip}` : ' in your media directory'}
{mediaFilter ? ' matching that filter.' : '.'}
</p>
: <Table compact selectable unstackable>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File</Table.HeaderCell>
<Table.HeaderCell>Path</Table.HeaderCell>
<Table.HeaderCell>Size</Table.HeaderCell>
<Table.HeaderCell>Chip</Table.HeaderCell>
<Table.HeaderCell>Kind</Table.HeaderCell>
<Table.HeaderCell width={1}/>
</Table.Row>
</Table.Header>
<Table.Body>
{mediaResults.map(result => {
const name = result.name || (result.primary_path || '').split('/').pop();
// esp_kind is 'not_esp_image' for parts with no chip (partitions, littlefs).
const kind = result.esp_kind && result.esp_kind !== 'not_esp_image'
? result.esp_kind : null;
return <Table.Row key={result.primary_path}>
<Table.Cell>{name}</Table.Cell>
<Table.Cell {...t} style={{opacity: 0.7, wordBreak: 'break-all'}}>
{result.primary_path}
</Table.Cell>
<Table.Cell>{humanFileSize(result.size)}</Table.Cell>
<Table.Cell>
{result.esp_chip
? <Label size='tiny' style={{
backgroundColor: chipColor(result.esp_chip),
color: chipTextColor(chipColor(result.esp_chip)),
borderColor: 'transparent',
}}>{result.esp_chip}</Label>
: <span style={{opacity: 0.5}}>—</span>}
</Table.Cell>
<Table.Cell>
{kind || <span style={{opacity: 0.5}}>—</span>}
</Table.Cell>
<Table.Cell textAlign='center'>
<Button icon='plus' size='mini' primary disabled={busy}
title='Add to selected firmware'
onClick={() => handleAddMediaFile(result)}/>
</Table.Cell>
</Table.Row>;
})}
</Table.Body>
</Table>)}
</div>
</Tab.Pane>,
};
const savedPane = {
menuItem: 'Saved Firmwares',
render: () => <Tab.Pane>
<p {...t} style={{opacity: 0.8}}>
Configure the firmware files and offsets, then save them as a named set to re-flash later
(e.g. a Meshtastic T-Deck: firmware at <code>0x0</code>, littlefs at <code>0xc90000</code>).
</p>
{savedConfigs.length > 0
? <List divided relaxed selection>
{savedConfigs.map(configuration => <List.Item key={configuration.name}>
<List.Content floated='right'>
<Button icon='trash' size='mini' color='red' disabled={busy}
onClick={() => handleDeleteConfig(configuration.name)}/>
</List.Content>
<List.Icon name='save' verticalAlign='middle'/>
<List.Content onClick={busy ? undefined : () => handleLoadConfig(configuration)}
style={busy ? {} : {cursor: 'pointer'}}>
<List.Header>{configuration.name}</List.Header>
<List.Description {...t}>
{(configuration.files || []).length} file(s)
{configuration.erase_all ? ' — erases flash' : ''}
</List.Description>
</List.Content>
</List.Item>)}
</List>
: <p {...t} style={{opacity: 0.7}}>No saved configurations yet.</p>}
</Tab.Pane>,
};
// Tab order: saved sets first, then the media picker, then a local file.
const firmwarePanes = [savedPane, wrolpiPane, computerPane];
<div
onClick={busy ? undefined : () => handleLoadConfig(configuration)}
style={{
display: 'flex', alignItems: 'center', gap: 10, flex: 1,
cursor: busy ? 'default' : 'pointer',
}}
>
<Icon name='save'/>
<div>
<div style={{fontWeight: 600}}>{configuration.name}</div>
<div style={{fontSize: '0.75rem', opacity: 0.8}}>
{(configuration.files || []).length} file(s)
{configuration.erase_all ? ' — erases flash' : ''}
</div>
</div>
</div>
<IconButton icon='trash' label={`Delete ${configuration.name}`} role='danger' size='xs'
disabled={busy}
onClick={() => handleDeleteConfig(configuration.name)}/>
</div>)}
</Panel>
: <p style={{opacity: 0.7}}>No saved configurations yet.</p>}
</>;
return <div {...getRootProps()}>
{/* The dropzone root wraps the whole page: firmware .bin files can be dropped anywhere and are routed to
the "Add from computer" tab. noClick is set, so this wrapper never opens the file dialog itself. */}
<input {...getInputProps()}/>
<PageContainer>
<Header as='h1'>Flasher</Header>
<p {...t}>
Flash firmware onto an ESP32/ESP8266 device directly from your browser over USB. Choose one or more
firmware <code>.bin</code> files &mdash; from your computer or from your WROLPi's media directory &mdash;
connect your device, then flash. The flash happens entirely in your browser and is written straight to
the device over USB.
</p>
<Header as='h1'>Flasher</Header>
<p>
Flash firmware onto an ESP32/ESP8266 device directly from your browser over USB. Choose one or
more firmware <code>.bin</code> files &mdash; from your computer or from your WROLPi's media
directory &mdash; connect your device, then flash. The flash happens entirely in your browser and
is written straight to the device over USB.
</p>
{/* Connection errors render inside the Connect segment (below), where the user just clicked. Other
errors show here at the top. */}
{error && !bootHint && <Message error onDismiss={() => setError('')}>
<Message.Header>Error</Message.Header>
<p>{error}</p>
</Message>}
{/* Connection errors render inside the Connect panel (below), where the user just clicked. Other
errors show here at the top. */}
{error && !bootHint && <Message kind='error' title='Error' onDismiss={() => setError('')}>
<p>{error}</p>
</Message>}
<Segment>
<Header as='h3'>1. Firmware files</Header>
<Tab
menu={tabMenu}
panes={firmwarePanes}
activeIndex={activeTab}
onTabChange={(e, {activeIndex}) => setActiveTab(activeIndex)}
/>
<Panel>
<Header as='h3'>1. Firmware files</Header>
<Tabs value={activeTab} onChange={setActiveTab} keepMounted={false}>
<Tabs.List>
<Tabs.Tab value={TAB_SAVED}>Saved Firmwares</Tabs.Tab>
<Tabs.Tab value={TAB_WROLPI}>Choose from your WROLPi</Tabs.Tab>
<Tabs.Tab value={TAB_COMPUTER}>Add from computer</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value={TAB_SAVED} pt='md'>{savedPaneContent}</Tabs.Panel>
<Tabs.Panel value={TAB_WROLPI} pt='md'>{wrolpiPaneContent}</Tabs.Panel>
<Tabs.Panel value={TAB_COMPUTER} pt='md'>{computerPaneContent}</Tabs.Panel>
</Tabs>
{/* The selected firmware is shown below the source tabs so it stays visible regardless of which tab
(computer / WROLPi / saved) the user is on. */}
{files.length > 0 && <>
<Divider/>
<Header as='h4'>Selected firmware</Header>
<Table compact unstackable>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File</Table.HeaderCell>
<Table.HeaderCell width={4}>Flash offset</Table.HeaderCell>
<Table.HeaderCell width={1}/>
</Table.Row>
</Table.Header>
<Table.Body>
{files.map((item, index) => {
// Flag (but never block) a file whose image targets a different chip than the device.
const fileChipId = fileChipIds[index];
const chipMismatch = deviceChipId !== null && fileChipId != null
&& fileChipId !== deviceChipId;
return <Table.Row key={`${item.name}-${index}`} warning={chipMismatch}>
<Table.Cell>
<Icon name={item.mediaPath ? 'database' : 'file'}/>
{item.name} <span style={{opacity: 0.6}}>({humanFileSize(item.size)})</span>
{chipMismatch &&
<Label color='red' size='tiny' style={{marginLeft: '0.5em'}}>
<SIcon name='warning sign'/>
Built for {chipIdName(fileChipId)}, not {chipIdName(deviceChipId)}
</Label>}
</Table.Cell>
<Table.Cell>
<Form.Input
fluid
size='small'
placeholder='0x10000'
value={item.address}
disabled={busy}
error={!isValidHexOffset(item.address)}
onChange={(e, {value}) => handleAddressChange(index, value)}
/>
</Table.Cell>
<Table.Cell textAlign='center'>
<Button icon='trash' size='mini' color='red' disabled={busy}
onClick={() => handleRemoveFile(index)}/>
</Table.Cell>
</Table.Row>;
})}
</Table.Body>
</Table>
{/* Save lives here (not in the Saved Firmwares tab) so the current selection can be saved from
any source tab. */}
<Button icon labelPosition='left' disabled={busy || saveableFiles.length === 0}
onClick={() => setSaveModalOpen(true)}>
<Icon name='save'/>
Save current firmware
</Button>
</>}
</Segment>
{/* The selected firmware is shown below the source tabs so it stays visible regardless of which
tab (computer / WROLPi / saved) the user is on. */}
{files.length > 0 && <>
<Divider/>
<Header as='h4'>Selected firmware</Header>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File</Table.HeaderCell>
<Table.HeaderCell style={{width: '30%'}}>Flash offset</Table.HeaderCell>
<Table.HeaderCell style={{width: '1%'}}/>
</Table.Row>
</Table.Header>
<Table.Body>
{files.map((item, index) => {
// Flag (but never block) a file whose image targets a different chip than the
// device.
const fileChipId = fileChipIds[index];
const chipMismatch = deviceChipId !== null && fileChipId != null
&& fileChipId !== deviceChipId;
return <Table.Row key={`${item.name}-${index}`}>
<Table.Cell>
<Icon name={item.mediaPath ? 'disk' : 'file'}/>
{' '}{item.name} <span style={{opacity: 0.6}}>
({humanFileSize(item.size)})
</span>
{chipMismatch &&
<span style={{marginLeft: '0.5em'}}>
<Label color='warning' icon='warning sign'>
Built for {chipIdName(fileChipId)}, not{' '}
{chipIdName(deviceChipId)}
</Label>
</span>}
</Table.Cell>
<Table.Cell>
<TextInput
size='xs'
placeholder='0x10000'
value={item.address}
disabled={busy}
error={!isValidHexOffset(item.address)}
onChange={(e) => handleAddressChange(index, e.currentTarget.value)}
/>
</Table.Cell>
<Table.Cell style={{textAlign: 'center'}}>
<IconButton icon='trash' label={`Remove ${item.name}`} role='danger'
size='xs' disabled={busy}
onClick={() => handleRemoveFile(index)}/>
</Table.Cell>
</Table.Row>;
})}
</Table.Body>
</Table>
{/* Save lives here (not in the Saved Firmwares tab) so the current selection can be saved
from any source tab. */}
<Button icon='save' role='save' disabled={busy || saveableFiles.length === 0}
onClick={() => setSaveModalOpen(true)}>
Save current firmware
</Button>
</>}
</Panel>
<Modal open={saveModalOpen} onClose={() => setSaveModalOpen(false)} size='tiny'>
<Modal.Header>Save firmware configuration</Modal.Header>
<Modal.Content>
<Form onSubmit={handleSaveConfig}>
<Form.Input
<Modal open={saveModalOpen} onClose={() => setSaveModalOpen(false)} size='tiny'>
<Modal.Header>Save firmware configuration</Modal.Header>
<Modal.Content>
<TextInput
autoFocus
label='Name'
placeholder='e.g. T-Deck Meshtastic UI'
value={saveName}
onChange={(e, {value}) => setSaveName(value)}
onChange={(e) => setSaveName(e.currentTarget.value)}
/>
<p {...t} style={{opacity: 0.8}}>
<p style={{opacity: 0.8, marginTop: '0.75em'}}>
Saving {saveableFiles.length} firmware file(s) from your WROLPi. An existing configuration
with the same name will be replaced.
</p>
</Form>
</Modal.Content>
<Modal.Actions>
<SButton onClick={() => setSaveModalOpen(false)}>Cancel</SButton>
<Button primary disabled={!saveName.trim()} onClick={handleSaveConfig}>Save</Button>
</Modal.Actions>
</Modal>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={() => setSaveModalOpen(false)}>Cancel</Button>
<Button role='save' disabled={!saveName.trim()} onClick={handleSaveConfig}>Save</Button>
</Modal.Actions>
</Modal>
<Segment>
<Header as='h3'>2. Connect</Header>
<Form>
<Form.Field>
<label>Baud rate</label>
<Select
options={BAUD_OPTIONS}
value={baudrate}
disabled={connected || busy}
onChange={(e, {value}) => setBaudrate(value)}
/>
</Form.Field>
{connected &&
<Message positive>
<SIcon name='usb'/> Connected to <b>{chip}</b>
</Message>}
{connected
? <Button color='grey' disabled={busy} onClick={disconnect}>Disconnect</Button>
: <Button primary loading={connecting} disabled={busy} onClick={handleConnect}>
Connect Device
</Button>}
<Button icon labelPosition='left' disabled={!log} onClick={() => setLogOpen(true)}>
<Icon name='terminal'/> Logs
</Button>
</Form>
{/* Rendered outside <Form> so Semantic's ".ui.form .error.message { display:none }" rule doesn't
hide it, and here (not at the top) so it is visible right where the user clicked Connect. */}
{connectErrorMessage()}
</Segment>
<Panel>
<Header as='h3'>2. Connect</Header>
<Stack gap={12} style={{maxWidth: 320}}>
<div>
<div style={{marginBottom: 4, fontSize: '0.8125rem', fontWeight: 500}}>Baud rate</div>
<Select
data={BAUD_OPTIONS.map(o => ({value: String(o.value), label: o.text}))}
value={String(baudrate)}
disabled={connected || busy}
allowDeselect={false}
onChange={(value) => value && setBaudrate(Number(value))}
/>
</div>
{connected &&
<Message kind='success' icon='usb'>Connected to <b>{chip}</b></Message>}
<Group>
{connected
? <Button role='cancel' disabled={busy} onClick={disconnect}>Disconnect</Button>
: <Button role='primary' loading={connecting} disabled={busy} onClick={handleConnect}>
Connect Device
</Button>}
<Button icon='terminal' disabled={!log} onClick={() => setLogOpen(true)}>
Logs
</Button>
</Group>
</Stack>
{/* Rendered outside the fields above, and here (not at the top) so it is visible right where
the user clicked Connect. */}
{connectErrorMessage()}
</Panel>
<Segment>
<Header as='h3'>3. Flash</Header>
<Form>
<Form.Field>
<Panel danger>
<Header as='h3'>3. Flash</Header>
<Stack gap={12}>
<Checkbox
label='Erase all flash before writing'
checked={eraseAll}
disabled={busy}
onChange={(e, {checked}) => setEraseAll(checked)}
onChange={(e) => setEraseAll(e.currentTarget.checked)}
/>
</Form.Field>
<Button
color='red'
loading={flashing}
disabled={!connected || flashing || files.length === 0 || !offsetsValid}
onClick={handleFlash}
>
<Icon name='lightning'/> Flash Device
</Button>
{offsetsValid && flashEtaText &&
<span {...t} style={{marginLeft: '1em', opacity: 0.8}}>
Estimated time: ~{flashEtaText} at {baudrate.toLocaleString()} baud
</span>}
{files.length > 0 && !offsetsValid &&
<p {...t} style={{marginTop: '0.5em', opacity: 0.8}}>
Every firmware file needs a valid hex flash offset (e.g. <code>0x0</code> or{' '}
<code>0x10000</code>).
</p>}
</Form>
</Segment>
<div>
<Button
role='danger'
icon='lightning'
loading={flashing}
disabled={!connected || flashing || files.length === 0 || !offsetsValid}
onClick={handleFlash}
>
Flash Device
</Button>
{offsetsValid && flashEtaText &&
<span style={{marginLeft: '1em', opacity: 0.8}}>
Estimated time: ~{flashEtaText} at {baudrate.toLocaleString()} baud
</span>}
</div>
{files.length > 0 && !offsetsValid &&
<p style={{opacity: 0.8}}>
Every firmware file needs a valid hex flash offset (e.g. <code>0x0</code> or{' '}
<code>0x10000</code>).
</p>}
</Stack>
</Panel>
<Modal
open={flashOpen}
onClose={() => !flashing && setFlashOpen(false)}
closeOnDimmerClick={!flashing}
size='fullscreen'
>
<Modal.Header>{flashing ? 'Flashing device…' : (flashError ? 'Flash failed' : 'Flash complete')}</Modal.Header>
<Modal.Content scrolling>
{progress !== null &&
<Progress percent={progress.percent} progress indicating={flashing}
error={!!flashError} autoSuccess={!flashError}>
{flashing ? `Writing file ${progress.fileIndex + 1} of ${files.length}`
: (flashError ? 'Failed' : 'Done')}
</Progress>}
{flashing && flashEtaText &&
<p {...t} style={{opacity: 0.8}}>
Estimated total time: ~{flashEtaText} at {baudrate.toLocaleString()} baud. Keep this tab
open and do not unplug the device.
</p>}
{/* Surface a failure right here — otherwise a failed flash looks identical to a frozen one. */}
{flashError &&
<Message error>
<SIcon name='warning circle'/>
<Message.Content>
<Message.Header>Flashing failed</Message.Header>
<Modal
open={flashOpen}
onClose={() => !flashing && setFlashOpen(false)}
closeOnClickOutside={!flashing}
closeOnEscape={!flashing}
size='fullscreen'
>
<Modal.Header>
{flashing ? 'Flashing device…' : (flashError ? 'Flash failed' : 'Flash complete')}
</Modal.Header>
<Modal.Content>
{progress !== null &&
<Progress
percent={progress.percent}
color={flashError ? 'red' : (flashing ? 'blue' : 'green')}
label={flashing ? `Writing file ${progress.fileIndex + 1} of ${files.length}`
: (flashError ? 'Failed' : 'Done')}
/>}
{flashing && flashEtaText &&
<p style={{opacity: 0.8}}>
Estimated total time: ~{flashEtaText} at {baudrate.toLocaleString()} baud. Keep this
tab open and do not unplug the device.
</p>}
{/* Surface a failure right here — otherwise a failed flash looks identical to a frozen
one. */}
{flashError &&
<Message kind='error' icon='warning circle' title='Flashing failed'>
<p>{flashError}</p>
</Message.Content>
</Message>}
{!flashing && !flashError && progress !== null &&
<Message success>
<SIcon name='check circle'/>
<Message.Content>
<Message.Header>Flashing complete</Message.Header>
</Message>}
{!flashing && !flashError && progress !== null &&
<Message kind='success' icon='check circle' title='Flashing complete'>
<p>Unplug the device and power it back on to run the new firmware.</p>
</Message.Content>
</Message>}
<AutoScrollLog
value={log || 'Starting…'}
style={{fontFamily: 'monospace', width: '100%', minHeight: '60vh', whiteSpace: 'pre'}}
/>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setFlashOpen(false)} disabled={flashing}>Close</Button>
</Modal.Actions>
</Modal>
</Message>}
<AutoScrollLog
value={log || 'Starting…'}
style={{fontFamily: 'var(--font-mono)', width: '100%', minHeight: '60vh', whiteSpace: 'pre'}}
/>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={() => setFlashOpen(false)} disabled={flashing}>Close</Button>
</Modal.Actions>
</Modal>
<Modal open={logOpen} onClose={() => setLogOpen(false)} size='fullscreen'>
<Modal.Header>Flasher Log</Modal.Header>
<Modal.Content scrolling>
<AutoScrollLog
value={log || 'No activity yet.'}
style={{fontFamily: 'monospace', width: '100%', minHeight: '70vh', whiteSpace: 'pre'}}
/>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setLogOpen(false)}>Close</Button>
</Modal.Actions>
</Modal>
<Modal open={logOpen} onClose={() => setLogOpen(false)} size='fullscreen'>
<Modal.Header>Flasher Log</Modal.Header>
<Modal.Content>
<AutoScrollLog
value={log || 'No activity yet.'}
style={{fontFamily: 'var(--font-mono)', width: '100%', minHeight: '70vh', whiteSpace: 'pre'}}
/>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={() => setLogOpen(false)}>Close</Button>
</Modal.Actions>
</Modal>
</PageContainer>
</div>;
}

View file

@ -1,5 +1,5 @@
import React from 'react';
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import {fireEvent, render, screen, waitFor} from '../test-utils';
// esptool-js ships untranspiled ESM which Jest will not transform; mock it so importing Flasher.js is safe.
// Real classes (not jest.fn mock impls) so `new ESPLoader()` reliably yields an instance with main()/after().

View file

@ -1,21 +1,17 @@
import {Placeholder, Segment} from "./Theme";
import {Image, PlaceholderLine} from "semantic-ui-react";
import {Grid, Panel, Placeholder} from "./ui";
import React from "react";
import {ThemeContext} from "../contexts/contexts";
import {encodeMediaPath, FileIcon, findPosterPath, PreviewLink} from "./Common";
import {Link} from "react-router";
import _ from "lodash";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {FileRowTagIcon} from "./Files";
export const HeadlineText = ({headline, openTag = '<u>', closeTag = '</u>'}) => {
const {s} = React.useContext(ThemeContext);
if (headline) {
headline = headline.replaceAll('<b>', openTag);
headline = headline.replaceAll('</b>', closeTag);
headline = headline.replaceAll('\n', '<br/>');
// FTS headlines use <b>...</b> tags which we will restyle.
return <span {...s} dangerouslySetInnerHTML={{__html: headline}}></span>
return <span dangerouslySetInnerHTML={{__html: headline}}></span>
}
}
@ -26,7 +22,7 @@ const FileHeadline = ({file, to}) => {
const posterPath = findPosterPath(file);
if (posterPath) {
const posterUrl = posterPath ? `/media/${encodeMediaPath(posterPath)}` : null;
poster = <Image src={posterUrl} size='tiny'/>;
poster = <img alt='poster' src={posterUrl} style={{maxWidth: '80px', maxHeight: '80px'}}/>;
} else {
poster = <FileIcon file={file}/>;
}
@ -48,32 +44,26 @@ const FileHeadline = ({file, to}) => {
</>;
}
return <Segment>
return <Panel>
<Grid>
<Grid.Row>
<Grid.Column mobile={4} computer={2}>{poster}</Grid.Column>
<Grid.Column mobile={12} computer={12}>
<big>
<FileRowTagIcon file={file}/>
{header}
</big>
</Grid.Column>
</Grid.Row>
<Grid.Col span={{base: 4, sm: 2}}>{poster}</Grid.Col>
<Grid.Col span={{base: 12, sm: 10}}>
<big>
<FileRowTagIcon file={file}/>
{header}
</big>
</Grid.Col>
</Grid>
<br/>
{body}
</Segment>
</Panel>
}
export const Headlines = ({results}) => {
if (results === null || results === undefined) {
return <Placeholder>
<PlaceholderLine/>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>
return <Placeholder lines={3}/>
} else if (results && results.length === 0) {
return <Segment>No results!</Segment>
return <Panel>No results!</Panel>
}
let headlines = [];
@ -93,4 +83,4 @@ export const Headlines = ({results}) => {
return <>
{headlines}
</>
}
}

View file

@ -1,7 +1,5 @@
import React, {useContext} from "react";
import {Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Button, Header, Modal} from "./Theme";
import {ThemeContext} from "../contexts/contexts";
import React from "react";
import {Button, Header, Modal, Table} from "./ui";
import {SHORTCUTS} from "./KeyboardShortcutsProvider";
import {usePlatformModifier} from "../hooks/customHooks";
@ -9,13 +7,11 @@ import {usePlatformModifier} from "../hooks/customHooks";
function KeyboardKey({children}) {
return (
<kbd style={{
backgroundColor: '#f7f7f7',
border: '1px solid #ccc',
borderRadius: '3px',
boxShadow: '0 1px 0 rgba(0,0,0,0.2)',
color: '#333',
backgroundColor: 'var(--head)',
border: '1px solid var(--border)',
color: 'var(--text)',
display: 'inline-block',
fontFamily: 'monospace',
fontFamily: 'var(--font-mono)',
fontSize: '0.85em',
lineHeight: '1.4',
margin: '0 2px',
@ -116,7 +112,6 @@ function groupShortcutsByCategory(shortcuts) {
}
export default function HelpModal({open, onClose}) {
const {i, t} = useContext(ThemeContext);
const {isMac} = usePlatformModifier();
const groupedShortcuts = groupShortcutsByCategory(SHORTCUTS);
@ -125,29 +120,29 @@ export default function HelpModal({open, onClose}) {
const orderedCategories = categoryOrder.filter(cat => groupedShortcuts[cat]);
return (
<Modal closeIcon open={open} onClose={onClose} size='small'>
<Modal open={open} onClose={onClose} size='small'>
<Modal.Header>Keyboard Shortcuts</Modal.Header>
<Modal.Content scrolling>
<Modal.Content>
{orderedCategories.map(category => (
<div key={category} style={{marginBottom: '1.5em'}}>
<Header as='h4' {...t}>{category}</Header>
<Table basic='very' compact {...i}>
<TableBody>
<Header as='h4'>{category}</Header>
<Table>
<Table.Body>
{groupedShortcuts[category].map((shortcut, idx) => (
<TableRow key={idx}>
<TableCell width={6}>
<Table.Row key={idx}>
<Table.Cell width={6}>
<ShortcutKeys keys={shortcut.keys} isMac={isMac}/>
</TableCell>
<TableCell {...t}>{shortcut.description}</TableCell>
</TableRow>
</Table.Cell>
<Table.Cell>{shortcut.description}</Table.Cell>
</Table.Row>
))}
</TableBody>
</Table.Body>
</Table>
</div>
))}
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Close</Button>
<Button role='cancel' onClick={onClose}>Close</Button>
</Modal.Actions>
</Modal>
);

View file

@ -2,7 +2,7 @@ import React, {useCallback, useEffect, useRef, useState} from "react";
import {useNavigate} from "react-router";
import {useHotkeys} from "react-hotkeys-hook";
import {KeyboardShortcutsContext} from "../contexts/KeyboardShortcutsContext";
import {Modal} from "./Theme";
import {Modal} from "./ui";
import {SearchResultsInput} from "./Common";
import {useSearchSuggestions} from "./Search";
import HelpModal from "./HelpModal";
@ -64,7 +64,15 @@ function SearchModal({open, onClose}) {
}, [open, setSearchStr]);
useEffect(() => {
// Focus on the Search's <input/> when the modal is opened.
/*
* Focus the search input when the modal opens.
*
* `autoFocus` on the input below is what actually does it: the modal mounts its
* content on a later tick, so this effect runs while `inputRef.current` is still
* null. The modal this replaced mounted synchronously, which is why focusing here used
* to be enough. The ref call is kept for the case where the modal is already
* mounted and merely re-opened.
*/
if (open && inputRef.current) {
inputRef.current.focus();
}
@ -73,8 +81,18 @@ function SearchModal({open, onClose}) {
if (!open) return null;
return (
<Modal open={open} onClose={onClose} centered={false}>
<Modal size='small' open={open} onClose={onClose} centered={false} title='Search'>
<Modal.Content>
{/*
* `wrolpi-search-modal` makes the suggestion list part of the panel rather
* than an overlay on the page; see ui.css. Everywhere else the list floats
* over the page, which is right -- results that reflow the page while you
* type are unusable. Inside a modal it is wrong twice over: an absolutely
* positioned list adds no height, so the panel stayed 114px tall, and the
* panel's own `overflow: auto` then clipped the list at the panel's edge.
* The modal showed a search box and the first word of the first result.
*/}
<div className='wrolpi-search-modal'>
<SearchResultsInput
clearable
searchStr={searchStr}
@ -87,7 +105,9 @@ function SearchModal({open, onClose}) {
resultRenderer={resultRenderer}
loading={loading}
inputRef={inputRef}
autoFocus
/>
</div>
</Modal.Content>
</Modal>
);

View file

@ -5,7 +5,6 @@ import {
HandPointMessage,
humanFileSize,
InfoMessage,
PageContainer,
TabLinks,
useTitle,
WROLModeMessage
@ -15,19 +14,10 @@ import {
deleteMapFile, deleteMapPin, fetchMapSubscriptions, getMapFiles, getMapPins,
mapSubscribe, mapUnsubscribe, rebuildMapSearchIndex, updateMapPin
} from "../api";
import {
Button,
Divider,
Input,
TableCell,
TableRow
} from "semantic-ui-react";
import {Button, Divider, Group, Header, Icon, Modal, Table, TextInput} from "./ui";
import {SortableTable} from "./SortableTable";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import {Header} from "./Theme";
import {Media, StatusContext} from "../contexts/contexts";
import {MAP_VIEWER_URI} from "./Vars";
import {Modal} from "./Theme";
import MapViewer from "./MapViewer";
import {AddToPlaylistButton} from "./AddToPlaylist";
import maplibregl from "maplibre-gl";
@ -124,15 +114,16 @@ function RegionPreviewModal({bbox, name, open, onClose}) {
return () => { if (map) map.remove(); };
}, [open, bbox]);
return <Modal open={open} onClose={onClose} size='large' closeIcon>
<Modal.Header>{name}</Modal.Header>
<Modal.Content>
<div ref={mapContainer} style={{width: '100%', height: '50vh'}}/>
</Modal.Content>
return <Modal open={open} onClose={onClose} size='fullscreen' closeIcon title={name}>
{/* No `.media` here: the bbox highlight is drawn as a map layer on the canvas itself
(see the `bbox` source/layers above), so the automatic `canvas` element-type filter
already covers it. There is no separate DOM overlay to wrap. */}
<div ref={mapContainer} style={{width: '100%', height: '50vh'}}/>
</Modal>;
}
// Random-ish distinct colors for region boxes.
// Random-ish distinct colors for region boxes. These are map-data paint colors (drawn
// on the WebGL canvas itself), not UI chrome, so they are not theme tokens.
const REGION_COLORS = [
'#6435c9', '#e03997', '#00b5ad', '#2185d0', '#f2711c', '#b5cc18',
'#a333c8', '#21ba45', '#db2828', '#fbbd08', '#a5673f', '#767676',
@ -211,11 +202,9 @@ function AllRegionsPreviewModal({catalog, open, onClose}) {
return () => { if (map) map.remove(); };
}, [open, catalog]);
return <Modal open={open} onClose={onClose} size='fullscreen' closeIcon>
<Modal.Header>All Map Regions</Modal.Header>
<Modal.Content>
<div ref={mapContainer} style={{width: '100%', height: '75vh'}}/>
</Modal.Content>
return <Modal open={open} onClose={onClose} size='fullscreen' closeIcon title='All Map Regions'>
{/* No `.media` here either — same reasoning as RegionPreviewModal. */}
<div ref={mapContainer} style={{width: '100%', height: '75vh'}}/>
</Modal>;
}
@ -235,30 +224,36 @@ function MapCatalogRow({item, subscribedRegions, fetchData}) {
}
};
return <TableRow>
<TableCell>{name}</TableCell>
<TableCell>{humanFileSize(size_estimate)}</TableCell>
<TableCell collapsing>
{bbox && <Button
size='tiny'
icon='eye'
onClick={() => setPreviewOpen(true)}
/>}
<Button
color={isSubscribed ? 'red' : 'violet'}
size='tiny'
onClick={handleButton}
>
{isSubscribed ? 'Unsubscribe' : 'Subscribe'}
</Button>
</TableCell>
return <Table.Row>
<Table.Cell>{name}</Table.Cell>
<Table.Cell>{humanFileSize(size_estimate)}</Table.Cell>
<Table.Cell>
{/* See PinEditRow: a Group so the preview and subscribe controls line up and are
spaced, rather than sitting on baselines they do not share. */}
<Group gap='xs' wrap='nowrap'>
{bbox && <Button
size='xs'
icon='eye'
aria-label={`Preview ${name}`}
onClick={() => setPreviewOpen(true)}
/>}
<Button
role={isSubscribed ? 'danger' : undefined}
color={isSubscribed ? undefined : 'violet'}
size='xs'
onClick={handleButton}
>
{isSubscribed ? 'Unsubscribe' : 'Subscribe'}
</Button>
</Group>
</Table.Cell>
{previewOpen && <RegionPreviewModal
bbox={bbox}
name={name}
open={previewOpen}
onClose={() => setPreviewOpen(false)}
/>}
</TableRow>;
</Table.Row>;
}
function ManageMap() {
@ -319,38 +314,39 @@ function ManageMap() {
};
const fileRowFunc = (file) => {
return <TableRow key={file.name}>
<TableCell>{file.name}</TableCell>
<TableCell>{humanFileSize(file.size)}</TableCell>
<TableCell collapsing>
return <Table.Row key={file.name}>
<Table.Cell>{file.name}</Table.Cell>
<Table.Cell>{humanFileSize(file.size)}</Table.Cell>
<Table.Cell>
{file.name.startsWith('terrain')
? null
: file.has_search_index
? <Button size='tiny' icon='check' content='Built' disabled color='green'/>
: <APIButton size='tiny' icon='search'
content={searchBuilding ? 'Building...' : 'Build'}
? <Button size='xs' icon='check' disabled role='save'>Built</Button>
: <APIButton size='xs' icon='search'
disabled={searchBuilding}
loading={searchBuilding}
onClick={() => handleBuildSearch(file.name)}/>
onClick={() => handleBuildSearch(file.name)}>
{searchBuilding ? 'Building...' : 'Build'}
</APIButton>
}
</TableCell>
<TableCell collapsing>
</Table.Cell>
<Table.Cell>
<APIButton
color='red'
size='tiny'
role='danger'
size='xs'
confirmContent='Delete this map file?'
confirmButton='Delete'
onClick={() => handleDelete(file.name)}
>
Delete
</APIButton>
</TableCell>
</TableRow>;
</Table.Cell>
</Table.Row>;
};
const emptyRow = <TableRow>
<TableCell colSpan={4}>No PMTiles map files were found in <b>map/</b></TableCell>
</TableRow>;
const emptyRow = <Table.Row>
<Table.Cell colSpan={4}>No PMTiles map files were found in <b>map/</b></Table.Cell>
</Table.Row>;
// --- Catalog table ---
const catalogHeaders = [
@ -369,13 +365,18 @@ function ManageMap() {
};
if (files === undefined) {
return <PageContainer>
return <>
<WROLModeMessage content='Cannot modify Map'/>
<ErrorMessage>Could not fetch map files</ErrorMessage>
</PageContainer>;
</>;
}
return <PageContainer>
/*
* A fragment, not a `PageContainer`. `MapRoute` already provides the page chrome -- a
* `wrolpi-stack` wrapper with its own top margin -- so a second one put this page a further
* 1em down and 1em in from the tab bar than the viewer beside it.
*/
return <>
<WROLModeMessage content='Cannot modify Map'/>
<Header as='h3'>Map Files</Header>
@ -392,7 +393,7 @@ function ManageMap() {
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5em'}}>
<Header as='h3'>Map Subscriptions</Header>
<Button size='tiny' icon='globe' content='View All Regions' onClick={() => setAllRegionsOpen(true)}/>
<Button size='xs' icon='globe' onClick={() => setAllRegionsOpen(true)}>View All Regions</Button>
</div>
<AllRegionsPreviewModal catalog={catalog} open={allRegionsOpen} onClose={() => setAllRegionsOpen(false)}/>
@ -406,7 +407,7 @@ function ManageMap() {
/>
<InfoMessage storageName='hint_map_subscribe'>
<Message.Header>How to get map files</Message.Header>
<div style={{fontWeight: 600, marginBottom: 4}}>How to get map files</div>
<p>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
@ -423,7 +424,7 @@ function ManageMap() {
<HandPointMessage>
<p>You can also view the map at <a href={MAP_VIEWER_URI}>{MAP_VIEWER_URI}</a></p>
</HandPointMessage>
</PageContainer>
</>
}
const PIN_COLORS = ["red", "blue", "green", "yellow", "orange", "purple"];
@ -432,8 +433,8 @@ function PinEditRow({pin, onSave, onCancel}) {
const [label, setLabel] = useState(pin.label);
const [color, setColor] = useState(pin.color);
return <TableRow>
<TableCell collapsing>
return <Table.Row>
<Table.Cell>
<div style={{display: "flex", gap: 4}}>
{PIN_COLORS.map(c =>
<div
@ -441,27 +442,32 @@ function PinEditRow({pin, onSave, onCancel}) {
onClick={() => 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)",
}}
/>
)}
</div>
</TableCell>
<TableCell>
<Input
size='small'
</Table.Cell>
<Table.Cell>
<TextInput
size='sm'
value={label}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && label.trim() && onSave(pin.id, label.trim(), color)}
/>
</TableCell>
<TableCell>{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}</TableCell>
<Media greaterThan='mobile'><TableCell>{pin.created}</TableCell></Media>
<TableCell collapsing>
<Button size='tiny' color='green' onClick={() => label.trim() && onSave(pin.id, label.trim(), color)}>Save</Button>
<Button size='tiny' onClick={onCancel}>Cancel</Button>
</TableCell>
</TableRow>;
</Table.Cell>
<Table.Cell>{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}</Table.Cell>
<Media greaterThan='mobile'><Table.Cell>{pin.created}</Table.Cell></Media>
<Table.Cell>
{/* A Group, not bare siblings: inline-block controls align on text baselines they
do not share, and JSX leaves no whitespace between them to space them out. */}
<Group gap='xs' wrap='nowrap'>
<Button size='xs' role='save'
onClick={() => label.trim() && onSave(pin.id, label.trim(), color)}>Save</Button>
<Button size='xs' role='cancel' onClick={onCancel}>Cancel</Button>
</Group>
</Table.Cell>
</Table.Row>;
}
function MapPins() {
@ -517,38 +523,43 @@ function MapPins() {
onCancel={() => setEditingId(null)}
/>;
}
return <TableRow key={pin.id}>
<TableCell collapsing>
return <Table.Row key={pin.id}>
<Table.Cell>
<div style={{
width: 16, height: 16, borderRadius: "50%",
background: pin.color || "red", border: "1px solid #ccc",
background: pin.color || "red", border: "1px solid var(--border)",
}}/>
</TableCell>
<TableCell>{pin.label}</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>{pin.label}</Table.Cell>
<Table.Cell>
<a href="#" onClick={(e) => {e.preventDefault(); handleNavigate(pin);}}>
{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}
</a>
</TableCell>
<TableCell>{pin.created}</TableCell>
<TableCell collapsing>
<Button size='tiny' icon='edit' onClick={() => setEditingId(pin.id)}/>
<AddToPlaylistButton
size='tiny'
icon='list'
content=''
url={{url: `/map?lat=${pin.lat}&lon=${pin.lon}&z=14`, title: pin.label}}
/>
<APIButton
size='tiny'
color='red'
icon='trash'
confirmContent='Delete this pin?'
confirmButton='Delete'
onClick={() => handleDelete(pin.id)}
/>
</TableCell>
</TableRow>;
</Table.Cell>
<Table.Cell>{pin.created}</Table.Cell>
<Table.Cell>
{/* See PinEditRow: a Group so the three controls line up and are spaced. */}
<Group gap='xs' wrap='nowrap'>
<Button size='xs' icon='edit' aria-label={`Edit ${pin.label}`}
onClick={() => setEditingId(pin.id)}/>
<AddToPlaylistButton
size='xs'
icon='list'
content=''
url={{url: `/map?lat=${pin.lat}&lon=${pin.lon}&z=14`, title: pin.label}}
/>
<APIButton
size='xs'
role='danger'
icon='trash'
aria-label={`Delete ${pin.label}`}
confirmContent='Delete this pin?'
confirmButton='Delete'
onClick={() => handleDelete(pin.id)}
/>
</Group>
</Table.Cell>
</Table.Row>;
};
// --- Mobile headers and row ---
@ -559,35 +570,36 @@ function MapPins() {
];
const mobileRowFunc = (pin) => {
return <TableRow key={pin.id}>
<TableCell collapsing>
return <Table.Row key={pin.id}>
<Table.Cell>
<div style={{
width: 16, height: 16, borderRadius: "50%",
background: pin.color || "red", border: "1px solid #ccc",
background: pin.color || "red", border: "1px solid var(--border)",
}}/>
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
<a href="#" onClick={(e) => {e.preventDefault(); handleNavigate(pin);}}>
{pin.label}
</a>
<div style={{fontSize: 11, color: '#888'}}>{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}</div>
</TableCell>
<TableCell collapsing>
<div style={{fontSize: '0.6875rem', color: 'var(--muted)'}}>{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}</div>
</Table.Cell>
<Table.Cell>
<APIButton
size='tiny'
color='red'
size='xs'
role='danger'
icon='trash'
aria-label={`Delete ${pin.label}`}
confirmContent='Delete this pin?'
confirmButton='Delete'
onClick={() => handleDelete(pin.id)}
/>
</TableCell>
</TableRow>;
</Table.Cell>
</Table.Row>;
};
const emptyRow = <TableRow>
<TableCell colSpan={5}>No pins. Right-click on the map to add one.</TableCell>
</TableRow>;
const emptyRow = <Table.Row>
<Table.Cell colSpan={5}>No pins. Right-click on the map to add one.</Table.Cell>
</Table.Row>;
const lowerFilter = filter.toLowerCase();
const filteredPins = lowerFilter
@ -597,17 +609,18 @@ function MapPins() {
)
: pins;
return <PageContainer>
<Input
icon='search'
// A fragment for the same reason as ManageMap above. The filter carries no `marginBottom`
// either: it is a top-level block of the page, and the route's stack spaces it.
return <>
<TextInput
leftSection={<Icon name='search'/>}
placeholder='Filter by label or coordinates...'
value={filter}
onChange={(e) => setFilter(e.target.value)}
style={{marginBottom: '1em', width: '100%'}}
style={{width: '100%'}}
/>
<Media at='mobile'>
<SortableTable
tableProps={{unstackable: true}}
tableHeaders={mobileHeaders}
data={filteredPins}
rowFunc={mobileRowFunc}
@ -618,7 +631,6 @@ function MapPins() {
</Media>
<Media greaterThanOrEqual='tablet'>
<SortableTable
tableProps={{unstackable: true}}
tableHeaders={fullHeaders}
data={filteredPins}
rowFunc={fullRowFunc}
@ -627,7 +639,7 @@ function MapPins() {
emptyRow={emptyRow}
/>
</Media>
</PageContainer>;
</>;
}
function MapPage() {
@ -643,7 +655,9 @@ export function MapRoute() {
{text: 'Manage', to: '/map/manage', key: 'manage'},
];
return <div style={{marginTop: '2em'}}>
// `wrolpi-stack`: as in ZimRoute, this route wraps its own content instead of using
// PageContainer, so the spacing below the tab bar is this element's to provide.
return <div className='wrolpi-stack' style={{marginTop: '2em'}}>
<TabLinks links={links}/>
<Routes>
<Route path='/' exact element={<MapPage/>}/>
@ -651,4 +665,4 @@ export function MapRoute() {
<Route path='manage' exact element={<ManageMap/>}/>
</Routes>
</div>
}
}

View file

@ -0,0 +1,158 @@
import fs from 'fs';
import path from 'path';
/*
* The map pin table's action cells.
*
* Each row offers edit, add-to-playlist and delete, and they were laid out as bare siblings in
* a table cell. That makes them inline-block boxes aligned on their text BASELINES, and a
* Button's baseline is not an ActionIcon's -- measured on the running app, the middle control
* sat 4.3px above the other two -- and they touched, because JSX leaves no whitespace between
* elements. `Group` is what every other row of buttons in the app uses.
*
* Checked in the source because neither row is reachable from a test that could measure it:
* `MapPins` fetches its pins from the API and builds its rows through SortableTable, and the
* row builders are internal to the module. ui-layout.cy.js measures the pattern itself --
* that bare siblings really are misaligned and that a Group really does fix it -- so what is
* left to guard is that these cells use one.
*/
const source = fs.readFileSync(path.join(__dirname, 'Map.js'), 'utf8');
/** The contents of every `<Table.Cell>` in the file. */
const cells = () => {
const found = [];
const open = '<Table.Cell';
for (let at = source.indexOf(open); at !== -1; at = source.indexOf(open, at + 1)) {
const end = source.indexOf('</Table.Cell>', at);
if (end !== -1) found.push(source.slice(at, end));
}
return found;
};
describe('the map pin action cells', () => {
it('finds the cells at all', () => {
// The premise. A parser that matched nothing would report no offenders and read
// exactly like a file that was already correct.
expect(cells().length).toBeGreaterThan(5);
});
/*
* Named cells, not a count of control tags.
*
* Counting was tried first and does not work: a cell whose buttons are the two arms of a
* ternary renders exactly one of them and needs no Group -- the "Built" / "Build" cell on
* the map files table is that shape -- and excluding ternaries to allow for it also
* excluded the subscribe cell, whose `role={isSubscribed ? ... : ...}` has nothing to do
* with how many controls it holds. A guard that quietly stops covering a cell is worse
* than one with a stated scope.
*
* So each cell is named by something only it contains, and the general claim -- that bare
* inline controls really are misaligned and that a Group really does fix it -- is measured
* in ui-layout.cy.js instead.
*/
const rows = [
{name: 'pin actions', marker: 'setEditingId(pin.id)'},
/*
* `role='cancel'`, not `onSave(...)`: the edit row's TextInput calls onSave from its
* own onKeyDown, in a different cell, so that marker matched the input's cell and this
* quietly checked the wrong one. A marker has to be unique to the cell it names.
*/
{name: 'pin edit save/cancel', marker: "role='cancel'"},
{name: 'map file subscribe', marker: 'setPreviewOpen(true)'},
];
rows.forEach(({name, marker}) => {
it(`groups the ${name} cell`, () => {
const cell = cells().find(candidate => candidate.includes(marker));
/*
* The premise and the claim in one object, because jest's `expect` takes no
* message argument -- that is chai, and passing one throws rather than annotating.
* A rename of the marker would otherwise turn this into a test of nothing.
*/
/*
* The Group must CONTAIN the cell's controls, not merely appear in it. Asserting
* that `<Group` is present anywhere would pass on an empty one left beside the
* bare siblings it was supposed to wrap.
*/
const between = (text, open, close) => {
const from = text.indexOf(open);
if (from === -1) return '';
const to = text.indexOf(close, from);
// An unclosed Group would otherwise swallow the rest of the cell, which is how
// the first version of this let an EMPTY Group pass with the controls left
// outside it.
return to === -1 ? '' : text.slice(from, to);
};
const inGroup = cell === undefined ? '' : between(cell, '<Group', '</Group>');
const controls = (text) =>
(text.match(/<(?:API)?Button\b|<IconButton\b|<AddToPlaylistButton\b/g) || []).length;
expect({
marker,
found: cell !== undefined,
grouped: !!cell?.includes('<Group'),
// Every control the cell has is inside the Group, none left outside it.
controlsInsideGroup: cell === undefined ? 0 : controls(inGroup),
controlsInCell: cell === undefined ? -1 : controls(cell),
}).toEqual({
marker, found: true, grouped: true,
controlsInsideGroup: cell === undefined ? 0 : controls(cell),
controlsInCell: cell === undefined ? -1 : controls(cell),
});
});
});
});
describe('the map\'s icon-only controls have accessible names', () => {
/*
* An icon-only Button carries no text, so without `aria-label` it reaches assistive tech
* as an unnamed button. `IconButton` requires a label by construction and
* AddToPlaylistButton supplies one; the plain Button and APIButton used for edit, delete
* and the catalog preview did not, and this change rewrote all three rows.
*
* In the source, for the same reason as the Group guard: these rows are built inside the
* module and fetched from the API.
*/
const openingTag = (text, at) => {
let depth = 0;
for (let i = at; i < text.length; i++) {
const c = text[i];
if (c === '{' || c === '(') depth += 1;
else if (c === '}' || c === ')') depth -= 1;
else if (c === '>' && depth === 0) return {tag: text.slice(at, i + 1), end: i};
}
return {tag: '', end: at};
};
/** Every `<Button>`/`<APIButton>` in the file whose only content is an icon. */
const iconOnlyControls = () => {
const found = [];
for (const match of source.matchAll(/<(?:API)?Button\b/g)) {
const {tag, end} = openingTag(source, match.index);
if (!/\bicon=/.test(tag)) continue;
// Self-closing, or immediately followed by another tag rather than by a label.
const following = source.slice(end + 1, end + 40).trim();
if (!(tag.endsWith('/>') || following.startsWith('<') || following.startsWith('}'))) {
continue;
}
found.push({line: source.slice(0, match.index).split('\n').length, tag});
}
return found;
};
it('finds the icon-only controls at all', () => {
// The premise. A scanner that matched nothing would report no offenders and read
// exactly like a file that was already correct.
expect(iconOnlyControls().length).toBeGreaterThan(2);
});
it('names every one of them', () => {
const unnamed = iconOnlyControls()
.filter(({tag}) => !/aria-label/.test(tag))
.map(({line, tag}) => `line ${line}: ${tag.split('\n')[0].trim()}`);
expect(unnamed).toEqual([]);
});
});

View file

@ -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 <Card onClick={handleClick} style={{cursor: "pointer"}}>
<div style={{height: 150, overflow: "hidden", position: "relative", background: "#1a1a2e"}}>
{iframeSrc && <iframe
src={iframeSrc}
title={place.name}
loading="lazy"
style={{
width: "100%",
height: "100%",
border: "none",
pointerEvents: "none",
}}
/>}
</div>
<CardContent {...s}>
<CardHeader {...s}>{place.name}</CardHeader>
{subtitle.length > 0 && <CardMeta {...s}>{subtitle.join(" \u00b7 ")}</CardMeta>}
<CardDescription {...s}>
{place.lat.toFixed(4)}, {place.lon.toFixed(4)}
</CardDescription>
</CardContent>
const media = <div style={{height: 150, overflow: "hidden", position: "relative", background: "var(--head)"}}>
{iframeSrc && <iframe
src={iframeSrc}
title={place.name}
loading="lazy"
style={{
width: "100%",
height: "100%",
border: "none",
pointerEvents: "none",
}}
/>}
</div>;
return <Card
onClick={handleClick}
media={media}
title={place.name}
meta={subtitle.length > 0 ? subtitle.join(" · ") : undefined}
>
<div>{place.lat.toFixed(4)}, {place.lon.toFixed(4)}</div>
</Card>;
}
@ -101,33 +99,33 @@ export function MapSearchView() {
}, [data, setTotal]);
if (hasMaps === false) {
return <Segment>
return <Panel>
<Header as="h4">No maps are installed.</Header>
<p>Subscribe to a map region to search for places.</p>
<Link to="/map/manage">Go to Map Manager</Link>
</Segment>;
</Panel>;
}
if (loading) {
return <Segment><Loader active inline="centered"/></Segment>;
return <Panel><Loading/></Panel>;
}
if (!searchStr) {
return <Segment><Header as="h4">Enter a search query to find map locations.</Header></Segment>;
return <Panel><Header as="h4">Enter a search query to find map locations.</Header></Panel>;
}
if (results.length === 0) {
return <Segment><Header as="h4">No map locations found for "{searchStr}".</Header></Segment>;
return <Panel><Header as="h4">No map locations found for "{searchStr}".</Header></Panel>;
}
return <React.Fragment>
<CardGroup itemsPerRow={3} stackable style={{marginTop: "1em"}}>
<CardGroup minWidth={275}>
{results.map((place, i) =>
<MapPlaceCard key={`${place.name}-${place.lat}-${place.lon}-${i}`} place={place} index={i}/>
)}
</CardGroup>
<center style={{marginTop: '2em'}}>
<div style={{marginTop: '2em'}}>
<Paginator activePage={activePage} totalPages={totalPages} onPageChange={setPage}/>
</center>
</div>
</React.Fragment>;
}

View file

@ -7,9 +7,9 @@ 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";
import {mapFlavor, mapSprite} from "../themes/names";
// Terrain DEM file prefix for hillshade and contours.
const TERRAIN_PREFIX = "terrain-";
@ -69,7 +69,10 @@ function initContourSource(filename) {
// sources: [{name, url}] where name is used as the MapLibre source ID and url is the pmtiles:// URL.
// hasTerrain: whether to include hillshade and contour layers.
// scaleUnit: "metric" or "imperial" — affects contour intervals and labels.
function buildStyle(sources, flavor = "light", hasTerrain = false, scaleUnit = "metric") {
// `sprite` is deliberately separate from `flavor`: we ship two sprite sets (light, dark)
// and the monochrome themes draw a `black` basemap, which has no sprites of its own.
function buildStyle(sources, flavor = "light", hasTerrain = false, scaleUnit = "metric",
sprite = "light") {
const styleSources = {};
const allLayers = [];
@ -182,8 +185,8 @@ function buildStyle(sources, flavor = "light", hasTerrain = false, scaleUnit = "
"text-size": 10,
},
paint: {
"text-color": flavor === "dark" ? "rgba(210,180,140,0.9)" : "rgba(150,100,50,0.8)",
"text-halo-color": flavor === "dark" ? "rgba(0,0,0,0.8)" : "rgba(255,255,255,0.8)",
"text-color": flavor === "light" ? "rgba(150,100,50,0.8)" : "rgba(210,180,140,0.9)",
"text-halo-color": flavor === "light" ? "rgba(255,255,255,0.8)" : "rgba(0,0,0,0.8)",
"text-halo-width": 1,
},
},
@ -193,7 +196,7 @@ function buildStyle(sources, flavor = "light", hasTerrain = false, scaleUnit = "
return {
version: 8,
glyphs: "/map-assets/fonts/{fontstack}/{range}.pbf",
sprite: `${window.location.origin}/map-assets/sprites/${flavor}`,
sprite: `${window.location.origin}/map-assets/sprites/${sprite}`,
sources: styleSources,
layers: allLayers,
};
@ -256,30 +259,33 @@ 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,
top: "0.625rem",
left: "0.625rem",
zIndex: 1000,
background: "white",
borderRadius: 8,
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
maxHeight: "calc(100vh - 120px)",
background: "var(--panel)",
border: "1px solid var(--border)",
color: "var(--text)",
maxHeight: "calc(100vh - 7.5rem)",
overflowY: "auto",
minWidth: iconOnly ? 0 : 200,
fontSize: 13,
minWidth: iconOnly ? 0 : "12.5rem",
fontSize: '0.8125rem',
};
const headerStyle = iconOnly
? {
padding: 0, cursor: "pointer", userSelect: "none",
display: "flex", alignItems: "center", justifyContent: "center",
width: 40, height: 40,
width: "2.5rem", height: "2.5rem",
}
: {
padding: "8px 12px", cursor: "pointer", fontWeight: 600,
padding: "0.5rem 0.75rem", 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 <div style={panelStyle}>
@ -296,22 +302,21 @@ function LayerControl({map, scaleUnit, onScaleUnitChange, visibilityRef}) {
<Icon name={collapsed ? "chevron right" : "chevron down"} size="small"/>
</>}
</div>
{!collapsed && <div style={{padding: "4px 12px 8px"}}>
{!collapsed && <div style={{padding: "0.25rem 0.75rem 0.5rem"}}>
{Object.keys(LAYER_GROUPS).map(name =>
<div key={name} style={{padding: "2px 0"}}>
<div key={name} style={{padding: "0.125rem 0"}}>
<Checkbox
label={name}
checked={visibility[name]}
onChange={(e, {checked}) => toggleGroup(name, checked)}
onChange={(e) => toggleGroup(name, e.currentTarget.checked)}
/>
</div>
)}
<div style={{borderTop: "1px solid #eee", marginTop: 6, paddingTop: 6}}>
<Checkbox
toggle
<div style={{borderTop: "1px solid var(--border)", marginTop: "0.375rem", paddingTop: "0.375rem"}}>
<Toggle
label={scaleUnit === "imperial" ? "Imperial" : "Metric"}
checked={scaleUnit === "imperial"}
onChange={(e, {checked}) => onScaleUnitChange(checked ? "imperial" : "metric")}
onChange={(e) => onScaleUnitChange(e.currentTarget.checked ? "imperial" : "metric")}
/>
</div>
</div>}
@ -324,43 +329,45 @@ 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 <div style={{
position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)",
zIndex: 1002, background: "white", borderRadius: 8, boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
padding: 16, minWidth: 250,
zIndex: 1002, background: "var(--panel)", border: "1px solid var(--border)", color: "var(--text)",
padding: "1rem", minWidth: "15.625rem",
}}>
<div style={{fontWeight: 600, marginBottom: 8}}>Add Pin</div>
<div style={{fontSize: 12, color: "#666", marginBottom: 8}}>{lat.toFixed(4)}, {lon.toFixed(4)}</div>
<input
type="text"
<div style={{fontWeight: 600, marginBottom: "0.5rem"}}>Add Pin</div>
<div style={{fontSize: '0.75rem', color: "var(--muted)", marginBottom: "0.5rem"}}>{lat.toFixed(4)}, {lon.toFixed(4)}</div>
<TextInput
placeholder="Label"
value={label}
onChange={e => 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: "0.5rem"}}
/>
<div style={{display: "flex", gap: 6, marginBottom: 12}}>
<div style={{display: "flex", gap: "0.375rem", marginBottom: "0.75rem"}}>
{PIN_COLORS.map(c =>
<div
key={c}
onClick={() => setColor(c)}
style={{
width: 24, height: 24, borderRadius: "50%", background: c, cursor: "pointer",
border: color === c ? "3px solid #333" : "2px solid #ccc",
width: "1.5rem", height: "1.5rem", borderRadius: "50%", background: c, cursor: "pointer",
border: color === c ? "3px solid var(--text)" : "2px solid var(--border)",
}}
/>
)}
</div>
<div style={{display: "flex", gap: 8, justifyContent: "flex-end"}}>
<button onClick={onCancel} style={{padding: "4px 12px", cursor: "pointer"}}>Cancel</button>
<button
<div style={{display: "flex", gap: "0.5rem", justifyContent: "flex-end"}}>
<Button role='cancel' size='sm' onClick={onCancel}>Cancel</Button>
<Button
role='primary'
size='sm'
onClick={() => label.trim() && onSubmit(label.trim(), color)}
disabled={!label.trim()}
style={{padding: "4px 12px", cursor: "pointer", background: "#6435c9", color: "white", border: "none", borderRadius: 4}}
>
Add
</button>
</Button>
</div>
</div>;
}
@ -474,9 +481,11 @@ 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 <div ref={containerRef} style={{
position: "absolute", top: 10, left: "50%", transform: "translateX(-50%)",
zIndex: 5, width: 320, maxWidth: "calc(100% - 100px)",
position: "absolute", top: "0.625rem", left: "50%", transform: "translateX(-50%)",
zIndex: 5, width: "20rem", maxWidth: "calc(100% - 100px)",
}}>
<input
type="text"
@ -486,24 +495,23 @@ function MapSearch({map}) {
onFocus={() => 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: "0.5rem 0.75rem", border: "1px solid var(--border)",
fontSize: '0.875rem', background: "var(--panel)", color: "var(--text)",
}}
/>
{showResults && results.length > 0 && <div style={{
background: "white", border: "1px solid #ccc", borderTop: "none",
borderRadius: "0 0 4px 4px", maxHeight: 300, overflowY: "auto",
boxShadow: "0 4px 8px rgba(0,0,0,0.2)",
background: "var(--panel)", border: "1px solid var(--border)", borderTop: "none",
maxHeight: "18.75rem", overflowY: "auto",
}}>
{results.map((r, i) => <div
key={i}
onClick={() => 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: "0.5rem 0.75rem", cursor: "pointer", borderBottom: "1px solid var(--border)"}}
>
<div style={{fontWeight: 500}}>{r.name}</div>
{resultSubtext(r) && <div style={{fontSize: 11, color: "#888"}}>{resultSubtext(r)}</div>}
{resultSubtext(r) && <div style={{fontSize: '0.6875rem', color: "var(--muted)"}}>{resultSubtext(r)}</div>}
</div>)}
</div>}
</div>;
@ -607,7 +615,7 @@ export default function MapViewer() {
try {
map = new maplibregl.Map({
container: mapContainer.current,
style: buildStyle(mapSources, theme, hasTerrain, scaleUnit),
style: buildStyle(mapSources, mapFlavor(theme), hasTerrain, scaleUnit, mapSprite(theme)),
center: [initialLon, initialLat],
zoom: initialZoom,
attributionControl: true,
@ -753,7 +761,8 @@ export default function MapViewer() {
const center = map.getCenter();
const zoom = map.getZoom();
map.setStyle(buildStyle(activeFilesRef.current, theme, hasTerrainRef.current, scaleUnit));
map.setStyle(buildStyle(activeFilesRef.current, mapFlavor(theme), hasTerrainRef.current,
scaleUnit, mapSprite(theme)));
map.once("style.load", () => {
map.setCenter(center);
map.setZoom(zoom);
@ -906,12 +915,12 @@ export default function MapViewer() {
}, [addingPin, loadPins]);
if (error) {
return <Segment>
return <Panel>
<Header as="h3">Map failed to load</Header>
<p>Your browser may not support WebGL. You can access the standalone map viewer
at <a href={MAP_VIEWER_URI}>{MAP_VIEWER_URI}</a></p>
<p>Error: {error}</p>
</Segment>;
</Panel>;
}
return <div ref={mapContainer} style={{position: "relative", width: "100%", height: "85vh"}}>
@ -924,14 +933,14 @@ export default function MapViewer() {
top: contextMenu.y,
zIndex: 1001,
background: "white",
borderRadius: 6,
borderRadius: "0.375rem",
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
padding: "4px 0",
fontSize: 13,
minWidth: 180,
padding: "0.25rem 0",
fontSize: '0.8125rem',
minWidth: "11.25rem",
}}
>
<div style={{padding: "6px 12px", color: "#666", fontSize: 12, borderBottom: "1px solid #eee"}}>
<div style={{padding: "0.375rem 0.75rem", color: "#666", fontSize: '0.75rem', borderBottom: "1px solid #eee"}}>
{(() => {
const map = mapRef.current;
const zoom = map ? map.getZoom() : 10;
@ -940,7 +949,7 @@ export default function MapViewer() {
})()}
</div>
<div
style={{padding: "8px 12px", cursor: "pointer", userSelect: "none"}}
style={{padding: "0.5rem 0.75rem", cursor: "pointer", userSelect: "none"}}
onMouseEnter={e => e.target.style.background = "#f0f0f0"}
onMouseLeave={e => e.target.style.background = "transparent"}
onClick={handleCopyCoords}
@ -948,7 +957,7 @@ export default function MapViewer() {
Copy GPS coordinates
</div>
<div
style={{padding: "8px 12px", cursor: "pointer", userSelect: "none"}}
style={{padding: "0.5rem 0.75rem", cursor: "pointer", userSelect: "none"}}
onMouseEnter={e => e.target.style.background = "#f0f0f0"}
onMouseLeave={e => e.target.style.background = "transparent"}
onClick={handleExportPng}
@ -956,7 +965,7 @@ export default function MapViewer() {
Export as PNG
</div>
<div
style={{padding: "8px 12px", cursor: "pointer", userSelect: "none"}}
style={{padding: "0.5rem 0.75rem", cursor: "pointer", userSelect: "none"}}
onMouseEnter={e => e.target.style.background = "#f0f0f0"}
onMouseLeave={e => e.target.style.background = "transparent"}
onClick={handleAddPin}
@ -964,7 +973,7 @@ export default function MapViewer() {
Add Pin Here
</div>
<div
style={{padding: "8px 12px", cursor: "pointer", userSelect: "none"}}
style={{padding: "0.5rem 0.75rem", cursor: "pointer", userSelect: "none"}}
onMouseEnter={e => e.target.style.background = "#f0f0f0"}
onMouseLeave={e => e.target.style.background = "transparent"}
onClick={handleSetDefaultLocation}

View file

@ -1,7 +1,8 @@
import React from "react";
import {Link, NavLink} from "react-router";
import {Dropdown, Icon as SIcon, Menu} from "semantic-ui-react";
import {Media, SettingsContext, StatusContext, ThemeContext} from "../contexts/contexts";
import {Link, NavLink, useLocation} from "react-router";
import {IconMenu2, IconPlugOff, IconTemperature, IconTemperaturePlus} from "@tabler/icons-react";
import {Icon, IconButton, Menu, Tooltip} from "./ui";
import {Media, SettingsContext, StatusContext} from "../contexts/contexts";
import {DarkModeToggle, HotspotStatusIcon, useLocalStorage} from "./Common";
import {ShareButton} from "./Share";
import {
@ -16,14 +17,14 @@ import {
} from "../hooks/customHooks";
import {useReorganizationStatus} from "../contexts/FileWorkerStatusContext";
import {SearchIconButton} from "./Search";
import {Icon, Popup} from "./Theme";
import {HELP_VIEWER_URI, NAME, semanticUIColorMap} from "./Vars";
import {HELP_VIEWER_URI, NAME, navColorHexMap} from "./Vars";
import {useOverflowNav} from "../hooks/useOverflowNav";
import {defaultNavColor, navBarStyle, useNavColors} from "../themes/navColors";
import _ from "lodash";
function updateFavicon(colorName) {
// Fall back to violet if invalid color
const safeColor = colorName in semanticUIColorMap ? colorName : 'violet';
const safeColor = colorName in navColorHexMap ? colorName : 'violet';
const faviconPath = `/favicon-${safeColor}.svg`;
// Update all favicon links
@ -39,11 +40,15 @@ function updateFavicon(colorName) {
if (icon16) icon16.href = faviconPath;
// Update theme-color meta tag for iOS/Android status bar
const hexColor = semanticUIColorMap[safeColor];
const hexColor = navColorHexMap[safeColor];
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) themeColorMeta.content = hexColor;
}
// A theme token name, or null to inherit the surrounding text/icon color (never
// literally "var(--null)").
const colorVar = (color) => color ? `var(--${color})` : undefined;
const help = {to: HELP_VIEWER_URI, text: 'Help', key: 'help', target: '_blank'};
const admin = {to: '/admin', text: 'Admin', key: 'admin'};
const rightLinks = [help, admin];
@ -56,40 +61,149 @@ const allLinks = [
{text: 'Playlists', to: '/playlists', key: 'playlists'},
{text: 'Zim', to: '/zim', key: 'zim'},
{text: 'Inventory', to: '/inventory', key: 'inventory'},
{to: '/more/calculators', text: 'Calculators', key: 'calculators', end: true},
// No `end` on these two: a tab stays current for the pages beneath it, and `end` would
// drop the mark the moment either grows a sub-page.
{to: '/more/calculators', text: 'Calculators', key: 'calculators'},
{text: 'Flasher', to: '/flasher', key: 'flasher'},
{to: '/more/statistics', text: 'Statistics', key: 'statistics', end: true},
{to: '/more/statistics', text: 'Statistics', key: 'statistics'},
];
function DropdownLinks({link}) {
return <Dropdown item text={link.text} direction='left'>
<Dropdown.Menu>
{link.links.map(l => <MenuLink key={l.to} link={l}/>)}
</Dropdown.Menu>
</Dropdown>
}
function MenuLink({link}) {
// Wrapper around NavLink to handle Navlink/Dropdown change.
// A top-level tab rendered directly in the bar (not inside a dropdown).
if (link.links) {
return <DropdownLinks link={link}/>
} else {
const end = link.end ? {end: true} : {end: undefined};
const target = link.target ? {target: link.target} : {};
return <NavLink
className='item'
to={link.to}
{...end}
{...target}
>
{link.text}
</NavLink>
}
const end = link.end ? {end: true} : {};
const target = link.target ? {target: link.target, rel: 'noopener noreferrer'} : {};
return <NavLink
className='wrolpi-navbar-link'
to={link.to}
{...end}
{...target}
>
{link.text}
</NavLink>
}
function NavIconWrapper({children}) {
function DropdownMenuItem({link}) {
// An item inside a Menu.Dropdown: the mobile hamburger menu, or the desktop
// "More" overflow menu.
if (link.links) {
// Mantine's Menu does not nest submenus without extra plumbing; a labelled,
// indented group stands in for one. No current data uses this path (no
// allLinks entry has its own `links`).
return <React.Fragment>
<Menu.Label>{link.text}</Menu.Label>
{link.links.map(l => <DropdownMenuItem key={l.key} link={l}/>)}
</React.Fragment>
}
const end = link.end ? {end: true} : {};
const target = link.target ? {target: link.target, rel: 'noopener noreferrer'} : {};
return <Menu.Item component={NavLink} to={link.to} {...end} {...target}>
{link.text}
</Menu.Item>
}
/*
* The trigger for a navbar dropdown.
*
* Shared with the hidden placeholder DesktopNav measures on its first render, and that is
* the point of extracting it. The placeholder used to be a plain `<button>More</button>`
* with no caret, so the space reserved for the overflow menu was about 21px short of the
* button that eventually rendered -- the row overflowed by that much and the bar wrapped.
* Measuring one thing and drawing another is only ever right by coincidence.
*/
const NavDropdownTrigger = React.forwardRef(({text, className, ...props}, ref) =>
/*
* `className` is pulled out and MERGED, never spread over. Menu.Target clones its child
* and hands it a className of its own, and a `{...props}` spread placed after the
* attribute replaces ours with it -- which left the real More button with no class at
* all, so it rendered as a bare grey UA button instead of taking the bar's color, its
* padding and its hover. The hidden placeholder is not cloned by Menu.Target, so it
* kept its class and measured wider than the button that actually drew.
*/
<button type='button' ref={ref}
className={['wrolpi-navbar-link', 'wrolpi-navbar-link-button', className]
.filter(Boolean).join(' ')}
{...props}>
{text}
<Icon name='dropdown' size='small' style={{marginLeft: '0.35em'}}/>
</button>
);
NavDropdownTrigger.displayName = 'NavDropdownTrigger';
/**
* Whether `link` covers `pathname`, matching NavLink's own rule: a link owns the pages
* beneath it unless it asks to match its path exactly.
*
* Duplicated from NavLink deliberately. The question here is asked about a link that is
* NOT rendered -- one hidden inside the overflow menu -- so there is no NavLink to ask, and
* the hooks that would answer it cannot be called in a loop.
*/
export const isLinkActive = (pathname, link) => {
// An off-site link (Help) is never the current section.
if (!link.to || /^[a-z]+:\/\//i.test(link.to)) return false;
const to = link.to.length > 1 ? link.to.replace(/\/+$/, '') : link.to;
if (link.end || to === '/') return pathname === to;
return pathname === to || pathname.startsWith(`${to}/`);
}
function DropdownLinks({link}) {
// A labelled dropdown trigger: the desktop "More" overflow, or any nested
// link group handed to MenuLink.
const {pathname} = useLocation();
/*
* Mark the trigger when the current section is one of the links folded inside it.
*
* Without this the bar goes blank the moment the window narrows enough to push the
* current tab into "More" -- the mark exists, but only after the user opens the menu
* to look for it, which is precisely when they least need telling.
*
* The class only, not `aria-current`: the real NavLink inside the menu already carries
* that, and a second "current" on the button that opens the menu would announce the
* same page twice.
*/
const holdsCurrent = (link.links || []).some(child => isLinkActive(pathname, child));
return <Menu position='bottom-end' withinPortal>
<Menu.Target>
<NavDropdownTrigger text={link.text} className={holdsCurrent ? 'active' : undefined}/>
</Menu.Target>
<Menu.Dropdown>
{link.links.map(l => <DropdownMenuItem key={l.key} link={l}/>)}
</Menu.Dropdown>
</Menu>
}
function MobileMenu({links}) {
// The mobile hamburger menu: an icon-only trigger holding every link.
return <Menu position='bottom-end' withinPortal>
<Menu.Target>
<IconButton icon={IconMenu2} label='Menu' variant='subtle'/>
</Menu.Target>
<Menu.Dropdown>
{links.map(link => <DropdownMenuItem key={link.key} link={link}/>)}
</Menu.Dropdown>
</Menu>
}
/*
* One status indicator's slot in the right-hand corner.
*
* This used to carry `marginTop: 0.8em`, a nudge inherited from the bar this replaced. The
* corner centres its children, and a top margin is part of the margin box being centred, so the
* nudge pushed every indicator down by half of it -- about 6px.
*
* That read as an error on some and not others, which is why it survived: the bare-icon
* anchors (share, search) sit high inside their line box because it reserves descender space
* for text that is not there, so the nudge happened to cancel out. An IconButton is a square
* ActionIcon that centres its glyph exactly, so for the hotspot and the theme picker the
* nudge was 6px of pure error. Laying the slot out as a flex box centres both shapes on
* their real glyph box and needs no nudge at all.
*/
export function NavIconWrapper({children}) {
if (children) {
return <div style={{marginTop: '0.8em', marginLeft: '1.5em'}}>{children}</div>
return <div className='wrolpi-navbar-icon'>{children}</div>
} else {
// Do not use navbar space if children is empty.
return <React.Fragment/>
@ -99,7 +213,7 @@ function NavIconWrapper({children}) {
function useNavColorSetting() {
// Use localstorage to avoid flickering navbar color on startup.
const {settings} = React.useContext(SettingsContext);
const [navColor, setNavColor] = useLocalStorage('nav_color', 'violet');
const [navColor, setNavColor] = useLocalStorage('nav_color', defaultNavColor);
React.useEffect(() => {
if (!_.isEmpty(settings)) {
@ -121,12 +235,16 @@ export function NavBar() {
const wrolModeEnabled = useWROLMode();
const {status} = React.useContext(StatusContext);
const navColor = useNavColorSetting();
/*
* The bar's foreground is measured against the background the theme resolved for the
* user's color, not taken from a single token. See themes/navColors.
*/
const navColors = useNavColors(navColor);
const wrolpiIcon = <img src='/icon.svg' height='32px' width='32px' alt='WROLPi Home Icon'/>;
const name = <i>{NAME || wrolpiIcon}</i>;
const topNavText = wrolModeEnabled ? <>{name}&nbsp; <SIcon name='lock'/></> : name;
const {i} = React.useContext(ThemeContext);
const topNavText = wrolModeEnabled ? <>{name}&nbsp; <Icon name='lock'/></> : name;
const homeLink = <NavLink className='item' to='/' style={{paddingTop: 0, paddingBottom: 0}}>
const homeLink = <NavLink className='wrolpi-navbar-link' to='/' style={{paddingTop: 0, paddingBottom: 0}}>
{topNavText}
</NavLink>;
@ -143,9 +261,10 @@ export function NavBar() {
// System load is high, display a warning icon.
const color = highLoad ? highWarningColor : lowWarningColor;
const icon = <Link to='/admin/status'>
<Icon name='tachometer alternate' size='large' color={color}/>
<Icon name='tachometer alternate' size='large' style={{color: colorVar(color)}}
label='System load warning'/>
</Link>;
systemLoadIcon = <Popup content={`Load: ${minute_1}`} trigger={icon}/>
systemLoadIcon = <Tooltip label={`Load: ${minute_1}`}>{icon}</Tooltip>
}
// RAM consumption.
@ -153,10 +272,16 @@ export function NavBar() {
let memoryIcon;
if (memoryPercent > 80) {
const color = memoryPercent > 90 ? highWarningColor : lowWarningColor;
const icon = <Link to='/admin/status' color={color}>
<Icon name='microchip' size='large'/>
{/*
The color goes on the Icon, as it does for every other indicator. It used to
be passed to `Link`, which ignores it, so the memory warning never actually
changed color -- 81% and 95% looked identical.
*/}
const icon = <Link to='/admin/status'>
<Icon name='microchip' size='large' label='Memory usage warning'
style={{color: colorVar(color)}}/>
</Link>;
memoryIcon = <Popup content={`System Memory: ${memoryPercent.toFixed()}%`} trigger={icon}/>
memoryIcon = <Tooltip label={`System Memory: ${memoryPercent.toFixed()}%`}>{icon}</Tooltip>
}
// Any disk is busy and processes are waiting.
@ -166,9 +291,9 @@ export function NavBar() {
// Processes are waiting on disk, display a warning icon.
const color = percentIOWait > 75 ? highWarningColor : lowWarningColor;
const icon = <Link to='/admin/status'>
<Icon name='disk' size='large' color={color}/>
<Icon name='disk' size='large' style={{color: colorVar(color)}} label='Disk I/O wait warning'/>
</Link>;
diskWaitIcon = <Popup content={`Processes waiting on disk: ${percentIOWait.toFixed()}%`} trigger={icon}/>
diskWaitIcon = <Tooltip label={`Processes waiting on disk: ${percentIOWait.toFixed()}%`}>{icon}</Tooltip>
}
// CPU temperature.
@ -177,10 +302,11 @@ export function NavBar() {
if (temperature && temperature >= highTemperature) {
// CPU temperature is high, display a warning icon.
const color = temperature >= criticalTemperature ? highWarningColor : lowWarningColor;
const name = temperature >= criticalTemperature ? 'thermometer' : 'thermometer half';
const icon = <Icon data-testid='cpuTemperatureIcon' name={name} size='large' color={color}/>
const temperatureComponent = temperature >= criticalTemperature ? IconTemperaturePlus : IconTemperature;
const icon = <Icon data-testid='cpuTemperatureIcon' component={temperatureComponent} size='large'
style={{color: colorVar(color)}} label='CPU temperature warning'/>
const link = <Link to='/admin/status'>{icon}</Link>;
temperatureIcon = <Popup content={`CPU: ${temperature.toFixed()}°C`} trigger={link}/>;
temperatureIcon = <Tooltip label={`CPU: ${temperature.toFixed()}°C`}>{link}</Tooltip>;
}
// Hard-drive temperature. An overheating drive risks data loss.
@ -193,9 +319,10 @@ export function NavBar() {
let driveTemperatureIcon;
if (driveTemperature && driveTemperature >= driveHighTemperature) {
const color = driveTemperature >= driveCriticalTemperature ? highWarningColor : lowWarningColor;
const icon = <Icon data-testid='driveTemperatureIcon' name='hdd' size='large' color={color}/>
const icon = <Icon data-testid='driveTemperatureIcon' name='hdd' size='large'
style={{color: colorVar(color)}} label='Drive temperature warning'/>
const link = <Link to='/admin/controller'>{icon}</Link>;
driveTemperatureIcon = <Popup content={`${hotDrive}: ${driveTemperature.toFixed()}°C`} trigger={link}/>;
driveTemperatureIcon = <Tooltip label={`${hotDrive}: ${driveTemperature.toFixed()}°C`}>{link}</Tooltip>;
}
// A drive's SMART health is degraded. FAIL is an imminent failure (back
@ -206,31 +333,36 @@ export function NavBar() {
const {failingDevices, failing: driveFailing, warningDevices, warning: driveWarning} = useDriveHealth();
let driveHealthIcon;
if (driveFailing) {
const icon = <Icon data-testid='driveHealthIcon' name='warning sign' size='large' color={highWarningColor}/>;
const icon = <Icon data-testid='driveHealthIcon' name='warning sign' size='large'
style={{color: colorVar(highWarningColor)}} label='Drive health failing'/>;
const link = <Link to='/admin/controller'>{icon}</Link>;
const message = failingDevices.length === 1
? `Drive ${failingDevices[0]} is failing its SMART health check! Back up your data.`
: `Drives failing SMART health check: ${failingDevices.join(', ')}. Back up your data.`;
driveHealthIcon = <Popup content={message} trigger={link}/>;
driveHealthIcon = <Tooltip label={message}>{link}</Tooltip>;
} else if (driveWarning) {
const icon = <Icon data-testid='driveHealthIcon' name='warning sign' size='large' color={lowWarningColor}/>;
const icon = <Icon data-testid='driveHealthIcon' name='warning sign' size='large'
style={{color: colorVar(lowWarningColor)}} label='Drive health warning'/>;
const link = <Link to='/admin/controller'>{icon}</Link>;
const message = warningDevices.length === 1
? `Drive ${warningDevices[0]} has unreadable or pending sectors — check its SMART health.`
: `Drives with unreadable or pending sectors: ${warningDevices.join(', ')}. Check their SMART health.`;
driveHealthIcon = <Popup content={message} trigger={link}/>;
driveHealthIcon = <Tooltip label={message}>{link}</Tooltip>;
}
// Power issues, this is always displayed if detected.
const {underVoltage, overCurrent} = usePowerStats();
let powerIcon;
if (underVoltage || overCurrent) {
const name = underVoltage ? 'power cord' : 'lightning';
const icon = <Icon name={name} size='large' color={highWarningColor}/>;
const icon = underVoltage
? <Icon component={IconPlugOff} size='large' style={{color: colorVar(highWarningColor)}}
label='Under-voltage warning'/>
: <Icon name='lightning' size='large' style={{color: colorVar(highWarningColor)}}
label='Over-current warning'/>;
const message = underVoltage
? 'Under-voltage detected! Your power supply is insufficient!'
: 'Over-current detected! Your peripherals are using too much power!';
powerIcon = <Popup content={message} trigger={icon}/>;
powerIcon = <Tooltip label={message}><span>{icon}</span></Tooltip>;
}
// Display the temperature icon first because it can cause the system to throttle. The rest are in order of effects
@ -269,15 +401,14 @@ export function NavBar() {
}
const processingIcon = processingLink &&
<Link to={processingLink}>
<Icon loading name='circle notch' size='large'/>
<Icon loading name='circle notch' size='large' label='File worker busy'/>
</Link>;
let apiDownIcon;
if (window.apiDown) {
apiDownIcon = <Popup
content='API is not responding'
trigger={<Icon name='plug' color={highWarningColor}/>}
/>
apiDownIcon = <Tooltip label='API is not responding'>
<span><Icon name='plug' style={{color: colorVar(highWarningColor)}} label='API is not responding'/></span>
</Tooltip>
}
// Upgrade available notification - only show on native (non-Docker) installs and when WROL Mode is disabled
@ -286,12 +417,12 @@ export function NavBar() {
const commitsBehind = status.commits_behind || 0;
const branch = status.git_branch || 'unknown';
const icon = <Link to='/admin/settings#upgrade'>
<Icon name='arrow alternate circle up' size='large' color='green'/>
<Icon name='arrow alternate circle up' size='large' style={{color: 'var(--green)'}}
label='Upgrade available'/>
</Link>;
upgradeIcon = <Popup
content={`Upgrade available: ${commitsBehind} commit(s) behind on ${branch}`}
trigger={icon}
/>;
upgradeIcon = <Tooltip label={`Upgrade available: ${commitsBehind} commit(s) behind on ${branch}`}>
{icon}
</Tooltip>;
}
const icons = <React.Fragment>
@ -308,27 +439,22 @@ export function NavBar() {
return <>
<Media between={['mobile', 'tablet']}>
<Menu {...i} attached='top' color={navColor} id='global_navbar'>
<nav className='wrolpi-navbar' id='global_navbar' style={navBarStyle(navColors)}>
{homeLink}
<Menu.Menu position='right'>
<div className='wrolpi-navbar-right'>
{icons}
<SearchIconButton/>
<Dropdown item icon="bars">
<Dropdown.Menu>
{allLinks.map(i => <MenuLink link={i} key={i.key}/>)}
{rightLinks.map(i => <MenuLink link={i} key={i.key}/>)}
</Dropdown.Menu>
</Dropdown>
</Menu.Menu>
</Menu>
<MobileMenu links={[...allLinks, ...rightLinks]}/>
</div>
</nav>
</Media>
<Media greaterThanOrEqual='tablet'>
<DesktopNav i={i} navColor={navColor} homeLink={homeLink} icons={icons}/>
<DesktopNav navColors={navColors} homeLink={homeLink} icons={icons}/>
</Media>
</>
}
function DesktopNav({i, navColor, homeLink, icons}) {
export function DesktopNav({navColors, homeLink, icons}) {
const containerRef = React.useRef(null);
const homeRef = React.useRef(null);
const rightMenuRef = React.useRef(null);
@ -343,7 +469,7 @@ function DesktopNav({i, navColor, homeLink, icons}) {
return (
<div ref={containerRef}>
<Menu {...i} attached='top' color={navColor} id='global_navbar'>
<nav className='wrolpi-navbar' id='global_navbar' style={navBarStyle(navColors)}>
<span ref={homeRef} style={{display: 'contents'}}>{homeLink}</span>
{(isReady ? visibleLinks : allLinks).map((link, idx) => (
<span
@ -355,16 +481,16 @@ function DesktopNav({i, navColor, homeLink, icons}) {
</span>
))}
{!isReady && <span ref={moreRef} style={{visibility: 'hidden', flexShrink: 0}}>
<Dropdown item text='More'><Dropdown.Menu/></Dropdown>
<NavDropdownTrigger text='More'/>
</span>}
{isReady && overflowLinks.length > 0 &&
<DropdownLinks link={{text: 'More', key: 'more', links: overflowLinks}}/>}
<div ref={rightMenuRef} className='right menu'>
<div ref={rightMenuRef} className='wrolpi-navbar-right'>
{icons}
<SearchIconButton/>
{rightLinks.map(i => <MenuLink link={i} key={i.key}/>)}
</div>
</Menu>
</nav>
</div>
);
}

View file

@ -0,0 +1,233 @@
import fs from 'fs';
import path from 'path';
import React from 'react';
import {screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {render} from '../test-utils';
import {FileWorkerStatusContext} from '../contexts/FileWorkerStatusContext';
import {NavBar} from './Nav';
/*
* Which navbar tab is marked as the current page.
*
* `NavLink` marks itself with `aria-current="page"` and an `active` class, and both need to
* be right: the class is what the bar styles, and the attribute is what tells a screen
* reader which section the user is in.
*
* The rule these tests encode is "a tab stays current for everything underneath it" --
* /videos/1234 is still Videos. That is `NavLink`'s default, so the tests exist to catch
* an `end` prop added to a link that has pages beneath it, which silently drops the mark on
* every one of those pages.
*/
/**
* Report a viewport to fresnel, which renders only the breakpoints whose query matches: a
* mock matching nothing renders no navbar at all, and one matching everything renders the
* mobile and desktop bars at once.
*/
const useViewport = (kind) => {
// Fresnel's breakpoints are mobile 0 / tablet 700 / wideTablet 880 / computer 1024.
const matches = kind === 'desktop'
? (query) => query.includes('1024')
: (query) => !/\d{3,}px/.test(query) || /\(max-width/.test(query);
window.matchMedia = jest.fn().mockImplementation(query => ({
matches: matches(query),
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
};
/**
* Give jsdom a bar of `barWidth` holding tabs of 60px each.
*
* Every width is 0 otherwise, so useOverflowNav concludes that nothing fits and sweeps all
* eleven tabs into the closed "More" menu -- the bar renders no tabs at all and every
* assertion about them passes vacuously. A narrow bar is how the overflow path is reached
* on purpose.
*/
const useBarWidth = (barWidth) => {
rectSpy = rectSpy || null;
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {configurable: true, value: barWidth});
rectSpy = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({
width: 60, height: 40, top: 0, left: 0, right: 60, bottom: 40, x: 0, y: 0,
toJSON: () => ({}),
}));
};
/**
* Hand back the real `getBoundingClientRect`.
*
* The stub above is for the overflow measurement, which the mobile bar never runs -- and
* floating-ui, which positions the menu's portal, walks up to the document element and
* crashes the worker on a rect that is not a real DOMRect.
*/
const useRealRects = () => {
if (rectSpy) {
rectSpy.mockRestore();
rectSpy = null;
}
};
let rectSpy = null;
beforeEach(() => {
rectSpy = null;
useViewport('desktop');
useBarWidth(2000);
});
// NavBar reads the file-worker status for its indicator icons. That context lives outside
// contexts/contexts.js, so the spec supplies it -- see the note at the top of test-utils.
const fileWorkerStatus = {status: null, error: null, refresh: jest.fn(), setFastPolling: jest.fn()};
const renderNav = (route) => render(<NavBar/>, {
route,
withMedia: true,
contexts: [[FileWorkerStatusContext, fileWorkerStatus]],
});
/** The tab marked as the current page, by its text. */
const currentTab = () => {
const current = document.querySelectorAll('[aria-current="page"]');
return [...current].map(el => el.textContent.trim());
};
describe('NavBar current tab', () => {
it.each([
['/videos', 'Videos'],
['/archives', 'Archive'],
['/docs', 'Docs'],
['/map', 'Map'],
['/files', 'Files'],
['/playlists', 'Playlists'],
['/zim', 'Zim'],
['/inventory', 'Inventory'],
['/flasher', 'Flasher'],
['/more/calculators', 'Calculators'],
['/more/statistics', 'Statistics'],
])('marks %s as %s', (route, text) => {
renderNav(route);
// Exactly, not `toContain`: a regression leaving Home permanently marked alongside
// the real tab satisfies "contains" on every row here.
expect(currentTab()).toEqual([text]);
});
it.each([
['/videos/1234', 'Videos'],
['/videos/channel/5/video', 'Videos'],
['/archives/domain/example.com', 'Archive'],
['/files/some/deep/directory', 'Files'],
['/inventory/1/items', 'Inventory'],
['/more/calculators/electrical', 'Calculators'],
])('keeps the tab marked on a page underneath it: %s stays %s', (route, text) => {
/*
* Not where the reported bug was -- Videos never carried `end`, so opening a video
* always marked it in the DOM; what was missing was the CSS that draws the mark.
* These cases guard the other half: that nobody adds `end` to a link with pages
* beneath it, which is what Calculators and Statistics used to have.
*/
renderNav(route);
expect(currentTab()).toEqual([text]);
});
it('marks the home link only at the root', () => {
// `to='/'` matches every path unless it is told not to, which would leave the home
// link permanently marked and two tabs claiming to be the current page at once.
renderNav('/videos');
const home = screen.getByRole('link', {name: /WROLPi Home Icon|WROLPi/i});
expect(home).not.toHaveAttribute('aria-current');
});
it('marks exactly one tab at a time', () => {
renderNav('/videos/1234');
expect(currentTab()).toHaveLength(1);
});
it('marks the home link at the root', () => {
renderNav('/');
expect(currentTab()).toHaveLength(1);
});
it('gives the current tab the class the bar styles', () => {
renderNav('/videos');
expect(document.querySelector('[aria-current="page"]')).toHaveClass('active');
});
it('marks the current item inside the mobile menu', async () => {
// On a phone the bar is a hamburger, so these menu items are the only place the
// mark can appear -- and `.mantine-Menu-item.active` is the only rule that draws it.
useViewport('mobile');
useRealRects();
renderNav('/videos');
await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
const videos = await screen.findByRole('menuitem', {name: 'Videos'});
expect(videos).toHaveAttribute('aria-current', 'page');
expect(videos).toHaveClass('active');
});
it('marks the More button when the current section is folded inside it', () => {
/*
* A narrow bar pushes tabs into the overflow menu. Nothing in the bar was marked
* then: the trigger is a plain button, not a NavLink, so the bar went blank exactly
* when the window got too small to show the tab -- and the user had to open the menu
* to find out where they were.
*/
useBarWidth(200);
renderNav('/videos');
const more = screen.getByRole('button', {name: /More/});
expect(more).toHaveClass('active');
});
it('leaves the More button unmarked when the current section is not inside it', () => {
useBarWidth(200);
// Nothing in the bar's link list covers /admin/settings; Admin sits outside it.
renderNav('/admin/settings');
expect(screen.getByRole('button', {name: /More/})).not.toHaveClass('active');
});
it('styles that class, so the current tab actually looks current', () => {
/*
* The reported bug in full: react-router was already marking the tab, and the mark
* was already reaching the DOM -- nothing styled it, so every tab looked identical
* and clicking Videos appeared to do nothing to the bar.
*
* jsdom applies no stylesheet, so the rule itself is the thing to guard, and it is
* guarded by its declarations rather than by its selector: a check for the selector
* alone still passes with the body emptied, or with the underline deleted -- and the
* underline is the half that survives on a dark bar, where the wash barely reads.
*/
const css = fs.readFileSync(path.join(__dirname, '..', 'App.css'), 'utf8');
const ruleBody = (selector) => {
const match = css.match(new RegExp(`${selector}\\s*{([^}]*)}`));
return match ? match[1] : '';
};
// The wash.
expect(ruleBody('\\.wrolpi-navbar-link\\.active')).toMatch(/background:/);
// The underline: drawn, in the bar's own foreground, with a height to draw.
const underline = ruleBody('\\.wrolpi-navbar-link\\.active::after');
expect(underline).toMatch(/content:/);
expect(underline).toMatch(/background:\s*currentColor/);
// Parsed, not pattern-matched: `(?!0)` would have called 0.1875rem zero.
const height = underline.match(/height:\s*([\d.]+)\s*(?:rem|em|px)/);
expect(height).not.toBeNull();
expect(parseFloat(height[1])).toBeGreaterThan(0);
// The menu items, which are the whole of the mark on a phone.
expect(ruleBody('\\.mantine-Menu-item\\.active')).toMatch(/background:|font-weight:/);
});
});

View file

@ -1,61 +1,38 @@
import {CardContent, CardGroup, PlaceholderImage, PlaceholderLine} from "semantic-ui-react";
import React, {useContext} from "react";
import {ThemeContext} from "../contexts/contexts";
import {Card, Placeholder} from "./Theme";
import React from "react";
import {Card, CardGroup, Placeholder, Skeleton} from "./ui";
export function CardPlaceholder() {
const {s} = useContext(ThemeContext);
return <Card>
<Placeholder>
<PlaceholderImage rectangular/>
</Placeholder>
<CardContent {...s}>
<Placeholder>
<PlaceholderLine/>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>
</CardContent>
return <Card media={<Skeleton height={140} radius={0} animate/>}>
<Placeholder lines={3}/>
</Card>
}
export function VideoPlaceholder() {
return <CardGroup doubling stackable>
return <CardGroup>
<CardPlaceholder/>
</CardGroup>
}
export function ChannelPlaceholder() {
return <Placeholder>
<PlaceholderLine length='long'/>
<PlaceholderLine length='short'/>
</Placeholder>
return <Placeholder lines={2}/>
}
export function ProgressPlaceholder() {
return <Placeholder style={{marginBottom: '1em'}}>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>
return <div style={{marginBottom: '1em'}}>
<Placeholder lines={2}/>
</div>
}
export function TextPlaceholder() {
return <Placeholder style={{marginBottom: '1em'}}>
<PlaceholderLine/>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>
return <div style={{marginBottom: '1em'}}>
<Placeholder lines={3}/>
</div>
}
export function TagPlaceholder() {
return <Placeholder style={{height: 30, width: 80}}>
<PlaceholderImage/>
</Placeholder>
return <Skeleton height={30} width={80} radius={0} animate/>
}
export function TableRowPlaceholder() {
return <Placeholder>
<PlaceholderLine/>
</Placeholder>
}
return <Placeholder lines={1}/>
}

View file

@ -1,9 +1,19 @@
import React, {useEffect, useState} from "react";
import {Link, Route, Routes, useNavigate, useParams} from "react-router";
import {Grid, Image, Input, Message} from "semantic-ui-react";
import {toast} from "react-semantic-toasts-2";
import {Button, Form, Header, Icon, Loader, Modal, Segment, Table} from "./Theme";
import {IconMapPin} from "@tabler/icons-react";
import {
Button,
Group,
Header,
Icon,
Loading,
Message,
Modal,
Panel,
Table,
TextInput,
toast,
} from "./ui";
import {
APIButton,
BackButton,
@ -35,7 +45,6 @@ import {
setPlaylistTag,
updatePlaylist,
} from "../api";
import {ThemeContext} from "../contexts/contexts";
import {TagsSelector} from "../Tags";
@ -119,39 +128,38 @@ export function PlaylistsPage() {
}
};
const header = <div style={{marginBottom: '1em'}}>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
<SearchInput
placeholder='Name filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(playlists) || playlists.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
</Grid.Column>
<Grid.Column textAlign='right'>
<Button secondary onClick={() => setModalOpen(true)}>New Playlist</Button>
</Grid.Column>
</Grid.Row>
</Grid>
</div>;
// No `mb`: this is a top-level block of the page, and the page's stack spaces it. Its own
// margin was added to that gap -- the same fix as the twin filter rows on Channels/Domains.
const header = <Group justify='space-between' align='flex-end' wrap='wrap'>
<SearchInput
placeholder='Name filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(playlists) || playlists.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
<Button role='primary' onClick={() => setModalOpen(true)}>New Playlist</Button>
</Group>;
const createModal = <Modal open={modalOpen} onClose={() => setModalOpen(false)} size='tiny'>
<Modal.Header>New Playlist</Modal.Header>
<Modal.Content>
<Form onSubmit={handleCreate}>
<Form.Field>
<label>Name</label>
<Input autoFocus placeholder='Playlist name...' value={name}
onChange={(e, {value}) => setName(value)}/>
</Form.Field>
<Form.Field>
<label>
<form onSubmit={e => {
e.preventDefault();
handleCreate();
}}>
<TextInput
autoFocus
label='Name'
placeholder='Playlist name...'
value={name}
onChange={(e) => setName(e.currentTarget.value)}
/>
<div style={{marginTop: '1em'}}>
<label style={{display: 'flex', alignItems: 'center', gap: '0.3em', marginBottom: '0.3em'}}>
Tag
<InfoPopup content='Optional. A tagged playlist lives under its tag in the
Playlists Directory.'/>
@ -162,12 +170,12 @@ export function PlaylistsPage() {
onAdd={setTagName}
onRemove={() => setTagName(null)}
/>
</Form.Field>
</Form>
</div>
</form>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setModalOpen(false)}>Cancel</Button>
<Button primary disabled={!name.trim()} onClick={handleCreate}>Create</Button>
<Button role='cancel' onClick={() => setModalOpen(false)}>Cancel</Button>
<Button role='save' disabled={!name.trim()} onClick={handleCreate}>Create</Button>
</Modal.Actions>
</Modal>;
@ -185,22 +193,24 @@ export function PlaylistsPage() {
}
// Icon (name + color) for a playlist item. File items use the FileGroup's mimetype/model icon
// and color (video, pdf, ebook, image, ...) so they match the rest of the UI; zim/url are fixed.
function itemIcon(item) {
// Icon for a playlist item. File items use FileGroup's mimetype/model icon and color (video,
// pdf, ebook, image, ...) so they match the rest of the UI; zim/url are fixed.
function ItemIcon({item}) {
if (item.item_kind === 'file' && item.file_group) {
const fg = item.file_group;
const lowerPath = (fg.primary_path || '').toLowerCase();
return {name: mimetypeIconName(fg.mimetype, lowerPath), color: mimetypeColor(fg.mimetype, lowerPath)};
const name = mimetypeIconName(fg.mimetype, lowerPath);
const color = mimetypeColor(fg.mimetype, lowerPath);
return <Icon name={name} size='large' style={{color: `var(--${color})`}}/>;
}
if (item.item_kind === 'zim') return {name: 'book'};
if (item.item_kind === 'zim') return <Icon name='book' size='large'/>;
if (item.item_kind === 'url') {
// A map-location URL (e.g. /map?lat=&lon=) gets a map marker; other URLs get the link icon.
const u = (item.url || '').toLowerCase();
if (u.startsWith('/map')) return {name: 'map marker alternate'};
return {name: 'linkify'};
if (u.startsWith('/map')) return <Icon component={IconMapPin} size='large'/>;
return <Icon name='linkify' size='large'/>;
}
return {name: 'file'};
return <Icon name='file' size='large'/>;
}
@ -278,13 +288,13 @@ function ItemTitle({item, label}) {
// The ordered items table, shared by the view and edit pages. When `editable`, the reorder/remove
// action buttons (and their column) are shown; otherwise the table is read-only.
function PlaylistItemsTable({items, editable, onMove, onRemove}) {
return <Table unstackable celled>
return <Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell collapsing>#</Table.HeaderCell>
<Table.HeaderCell collapsing>Item</Table.HeaderCell>
<Table.HeaderCell>#</Table.HeaderCell>
<Table.HeaderCell>Item</Table.HeaderCell>
<Table.HeaderCell>Title</Table.HeaderCell>
{editable && <Table.HeaderCell collapsing/>}
{editable && <Table.HeaderCell/>}
</Table.Row>
</Table.Header>
<Table.Body>
@ -292,24 +302,26 @@ function PlaylistItemsTable({items, editable, onMove, onRemove}) {
const label = itemLabel(item);
const poster = item.item_kind === 'file' && item.file_group
? findPosterPath(item.file_group) : null;
return <Table.Row key={item.id} verticalAlign='middle'>
<Table.Cell collapsing>{String(index + 1).padStart(2, '0')}</Table.Cell>
<Table.Cell collapsing textAlign='center' style={{width: '90px'}}>
return <Table.Row key={item.id}>
<Table.Cell>{String(index + 1).padStart(2, '0')}</Table.Cell>
<Table.Cell style={{width: '90px', textAlign: 'center'}}>
{poster
? <Image src={`/media/${encodeMediaPath(poster)}`} alt=''
centered style={{maxHeight: '45px', maxWidth: '80px', width: 'auto'}}/>
: <Icon {...itemIcon(item)} size='large'/>}
? <img alt='' src={`/media/${encodeMediaPath(poster)}`}
style={{maxHeight: '45px', maxWidth: '80px', width: 'auto', display: 'block', margin: '0 auto'}}/>
: <ItemIcon item={item}/>}
</Table.Cell>
<Table.Cell>
<ItemTitle item={item} label={label}/>
</Table.Cell>
{editable && <Table.Cell collapsing textAlign='right'>
<Button icon='arrow up' size='mini' disabled={index === 0}
onClick={() => onMove(index, -1)}/>
<Button icon='arrow down' size='mini' disabled={index === items.length - 1}
onClick={() => onMove(index, 1)}/>
<Button icon='trash' color='red' size='mini'
onClick={() => onRemove(item.id)}/>
{editable && <Table.Cell>
<div className='wrolpi-button-row' style={{justifyContent: 'flex-end'}}>
<Button icon='arrow up' size='xs' disabled={index === 0}
onClick={() => onMove(index, -1)}/>
<Button icon='arrow down' size='xs' disabled={index === items.length - 1}
onClick={() => onMove(index, 1)}/>
<Button role='danger' icon='trash' size='xs'
onClick={() => onRemove(item.id)}/>
</div>
</Table.Cell>}
</Table.Row>;
})}
@ -323,7 +335,6 @@ function PlaylistItemsTable({items, editable, onMove, onRemove}) {
export function PlaylistViewPage() {
const {playlistId} = useParams();
const {playlist} = usePlaylist(playlistId);
const {t} = React.useContext(ThemeContext);
useTitle(playlist && playlist.name ? `${playlist.name} Playlist` : 'Playlist');
@ -331,13 +342,13 @@ export function PlaylistViewPage() {
return <ErrorMessage>Could not fetch playlist</ErrorMessage>;
}
if (playlist === null) {
return <Loader active inline='centered'/>;
return <Loading/>;
}
const items = playlist.items || [];
return <>
<BackButton/>
<div className='wrolpi-button-row'><BackButton/></div>
<Header as='h1'>
{playlist.name}
@ -345,10 +356,10 @@ export function PlaylistViewPage() {
<Icon name='edit' style={{marginLeft: '0.5em'}}/>
</Link>
</Header>
{playlist.description && <p {...t}>{playlist.description}</p>}
{playlist.description && <p>{playlist.description}</p>}
{items.length === 0
? <Message><Message.Header>This playlist is empty</Message.Header></Message>
? <Message title='This playlist is empty'/>
: <PlaylistItemsTable items={items} editable={false}/>}
</>;
}
@ -385,7 +396,7 @@ export function PlaylistEditPage() {
return <ErrorMessage>Could not fetch playlist</ErrorMessage>;
}
if (playlist === null) {
return <Loader active inline='centered'/>;
return <Loading/>;
}
const items = playlist.items || [];
@ -463,23 +474,21 @@ export function PlaylistEditPage() {
};
const deleteButton = <APIButton
color='red'
role='danger'
size='small'
confirmContent='Are you sure you want to delete this playlist? Its directory will be removed.'
confirmButton='Delete'
confirmHeader='Delete Playlist?'
onClick={handleDelete}
obeyWROLMode={true}
style={{marginTop: '1em'}}
>Delete</APIButton>;
const tagButton = <Button
type="button"
size='small'
onClick={() => setTagModalOpen(true)}
color='violet'
role='primary'
disabled={!editable}
style={{marginTop: '1em'}}
>Tag</Button>;
const actionButtons = <>
@ -491,10 +500,12 @@ export function PlaylistEditPage() {
const form = {error: null, loading: false, disabled: !editable || !name.trim(), ready: true};
return <>
<BackButton/>
<Link to={`/playlists/${playlistId}`}>
<Button>View</Button>
</Link>
<div className='wrolpi-button-row'>
<BackButton/>
<Link to={`/playlists/${playlistId}`}>
<Button>View</Button>
</Link>
</div>
<CollectionEditForm
form={form}
@ -504,47 +515,35 @@ export function PlaylistEditPage() {
appliedTagName={playlist.tag_name}
onSubmit={handleSave}
>
<Grid.Row>
<Grid.Column width={8}>
<Form.Field>
<label>Playlist Name</label>
<Input
placeholder='Playlist name...'
value={name}
disabled={!editable}
onChange={(e, {value}) => setName(value)}
/>
</Form.Field>
</Grid.Column>
<Grid.Column width={8}>
<Form.Field>
<label>
Directory
<InfoPopup content='Where the playlist lives on disk. By default it is
managed automatically in the Playlists Directory (under its tag, if
tagged); choose a different directory to manage it manually.'/>
</label>
<DirectorySearch
value={directory}
disabled={!editable}
onSelect={value => setDirectory(value || '')}
/>
</Form.Field>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<Form.Field>
<label>Description</label>
<Input
placeholder='Optional description...'
value={description}
disabled={!editable}
onChange={(e, {value}) => setDescription(value)}
/>
</Form.Field>
</Grid.Column>
</Grid.Row>
<TextInput
label='Playlist Name'
placeholder='Playlist name...'
value={name}
disabled={!editable}
onChange={(e) => setName(e.currentTarget.value)}
/>
<div style={{marginTop: '1em'}}>
<label style={{display: 'flex', alignItems: 'center', gap: '0.3em', marginBottom: '0.3em'}}>
Directory
<InfoPopup content='Where the playlist lives on disk. By default it is
managed automatically in the Playlists Directory (under its tag, if
tagged); choose a different directory to manage it manually.'/>
</label>
<DirectorySearch
value={directory}
disabled={!editable}
onSelect={value => setDirectory(value || '')}
/>
</div>
<div style={{marginTop: '1em'}}>
<TextInput
label='Description'
placeholder='Optional description...'
value={description}
disabled={!editable}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
</div>
</CollectionEditForm>
{/* Tag Modal */}
@ -559,34 +558,34 @@ export function PlaylistEditPage() {
/>
{/* Items Segment */}
<Segment>
<Panel>
<Header as='h1'>Items</Header>
{items.length === 0
? <Message>
<Message.Header>This playlist is empty</Message.Header>
{editable && <Message.Content>Add a link below.</Message.Content>}
? <Message title='This playlist is empty'>
{editable && 'Add a link below.'}
</Message>
: <PlaylistItemsTable items={items} editable={editable} onMove={move} onRemove={handleRemove}/>}
<Header as='h4'>Add a link</Header>
<Form onSubmit={handleAddUrl}>
<Form.Group>
<Form.Field width={8}>
<Input placeholder='URL (e.g. /map?lat=40.76&lon=-111.89&z=10)'
value={url} disabled={!editable}
onChange={(e, {value}) => setUrl(value)}/>
</Form.Field>
<Form.Field width={6}>
<Input placeholder='Title (optional)' value={urlTitle} disabled={!editable}
onChange={(e, {value}) => setUrlTitle(value)}/>
</Form.Field>
<Form.Field>
<Button primary type='submit' disabled={!editable || !url.trim()}>Add</Button>
</Form.Field>
</Form.Group>
</Form>
</Segment>
<form onSubmit={e => {
e.preventDefault();
handleAddUrl();
}}>
<Group align='flex-end' wrap='wrap'>
<TextInput
style={{flex: '2 1 300px'}}
placeholder='URL (e.g. /map?lat=40.76&lon=-111.89&z=10)'
value={url} disabled={!editable}
onChange={(e) => setUrl(e.currentTarget.value)}/>
<TextInput
style={{flex: '1 1 200px'}}
placeholder='Title (optional)' value={urlTitle} disabled={!editable}
onChange={(e) => setUrlTitle(e.currentTarget.value)}/>
<Button role='primary' type='submit' disabled={!editable || !url.trim()}>Add</Button>
</Group>
</form>
</Panel>
</>;
}

View file

@ -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 = [
@ -248,7 +247,7 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
const noResults = [{title: 'No results'}];
const normalizeSuggestionsResults = (newSuggestions) => {
// Convert the suggestions from the Backend to what the Semantic <Search> expects.
// Convert the suggestions from the Backend to what SearchBox expects.
const lowerSearchStr = searchStr ? searchStr.toLowerCase() : '';
let results = {};
@ -460,7 +459,7 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
if (description) {
return <>
<SHeader as='h4'>{title}</SHeader>
<Header as='h4'>{title}</Header>
{description}
</>
}
@ -514,11 +513,20 @@ export function SearchView({suggestions, suggestionsSums, loading}) {
}
export function SearchIconButton() {
// A single button which opens the search modal via keyboard shortcuts context.
/*
* A single button which opens the search modal via keyboard shortcuts context.
*
* `wrolpi-navbar-link`, not the leftover `item` class, which carries no rules at all
* now that the stylesheet defining it is gone: no pointer cursor, no hover highlight, and a hit area of
* whatever the 18px glyph occupied rather than the height of the bar. It is the same
* kind of thing as Help and Admin and now has the same affordance.
*/
const {openSearchModal} = React.useContext(KeyboardShortcutsContext);
return (
<a className='item' style={{paddingRight: '0.7em'}} onClick={openSearchModal}>
<a className='wrolpi-navbar-link' onClick={openSearchModal}
role='button' tabIndex={0} aria-label='Search'
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && openSearchModal()}>
<Icon name='search'/>
<ShortcutHint shortcutKey="K"/>
</a>
@ -526,56 +534,38 @@ export function SearchIconButton() {
}
function SearchChannelPreview({channel}) {
const {t} = React.useContext(ThemeContext);
return <GridRow {...t}>
<GridColumn>
<Link to={`/videos/channel/${channel.id}/video`}>
{channel.name}
</Link>
</GridColumn>
</GridRow>
return <div>
<Link to={`/videos/channel/${channel.id}/video`}>
{channel.name}
</Link>
</div>
}
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 <Accordion>
<Segment><Loader active/></Segment>
</Accordion>
return <Panel><Loading/></Panel>
}
const channelsAccordion = <React.Fragment>
<AccordionTitle
index={0}
active={activeIndex === 0}
onClick={() => handleClick(0)}
>
<Header as='h3'>
<Icon name='dropdown'/>
Channels
<Label>{normalizeEstimate(channels.length)}</Label>
</Header>
</AccordionTitle>
<AccordionContent active={activeIndex === 0}>
<Grid>
{!_.isEmpty(channels) ?
channels.map(i => <SearchChannelPreview channel={i}/>)
: 'No Channels'}
</Grid>
</AccordionContent>
</React.Fragment>;
return <Accordion>
{channelsAccordion}
return <Accordion value={activeValue} onChange={setActiveValue}>
<Accordion.Item value='channels'>
<Accordion.Control>
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8}}>
<Header as='h3' style={{margin: 0}}>Channels</Header>
<Label>{normalizeEstimate(channels.length)}</Label>
</span>
</Accordion.Control>
<Accordion.Panel>
<Stack gap='xs'>
{!_.isEmpty(channels) ?
channels.map(i => <SearchChannelPreview key={i.id} channel={i}/>)
: 'No Channels'}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
}
}

View file

@ -1,12 +1,15 @@
import React from 'react';
import {fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router';
import {fireEvent, render, screen} from '../test-utils';
import {SearchFilterButton} from './Files';
import {QueryContext, ThemeContext} from '../contexts/contexts';
import {QueryContext} from '../contexts/contexts';
// Render SearchFilterButton with a controlled QueryContext so the search hooks
// (useSearchFilter/useSearchOrder/useSearchDate/useSearch) can read the URL
// query params without a real router/provider.
//
// `render` here comes from `../test-utils`, which already supplies MantineProvider
// (needed by components under src/components/ui, e.g. the Toggle/Switch) and a
// BrowserRouter, plus a default ThemeContext -- so only QueryContext needs wiring here.
function renderButton(props = {}, initialParams = {}, onUpdateQuery = null) {
const params = new URLSearchParams(initialParams);
@ -29,17 +32,10 @@ function renderButton(props = {}, initialParams = {}, onUpdateQuery = null) {
});
setSearchParams(next);
};
const theme = {
i: {}, s: {}, t: {}, theme: 'light', inverted: false, setInverted: () => {},
};
return (
<MemoryRouter>
<ThemeContext.Provider value={theme}>
<QueryContext.Provider value={{searchParams, updateQuery, getLocationStr: () => '/'}}>
{children}
</QueryContext.Provider>
</ThemeContext.Provider>
</MemoryRouter>
<QueryContext.Provider value={{searchParams, updateQuery, getLocationStr: () => '/'}}>
{children}
</QueryContext.Provider>
);
}
@ -68,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();
@ -83,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.
@ -98,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);
@ -125,8 +121,8 @@ describe('SearchFilterButton', () => {
fireEvent.click(screen.getByText('Filter'));
// The Availability section/toggle is shown.
expect(screen.getByText('Availability')).toBeInTheDocument();
fireEvent.mouseUp(screen.getByTestId('toggle'));
expect(await screen.findByText('Availability')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('toggle'));
// Draft only — nothing applied yet.
expect(spy).not.toHaveBeenCalled();
@ -135,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();
});
@ -146,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);
@ -155,8 +152,8 @@ describe('SearchFilterButton', () => {
fireEvent.click(screen.getByText('Filter'));
// The Search Depth section/toggle is shown.
expect(screen.getByText('Search Depth')).toBeInTheDocument();
fireEvent.mouseUp(screen.getByTestId('toggle'));
expect(await screen.findByText('Search Depth')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('toggle'));
// Draft only — nothing applied yet.
expect(spy).not.toHaveBeenCalled();
@ -165,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();
});
@ -176,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();
});

View file

@ -1,6 +1,5 @@
import React from "react";
import {Button, Header, Icon, Modal} from "./Theme";
import {IconGroup} from "semantic-ui-react";
import {Button, Header, Icon, Modal} from "./ui";
import QRCode from "react-qr-code";
import {sendNotification} from "../api";
@ -39,26 +38,26 @@ export const ShareButton = () => {
}
return <>
<Modal closeIcon
<Modal size='small' closeIcon
open={open}
onClose={handleClose}
>
<Modal.Header>Share this page</Modal.Header>
<Modal.Content>
<Header as='h4'>Another user can scan this QR code to view this page</Header>
<div style={{padding: '1em', backgroundColor: '#ffffff', display: "inline-block"}}>
{/* `media`: filtered as a unit by night mode; see DonatePage. */}
<div className='media'
style={{padding: '1em', backgroundColor: '#ffffff', display: "inline-block"}}>
<QRCode value={window.location.href}/>
</div>
</Modal.Content>
<Modal.Actions>
<ShareEveryoneButton url={window.location.href} onClick={handleClose}/>
<Button onClick={handleClose}>Close</Button>
<Button role='cancel' onClick={handleClose}>Close</Button>
</Modal.Actions>
</Modal>
<a href='#' onClick={handleOpen}>
<IconGroup size='large'>
<Icon name='share'/>
</IconGroup>
<Icon name='share' size='large'/>
</a>
</>
}
}

View file

@ -1,7 +1,6 @@
import React from "react";
import _ from "lodash";
import {TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Table} from "./Theme";
import {Table} from "./ui";
import {TextPlaceholder} from "./Placeholder";
export class SortableTable extends React.Component {
@ -39,29 +38,29 @@ export class SortableTable extends React.Component {
render() {
let {rowFunc, tableHeaders, rowKey} = this.props;
let {direction} = this.state;
let {direction, sortColumn} = this.state;
rowKey = rowKey || 'key';
const data = this.props['data'] ? this.sortData(this.props['data']) : null;
const tableHeader = (spec) => {
const {key, text, sortBy, width} = spec;
const {key, text, sortBy} = spec;
if (sortBy) {
return <TableHeaderCell
const active = sortColumn === key;
return <Table.HeaderCell
key={key}
sorted={this.state['sortColumn'] === key ? direction : null}
onClick={() => this.changeSort(key)}
width={width || undefined}
sorted={active ? direction : null}
onSort={() => this.changeSort(key)}
>
{text}
</TableHeaderCell>;
</Table.HeaderCell>;
} else {
return <TableHeaderCell key={key} width={width}>{text}</TableHeaderCell>
return <Table.HeaderCell key={key}>{text}</Table.HeaderCell>
}
}
// Use placeholder while data is null.
let rows = <TableRow><TableCell colSpan={tableHeaders.length}><TextPlaceholder/></TableCell></TableRow>;
let rows = <Table.Row><Table.Cell colSpan={tableHeaders.length}><TextPlaceholder/></Table.Cell></Table.Row>;
if (data !== null) {
if (data.length > 0) {
// Convert data to table rows.
@ -69,19 +68,19 @@ export class SortableTable extends React.Component {
} else {
// No results in data.
rows = this.props.emptyRow
|| <TableRow><TableCell colSpan={tableHeaders.length}>No results</TableCell></TableRow>;
|| <Table.Row><Table.Cell colSpan={tableHeaders.length}>No results</Table.Cell></Table.Row>;
}
}
return <Table sortable {...this.props.tableProps}>
<TableHeader>
<TableRow>
return <Table {...this.props.tableProps}>
<Table.Header>
<Table.Row>
{tableHeaders.map(tableHeader)}
</TableRow>
</TableHeader>
<TableBody>
</Table.Row>
</Table.Header>
<Table.Body>
{rows}
</TableBody>
</Table.Body>
{this.props.footer}
</Table>
}

View file

@ -1,27 +1,26 @@
import {Table} from "./Theme";
import {Checkbox, TableBody, TableCell, TableFooter, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Checkbox, Table} from "./ui";
import React from "react";
export function SelectableTable({headerContents, selectOn, onSelect, selectedKeys, footer, rows}) {
footer = footer ? <TableFooter>
<TableRow>
footer = footer ? <Table.Footer>
<Table.Row>
{footer}
</TableRow>
</TableFooter> : null;
</Table.Row>
</Table.Footer> : null;
return <Table unstackable striped compact='very'>
<TableHeader>
<TableRow>
{selectOn && <TableHeaderCell/>}
{headerContents.map(i => <TableHeaderCell key={i}>{i}</TableHeaderCell>)}
</TableRow>
</TableHeader>
<TableBody>
return <Table>
<Table.Header>
<Table.Row>
{selectOn && <Table.HeaderCell/>}
{headerContents.map(i => <Table.HeaderCell key={i}>{i}</Table.HeaderCell>)}
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map(row =>
<SelectableRow key={row.key} selectOn={selectOn} onSelect={onSelect} selectedKeys={selectedKeys}>
{row}
</SelectableRow>)}
</TableBody>
</Table.Body>
{footer}
</Table>
}
@ -32,19 +31,19 @@ function SelectableRow(props) {
const c = selectedKeys && selectedKeys.indexOf(key) >= 0;
let selectCell;
if (selectOn) {
const localOnSelect = (e, {checked}) => {
const localOnSelect = (e) => {
try {
onSelect(key, checked);
onSelect(key, e.currentTarget.checked);
} catch (e) {
console.error('No onSelect declared');
}
};
selectCell = <TableCell>
selectCell = <Table.Cell>
<Checkbox onChange={localOnSelect} checked={c}/>
</TableCell>;
</Table.Cell>;
}
return <TableRow key={key}>
return <Table.Row key={key}>
{selectCell}
{props.children}
</TableRow>
</Table.Row>
}

View file

@ -1,34 +1,39 @@
import React from "react";
import {Button, Icon, Modal, Table} from "./Theme";
import {TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Confirm, Icon, Table} from "./ui";
export function TaggedDeleteConfirmModal({open, taggedFileGroups, onConfirm, onCancel}) {
return <Modal open={open} onClose={onCancel} closeIcon>
<Modal.Header><Icon name='warning sign'/> Tagged Files Will Be Deleted</Modal.Header>
<Modal.Content>
<p>The following tagged files will be deleted:</p>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>File</TableHeaderCell>
<TableHeaderCell>Tags</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{(taggedFileGroups || []).map((fg, idx) => (
<TableRow key={fg.id ?? idx}>
<TableCell>{fg.primary_path || fg.name}</TableCell>
<TableCell>{(fg.tags || []).join(', ')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Modal.Content>
<Modal.Actions>
<Button onClick={onCancel}>Cancel</Button>
<Button color='red' onClick={onConfirm}>
<Icon name='trash'/> Delete
</Button>
</Modal.Actions>
</Modal>;
return <Confirm
open={open}
/*
* Audited: `large` (620px). The only confirmation that is not a one-line question --
* it lists file paths beside their tags, and `videos/Practical Engineering/Season
* 2024/` alone is 40 characters before the filename. The table scrolls sideways in
* its own container, so the width decides how much of a path is readable at a glance;
* at Confirm's default 380px that was the first two directories.
*/
size='large'
title={<><Icon name='warning sign'/> Tagged Files Will Be Deleted</>}
confirmLabel='Delete'
destructive
onConfirm={onConfirm}
onCancel={onCancel}
>
<p>The following tagged files will be deleted:</p>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File</Table.HeaderCell>
<Table.HeaderCell>Tags</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{(taggedFileGroups || []).map((fg, idx) => (
<Table.Row key={fg.id ?? idx}>
<Table.Cell>{fg.primary_path || fg.name}</Table.Cell>
<Table.Cell>{(fg.tags || []).join(', ')}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Confirm>;
}

View file

@ -1,6 +1,7 @@
import React from 'react';
import {screen, fireEvent} from '@testing-library/react';
import {renderWithProviders} from '../test-utils';
import {Confirm} from './ui';
import {TaggedDeleteConfirmModal} from './TaggedDeleteConfirmModal';
describe('TaggedDeleteConfirmModal', () => {
@ -79,6 +80,37 @@ describe('TaggedDeleteConfirmModal', () => {
expect(screen.getAllByText(/Tagged Files Will Be Deleted/i).length).toBeGreaterThan(0);
});
it('is wider than a one-sentence confirmation, because it lists paths in a table', () => {
/*
* Every Confirm in the app was 380px, because Confirm hardcoded `size='tiny'` and
* accepted no size. 380px is right for the eleven that ask a one-line question and
* wrong for this one: two columns of file paths, and a path is long. The table
* scrolls horizontally inside the modal rather than overflowing it, so the width
* decides how much of a path is readable without dragging.
*/
/*
* Both in one render, and every modal root read: a modal is portalled to
* `document.body`, so two separate renders both land there and reading only the first
* root reports one modal's size twice.
*
* Paired with a plain confirmation so this cannot pass by Confirm having become wide
* for everyone -- the point is that this one asked and the rest did not.
*/
const {baseElement} = renderWithProviders(<>
<TaggedDeleteConfirmModal
open={true}
taggedFileGroups={sampleGroups}
onConfirm={jest.fn()}
onCancel={jest.fn()}
/>
<Confirm open title='Delete?'/>
</>);
const sizes = [...baseElement.querySelectorAll('.mantine-Modal-root')]
.map(root => (root.getAttribute('style') ?? '').match(/--modal-size:\s*([^;]+)/)?.[1]);
expect(sizes).toEqual(['var(--modal-size-lg)', 'var(--modal-size-sm)']);
});
it('falls back to name when primary_path is missing', () => {
renderWithProviders(
<TaggedDeleteConfirmModal

View file

@ -1,109 +0,0 @@
import React from 'react';
import {Button, Modal, Progress} from './Theme';
import {renderInDarkMode, renderInLightMode} from '../test-utils';
// All semantic-ui colors.
const allColors = ['red', 'orange', 'yellow', 'olive', 'green', 'teal', 'blue', 'violet', 'purple', 'pink', 'brown', 'grey'];
// Light bar colors that always need dark percent text (light bars in any theme).
const lightBarColors = ['yellow', 'olive', 'teal'];
// Bar colors that need dark percent text only in dark mode (inverted makes bar lighter).
const darkModeLightBarColors = ['grey', 'blue'];
describe('Button ref forwarding', () => {
// Regression: SButton is a class component, so forwarding a ref straight to it resolves to the class instance
// (no `.contains`). Semantic UI's Modal/Popup Portal calls `node.contains(...)` on the trigger's ref during
// handleDocumentClick, throwing "node.contains is not a function". The forwarded ref must be the DOM <button>.
it('resolves a forwarded ref to the underlying DOM element', () => {
const ref = React.createRef();
renderInLightMode(<Button ref={ref} content='Click'/>);
expect(ref.current).toBeInstanceOf(HTMLElement);
expect(typeof ref.current.contains).toBe('function');
expect(ref.current.tagName).toBe('BUTTON');
});
it('does not throw when a Button is used as a controlled Modal trigger and the document is clicked', () => {
// Reproduces the Control page crash: an open Modal whose trigger is a themed Button. A document click
// invokes the Portal's handleDocumentClick -> doesNodeContainClick(triggerRef.current, e).
renderInLightMode(
<Modal open trigger={<Button content='Open'/>}>
<Modal.Content>content</Modal.Content>
</Modal>
);
expect(() => {
document.body.dispatchEvent(new MouseEvent('click', {bubbles: true}));
}).not.toThrow();
});
});
describe('Progress component theme support', () => {
describe('dark mode', () => {
it('adds inverted-progress-text class for label readability', () => {
const {container} = renderInDarkMode(
<Progress percent={39} progress indicating color='violet'>
2.1 GB / 5.3 GB
</Progress>
);
const progressEl = container.querySelector('.ui.progress');
expect(progressEl).toHaveClass('inverted-progress-text');
const progressLabel = container.querySelector('.bar > .progress');
expect(progressLabel).toHaveTextContent('39%');
const label = container.querySelector('.ui.progress > .label');
expect(label).toHaveTextContent('2.1 GB / 5.3 GB');
});
it('does NOT add inverted-progress-text in light mode', () => {
const {container} = renderInLightMode(
<Progress percent={39} progress indicating color='violet'>
2.1 GB / 5.3 GB
</Progress>
);
const progressEl = container.querySelector('.ui.progress');
expect(progressEl).not.toHaveClass('inverted-progress-text');
});
});
describe('percent text contrast per color', () => {
allColors.forEach(color => {
it(`${color} bar has readable percent text in light mode`, () => {
const {container} = renderInLightMode(
<Progress percent={50} progress color={color}/>
);
const progressLabel = container.querySelector('.bar > .progress');
expect(progressLabel).toBeInTheDocument();
expect(progressLabel).toHaveTextContent('50%');
const progressEl = container.querySelector('.ui.progress');
if (lightBarColors.includes(color)) {
expect(progressEl).toHaveClass('light-bar-progress-text');
} else {
expect(progressEl).not.toHaveClass('light-bar-progress-text');
}
});
it(`${color} bar has readable percent text in dark mode`, () => {
const {container} = renderInDarkMode(
<Progress percent={50} progress color={color}/>
);
const progressLabel = container.querySelector('.bar > .progress');
expect(progressLabel).toBeInTheDocument();
expect(progressLabel).toHaveTextContent('50%');
const progressEl = container.querySelector('.ui.progress');
if (lightBarColors.includes(color) || darkModeLightBarColors.includes(color)) {
expect(progressEl).toHaveClass('light-bar-progress-text');
} else {
expect(progressEl).not.toHaveClass('light-bar-progress-text');
}
});
});
});
});

View file

@ -1,108 +1,88 @@
import React, {useContext, useEffect, useState, forwardRef, HTMLAttributes, CSSProperties} from 'react';
import React, {useEffect, useState} from 'react';
import {ThemeContext} from "../contexts/contexts";
import {MantineProvider} from "@mantine/core";
import {Notifications} from "@mantine/notifications";
import {cssVariablesResolver, mantineTheme} from "../themes/mantine";
import {
Accordion as SAccordion,
AccordionContent as SAccordionContent,
AccordionTitle as SAccordionTitle,
Breadcrumb as SBreadcrumb,
Button as SButton,
ButtonGroup as SButtonGroup,
Card as SCard,
Divider as SDivider,
Form as SForm,
FormField as SFormField,
FormGroup as SFormGroup,
FormInput as SFormInput,
Header as SHeader,
Icon as SIcon,
List as SList,
ListContent as SListContent,
ListDescription as SListDescription,
ListHeader as SListHeader,
ListIcon as SListIcon,
ListItem as SListItem,
Loader as SLoader,
Menu as SMenu,
MenuItem as SMenuItem,
MenuHeader as SMenuHeader,
MenuMenu as SMenuMenu,
Modal as SModal,
ModalActions as SModalActions,
ModalContent as SModalContent,
ModalDescription as SModalDescription,
ModalHeader as SModalHeader,
Placeholder as SPlaceholder,
Popup as SPopup,
Progress as SProgress,
Ref as SRef,
Segment as SSegment,
Statistic as SStatistic,
StatisticGroup as SStatisticGroup,
Tab as STab,
Table as STable,
TableBody as STableBody,
TableCell as STableCell,
TableFooter as STableFooter,
TableHeader as STableHeader,
TableHeaderCell as STableHeaderCell,
TableRow as STableRow,
TabPane as STabPane,
TextArea as STextArea,
AccordionContentProps,
AccordionProps,
AccordionTitleProps,
BreadcrumbProps,
ButtonProps,
ButtonGroupProps,
CardProps as SCardProps,
DividerProps,
FormProps,
FormFieldProps,
FormGroupProps,
FormInputProps,
HeaderProps,
IconProps,
ListProps,
ListItemProps,
ListIconProps,
ListContentProps,
ListHeaderProps,
ListDescriptionProps,
LoaderProps,
MenuProps,
MenuItemProps,
MenuMenuProps,
ModalProps,
ModalActionsProps,
ModalContentProps,
ModalDescriptionProps,
ModalHeaderProps,
PlaceholderProps,
PopupProps,
ProgressProps,
SegmentProps,
StatisticProps,
StatisticGroupProps,
TabProps,
TableProps,
TableBodyProps,
TableCellProps,
TableFooterProps,
TableHeaderProps,
TableHeaderCellProps,
TableRowProps,
TabPaneProps,
TextAreaProps,
} from "semantic-ui-react";
import {ColorToSemanticHexColor} from "./Vars";
amberTheme,
darkTheme,
defaultTheme,
isDarkTheme,
isThemeName,
lightTheme,
mediaFilterSessionKey,
nightTheme,
resolveMediaFilter,
systemTheme,
themeChoices,
themeMediaFilter,
themeNames,
themeSessionKey,
} from "../themes/names";
import _ from "lodash";
import {ThemeContextValue, ThemeName, SavedThemeName} from "../types/theme";
export const darkTheme = 'dark';
export const lightTheme = 'light';
export const defaultTheme = lightTheme;
export const systemTheme = 'system';
export const themeSessionKey = 'color-scheme';
// Theme names and picker choices live in themes/names, and are re-exported here because
// most call sites already import them from Theme.
export {
amberTheme,
darkTheme,
defaultTheme,
isDarkTheme,
lightTheme,
nightTheme,
systemTheme,
themeChoices,
themeMediaFilter,
themeNames,
themeSessionKey,
};
/** Resolve the saved preference to the theme that should be applied right now. */
export const resolveTheme = (saved: SavedThemeName): ThemeName => {
if (isThemeName(saved)) {
return saved;
}
// `system`, null, or a value written by an older version.
const prefersDark = typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? darkTheme : lightTheme;
}
const readSavedTheme = (): SavedThemeName => {
try {
const value = localStorage.getItem(themeSessionKey);
if (isThemeName(value) || value === systemTheme) {
return value;
}
} catch (e) {
// localStorage can throw when cookies/storage are blocked.
console.error('Unable to read the saved theme', e);
}
return null;
}
/**
* The user's per-theme media filter choices: `{night: false}` means "night, but
* do not filter media". A theme absent from the map follows its own default.
*/
const readMediaFilters = (): Partial<Record<ThemeName, boolean>> => {
try {
const raw = localStorage.getItem(mediaFilterSessionKey);
if (raw) {
const parsed = JSON.parse(raw);
// Only keep known themes with boolean values; the rest is someone else's data.
if (parsed && typeof parsed === 'object') {
return Object.fromEntries(Object.entries(parsed)
.filter(([key, value]) => isThemeName(key) && typeof value === 'boolean'));
}
}
} catch (e) {
// Blocked storage, or a value an older version wrote.
console.error('Unable to read the media filter settings', e);
}
return {};
}
interface ThemeProviderProps {
children: React.ReactNode;
@ -114,634 +94,144 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) {
console.error('ThemeWrapper does not support props!');
}
// Properties to manipulate elements with theme.
// Invert when Semantic supports it. Example: <Menu {...i}>
const [i, setI] = useState<{inverted?: boolean}>({});
// Invert style when Semantic does not support it. Example: <p {...s}>This paragraph changes style</p>
const [s, setS] = useState<{style?: CSSProperties}>({});
// Invert text when Semantic does not support it.
const [t, setT] = useState<{style?: CSSProperties}>({});
const [inverted, setInverted] = useState<string>('');
// savedTheme is the user's preference: a theme name, `system`, or null (never chosen).
const [savedTheme, setSavedTheme] = useState<SavedThemeName>(readSavedTheme);
// theme is what is currently applied.
const [theme, setThemeName] = useState<ThemeName>(() => resolveTheme(readSavedTheme()));
// theme can be one of [darkTheme, lightTheme]
const [theme, setTheme] = useState<ThemeName>(lightTheme);
// savedTheme can be one of [null, darkTheme, lightTheme, systemTheme]
const [savedTheme, setSavedTheme] = useState<SavedThemeName>(
localStorage.getItem('color-scheme') as SavedThemeName
);
// Which themes the user has turned media filtering on or off for.
const [mediaFilters, setMediaFilters] = useState<Partial<Record<ThemeName, boolean>>>(
readMediaFilters);
const setDarkTheme = (save = false) => {
console.debug('setDarkTheme');
setI({inverted: true});
setS({style: {backgroundColor: '#1B1C1D', color: '#dddddd'}});
setT({style: {color: '#eeeeee'}});
setInverted('inverted');
setTheme(darkTheme);
document.body.style.background = '#171616';
if (save) {
saveTheme(darkTheme);
// `data-theme` on <html> is what the token CSS keys off of. index.html stamps it before
// first paint from the same localStorage value, so the first render already matches.
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
// `data-media-filter` names the filter to apply, or is absent when nothing should be
// filtered. index.html stamps this too: a flash of unfiltered thumbnails before the
// bundle arrives would cost the user the dark adaptation night mode exists to protect.
useEffect(() => {
const filter = resolveMediaFilter(theme, mediaFilters);
if (filter) {
document.documentElement.setAttribute('data-media-filter', filter);
} else {
document.documentElement.removeAttribute('data-media-filter');
}
}, [theme, mediaFilters]);
// Follow the OS preference only while the user has not chosen a specific theme.
useEffect(() => {
if (isThemeName(savedTheme) || typeof window.matchMedia !== 'function') {
return;
}
const query = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => setThemeName(resolveTheme(savedTheme));
query.addEventListener('change', onChange);
return () => query.removeEventListener('change', onChange);
}, [savedTheme]);
const saveTheme = (value: SavedThemeName) => {
setSavedTheme(value);
try {
if (value === null) {
localStorage.removeItem(themeSessionKey);
} else {
localStorage.setItem(themeSessionKey, value);
}
} catch (e) {
console.error('Unable to save the theme', e);
}
}
/** Apply and persist a theme. Pass `system` to follow the OS preference. */
const setTheme = (value: SavedThemeName) => {
if (value !== systemTheme && value !== null && !isThemeName(value)) {
console.error(`Unknown theme! ${value}`);
return;
}
saveTheme(value);
setThemeName(resolveTheme(value));
}
/**
* Turn the current theme's media filter on or off. Stored per theme, so a user who
* wants amber's tint but not night's — or the reverse — gets both.
*/
const setMediaFilterEnabled = (enabled: boolean) => {
const next = {...mediaFilters, [theme]: enabled};
setMediaFilters(next);
try {
localStorage.setItem(mediaFilterSessionKey, JSON.stringify(next));
} catch (e) {
console.error('Unable to save the media filter setting', e);
}
}
// Retained for callers written against the old two-theme API.
const setDarkTheme = (save = false) => {
setThemeName(darkTheme);
if (save) saveTheme(darkTheme);
}
const setLightTheme = (save = false) => {
console.debug('setLightTheme');
setI({inverted: undefined});
setS({});
setT({});
setInverted('');
setTheme(lightTheme);
document.body.style.background = '#FFFFFF';
if (save) {
saveTheme(lightTheme);
}
}
const saveTheme = (value: ThemeName) => {
setSavedTheme(value);
localStorage.setItem(themeSessionKey, value);
setThemeName(lightTheme);
if (save) saveTheme(lightTheme);
}
const cycleSavedTheme = (e?: React.MouseEvent) => {
// Cycle: System -> Dark -> Light
// Cycle: System -> Light -> Dark -> Night -> Amber -> System
if (e) {
e.preventDefault();
}
if (savedTheme === systemTheme || savedTheme == null) {
saveTheme(darkTheme);
} else if (savedTheme === darkTheme) {
saveTheme(lightTheme);
} else if (savedTheme === lightTheme) {
saveTheme(systemTheme as ThemeName);
} else {
console.error(`Unknown theme! savedTheme=${savedTheme}`);
}
applyTheme();
const order: SavedThemeName[] = [systemTheme, ...themeNames];
const current = order.indexOf(isThemeName(savedTheme) ? savedTheme : systemTheme);
setTheme(order[(current + 1) % order.length]);
}
const matchTheme = (): ThemeName => {
// Returns darkTheme if saved theme is dark, lightTheme if saved theme is dark,
// darkTheme if system prefers dark, otherwise lightTheme.
const colorScheme = localStorage.getItem(themeSessionKey);
setSavedTheme(colorScheme as SavedThemeName);
if (colorScheme === darkTheme) {
return darkTheme;
} else if (colorScheme === lightTheme) {
return lightTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? darkTheme : lightTheme;
}
const applyTheme = () => {
const match = matchTheme();
if (match === darkTheme) {
setDarkTheme();
} else {
setLightTheme();
}
};
useEffect(() => {
applyTheme();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const dark = isDarkTheme(theme);
const themeValue: ThemeContextValue = {
i, // Used for Semantic elements which support "inverted".
s, // Used to invert the style some elements.
t, // Used to invert text.
inverted, // Used to add "invert" to className.
theme,
savedTheme,
isDark: dark,
// The filter this theme offers, if any, and whether it is currently applied.
mediaFilter: themeMediaFilter(theme),
mediaFilterEnabled: !!resolveMediaFilter(theme, mediaFilters),
setMediaFilterEnabled,
setTheme,
setDarkTheme,
setLightTheme,
cycleSavedTheme,
};
// Mantine's own scheme decides the surfaces its internals use (overlays, scrollbars,
// focus rings). Night and amber are neither of its two schemes, so they ride on dark
// and take their actual colors from our tokens. This is the theme `base` flag.
return <ThemeContext.Provider value={themeValue}>
{children}
<MantineProvider
theme={mantineTheme}
cssVariablesResolver={cssVariablesResolver}
forceColorScheme={dark ? 'dark' : 'light'}
>
{/*
* Top right. Bottom right put toasts over the sticky footer on the file browser
* and over the player controls on a video, which are the two places a background
* task is most likely to report while the user is doing something else.
*/}
<Notifications position='top-right' limit={5}/>
{children}
</MantineProvider>
</ThemeContext.Provider>
}
// Simple wrappers for Semantic UI elements to use the current theme.
const invertedNull = <T extends {inverted?: boolean | null}>(props: T): T => {
if (props['inverted'] === true) {
return {...props, inverted: null};
}
return props;
}
const defaultGrey = <T extends object>(props: T, inverted: boolean): T => {
// Some elements look softer when grey, use grey only if another color is not provided.
if (inverted) {
return {color: 'grey', ...props} as T;
}
return props;
}
// ----------------------------------------------------------------------------
// Accordion Compound Component
// ----------------------------------------------------------------------------
type AccordionTitleComponent = React.FC<AccordionTitleProps>;
type AccordionContentComponent = React.FC<AccordionContentProps>;
interface AccordionComponent extends React.FC<AccordionProps> {
Title: AccordionTitleComponent;
Content: AccordionContentComponent;
}
const AccordionTitle: AccordionTitleComponent = (props) => {
return <SAccordionTitle {...props}/>
};
const AccordionContent: AccordionContentComponent = (props) => {
return <SAccordionContent {...props}/>
};
const AccordionBase: React.FC<AccordionProps> = (props) => {
const {i, inverted} = useContext(ThemeContext);
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
return <SAccordion {...mergedProps}/>
};
export const Accordion: AccordionComponent = Object.assign(AccordionBase, {
Title: AccordionTitle,
Content: AccordionContent,
});
// ----------------------------------------------------------------------------
// Button Compound Component
// ----------------------------------------------------------------------------
type ButtonGroupComponent = React.FC<ButtonGroupProps>;
interface ButtonComponent extends React.ForwardRefExoticComponent<ButtonProps & React.RefAttributes<any>> {
Group: ButtonGroupComponent;
}
const ButtonGroup: ButtonGroupComponent = (props) => {
return <SButtonGroup {...props}/>
};
const ButtonBase = forwardRef<any, ButtonProps>((props, ref) => {
const {i, inverted} = useContext(ThemeContext);
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
// SButton is a class component, so a forwarded `ref` would resolve to the class instance rather than the DOM
// node. Semantic UI's Modal/Popup use the trigger's ref with `node.contains(...)`, which throws on a non-DOM
// value. Wrap in Ref (findDOMNode) so the forwarded ref always resolves to the underlying <button> element.
if (ref) {
return <SRef innerRef={ref}><SButton {...mergedProps}/></SRef>
}
return <SButton {...mergedProps}/>
});
ButtonBase.displayName = 'ButtonBase';
export const Button: ButtonComponent = Object.assign(ButtonBase, {
Group: ButtonGroup,
});
export function Divider(props: DividerProps) {
const {i} = useContext(ThemeContext);
return <SDivider {...i} {...props}/>
}
export function Header(props: HeaderProps) {
const {t} = useContext(ThemeContext);
// Merge the caller's style on top of the theme's text style; a bare `{...props}`
// would let an incoming `style` prop replace (and drop) the theme's text color.
return <SHeader {...t} {...props} style={{...t.style, ...props.style}}/>
}
export function Icon(props: IconProps) {
const {i} = useContext(ThemeContext);
return <SIcon {...i} {...props}/>
}
export function Loader(props: LoaderProps) {
const {i} = useContext(ThemeContext);
return <SLoader {...i} {...props}/>
}
// Menu is a compound component (Menu.Item / Menu.Header / Menu.Menu) so callers get the same API as Semantic's
// Menu while inheriting the theme. The parent menu is `inverted` in dark mode so item text stays readable.
const MenuBase: React.FC<MenuProps> = (props) => {
const {i} = useContext(ThemeContext);
return <SMenu {...i} {...props}/>;
};
const MenuItem: React.FC<MenuItemProps> = (props) => <SMenuItem {...props}/>;
const MenuHeader: React.FC<MenuItemProps> = (props) => <SMenuHeader {...props}/>;
const MenuMenu: React.FC<MenuMenuProps> = (props) => <SMenuMenu {...props}/>;
interface MenuComponent extends React.FC<MenuProps> {
Item: React.FC<MenuItemProps>;
Header: React.FC<MenuItemProps>;
Menu: React.FC<MenuMenuProps>;
}
export const Menu: MenuComponent = Object.assign(MenuBase, {
Item: MenuItem,
Header: MenuHeader,
Menu: MenuMenu,
});
export function Placeholder(props: PlaceholderProps) {
const {i} = useContext(ThemeContext);
return <SPlaceholder {...i} {...props}/>
}
export function Popup(props: PopupProps) {
const {i} = useContext(ThemeContext);
return <SPopup {...i} style={{border: '1px solid grey'}} {...props}/>
}
// Bar colors that always need dark percent text (light bars in any theme).
const PROGRESS_BAR_COLORS = ['yellow', 'olive', 'teal'];
// Bar colors that need dark percent text only in dark mode (inverted makes bar lighter).
const DARK_MODE_PROGRESS_BAR_COLORS = ['grey', 'blue'];
export function Progress(props: ProgressProps) {
const {i, inverted} = useContext(ThemeContext);
const classes = [props.className || ''];
if (inverted) classes.push('inverted-progress-text');
const color = props.color as string;
if (PROGRESS_BAR_COLORS.includes(color) || (inverted && DARK_MODE_PROGRESS_BAR_COLORS.includes(color))) {
classes.push('light-bar-progress-text');
}
return <SProgress {...i} {...props} className={classes.join(' ').trim()}/>
}
export function Segment(props: SegmentProps) {
const {i} = useContext(ThemeContext);
return <SSegment {...i} {...props}/>
}
// Table is now a compound component - see COMPOUND COMPONENTS section below
export function TextArea(props: TextAreaProps) {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <STextArea {...mergedProps}/>
}
// ============================================================================
// COMPOUND COMPONENTS
// ============================================================================
// ----------------------------------------------------------------------------
// Modal Compound Component
// ----------------------------------------------------------------------------
type ModalActionsComponent = React.FC<ModalActionsProps>;
type ModalContentComponent = React.FC<ModalContentProps>;
type ModalDescriptionComponent = React.FC<ModalDescriptionProps>;
type ModalHeaderComponent = React.FC<ModalHeaderProps>;
interface ModalComponent extends React.FC<ModalProps> {
Actions: ModalActionsComponent;
Content: ModalContentComponent;
Description: ModalDescriptionComponent;
Header: ModalHeaderComponent;
}
const ModalActions: ModalActionsComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalActions {...props} className={`${inverted}`}/>
};
const ModalContent: ModalContentComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalContent {...props} className={`${inverted}`}/>
};
const ModalDescription: ModalDescriptionComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalDescription {...props} className={`${inverted}`}/>
};
const ModalHeader: ModalHeaderComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalHeader {...props} className={`${inverted}`}>
{props.children}
</SModalHeader>
};
const ModalBase: React.FC<ModalProps> = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModal {...props} className={`${inverted}`}/>
};
export const Modal: ModalComponent = Object.assign(ModalBase, {
Actions: ModalActions,
Content: ModalContent,
Description: ModalDescription,
Header: ModalHeader,
});
// ----------------------------------------------------------------------------
// List Compound Component
// ----------------------------------------------------------------------------
// Semantic's List supports `inverted`; the inverted class on the parent handles the item text,
// but List.Icon must also receive `inverted` so its color flips in dark mode (the parent's
// inverted class alone does not restyle the icon).
interface ListComponent extends React.FC<ListProps> {
Item: React.FC<ListItemProps>;
Icon: React.FC<ListIconProps>;
Content: React.FC<ListContentProps>;
Header: React.FC<ListHeaderProps>;
Description: React.FC<ListDescriptionProps>;
}
const ListBase: React.FC<ListProps> = (props) => {
const {i} = useContext(ThemeContext);
return <SList {...i} {...props}/>
};
const ListIcon: React.FC<ListIconProps> = (props) => {
const {i} = useContext(ThemeContext);
return <SListIcon {...i} {...props}/>
};
export const List: ListComponent = Object.assign(ListBase, {
Item: SListItem,
Icon: ListIcon,
Content: SListContent,
Header: SListHeader,
Description: SListDescription,
});
// ----------------------------------------------------------------------------
// Confirm Component
// ----------------------------------------------------------------------------
export interface ConfirmProps {
open: boolean;
header?: string;
content?: string | React.ReactNode;
confirmButton?: string;
cancelButton?: string;
onConfirm?: () => void;
onCancel?: () => void;
onClose?: () => void;
size?: 'mini' | 'tiny' | 'small' | 'large' | 'fullscreen';
}
export const Confirm: React.FC<ConfirmProps> = ({
open,
header,
content,
confirmButton = 'OK',
cancelButton = 'Cancel',
onConfirm,
onCancel,
onClose,
size = 'tiny',
}) => {
const {i} = useContext(ThemeContext);
return (
<Modal open={open} onClose={onClose} size={size} className="themed-confirm">
{header && <Modal.Header>{header}</Modal.Header>}
<Modal.Content>
{typeof content === 'string' ? <p>{content}</p> : content}
</Modal.Content>
<Modal.Actions>
<Button onClick={onCancel} {...i}>{cancelButton}</Button>
<Button onClick={onConfirm} color="green" {...i}>{confirmButton}</Button>
</Modal.Actions>
</Modal>
);
};
// ----------------------------------------------------------------------------
// Form Compound Component
// ----------------------------------------------------------------------------
type FormFieldComponent = React.FC<FormFieldProps>;
type FormGroupComponent = React.FC<FormGroupProps>;
type FormInputComponent = React.FC<FormInputProps>;
interface FormComponent extends React.FC<FormProps> {
Field: FormFieldComponent;
Group: FormGroupComponent;
Input: FormInputComponent;
}
const FormField: FormFieldComponent = (props) => {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <SFormField {...mergedProps}/>
};
const FormGroup: FormGroupComponent = (props) => {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <SFormGroup {...mergedProps}/>
};
const FormInput: FormInputComponent = (props) => {
const {i} = useContext(ThemeContext);
return <SFormInput {...i} {...props}/>
};
const FormBase: React.FC<FormProps> = (props) => {
const {i} = useContext(ThemeContext);
return <SForm {...i} {...props}/>
};
export const Form: FormComponent = Object.assign(FormBase, {
Field: FormField,
Group: FormGroup,
Input: FormInput,
});
// ----------------------------------------------------------------------------
// Card Compound Component
// ----------------------------------------------------------------------------
interface CardIconProps extends HTMLAttributes<HTMLDivElement> {
onClick?: () => void;
children?: React.ReactNode;
}
type CardIconComponent = React.FC<CardIconProps>;
interface CardProps extends Omit<SCardProps, 'color'> {
color?: string;
}
interface CardComponent extends React.FC<CardProps> {
Icon: CardIconComponent;
}
const CardIcon: CardIconComponent = ({onClick, children, ...props}) => {
const {inverted} = useContext(ThemeContext);
const cardIcon = <center className={`card-icon ${inverted}`} {...props}>{children}</center>;
if (onClick) {
return <div onClick={onClick} className='clickable'>
{cardIcon}
</div>
} else {
return cardIcon;
}
};
const CardBase: React.FC<CardProps> = ({color, ...props}) => {
const {inverted} = useContext(ThemeContext);
const style: CSSProperties = props.style || {};
const emphasisColor = ColorToSemanticHexColor(color);
if (emphasisColor) {
// Increase drop shadow to emphasize color.
const borderColor = inverted ? '#888' : '#ddd';
style.boxShadow = `0 0 0 2px ${borderColor}, 0 5px 0 0 ${emphasisColor}, 0 0px 3px 0 #d4d4d5`;
}
return <SCard {...props} style={style}/>
};
export const Card: CardComponent = Object.assign(CardBase, {
Icon: CardIcon,
});
// ----------------------------------------------------------------------------
// Tab Compound Component
// ----------------------------------------------------------------------------
type TabPaneComponent = React.FC<TabPaneProps>;
interface TabComponent extends React.FC<TabProps> {
Pane: TabPaneComponent;
}
const TabPane: TabPaneComponent = (props) => {
const {i} = useContext(ThemeContext);
return <STabPane {...i} {...props}/>
};
const TabBase: React.FC<TabProps> = (props) => {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <STab {...mergedProps}/>
};
export const Tab: TabComponent = Object.assign(TabBase, {
Pane: TabPane,
});
// ----------------------------------------------------------------------------
// Statistic Compound Component
// ----------------------------------------------------------------------------
type StatisticGroupComponent = React.FC<StatisticGroupProps>;
interface StatisticComponent extends React.FC<StatisticProps> {
Group: StatisticGroupComponent;
}
const StatisticGroup: StatisticGroupComponent = (props) => {
const {i} = useContext(ThemeContext);
const style: CSSProperties = {...props.style, marginLeft: 0, marginRight: 0};
return <SStatisticGroup {...i} {...props} style={style}/>
};
const StatisticBase: React.FC<StatisticProps> = (props) => {
const {i, inverted} = useContext(ThemeContext);
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
return <SStatistic {...mergedProps}/>
};
export const Statistic: StatisticComponent = Object.assign(StatisticBase, {
Group: StatisticGroup,
});
// ----------------------------------------------------------------------------
// Breadcrumb Compound Component
// ----------------------------------------------------------------------------
interface BreadcrumbDividerProps {
icon?: string;
}
type BreadcrumbDividerComponent = React.FC<BreadcrumbDividerProps>;
interface BreadcrumbComponent extends React.FC<BreadcrumbProps> {
Divider: BreadcrumbDividerComponent;
}
const BreadcrumbDivider: BreadcrumbDividerComponent = ({icon, ...props}) => {
const {inverted} = useContext(ThemeContext);
// TODO this only handles icons for now.
const className = `divider icon ${inverted} ${icon || ''}`;
return <i aria-hidden="true" className={className}></i>
};
const BreadcrumbBase: React.FC<BreadcrumbProps> = (props) => {
const {t} = useContext(ThemeContext);
return <SBreadcrumb {...props} {...t}/>
};
export const Breadcrumb: BreadcrumbComponent = Object.assign(BreadcrumbBase, {
Divider: BreadcrumbDivider,
});
// ----------------------------------------------------------------------------
// Table Compound Component
// ----------------------------------------------------------------------------
type TableBodyComponent = React.FC<TableBodyProps>;
type TableCellComponent = React.FC<TableCellProps>;
type TableFooterComponent = React.FC<TableFooterProps>;
type TableHeaderComponent = React.FC<TableHeaderProps>;
type TableHeaderCellComponent = React.FC<TableHeaderCellProps>;
type TableRowComponent = React.FC<TableRowProps>;
interface TableComponent extends React.FC<TableProps> {
Body: TableBodyComponent;
Cell: TableCellComponent;
Footer: TableFooterComponent;
Header: TableHeaderComponent;
HeaderCell: TableHeaderCellComponent;
Row: TableRowComponent;
}
const TableBody: TableBodyComponent = (props) => {
return <STableBody {...props}/>
};
const TableCell: TableCellComponent = (props) => {
return <STableCell {...props}/>
};
const TableFooter: TableFooterComponent = (props) => {
return <STableFooter {...props}/>
};
const TableHeader: TableHeaderComponent = (props) => {
return <STableHeader {...props}/>
};
const TableHeaderCell: TableHeaderCellComponent = (props) => {
return <STableHeaderCell {...props}/>
};
const TableRow: TableRowComponent = (props) => {
return <STableRow {...props}/>
};
const TableBase: React.FC<TableProps> = (props) => {
const {i} = useContext(ThemeContext);
return <STable {...i} {...props}/>
};
export const Table: TableComponent = Object.assign(TableBase, {
Body: TableBody,
Cell: TableCell,
Footer: TableFooter,
Header: TableHeader,
HeaderCell: TableHeaderCell,
Row: TableRow,
});
/*
* This module is the theme provider and nothing else: it resolves the chosen theme, stamps
* `data-theme` and `data-media-filter` on <html>, persists both, and hands Mantine our
* tokens.
*
* It used to re-export a set of themed component wrappers as well, along with the
* dark-mode compatibility props they consumed (`i`, `s`, `t`, `inverted`). Those carried
* hardcoded greys, which is precisely what the token themes replaced; components come from
* `src/components/ui` instead.
*/

View file

@ -0,0 +1,371 @@
import fs from 'fs';
import path from 'path';
import React, {useContext} from 'react';
import {act, render, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {ThemeContext} from '../contexts/contexts';
import {
amberTheme,
darkTheme,
isDarkTheme,
lightTheme,
nightTheme,
resolveTheme,
systemTheme,
ThemeProvider,
themeSessionKey,
} from './Theme';
import {mediaFilterSessionKey, resolveMediaFilter, themeMediaFilter} from '../themes/names';
import {clearToasts, toast} from './ui';
// Lets a test drive `prefers-color-scheme` and assert on what the provider applied.
let prefersDark = false;
const mockMatchMedia = () => {
const listeners = new Set();
window.matchMedia = jest.fn().mockImplementation(query => ({
matches: query === '(prefers-color-scheme: dark)' ? prefersDark : false,
media: query,
addEventListener: (_event, listener) => listeners.add(listener),
removeEventListener: (_event, listener) => listeners.delete(listener),
addListener: (listener) => listeners.add(listener),
removeListener: (listener) => listeners.delete(listener),
dispatchEvent: () => false,
}));
return {
// Emulate the OS switching to dark/light while the app is open.
change: (value) => {
prefersDark = value;
act(() => listeners.forEach(listener => listener({matches: value})));
},
};
};
function ThemeProbe() {
const {
theme, savedTheme, isDark, setTheme, cycleSavedTheme,
mediaFilter, mediaFilterEnabled, setMediaFilterEnabled,
} = useContext(ThemeContext);
return <>
<span data-testid='theme'>{theme}</span>
<span data-testid='saved'>{String(savedTheme)}</span>
<span data-testid='dark'>{String(isDark)}</span>
<span data-testid='filter-offered'>{mediaFilter ? mediaFilter.id : 'none'}</span>
<span data-testid='filter-on'>{String(mediaFilterEnabled)}</span>
<button onClick={() => setTheme(nightTheme)}>night</button>
<button onClick={() => setTheme(amberTheme)}>amber</button>
<button onClick={() => setTheme(lightTheme)}>light</button>
<button onClick={() => setTheme(systemTheme)}>system</button>
<button onClick={cycleSavedTheme}>cycle</button>
<button onClick={() => setMediaFilterEnabled(false)}>filter off</button>
<button onClick={() => setMediaFilterEnabled(true)}>filter on</button>
</>
}
const renderProvider = () => render(<ThemeProvider><ThemeProbe/></ThemeProvider>);
const appliedTheme = () => document.documentElement.getAttribute('data-theme');
const appliedFilter = () => document.documentElement.getAttribute('data-media-filter');
describe('ThemeProvider', () => {
beforeEach(() => {
prefersDark = false;
localStorage.clear();
document.documentElement.removeAttribute('data-theme');
document.documentElement.removeAttribute('data-media-filter');
mockMatchMedia();
});
it('applies the saved theme and stamps it on <html>', () => {
localStorage.setItem(themeSessionKey, nightTheme);
renderProvider();
expect(screen.getByTestId('theme')).toHaveTextContent(nightTheme);
expect(appliedTheme()).toBe(nightTheme);
});
it('falls back to the OS preference when no theme is saved', () => {
prefersDark = true;
renderProvider();
expect(screen.getByTestId('theme')).toHaveTextContent(darkTheme);
expect(screen.getByTestId('saved')).toHaveTextContent('null');
});
it('persists a chosen theme so it survives a reload', async () => {
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'night'}));
expect(localStorage.getItem(themeSessionKey)).toBe(nightTheme);
expect(appliedTheme()).toBe(nightTheme);
});
it('follows the OS preference while `system` is chosen', async () => {
const media = mockMatchMedia();
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'system'}));
media.change(true);
expect(screen.getByTestId('theme')).toHaveTextContent(darkTheme);
expect(appliedTheme()).toBe(darkTheme);
});
it('ignores the OS preference once a specific theme is chosen', async () => {
const media = mockMatchMedia();
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'night'}));
media.change(true);
expect(screen.getByTestId('theme')).toHaveTextContent(nightTheme);
});
it('reports night and amber as dark themes', async () => {
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'night'}));
expect(screen.getByTestId('dark')).toHaveTextContent('true');
});
it('cycles through every theme and back to system', async () => {
renderProvider();
const cycle = screen.getByRole('button', {name: 'cycle'});
const seen = [];
for (let index = 0; index < 5; index++) {
await userEvent.click(cycle);
seen.push(screen.getByTestId('saved').textContent);
}
expect(seen).toEqual([lightTheme, darkTheme, nightTheme, amberTheme, systemTheme]);
});
it('recovers from a theme name written by an older version', () => {
localStorage.setItem(themeSessionKey, 'sepia');
renderProvider();
expect(screen.getByTestId('theme')).toHaveTextContent(lightTheme);
expect(appliedTheme()).toBe(lightTheme);
});
});
describe('ThemeProvider media filtering', () => {
beforeEach(() => {
prefersDark = false;
localStorage.clear();
document.documentElement.removeAttribute('data-theme');
document.documentElement.removeAttribute('data-media-filter');
mockMatchMedia();
});
it('filters media in night mode without being asked', async () => {
// An unfiltered thumbnail undoes dark adaptation, so night starts filtered.
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'night'}));
expect(appliedFilter()).toBe('night-red');
expect(screen.getByTestId('filter-on')).toHaveTextContent('true');
});
it('leaves amber media alone until the user asks for it', async () => {
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'amber'}));
expect(appliedFilter()).toBeNull();
expect(screen.getByTestId('filter-offered')).toHaveTextContent('amber-mono');
});
it('applies no filter, and offers none, in a theme without one', async () => {
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'light'}));
expect(appliedFilter()).toBeNull();
expect(screen.getByTestId('filter-offered')).toHaveTextContent('none');
});
it('turns the filter off when the user says so, and persists it', async () => {
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'night'}));
await userEvent.click(screen.getByRole('button', {name: 'filter off'}));
expect(appliedFilter()).toBeNull();
expect(JSON.parse(localStorage.getItem(mediaFilterSessionKey))).toEqual({night: false});
});
it('turns amber filtering on when the user asks', async () => {
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'amber'}));
await userEvent.click(screen.getByRole('button', {name: 'filter on'}));
expect(appliedFilter()).toBe('amber-mono');
});
it('keeps the choice separate for each theme', async () => {
// Wanting amber's tint says nothing about wanting night's, and turning night's off
// must not disarm a filter the user deliberately enabled elsewhere.
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'amber'}));
await userEvent.click(screen.getByRole('button', {name: 'filter on'}));
await userEvent.click(screen.getByRole('button', {name: 'night'}));
await userEvent.click(screen.getByRole('button', {name: 'filter off'}));
expect(appliedFilter()).toBeNull();
await userEvent.click(screen.getByRole('button', {name: 'amber'}));
expect(appliedFilter()).toBe('amber-mono');
});
it('restores the saved choice on the next load', () => {
localStorage.setItem(themeSessionKey, nightTheme);
localStorage.setItem(mediaFilterSessionKey, JSON.stringify({night: false}));
renderProvider();
expect(appliedFilter()).toBeNull();
});
it('falls back to the theme default when the stored settings are unusable', () => {
localStorage.setItem(themeSessionKey, nightTheme);
localStorage.setItem(mediaFilterSessionKey, 'not json');
renderProvider();
// Failing open would leave a user in the dark staring at a white thumbnail.
expect(appliedFilter()).toBe('night-red');
});
it('ignores stored entries for themes that no longer exist', () => {
localStorage.setItem(themeSessionKey, nightTheme);
localStorage.setItem(mediaFilterSessionKey, JSON.stringify({sepia: true, night: false}));
renderProvider();
expect(appliedFilter()).toBeNull();
});
});
describe('resolveMediaFilter', () => {
it('gives every filtering theme a filter defined in MediaFilterDefs', () => {
// A theme naming a filter that does not exist would silently filter nothing.
const defs = fs.readFileSync(
path.join(__dirname, '..', 'themes', 'MediaFilterDefs.tsx'), 'utf8');
[nightTheme, amberTheme].forEach(theme => {
const filter = themeMediaFilter(theme);
expect(defs).toContain(`id='wrolpi-${filter.id}'`);
});
});
it('honours an override in either direction', () => {
expect(resolveMediaFilter(nightTheme, {})).toBe('night-red');
expect(resolveMediaFilter(nightTheme, {night: false})).toBe('');
expect(resolveMediaFilter(amberTheme, {})).toBe('');
expect(resolveMediaFilter(amberTheme, {amber: true})).toBe('amber-mono');
expect(resolveMediaFilter(lightTheme, {light: true})).toBe('');
});
});
describe('theme helpers', () => {
beforeEach(() => {
prefersDark = false;
mockMatchMedia();
});
it('resolves explicit theme names as themselves', () => {
[lightTheme, darkTheme, nightTheme, amberTheme].forEach(theme => {
expect(resolveTheme(theme)).toBe(theme);
});
});
it('resolves `system` and null from the OS preference', () => {
expect(resolveTheme(systemTheme)).toBe(lightTheme);
expect(resolveTheme(null)).toBe(lightTheme);
prefersDark = true;
expect(resolveTheme(systemTheme)).toBe(darkTheme);
});
it('knows which themes are built on a dark background', () => {
expect(isDarkTheme(lightTheme)).toBe(false);
expect(isDarkTheme(darkTheme)).toBe(true);
expect(isDarkTheme(nightTheme)).toBe(true);
expect(isDarkTheme(amberTheme)).toBe(true);
});
});
describe('the toast container ThemeProvider mounts', () => {
/*
* Observing the wiring rather than re-stating it.
*
* `toast.test.js` and `ui-layout.cy.js` each mount their own `<Notifications>` with the
* position and limit they expect, so neither would notice if this file moved the container
* back, changed its limit, or dropped it altogether -- and a missing container while
* thirteen call sites keep firing is the failure that has already shipped once. Those
* suites test `toast()`; this tests that the app has somewhere to put what it produces.
*/
beforeEach(() => {
prefersDark = false;
localStorage.clear();
mockMatchMedia();
});
afterEach(() => act(() => clearToasts()));
const notifications = () =>
document.querySelectorAll('[class*="mantine-Notification-root"]');
it('shows a toast raised by a call site', async () => {
// The regression that already shipped: no container, thirteen call sites still firing,
// nothing thrown and nothing shown.
renderProvider();
act(() => toast({title: 'Refresh started'}));
expect(await screen.findByText('Refresh started')).toBeInTheDocument();
});
it('puts it in the top right', async () => {
/*
* Asserted through a real toast rather than by finding a container. Mantine renders an
* empty container for every position it supports, so querying for one returns whichever
* comes first in the DOM -- `top-center` -- whatever the provider was configured with.
*/
renderProvider();
act(() => toast({title: 'Somewhere'}));
const notification = await screen.findByText('Somewhere');
expect(notification.closest('[class*="mantine-Notifications-root"]'))
.toHaveAttribute('data-position', 'top-right');
});
it('shows five at once and queues the rest', async () => {
/*
* The user-visible behaviour, not the prop: Mantine's own default limit is also 5, so
* this stays green if `limit={5}` is deleted from the provider. It is here to catch a
* limit being *raised* -- a background task that reports twenty times should not bury
* the page -- rather than to prove the prop is present.
*/
renderProvider();
act(() => {
for (let i = 1; i <= 7; i += 1) {
toast({title: `Toast ${i}`});
}
});
await waitFor(() => expect(notifications()).toHaveLength(5));
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,11 @@
import React from "react";
import {useDropzone} from "react-dropzone";
import {Form, Header, Icon, Progress, Segment} from "./Theme";
import {DirectorySearch, mimetypeIconName, Toggle} from "./Common";
import {Box, Group, Header, Icon, Panel, Progress, Stack} from "./ui";
import {DirectorySearch, mimetypeIconName, RequiredAsterisk, Toggle} from "./Common";
import {useUploadFile} from "../hooks/customHooks";
import _ from "lodash";
import {ThemeContext} from "../contexts/contexts";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {Form as SForm} from "semantic-ui-react";
export function Upload({disabled}) {
const {t} = React.useContext(ThemeContext);
const {
setFiles,
progresses,
@ -57,33 +52,29 @@ export function Upload({disabled}) {
color = 'orange';
statusString = 'Already Exists:';
}
// Only indicating when upload is pending.
let indicating = status === 'pending';
return <Grid.Row key={name}>
<Grid.Column mobile={3} tablet={2} computer={1}>
<Icon name={mimetypeIconName(type, name.toLowerCase())} size='big'/>
</Grid.Column>
<Grid.Column mobile={13} tablet={14} computer={15}>
<Progress progress indicating={indicating} percent={percent} color={color}>
<p {...t}>{statusString} {name}</p>
</Progress>
</Grid.Column>
</Grid.Row>
return <Group key={name} align='center' wrap='nowrap' gap='sm'>
<Icon name={mimetypeIconName(type, name.toLowerCase())} size='large'/>
<Box style={{flex: 1}}>
<Progress percent={percent} color={color} label={`${statusString} ${name}`}/>
</Box>
</Group>
})
}
return <>
<Form>
<SForm.Field required>
<label>Destination</label>
<Stack gap='sm'>
<div>
<label style={{display: 'block', marginBottom: 4}}>
<b>Destination</b> <RequiredAsterisk/>
</label>
<DirectorySearch
onSelect={i => setDestination(i)}
disabled={disabled || inProgress}
style={{marginBottom: '0.5em'}}
/>
{tagsSelector}
</SForm.Field>
</div>
<Toggle
checked={overwrite}
@ -91,50 +82,30 @@ export function Upload({disabled}) {
onChange={() => setOverwrite(!overwrite)}
disabled={disabled || inProgress}
/>
</Form>
</Stack>
{destination ?
<Segment>
<Grid columns={1}>
<Grid.Row>
<Grid.Column style={{padding: '1em'}}>
<Form>
<div {...getRootProps()}>
<input {...getInputProps()}/>
<Grid textAlign='center'>
<Grid.Row>
<Grid.Column>
<Header icon>
<Icon name='file text'/>
Click here, or drop files here to upload
</Header>
</Grid.Column>
</Grid.Row>
</Grid>
</div>
</Form>
</Grid.Column>
</Grid.Row>
</Grid>
</Segment>
<Panel style={{marginTop: '1em'}}>
<div {...getRootProps()} style={{padding: '1em', textAlign: 'center', cursor: 'pointer'}}>
<input {...getInputProps()}/>
<Header as='h3' icon='file text'>
Click here, or drop files here to upload
</Header>
</div>
</Panel>
: <Header as='h3'>You must search for a directory to place your files</Header>
}
<br/>
<Grid>
<Grid.Row columns={1}>
<Grid.Column>
{progresses && Object.keys(progresses).length > 1 && <Progress progress
percent={overallProgress}
color='blue'
label='Overall Progress'
/>}
</Grid.Column>
</Grid.Row>
</Grid>
<Grid columns={2}>
{progresses && Object.keys(progresses).length > 1 && <Progress
percent={overallProgress}
color='blue'
label='Overall Progress'
/>}
<Stack gap='sm' mt='sm'>
{progressBars}
</Grid>
</Stack>
</>
}

View file

@ -77,7 +77,18 @@ export const allFrequencyOptions = {
[days180Option.value]: days180Option,
};
export const semanticUIColorMap = {
/*
* The named colors a user can pick for the navigation bar, and the fixed hex each one is.
*
* Fixed, and deliberately not tokens: these are the colors of the favicon files in
* `public/favicon-<color>.svg`, which `scripts/generate-favicons.js` writes from this very
* object, and of the `theme-color` meta tag the mobile browser paints its own chrome with.
* Neither can read a CSS variable, so both need a literal that cannot drift from the bar.
*
* The palette is a colorblind-friendly one and is shared with `themes/navColors.ts`, which
* measures a legible foreground against each.
*/
export const navColorHexMap = {
red: '#db2828',
orange: '#f2711c',
yellow: '#fbbd08',
@ -92,10 +103,6 @@ export const semanticUIColorMap = {
grey: '#767676',
}
export const ColorToSemanticHexColor = (color) => {
return semanticUIColorMap[color] || null;
}
export const validUrlRegex = /^(http|https):\/\/[^ "]+$/;
export const downloadOrderOptions = [

View file

@ -1,4 +1,4 @@
import React, {useContext, useEffect, useState} from 'react';
import React, {useEffect, useState} from 'react';
import {useHotkeys} from 'react-hotkeys-hook';
import {deleteFileGroups, downloadVideoMetadata, tagFileGroup, untagFileGroup} from "../api";
import {Link, useNavigate, useParams} from "react-router";
@ -17,17 +17,14 @@ import {
useMediaSession,
useTitle
} from "./Common";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import Container from "semantic-ui-react/dist/commonjs/elements/Container";
import {VideoPlaceholder} from "./Placeholder";
import {useChannel, useVideoCaptions, useVideoExtras} from "../hooks/customHooks";
import {ThemeContext} from "../contexts/contexts";
import {Button, darkTheme, Header, Icon, Loader, Segment, Tab} from "./Theme";
import {Button, Grid, Header, Icon, Label, Loading, Panel, Tabs, TextInput} from "./ui";
import {VideoCard} from "./Videos";
import {TagsSelector} from "../Tags";
import {AddToPlaylistButton} from "./AddToPlaylist";
import {Comment, CommentGroup, Input, Label, Transition} from "semantic-ui-react";
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
import {IconPlayerPause, IconPlayerPlay} from "@tabler/icons-react";
const MEDIA_PATH = '/media';
@ -117,7 +114,7 @@ const NoComments = ({video}) => {
<p>No comments have been been downloaded. Refresh the video?</p>
<APIButton
color='blue'
role='primary'
size='large'
onClick={handleRefresh}
>Refresh</APIButton>
@ -132,43 +129,43 @@ const NoComments = ({video}) => {
}
}
// The token library has no comment-thread component -- this is a nested
// comment thread, not a reusable widget, so it is built here from plain elements and tokens.
const VideoComment = ({comment, children}) => {
const {t} = React.useContext(ThemeContext);
const {is_favorited, like_count, author, timestamp, text, author_is_uploader} = comment;
// Author comments and favorited comments are important.
let specialIcon = null;
if (author_is_uploader) {
specialIcon = <Icon name='star' color='green'/>;
specialIcon = <Icon name='star' label='Uploader' style={{color: 'var(--green)'}}/>;
} else if (is_favorited) {
specialIcon = <Icon name='heart' color='red'/>;
specialIcon = <Icon name='heart' label='Favorited' style={{color: 'var(--red)'}}/>;
}
const dateElm = <div {...t}>{isoDatetimeToAgoPopup(timestamp * 1000)}</div>;
const likesElm = like_count && <div {...t}>
<Icon name='thumbs up'/>{humanNumber(comment['like_count'])}
</div>;
return <Comment>
<Comment.Content>
<Comment.Author as='a'>
<span {...t}>{specialIcon}{author}</span>
</Comment.Author>
<Comment.Metadata>
{dateElm}
{likesElm}
</Comment.Metadata>
<Comment.Text {...t}>
<MultilineText text={text} style={{marginLeft: '0.5em'}}/>
</Comment.Text>
</Comment.Content>
return <div style={{marginBottom: '1.2em'}}>
<div style={{fontWeight: 600, display: 'flex', alignItems: 'center', gap: '0.3em'}}>
{specialIcon}<span>{author}</span>
</div>
<div style={{display: 'flex', gap: '1em', fontSize: '0.85em', color: 'var(--muted)'}}>
<div>{isoDatetimeToAgoPopup(timestamp * 1000)}</div>
{like_count ? <div>
<Icon name='thumbs up' size={14}/> {humanNumber(comment['like_count'])}
</div> : null}
</div>
<div>
<MultilineText text={text} style={{marginLeft: '0.5em'}}/>
</div>
{children && !_.isEmpty(children) &&
<CommentGroup>
<div style={{
marginLeft: '1.5em',
marginTop: '0.7em',
paddingLeft: '1em',
borderLeft: '1px solid var(--border)',
}}>
{children.map((i, key) => <VideoComment key={key} comment={i}/>)}
</CommentGroup>
</div>
}
</Comment>
</div>
}
@ -202,9 +199,9 @@ const Comments = ({comments, video}) => {
groupedByParent[obj.parent] = _.sortBy(children, ['timestamp']);
})
return <CommentGroup>
return <div>
{rootComments.map((i, key) => <VideoComment key={key} comment={i} children={groupedByParent[i.id]}/>)}
</CommentGroup>
</div>
}
// Generate a video page URL for a file object.
@ -214,11 +211,10 @@ function getVideoLink(file) {
}
function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
const {theme} = useContext(ThemeContext);
const navigate = useNavigate();
const videoRef = React.useRef();
const [taggedFileGroups, setTaggedFileGroups] = useState(null);
const [activeTab, setActiveTab] = useState('comments');
useTitle(videoFile ? videoFile.title ? videoFile.title : videoFile.name : null);
let video;
@ -353,26 +349,9 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
description = formatVideoDescription(description, setVideoTime)
}
const descriptionPane = {
menuItem: 'Description', render: () => <Tab.Pane>
<pre className="wrap-text">
{description}
</pre>
</Tab.Pane>
};
const captionsPane = {
menuItem: 'Captions', render: () => <CaptionsPane
captions={captions}
captionsLoading={captionsLoading}
fetchCaptions={fetchCaptions}
setVideoTime={setVideoTime}
/>
};
const getFile = (suffix) => videoFile.files.find(i => i.path.toLowerCase().endsWith(suffix));
const fileSize = (file) => file && file.size
? <span style={{marginLeft: '1em', color: 'grey'}}>({humanFileSize(file.size)})</span>
? <span style={{marginLeft: '1em', color: 'var(--muted)'}}>({humanFileSize(file.size)})</span>
: null;
const {poster_file, info_json_file} = video;
@ -380,120 +359,6 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
const ffprobeJsonFile = getFile('.ffprobe.json');
const tableStyle = {width: '100%', borderCollapse: 'separate', borderSpacing: '0 0.7em'};
const labelStyle = {whiteSpace: 'nowrap', paddingRight: '1.5em', verticalAlign: 'top'};
const filesPane = {
menuItem: 'Files', render: () => <Tab.Pane>
<table style={tableStyle}>
<tbody>
<tr>
<td style={labelStyle}><strong>{isAudio ? 'Audio File' : 'Video File'}</strong></td>
<td>
{video['video_path']
? <><PreviewPath path={video['video_path']} mimetype={isAudio ? 'audio/*' : 'video/*'} taggable={false}>
{video['video_path']}
</PreviewPath>{fileSize(videoPathFile)}</>
: 'Unknown'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Caption Files</strong></td>
<td>
{caption_files
? caption_files.map(i => <div key={i['path']} style={{marginBottom: '0.3em'}}>
<PreviewPath {...i} taggable={false}>{i['path']}</PreviewPath>
{fileSize(i)}
</div>)
: 'No caption files'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Poster File</strong></td>
<td>
{poster_file
? <><PreviewPath path={poster_file['path']} mimetype={poster_file['mimetype']}
taggable={false}>
{poster_file['path']}
</PreviewPath>{fileSize(poster_file)}</>
: 'No poster file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Info JSON File</strong></td>
<td>
{info_json_file
? <><PreviewPath path={info_json_file['path']} mimetype={info_json_file['mimetype']}
taggable={false}>
{info_json_file['path']}
</PreviewPath>{fileSize(info_json_file)}</>
: 'No yt-dlp info json file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>ffprobe JSON File</strong></td>
<td>
{ffprobeJsonFile
? <><PreviewPath path={ffprobeJsonFile['path']} mimetype={ffprobeJsonFile['mimetype']}
taggable={false}>
{ffprobeJsonFile['path']}
</PreviewPath>{fileSize(ffprobeJsonFile)}</>
: 'No ffprobe json file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Directory</strong></td>
<td><DirectoryLink path={videoFile['directory']}/></td>
</tr>
</tbody>
</table>
</Tab.Pane>
}
const commentsPane = {
menuItem: 'Comments', render: () => <Tab.Pane>
<Header as='h3'>Top Comments</Header>
<Comments comments={comments} video={videoFile}/>
</Tab.Pane>,
};
const tabPanes = [commentsPane, descriptionPane, filesPane, captionsPane];
const tabMenu = theme === darkTheme ? {inverted: true, attached: true} : {attached: true};
const aboutSegment = <Segment>
<Header as='h2'>About Video</Header>
<Grid columns={2}>
<Grid.Row>
<Grid.Column>
<h3>Size</h3>
<p>{videoFile.size ? humanFileSize(videoFile.size) : 'Unknown'}</p>
</Grid.Column>
<Grid.Column>
<h3>View Count</h3>
<p>{video.view_count ? humanNumber(video.view_count) : 'N/A'}</p>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<h3>Codec Names</h3>
<>{video.codec_names ? video.codec_names.map(i => <Label key={i}>{i}</Label>) : 'N/A'}</>
</Grid.Column>
<Grid.Column>
<h3>Censored</h3>
<p>{videoFile.censored ? 'Yes' : 'No'}</p>
</Grid.Column>
</Grid.Row>
<Grid.Row columns={1}>
<Grid.Column>
<h3>Source URL</h3>
<p>{videoFile.url ? <a href={videoFile.url}>{videoFile.url}</a> : 'N/A'}</p>
</Grid.Column>
</Grid.Row>
</Grid>
</Segment>;
const localAddTag = async (name) => {
await tagFileGroup(videoFile, name);
@ -508,20 +373,18 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
let videoSource = <source src={videoUrl}/>;
const isAudio = videoFile.mimetype && videoFile.mimetype.startsWith('audio/');
let prevNextVideosSegment = <Segment><p>No related videos found.</p></Segment>;
let prevNextVideosPanel = <Panel><p>No related videos found.</p></Panel>;
if (prevFile || nextFile) {
prevNextVideosSegment = <Segment>
<Grid columns={2} stackable>
<Grid.Row>
<Grid.Column textAlign='left'>
{prevFile && <><Header as='h3'>Older</Header><VideoCard file={prevFile}/></>}
</Grid.Column>
<Grid.Column textAlign='left'>
{nextFile && <><Header as='h3'>Newer</Header><VideoCard file={nextFile}/></>}
</Grid.Column>
</Grid.Row>
prevNextVideosPanel = <Panel>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
{prevFile && <><Header as='h3'>Older</Header><VideoCard file={prevFile}/></>}
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
{nextFile && <><Header as='h3'>Newer</Header><VideoCard file={nextFile}/></>}
</Grid.Col>
</Grid>
</Segment>
</Panel>
}
let title = video.video_path;
@ -531,19 +394,35 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
title = videoFile.stem;
}
const audioPlayPauseLabel = audioPlaying ? 'Pause' : 'Play';
return <>
<Container style={{margin: '1em'}}>
<div className='wrolpi-button-row' style={{margin: '1em'}}>
<BackButton/>
</Container>
</div>
{isAudio ? <>
{posterUrl && <div
className='audio-poster-container'
// A wrapper whose custom play/pause/volume glyphs are drawn over the poster image: the whole
// composite must be tinted as one unit in night mode, so it carries `.media` even though the
// <img/> inside is already a filtered leaf on its own.
className='audio-poster-container media'
role='button'
tabIndex={0}
aria-label={audioPlaying ? 'Pause audio' : 'Play audio'}
onClick={() => {
if (videoRef.current) {
videoRef.current.paused ? videoRef.current.play() : videoRef.current.pause();
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (videoRef.current) {
videoRef.current.paused ? videoRef.current.play() : videoRef.current.pause();
}
}
}}
onMouseEnter={() => setAudioPosterHover(true)}
onMouseLeave={() => setAudioPosterHover(false)}
>
@ -552,16 +431,24 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
alt={mediaTitle || 'Audio thumbnail'}
style={{maxWidth: '100%', maxHeight: '400px', cursor: 'pointer'}}
/>
<Transition visible={showAudioOverlay} animation='fade' duration={500}>
<div className='audio-poster-overlay'>
<Icon name='volume up' size='massive'/>
</div>
</Transition>
<Transition visible={audioPosterHover && !showAudioOverlay} animation='fade' duration={200}>
<div className='audio-poster-overlay'>
<Icon name={audioPlaying ? 'pause circle outline' : 'play circle outline'} size='massive'/>
</div>
</Transition>
<div
className='audio-poster-overlay'
aria-hidden='true'
style={{opacity: showAudioOverlay ? 1 : 0, transition: 'opacity 500ms ease'}}
>
<Icon name='volume up' size={64}/>
</div>
<div
className='audio-poster-overlay'
aria-hidden='true'
style={{
opacity: (audioPosterHover && !showAudioOverlay) ? 1 : 0,
transition: 'opacity 200ms ease',
}}
>
<Icon component={audioPlaying ? IconPlayerPause : IconPlayerPlay} size={64}
label={audioPlayPauseLabel}/>
</div>
</div>}
<audio controls
autoPlay={props.autoplay !== undefined ? props.autoplay : true}
@ -585,8 +472,10 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
{captionUrls.map(i => <CaptionTrack key={i} src={i}/>)}
</video>}
<Container style={{marginTop: '1em'}}>
<Segment>
{/* `wrolpi-stack`: everything below the player is stacked in here rather than in the
page, so this is what spaces it. */}
<div className='wrolpi-stack wrolpi-page-inset' style={{marginTop: '1em'}}>
<Panel>
<Header as='h2'>{title}</Header>
{videoFile.published_datetime && <h3>{isoDatetimeToAgoPopup(videoFile.published_datetime)}</h3>}
@ -596,38 +485,159 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
</Link>}
</h3>
<p>
<Button as='a' href={downloadUrl}>
<Icon name='download'/>
<div className='wrolpi-button-row'>
<Button component='a' href={downloadUrl} icon='download'>
Download
</Button>
<APIButton
color='red'
role='danger'
confirmContent='Are you sure you want to delete this video? All files related to this video will be deleted. It will not be downloaded again!'
confirmButton='Delete'
onClick={async () => await handleDeleteVideo(videoFile.id)}
obeyWROLMode={true}
>Delete</APIButton>
<APIButton
color='blue'
role='primary'
onClick={handleRefresh}
obeyWROLMode={true}
disabled={!videoFile.url}
>Refresh</APIButton>
<AddToPlaylistButton fileGroupId={videoFile.id}/>
</p>
</Segment>
</div>
</Panel>
<Segment>
<Panel>
<TagsSelector selectedTagNames={videoFile['tags']} onAdd={localAddTag} onRemove={localRemoveTag}/>
</Segment>
</Panel>
{aboutSegment}
<Panel>
<Header as='h2'>About Video</Header>
<Tab menu={tabMenu} panes={tabPanes}/>
<Grid>
<Grid.Col span={6}>
<h3>Size</h3>
<p>{videoFile.size ? humanFileSize(videoFile.size) : 'Unknown'}</p>
</Grid.Col>
<Grid.Col span={6}>
<h3>View Count</h3>
<p>{video.view_count ? humanNumber(video.view_count) : 'N/A'}</p>
</Grid.Col>
<Grid.Col span={6}>
<h3>Codec Names</h3>
<>{video.codec_names ? video.codec_names.map(i => <Label key={i}>{i}</Label>) : 'N/A'}</>
</Grid.Col>
<Grid.Col span={6}>
<h3>Censored</h3>
<p>{videoFile.censored ? 'Yes' : 'No'}</p>
</Grid.Col>
<Grid.Col span={12}>
<h3>Source URL</h3>
<p>{videoFile.url ? <a href={videoFile.url}>{videoFile.url}</a> : 'N/A'}</p>
</Grid.Col>
</Grid>
</Panel>
{prevNextVideosSegment}
</Container>
<Tabs value={activeTab} onChange={setActiveTab} keepMounted={false}>
<Tabs.List>
<Tabs.Tab value='comments'>Comments</Tabs.Tab>
<Tabs.Tab value='description'>Description</Tabs.Tab>
<Tabs.Tab value='files'>Files</Tabs.Tab>
<Tabs.Tab value='captions'>Captions</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value='comments' style={{paddingTop: '1em'}}>
<Header as='h3'>Top Comments</Header>
<Comments comments={comments} video={videoFile}/>
</Tabs.Panel>
<Tabs.Panel value='description' style={{paddingTop: '1em'}}>
<pre className="wrap-text">
{description}
</pre>
</Tabs.Panel>
<Tabs.Panel value='files' style={{paddingTop: '1em'}}>
<table style={tableStyle}>
<tbody>
<tr>
<td style={labelStyle}><strong>{isAudio ? 'Audio File' : 'Video File'}</strong></td>
<td>
{video['video_path']
? <><PreviewPath path={video['video_path']} mimetype={isAudio ? 'audio/*' : 'video/*'} taggable={false}>
{video['video_path']}
</PreviewPath>{fileSize(videoPathFile)}</>
: 'Unknown'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Caption Files</strong></td>
<td>
{caption_files
? caption_files.map(i => <div key={i['path']} style={{marginBottom: '0.3em'}}>
<PreviewPath {...i} taggable={false}>{i['path']}</PreviewPath>
{fileSize(i)}
</div>)
: 'No caption files'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Poster File</strong></td>
<td>
{poster_file
? <><PreviewPath path={poster_file['path']} mimetype={poster_file['mimetype']}
taggable={false}>
{poster_file['path']}
</PreviewPath>{fileSize(poster_file)}</>
: 'No poster file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Info JSON File</strong></td>
<td>
{info_json_file
? <><PreviewPath path={info_json_file['path']} mimetype={info_json_file['mimetype']}
taggable={false}>
{info_json_file['path']}
</PreviewPath>{fileSize(info_json_file)}</>
: 'No yt-dlp info json file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>ffprobe JSON File</strong></td>
<td>
{ffprobeJsonFile
? <><PreviewPath path={ffprobeJsonFile['path']} mimetype={ffprobeJsonFile['mimetype']}
taggable={false}>
{ffprobeJsonFile['path']}
</PreviewPath>{fileSize(ffprobeJsonFile)}</>
: 'No ffprobe json file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Directory</strong></td>
<td><DirectoryLink path={videoFile['directory']}/></td>
</tr>
</tbody>
</table>
</Tabs.Panel>
<Tabs.Panel value='captions' style={{paddingTop: '1em'}}>
<CaptionsPane
captions={captions}
captionsLoading={captionsLoading}
fetchCaptions={fetchCaptions}
setVideoTime={setVideoTime}
/>
</Tabs.Panel>
</Tabs>
{prevNextVideosPanel}
</div>
<TaggedDeleteConfirmModal
open={taggedFileGroups !== null}
taggedFileGroups={taggedFileGroups}
@ -661,11 +671,11 @@ function CaptionsPane({captions, captionsLoading, fetchCaptions, setVideoTime})
}, []);
if (captionsLoading) {
return <Tab.Pane><Loader active/></Tab.Pane>;
return <Loading/>;
}
if (!captions || captions.length === 0) {
return <Tab.Pane>No captions available.</Tab.Pane>;
return <div>No captions available.</div>;
}
let filtered = captions;
@ -677,14 +687,13 @@ function CaptionsPane({captions, captionsLoading, fetchCaptions, setVideoTime})
});
}
return <Tab.Pane>
<Input
fluid
icon='filter'
return <>
<TextInput
leftSection={<Icon name='filter' size={14}/>}
placeholder='Filter captions...'
value={filter}
onChange={(e, {value}) => setFilter(value)}
style={{marginBottom: '1em'}}
onChange={(e) => setFilter(e.currentTarget.value)}
style={{marginBottom: '1em', width: '100%'}}
/>
<div style={{maxHeight: '400px', overflowY: 'auto'}}>
{filtered.map((chunk, idx) => (
@ -694,7 +703,7 @@ function CaptionsPane({captions, captionsLoading, fetchCaptions, setVideoTime})
e.preventDefault();
setVideoTime(chunk.start_seconds);
}}
style={{whiteSpace: 'nowrap', fontFamily: 'monospace'}}
style={{whiteSpace: 'nowrap', fontFamily: 'var(--font-mono)'}}
>
{formatCaptionTimestamp(chunk.start_seconds)}
</a>
@ -702,7 +711,7 @@ function CaptionsPane({captions, captionsLoading, fetchCaptions, setVideoTime})
</div>
))}
</div>
</Tab.Pane>;
</>;
}
export default VideoPage;

View file

@ -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 {
@ -27,28 +27,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 +60,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 +140,7 @@ export function VideosPage() {
</Header>
</>;
} else if (channelId) {
header = <Placeholder style={{marginBottom: '1em'}}>
<PlaceholderHeader>
<PlaceholderLine/>
</PlaceholderHeader>
</Placeholder>;
header = <div style={{marginBottom: '1em'}}><Placeholder lines={1}/></div>;
}
const onSelect = (path, checked) => {
@ -182,28 +175,28 @@ export function VideosPage() {
setSelectedVideos([]);
}
const selectElm = <div style={{marginTop: '0.5em'}}>
const selectElm = <div className='wrolpi-button-row' style={{marginTop: '0.5em'}}>
<Button
color='violet'
role='primary'
disabled={_.isEmpty(selectedVideos)}
onClick={() => setBulkTagOpen(true)}
>Tag</Button>
<APIButton
color='red'
role='danger'
disabled={_.isEmpty(selectedVideos)}
confirmButton='Delete'
confirmContent='Are you sure you want to delete these video files? This cannot be undone.'
onClick={() => onDelete(false)}
>Delete</APIButton>
<Button
color='grey'
role='cancel'
onClick={() => invertSelection()}
disabled={_.isEmpty(videos)}
>
Invert
</Button>
<Button
color='yellow'
role='cancel'
onClick={() => clearSelection()}
disabled={_.isEmpty(videos) || _.isEmpty(selectedVideos)}
>
@ -284,7 +277,6 @@ function VideoFileNameForm({form}) {
const label = <InfoHeader
headerSize='h5'
headerContent='Video File Format'
popupProps={{wide: 'very', position: 'top left'}}
popupContent={<>
<p>Common yt-dlp variables:</p>
<ul>
@ -313,6 +305,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 +379,140 @@ export function VideosSettingsPage() {
/>;
return <>
<Segment>
<Panel>
<Header as='h3'>Video Downloader Config</Header>
<Form>
<Grid>
<Grid.Row columns={2}>
<Grid.Column mobile={16} computer={8}>
<VideoResolutionSelectorForm
form={configForm}
name='video_resolutions'
path='video_resolutions'
/>
</Grid.Column>
<Grid.Column mobile={16} computer={4}>
<VideoFormatSelectorForm
form={configForm}
name='merge_output_format'
path='yt_dlp_options.merge_output_format'
/>
</Grid.Column>
<Grid.Column mobile={16} computer={4}>
<VideoFileNameForm form={configForm}/>
</Grid.Column>
</Grid.Row>
<Grid.Row columns={2}>
<Grid.Column mobile={16} computer={8}>
<ToggleForm
form={configForm}
label='Do not overwrite existing files'
name='nooverwrites'
path='yt_dlp_options.nooverwrites'
icon='file video'
/>
</Grid.Column>
<Grid.Column mobile={16} computer={8}>
<ToggleForm
form={configForm}
label='Download automatic subtitles'
name='writeautomaticsub'
path='yt_dlp_options.writeautomaticsub'
icon='closed captioning'
/>
</Grid.Column>
</Grid.Row>
<Grid.Row columns={2}>
<Grid.Column mobile={16} computer={8}>
<ToggleForm
form={configForm}
label='Download subtitles'
name='writesubtitles'
path='yt_dlp_options.writesubtitles'
icon='closed captioning outline'
/>
</Grid.Column>
<Grid.Column mobile={16} computer={8}>
<ToggleForm
form={configForm}
label='Download thumbnail'
name='writethumbnail'
path='yt_dlp_options.writethumbnail'
icon='image'
/>
</Grid.Column>
</Grid.Row>
<Grid.Row columns={2}>
<Grid.Column mobile={16} computer={8}>
<ToggleForm
form={configForm}
label='Download info JSON'
name='writeinfojson'
path='yt_dlp_options.writeinfojson'
icon='file code'
/>
</Grid.Column>
<Grid.Column mobile={16} computer={8}>
<ToggleForm
form={configForm}
label='Continue partial downloads'
name='continue_dl'
path='yt_dlp_options.continue_dl'
icon='play'
/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column mobile={16} computer={8}>
<ToggleForm
form={configForm}
label={downloadMissingVideoCommentsLabel}
name='download_missing_video_comments'
path='download_missing_video_comments'
icon='comments'
/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column mobile={16} computer={8}>
<InputForm
form={configForm}
name='yt_dlp_extra_args'
path='yt_dlp_extra_args'
label='Extra yt-dlp Arguments'
placeholder='--prefer-free-formats'
icon='terminal'
/>
</Grid.Column>
<Grid.Column mobile={16} computer={4}>
<Form.Field>
<InfoHeader
headerSize='h5'
headerContent='Sleep Between Requests'
popupContent='Seconds to sleep between yt-dlp API requests. Helps avoid rate limiting and bot detection. Set to 0 to disable. Does not affect video download speed.'
/>
<FormInput
type='number'
step='0.25'
min='0'
max='10'
value={configForm.formData.sleep_requests ?? 0.75}
onChange={(e) => configForm.setValue('sleep_requests', parseFloat(e.target.value) || 0)}
placeholder='0.75'
label={{basic: true, content: 'seconds'}}
labelPosition='right'
/>
</Form.Field>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column mobile={16} computer={16}>
<Form.Field>
<InfoHeader
headerSize='h5'
headerContent='User Agent'
popupContent='Custom user-agent string for yt-dlp. Should match the browser used to export cookies. Click "Use Current Browser" to auto-fill with your browser&apos;s user-agent.'
/>
<FormInput
value={configForm.formData.user_agent || ''}
onChange={(e) => configForm.setValue('user_agent', e.target.value)}
placeholder='Leave empty to use yt-dlp default'
action={{
content: 'Use Current Browser',
onClick: () => configForm.setValue('user_agent', suggestedUserAgent),
}}
/>
</Form.Field>
</Grid.Column>
</Grid.Row>
<form onSubmit={e => e.preventDefault()}>
<div style={settingsGridStyle}>
<VideoResolutionSelectorForm
form={configForm}
name='video_resolutions'
path='video_resolutions'
/>
<VideoFormatSelectorForm
form={configForm}
name='merge_output_format'
path='yt_dlp_options.merge_output_format'
/>
<VideoFileNameForm form={configForm}/>
</div>
<div style={settingsGridStyle}>
<ToggleForm
form={configForm}
label='Do not overwrite existing files'
name='nooverwrites'
path='yt_dlp_options.nooverwrites'
icon='file video'
/>
<ToggleForm
form={configForm}
label='Download automatic subtitles'
name='writeautomaticsub'
path='yt_dlp_options.writeautomaticsub'
icon='closed captioning'
/>
</div>
<div style={settingsGridStyle}>
<ToggleForm
form={configForm}
label='Download subtitles'
name='writesubtitles'
path='yt_dlp_options.writesubtitles'
icon='closed captioning outline'
/>
<ToggleForm
form={configForm}
label='Download thumbnail'
name='writethumbnail'
path='yt_dlp_options.writethumbnail'
icon='image'
/>
</div>
<div style={settingsGridStyle}>
<ToggleForm
form={configForm}
label='Download info JSON'
name='writeinfojson'
path='yt_dlp_options.writeinfojson'
icon='file code'
/>
<ToggleForm
form={configForm}
label='Continue partial downloads'
name='continue_dl'
path='yt_dlp_options.continue_dl'
icon='play'
/>
</div>
<div style={settingsGridStyle}>
<ToggleForm
form={configForm}
label={downloadMissingVideoCommentsLabel}
name='download_missing_video_comments'
path='download_missing_video_comments'
icon='comments'
/>
</div>
<div style={settingsGridStyle}>
<InputForm
form={configForm}
name='yt_dlp_extra_args'
path='yt_dlp_extra_args'
label='Extra yt-dlp Arguments'
placeholder='--prefer-free-formats'
icon='terminal'
/>
<div>
<InfoHeader
headerSize='h5'
headerContent='Sleep Between Requests'
popupContent='Seconds to sleep between yt-dlp API requests. Helps avoid rate limiting and bot detection. Set to 0 to disable. Does not affect video download speed.'
/>
<NumberInput
step={0.25}
min={0}
max={10}
value={configForm.formData.sleep_requests ?? 0.75}
onChange={(value) => configForm.setValue('sleep_requests', parseFloat(value) || 0)}
placeholder='0.75'
rightSection={<span style={{fontSize: '0.75rem', color: 'var(--muted)'}}>sec</span>}
rightSectionWidth={36}
/>
</div>
</div>
<div style={settingsGridStyle}>
<div style={{gridColumn: '1 / -1'}}>
<InfoHeader
headerSize='h5'
headerContent='User Agent'
popupContent="Custom user-agent string for yt-dlp. Should match the browser used to export cookies. Click &quot;Use Current Browser&quot; to auto-fill with your browser's user-agent."
/>
<ActionInput
value={configForm.formData.user_agent || ''}
onChange={(e) => configForm.setValue('user_agent', e.currentTarget.value)}
placeholder='Leave empty to use yt-dlp default'
action={<Button role='cancel'
onClick={() => configForm.setValue('user_agent', suggestedUserAgent)}>
Use Current Browser
</Button>}
/>
</div>
</div>
<Grid.Row columns={1}>
<Grid.Column textAlign='right'>
<APIButton
disabled={configForm.disabled || !configForm.ready}
type='submit'
style={{marginTop: '0.5em'}}
onClick={configForm.onSubmit}
id='video_settings_save_button'
>Save</APIButton>
</Grid.Column>
</Grid.Row>
</Grid>
</Form>
</Segment>
<div style={{textAlign: 'right'}}>
<APIButton
role='primary'
disabled={configForm.disabled || !configForm.ready}
type='submit'
style={{marginTop: '0.5em'}}
onClick={configForm.onSubmit}
id='video_settings_save_button'
>Save</APIButton>
</div>
</form>
</Panel>
<Segment>
<Panel>
<Header as='h4'>File Organization</Header>
<p>
{fetchingReorgCount
@ -557,15 +526,16 @@ export function VideosSettingsPage() {
}
</p>
<Button
color='orange'
role='retry'
icon='folder open outline'
onClick={() => setBatchModalOpen(true)}
id='reorganize_all_channels_button'
disabled={fetchingReorgCount || channelsNeedingReorg === 0}
loading={fetchingReorgCount}
>
<Icon name='folder open outline'/> Reorganize All Channels
Reorganize All Channels
</Button>
</Segment>
</Panel>
<CookiesSettingsSection/>
@ -644,14 +614,19 @@ function CookiesSettingsSection() {
fetchStatus();
};
// The two states that mean something take roles; the two that are just "nothing to
// report" stay --muted, which is dimmer than --neutral in night and amber and so keeps
// the least important states at the bottom.
const getStatusLabel = () => {
if (loading) return <Label color='grey'>Loading...</Label>;
if (!status.cookies_exist) return <Label color='grey'>No cookies stored</Label>;
if (status.cookies_unlocked) return <Label color='green'>Unlocked</Label>;
return <Label color='yellow'>Locked</Label>;
if (loading) return <span style={{color: 'var(--muted)'}}>Loading...</span>;
if (!status.cookies_exist) return <span style={{color: 'var(--muted)'}}>No cookies stored</span>;
if (status.cookies_unlocked) return <span style={{color: 'var(--success)'}}>Unlocked</span>;
// Locked is not an error the user made, but cookie-requiring downloads fail until
// somebody enters the password after a reboot -- that is attention, not failure.
return <span style={{color: 'var(--warning)'}}>Locked</span>;
};
return <Segment>
return <Panel>
<HelpHeader
headerSize='h4'
headerContent='Encrypted Cookies'
@ -665,59 +640,64 @@ function CookiesSettingsSection() {
<strong>Status:</strong> {getStatusLabel()}
</div>
<Button.Group>
<div style={{display: 'flex', gap: '0.5em', flexWrap: 'wrap'}}>
{!status.cookies_exist && (
<Button
color='blue'
role='primary'
icon='upload'
onClick={() => setUploadModalOpen(true)}
disabled={loading}
>
<Icon name='upload'/> Upload Cookies
Upload Cookies
</Button>
)}
{status.cookies_exist && !status.cookies_unlocked && (
<Button
color='green'
role='save'
icon='unlock'
onClick={() => setUnlockModalOpen(true)}
disabled={loading}
>
<Icon name='unlock'/> Unlock Cookies
Unlock Cookies
</Button>
)}
{status.cookies_exist && status.cookies_unlocked && (
<Button
color='yellow'
role='retry'
icon='lock'
onClick={handleLock}
loading={submitting}
disabled={loading || submitting}
>
<Icon name='lock'/> Lock Cookies
Lock Cookies
</Button>
)}
{status.cookies_exist && (
<>
<Button
color='blue'
role='primary'
icon='refresh'
onClick={() => setUploadModalOpen(true)}
disabled={loading}
>
<Icon name='refresh'/> Replace
Replace
</Button>
<APIButton
color='red'
role='danger'
icon='trash'
onClick={handleDelete}
confirmButton='Delete'
confirmContent='Are you sure you want to delete the stored cookies? This cannot be undone.'
disabled={loading || submitting}
>
<Icon name='trash'/> Delete
Delete
</APIButton>
</>
)}
</Button.Group>
</div>
{/* Upload Modal */}
<Modal
@ -727,39 +707,41 @@ function CookiesSettingsSection() {
>
<Modal.Header>Upload Cookies</Modal.Header>
<Modal.Content>
<Form>
<FormField>
<label>Cookies Content</label>
<TextArea
ref={cookiesContentRef}
placeholder='Paste your cookies.txt content here (Netscape/Mozilla format)'
value={cookiesContent}
onChange={(e) => setCookiesContent(e.target.value)}
rows={10}
/>
</FormField>
<FormInput
label='Encryption Password'
type='password'
placeholder='Minimum 8 characters'
value={password}
onChange={(e) => setPassword(e.target.value)}
error={password.length > 0 && password.length < 8 ? 'Password must be at least 8 characters' : null}
<form onSubmit={e => e.preventDefault()}>
<Textarea
ref={cookiesContentRef}
label='Cookies Content'
placeholder='Paste your cookies.txt content here (Netscape/Mozilla format)'
value={cookiesContent}
onChange={(e) => setCookiesContent(e.currentTarget.value)}
minRows={10}
/>
<p style={{color: 'grey', fontSize: '0.9em'}}>
<div style={{marginTop: '1em'}}>
<TextInput
label='Encryption Password'
type='password'
placeholder='Minimum 8 characters'
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
error={password.length > 0 && password.length < 8
? 'Password must be at least 8 characters' : null}
/>
</div>
<p style={{color: 'var(--muted)', fontSize: '0.9em'}}>
Remember this password - you'll need it to unlock cookies after each restart.
</p>
</Form>
</form>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setUploadModalOpen(false)}>Cancel</Button>
<Button role='cancel' onClick={() => setUploadModalOpen(false)}>Cancel</Button>
<Button
color='blue'
role='save'
icon='lock'
onClick={handleUpload}
loading={submitting}
disabled={!cookiesContent || password.length < 8 || submitting}
>
<Icon name='lock'/> Encrypt & Save
Encrypt & Save
</Button>
</Modal.Actions>
</Modal>
@ -769,7 +751,7 @@ function CookiesSettingsSection() {
onClose={() => setUnlockModalOpen(false)}
onSuccess={fetchStatus}
/>
</Segment>;
</Panel>;
}
export function VideosStatistics() {
@ -779,7 +761,7 @@ export function VideosStatistics() {
if (statistics === null) {
// Request is pending.
return <Loader active inline='centered'/>
return <Loading/>
} else if (statistics === undefined) {
return <ErrorMessage>Unable to fetch Video Statistics</ErrorMessage>
}
@ -810,26 +792,23 @@ export function VideosStatistics() {
{key: 'tagged_channels', label: 'Tagged Channels'},
];
const buildSegment = (title, names, stats) => {
return <Segment>
<Header textAlign='center' as='h1'>{title}</Header>
<Statistic.Group>
const buildPanel = (title, names, stats) => {
return <Panel>
<Header as='h1' style={{textAlign: 'center'}}>{title}</Header>
<StatisticGroup>
{names.map(
({key, label}) =>
<Statistic key={key} style={{margin: '2em'}}>
<StatisticValue>{stats[key]}</StatisticValue>
<StatisticLabel>{label}</StatisticLabel>
</Statistic>
<Statistic key={key} value={stats[key]} label={label}/>
)}
</Statistic.Group>
</Segment>
</StatisticGroup>
</Panel>
}
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 +828,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 +841,55 @@ export function VideoCard({file}) {
channel_url = `/videos/channel/${channel.id}/video`;
}
let poster = <CardPoster to={video_url} file={file}/>;
/*
* The duration goes through CardPoster's `overlay` rather than being pinned to a
* wrapper around it: the wrapper is the full width of the card, and a vertical video's
* poster is only about half that once the height cap applies, so the badge landed well
* clear of the frame it belongs to.
*/
const media = <CardPoster to={video_url} file={file}
overlay={<Duration totalSeconds={file.length}/>}/>;
const title = file.title || file.name || video.stem || video.name;
let header = <span {...s} className='card-title-ellipsis'>{title}</span>;
let titleElm = <Tooltip label={title}>
<span className='card-title-ellipsis'>{title}</span>
</Tooltip>;
if (video_url) {
// Link to Channel-Video page or Video page.
header = <Link to={video_url} className="no-link-underscore card-link">{header}</Link>;
titleElm = <Link to={video_url} className="no-link-underscore card-link">{titleElm}</Link>;
} else {
// Video is just a lone video file.
header = <PreviewLink file={file}>
{header}
titleElm = <PreviewLink file={file}>
{titleElm}
</PreviewLink>;
}
const color = mimetypeColor(file.mimetype);
return <Card color={color}>
{poster}
<Duration totalSeconds={file.length}/>
<CardContent {...s}>
<CardHeader>
<Container textAlign='left'>
<Popup on='hover'
trigger={header}
content={title}/>
</Container>
</CardHeader>
<CardDescription>
<Container textAlign='left'>
{channel && <Link to={channel_url} className="no-link-underscore card-link">
<b {...s}>{channel.name}</b>
</Link>}
{sortField === 'length'
? <p {...s}>{secondsToFullDuration(file.length || 0)}</p>
: sortField === 'size'
? <p {...s}>{humanFileSize(file.size)}</p>
: sortField === 'view_count'
? <p {...s}>{humanNumber(video.view_count || 0)} views</p>
: sortField === 'viewed'
? <p {...s}>{isoDatetimeToAgoPopup(file.viewed, false)}</p>
: sortField === 'download_datetime'
? <p {...s}>{isoDatetimeToAgoPopup(file.download_datetime, false)}</p>
: <p {...s}>{isoDatetimeToAgoPopup(file.published_datetime, false)}</p>
}
</Container>
</CardDescription>
</CardContent>
</Card>
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 && <div>
<Link to={channel_url} className="no-link-underscore card-link">
<b>{channel.name}</b>
</Link>
</div>}
<div>{detailLine}</div>
</>;
return <Card media={media} title={titleElm} meta={meta}
color={mimetypeColor(file.mimetype, file.primary_path)}/>
}
export function VideoRowCells({file}) {
@ -924,10 +904,7 @@ export function VideoRowCells({file}) {
let poster;
if (poster_url) {
poster = <CardLink to={video_url}>
<Image wrapped
src={poster_url}
width='50px'
/>
<img alt='' src={poster_url} style={{width: '50px', height: 'auto'}}/>
</CardLink>
} else {
poster = <FileIcon file={file} size='large'/>;
@ -946,17 +923,17 @@ export function VideoRowCells({file}) {
// Fragment for SelectableRow
return <React.Fragment>
<TableCell>
<center>
<Table.Cell>
<div style={{textAlign: 'center'}}>
{poster}
</center>
</TableCell>
<TableCell>
</div>
</Table.Cell>
<Table.Cell>
<CardLink to={video_url}>
<FileRowTagIcon file={file}/>
{textEllipsis(file.title || video.stem || video.video_path)}
</CardLink>
</TableCell>
<TableCell>{dataCell}</TableCell>
</Table.Cell>
<Table.Cell>{dataCell}</Table.Cell>
</React.Fragment>
}

View file

@ -1,5 +1,5 @@
import React from 'react';
import {render, screen} from '@testing-library/react';
import {render, screen} from '../test-utils';
import userEvent from '@testing-library/user-event';
import {VncToggle} from './Common';

View file

@ -1,31 +1,5 @@
import {useOutdatedZims, useSearchZim, useWROLMode} from "../hooks/customHooks";
import {
Accordion,
Button,
Divider,
Header,
Icon,
Loader,
Modal,
Placeholder,
Segment
} from "./Theme";
import {HeadlineText} from "./Headline";
import {TextPlaceholder} from "./Placeholder";
import {
AccordionContent,
AccordionTitle,
Button as SButton,
Dropdown,
Icon as SIcon,
Label,
Message,
PlaceholderHeader,
PlaceholderLine,
TableCell,
TableRow
} from "semantic-ui-react";
import React, {useContext, useState} from "react";
import React, {useState} from "react";
import {
APIButton,
encodeMediaPath,
@ -35,7 +9,6 @@ import {
IframeViewer,
InfoMessage,
normalizeEstimate,
PageContainer,
Paginator,
TabLinks,
TagIcon,
@ -43,7 +16,23 @@ import {
useTitle,
WarningMessage
} from "./Common";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {
Accordion,
Button,
Divider,
Group,
Header,
Label,
Loading,
Message,
Modal,
Panel,
Placeholder,
Select,
Table,
toast,
} from "./ui";
import {TextPlaceholder} from "./Placeholder";
import {TagsSelector} from "../Tags";
import {AddToPlaylistButton} from "./AddToPlaylist";
import {
@ -60,19 +49,17 @@ import {
} from "../api";
import {useSearch} from "./Search";
import {TagsQuerySelector} from "./Files";
import {ThemeContext} from "../contexts/contexts";
import {Link, Route, Routes} from "react-router";
import {SortableTable} from "./SortableTable";
import {toast} from "react-semantic-toasts-2";
import _ from "lodash";
import {ZIM_VIEWER_URI} from "./Vars";
import {HeadlineText} from "./Headline";
export const OutdatedZimsMessage = ({onClick}) => {
const [open, setOpen] = React.useState(false);
const {outdated, current} = useOutdatedZims();
const onOpen = () => setOpen(true);
const onClose = () => setOpen(false);
const handleDelete = async () => {
@ -106,9 +93,7 @@ export const OutdatedZimsMessage = ({onClick}) => {
}
}
let modalContent = <Placeholder>
<PlaceholderLine/>
</Placeholder>;
let modalContent = <Placeholder lines={1}/>;
if (outdated && outdated.length > 0) {
modalContent = <>
<Header as='h3'>To Delete</Header>
@ -119,38 +104,29 @@ export const OutdatedZimsMessage = ({onClick}) => {
</>
}
return <Message icon info>
<SIcon name='question'/>
<Message.Content>
<Message.Header>Outdated Zim Files</Message.Header>
New Zim files have been downloaded. Outdated Zim files can be removed.
<p></p>
return <Message kind='info' icon='question' title='Outdated Zim Files'>
New Zim files have been downloaded. Outdated Zim files can be removed.
<p></p>
<SButton primary onClick={() => setOpen(true)}>Delete</SButton>
<Modal closeIcon
open={open}
onClose={onClose}
onOpen={onOpen}
>
<Modal.Header>Delete</Modal.Header>
<Modal.Content>
{modalContent}
</Modal.Content>
<Modal.Actions>
<APIButton
color='red'
floated='left'
onClick={handleDelete}
>Delete</APIButton>
<Button onClick={onClose}>Close</Button>
</Modal.Actions>
</Modal>
<Button role='danger' onClick={() => setOpen(true)}>Delete</Button>
<Modal size='small' open={open}
onClose={onClose}
title='Delete'
>
{modalContent}
<Modal.Actions>
<APIButton
role='danger'
onClick={handleDelete}
>Delete</APIButton>
<Button role='cancel' onClick={onClose}>Close</Button>
</Modal.Actions>
</Modal>
<Link to='/files?folders=zims'><SButton>Delete Manually</SButton></Link>
<APIButton secondary
onClick={handleIgnore}
>Ignore Forever</APIButton>
</Message.Content>
<Link to='/files?folders=zims'><Button role='cancel'>Delete Manually</Button></Link>
<APIButton role='cancel'
onClick={handleIgnore}
>Ignore Forever</APIButton>
</Message>
}
@ -188,42 +164,38 @@ const ZimSearchEntry = ({zimId, onTag, onUntag, entry}) => {
{tagIcon}
</Header>
<HeadlineText headline={headline}/>
<Modal closeIcon
open={open}
<Modal size='large' open={open}
onClose={() => setOpen(false)}>
<Modal.Content>
<div className='preview-fit'>
<ZimViewer src={url} style={{
// Override IframeViewer's `fixed` so the iframe stays inside the Modal.
position: 'static',
height: '100%', width: '100%', border: 'none',
// Use white to avoid iframe displaying with dark-theme.
backgroundColor: '#ffffff',
}}/>
</div>
</Modal.Content>
<div className='preview-fit'>
<ZimViewer src={url} style={{
// Override IframeViewer's `fixed` so the iframe stays inside the Modal.
position: 'static',
height: '100%', width: '100%', border: 'none',
// Use white to avoid iframe displaying with dark-theme.
backgroundColor: '#ffffff',
}}/>
</div>
<Modal.Actions>
<Grid>
<Grid.Column mobile={10} tablet={12}>
<TagsSelector selectedTagNames={tag_names} onAdd={localAddTag} onRemove={localUntag}/>
</Grid.Column>
<Grid.Column width={4} textAlign='right'>
<Group justify='space-between' align='flex-start' wrap='wrap' style={{width: '100%'}}>
<TagsSelector selectedTagNames={tag_names} onAdd={localAddTag} onRemove={localUntag}/>
<Group gap='xs'>
<AddToPlaylistButton
zim={{zimId, entry: path, title: (title || path).replace(/<[^>]*>/g, '')}}
content={null} title='Add to Playlist'/>
<Button color='blue' as='a' href={url} target='_blank' rel='noopener noreferrer'>Open</Button>
</Grid.Column>
</Grid>
<Button role='primary' component='a' href={url} target='_blank' rel='noopener noreferrer'>
Open
</Button>
</Group>
</Group>
</Modal.Actions>
</Modal>
</div>
}
const ZimAccordion = ({data, index, activeIndex, onClick, searchStr, activeTags}) => {
const ZimAccordionItem = ({value, active, data, searchStr, activeTags}) => {
const {id, estimate, metadata} = data;
const {title, date} = metadata;
const {zim, fetchSearch, pages, loading} = useSearchZim(searchStr, id, index === activeIndex, activeTags);
const {t} = useContext(ThemeContext);
const {zim, fetchSearch, pages, loading} = useSearchZim(searchStr, id, active, activeTags);
const localAddTag = async (zimId, path, name) => {
await tagZimEntry(zimId, path, name);
@ -239,83 +211,69 @@ const ZimAccordion = ({data, index, activeIndex, onClick, searchStr, activeTags}
if (zim && !loading) {
const {search} = zim;
if (search && search.length > 0) {
body = search.map(i => <Segment vertical key={i['path']}>
body = search.map(i => <div
key={i['path']}
style={{borderBottom: '1px solid var(--border)', paddingBottom: '1em', marginBottom: '1em'}}
>
<ZimSearchEntry zimId={id} onTag={localAddTag} onUntag={localUntag} entry={i}/>
</Segment>);
</div>);
} else {
body = <p {...t}>No results</p>;
body = <p>No results</p>;
}
}
const paginator = <center style={{marginTop: '2em'}}>
const paginator = <div style={{marginTop: '2em'}}>
<Paginator activePage={pages.activePage} totalPages={pages.totalPages} onPageChange={pages.setPage}/>
</center>;
</div>;
const label = <Label color={estimate > 0 ? 'violet' : undefined}>{normalizeEstimate(estimate)}</Label>;
return <React.Fragment>
<AccordionTitle
index={index}
active={index === activeIndex}
onClick={() => onClick(index, activeIndex)}
>
<Header as='h3'>
<Icon name='dropdown'/> {title} {label}
</Header>
</AccordionTitle>
<AccordionContent active={index === activeIndex}>
return <Accordion.Item value={value}>
<Accordion.Control>
<Header as='h3' style={{marginBottom: 0}}>{title} {label}</Header>
</Accordion.Control>
<Accordion.Panel>
<Header as='h4'>{date}</Header>
{body}
{paginator}
</AccordionContent>
</React.Fragment>
</Accordion.Panel>
</Accordion.Item>
}
const ZimsRefreshWarning = () => {
return <Message icon warning onClick={refreshFiles}>
<SIcon name='hand point right'/>
<Message.Content>
<Message.Header>No Zims have been indexed.</Message.Header>
return <div onClick={refreshFiles} style={{cursor: 'pointer'}}>
<Message kind='warning' icon='hand point right' title='No Zims have been indexed.'>
<a href='#'>Click here</a> to refresh all your files.
</Message.Content>
</Message>;
</Message>
</div>;
}
export const ZimSearchView = ({suggestions, loading}) => {
const [activeIndex, setActiveIndex] = React.useState(null);
const [activeValue, setActiveValue] = React.useState(null);
const {searchStr, activeTags, setTags} = useSearch();
const {zimsEstimates} = suggestions;
const handleClick = (index, activeIndex_) => {
setActiveIndex(index === activeIndex_ ? -1 : index);
}
let body;
if (!_.isEmpty(zimsEstimates)) {
body = zimsEstimates.map((i, index) => <ZimAccordion
key={i['path']}
index={index}
activeIndex={activeIndex}
data={i}
searchStr={searchStr}
activeTags={activeTags}
onClick={handleClick}
/>);
body = <Accordion value={activeValue} onChange={setActiveValue}>
{zimsEstimates.map(i => <ZimAccordionItem
key={i['path']}
value={String(i['id'])}
active={activeValue === String(i['id'])}
data={i}
searchStr={searchStr}
activeTags={activeTags}
/>)}
</Accordion>;
} else if (loading) {
body = <AccordionContent>
<Segment placeholder>
<Loader active={true}/>
</Segment>
</AccordionContent>;
body = <Panel><Loading/></Panel>;
} else if (_.isEmpty(zimsEstimates)) {
body = <ZimsRefreshWarning/>;
}
return <>
<TagsQuerySelector onChange={(i, j) => setTags(i)}/>
<Accordion>
{body}
</Accordion>
{body}
</>
}
@ -333,7 +291,7 @@ const ViewerMessage = () => {
</HandPointMessage>
}
const ZimCatalogItemRow = ({item, subscriptions, iso_639_codes, fetchSubscriptions}) => {
export const ZimCatalogItemRow = ({item, subscriptions, iso_639_codes, fetchSubscriptions}) => {
const {name, languages, size} = item;
const subscription = name in subscriptions ? subscriptions[name] : null;
const subscriptionLanguage = subscription ? subscription['language'] : 'en';
@ -369,39 +327,52 @@ const ZimCatalogItemRow = ({item, subscriptions, iso_639_codes, fetchSubscriptio
}
}
const handleLanguageChange = (e, {value}) => {
if (e) {
e.preventDefault();
}
setLanguage(value);
}
/*
* The code itself when the API's table has no name for it.
*
* `iso_639_codes` covers 639-1 and 639-2; the Kiwix catalog also carries 639-3 codes and
* locale variants -- `ami`, `ary`, `arz`, `be-tarask` -- and 89 of the codes in the
* catalog on a live WROLPi are absent from its 672-entry table. Each of those produced
* `label: undefined`, and a `searchable` Mantine Select calls `option.label.toLowerCase()`
* while building its dropdown on mount, before anyone opens it. One unnamed language
* anywhere in the catalog therefore took the whole /zim/manage page down.
*
* The code rather than a blank: a user choosing which language to subscribe to has to be
* able to tell one unnamed language from another, and the code is all we know.
*
* `iso_639_codes` is also null on first render -- ManageZim initialises it that way and
* fills it from the API -- so it is read defensively too.
*/
const languageOptions = languages.map(i => {
return {key: i, value: i, text: iso_639_codes[i]}
return {value: i, label: (iso_639_codes && iso_639_codes[i]) || i}
});
const languageDropdown = <Dropdown fluid search selection
placeholder='Language'
options={languageOptions}
value={language}
disabled={wrolModeEnabled}
onChange={handleLanguageChange}
const languageDropdown = <Select
searchable
placeholder='Language'
data={languageOptions}
value={language}
disabled={wrolModeEnabled}
onChange={(value) => setLanguage(value)}
/>;
const isDestructive = subscription && !languageChange;
const subscribeButton = <APIButton
color='grey'
role={isDestructive ? 'danger' : 'primary'}
confirmContent={isDestructive ? 'Are you sure you want to unsubscribe from this Zim?' : undefined}
confirmButton={isDestructive ? 'Unsubscribe' : undefined}
disabled={pending}
onClick={handleButton}
obeyWROLMode={true}
>
{subscription && !languageChange ? 'Unsubscribe' : 'Subscribe'}
{isDestructive ? 'Unsubscribe' : 'Subscribe'}
</APIButton>
return <TableRow key={name}>
<TableCell>{name}</TableCell>
<TableCell>{languageDropdown}</TableCell>
<TableCell>{subscribeButton}</TableCell>
<TableCell>{humanFileSize(size)}</TableCell>
</TableRow>
return <Table.Row key={name}>
<Table.Cell>{name}</Table.Cell>
<Table.Cell>{languageDropdown}</Table.Cell>
<Table.Cell>{subscribeButton}</Table.Cell>
<Table.Cell>{humanFileSize(size)}</Table.Cell>
</Table.Row>
}
class ManageZim extends React.Component {
@ -458,11 +429,11 @@ class ManageZim extends React.Component {
popupContent='Enable/Disable searching this Zim file in the Global Search.'
/>
return <TableRow key={path}>
<TableCell>{path}</TableCell>
<TableCell>{humanFileSize(size)}</TableCell>
<TableCell>{toggle}</TableCell>
</TableRow>
return <Table.Row key={path}>
<Table.Cell>{path}</Table.Cell>
<Table.Cell>{humanFileSize(size)}</Table.Cell>
<Table.Cell>{toggle}</Table.Cell>
</Table.Row>
}
render() {
@ -473,15 +444,9 @@ class ManageZim extends React.Component {
{key: 'size', text: 'Size', sortBy: 'size', width: 2},
{key: 'search', text: 'Search', sortBy: 'auto_search', width: 2},
];
let zimFilesBody = <Placeholder>
<PlaceholderHeader>
<PlaceholderLine/>
<PlaceholderLine/>
</PlaceholderHeader>
</Placeholder>;
let zimFilesBody = <Placeholder lines={2}/>;
if (zims && zims.length >= 1) {
zimFilesBody = <SortableTable
tableProps={{striped: true}}
data={zims}
rowFunc={(i, sortData) => this.zimFileTableRow(i, sortData, this.fetchZims.bind(this))}
rowKey='path'
@ -505,12 +470,7 @@ class ManageZim extends React.Component {
{key: 'subscription', text: 'Subscription', 'sortBy': null, width: 2},
{key: 'size', text: 'Maximum Size', sortBy: 'size', width: 2},
];
let kiwixCatalog = <Placeholder>
<PlaceholderHeader>
<PlaceholderLine/>
<PlaceholderLine/>
</PlaceholderHeader>
</Placeholder>;
let kiwixCatalog = <Placeholder lines={2}/>;
if (catalog && catalog.length > 0) {
kiwixCatalog = <SortableTable
defaultSortColumn='name'
@ -528,7 +488,12 @@ class ManageZim extends React.Component {
kiwixCatalog = <ErrorMessage>Could not fetch catalog</ErrorMessage>;
}
return <PageContainer>
/*
* A fragment, not a `PageContainer`. `ZimRoute` already provides the page chrome -- a
* `wrolpi-stack` wrapper with its own top margin -- so a second one put this page a
* further 1em down and 1em in from the tab bar than the viewer beside it.
*/
return <>
<Header as='h2'>Zim Files</Header>
{zimFilesBody}
@ -539,12 +504,12 @@ class ManageZim extends React.Component {
<DownloadMessage/>
<ViewerMessage/>
</PageContainer>
</>
}
}
function ZimViewer({src = ZIM_VIEWER_URI, style = null}) {
const fallback = <Segment>
const fallback = <Panel>
<Header as='h3'>Failed to fetch Zim service.</Header>
<p>You may need to give permission to access the page: <a href={src}>{src}</a></p>
@ -553,7 +518,7 @@ function ZimViewer({src = ZIM_VIEWER_URI, style = null}) {
<p>Check the logs</p>
<pre>journalctl -u wrolpi-kiwix</pre>
</Segment>;
</Panel>;
return <IframeViewer title='zim' src={src} fallback={fallback} style={style}/>
}
@ -566,11 +531,13 @@ export function ZimRoute() {
{text: 'Manage', to: '/zim/manage', key: 'manage'},
]
return <div style={{marginTop: '2em'}}>
// `wrolpi-stack`: this route wraps its own content rather than using PageContainer, so it is
// what spaces the tab bar from the page below it.
return <div className='wrolpi-stack' style={{marginTop: '2em'}}>
<TabLinks links={links}/>
<Routes>
<Route path='/' exact element={<ZimViewer/>}/>
<Route path='manage' exact element={<ManageZim/>}/>
</Routes>
</div>
}
}

View file

@ -0,0 +1,75 @@
import React from 'react';
import {render, screen} from '../test-utils';
import {ZimCatalogItemRow} from './Zim';
import {Table} from './ui';
/*
* The Zim catalog's language dropdown.
*
* `/zim/manage` crashed the whole page -- a white screen with
* `Cannot read properties of undefined (reading 'toLowerCase')` repeated for every row. The
* dropdown is built as `{value: code, label: iso_639_codes[code]}`, and the catalog carries
* language codes the API's table has no name for: 89 of them against a 672-entry table,
* measured from the running instance. `ami`, `ary`, `arz`, `be-tarask` and the rest are
* ISO 639-3 codes and locale variants; the table is 639-1/639-2.
*
* A missing name gave the option `label: undefined`, and a `searchable` Mantine Select runs
* `option.label.toLowerCase()` while building its dropdown -- on mount, before anyone opens
* it. So one unnamed language anywhere in the catalog took the page down.
*/
const item = (languages) => ({name: 'Wikipedia (mini)', languages, size: 1024});
// The row renders `<Table.Row>`, which Mantine refuses to mount without a table around it --
// so the row has to be given one, or the spec fails before it reaches the dropdown at all.
const renderRow = (languages, iso_639_codes) => render(
<Table>
<Table.Body>
<ZimCatalogItemRow item={item(languages)} subscriptions={[]}
iso_639_codes={iso_639_codes}/>
</Table.Body>
</Table>);
// A deliberately small stand-in for the API's table: `eng` is named, `ami` is not. That is
// the whole shape of the bug.
const codes = {eng: 'English', fra: 'French'};
describe('the Zim catalog language dropdown', () => {
it('renders a language the code table has no name for', () => {
// `ami` is real -- it is in the catalog on a live WROLPi and absent from the table.
renderRow(['eng', 'ami'], codes);
expect(screen.getByPlaceholderText('Language')).toBeInTheDocument();
});
it('falls back to the code itself, so the option is still identifiable', () => {
/*
* Not a blank entry: a user subscribing to a Zim has to be able to tell one unnamed
* language from another, and the code is the only thing we know about it. Mantine
* renders the selected option's label in the input.
*/
renderRow(['ami'], codes);
const input = screen.getByPlaceholderText('Language');
// Mantine keeps the options in the DOM for a closed dropdown's combobox; the label
// is what a user reads when they open it.
expect(input).toBeInTheDocument();
expect(document.body.textContent).toContain('ami');
});
it('still prefers the real name when the table has one', () => {
// The inverse: falling back must not replace names that exist.
renderRow(['eng'], codes);
expect(document.body.textContent).toContain('English');
expect(document.body.textContent).not.toContain('eng<');
});
it('survives a table that never arrived', () => {
// ManageZim initialises `iso_639_codes: null` and fills it from the API, so the first
// render of every row happens with no table at all.
renderRow(['eng', 'ami'], null);
expect(screen.getByPlaceholderText('Language')).toBeInTheDocument();
});
});

View file

@ -1,19 +1,16 @@
import {APIButton} from "../Common";
import {Button, Header, Icon, Loader, Modal, Popup, Table} from "../Theme";
import React, {useContext, useEffect, useState} from "react";
import {TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {TableRowPlaceholder} from "../Placeholder";
import {Button, Header, Icon, IconButton, Loader, Loading, Modal, Placeholder, Table, Tooltip} from "../ui";
import React, {useEffect, useState} from "react";
import {getConfigText, getConfigBackups, postConfigBackupImport, postConfigBackupPreview} from "../../api";
import {ThemeContext} from "../../contexts/contexts";
function WarningIcon({ok}) {
// null is empty, true is valid, false is invalid.
const trigger = <Icon
color={ok === true ? 'violet' : ok === false ? 'red' : 'grey'}
name={ok === true ? 'check' : ok === false ? 'close' : 'check'}
/>;
const content = ok === true ? 'Valid and can be imported.' : ok === false ? 'Invalid, can be overwritten.' : 'Empty config, can be overwritten.';
return <Popup trigger={trigger} content={content}/>
return <Tooltip label={content}>
<span style={{color: ok === true ? 'var(--success)' : ok === false ? 'var(--danger)' : 'var(--neutral)'}}>
<Icon name={ok === false ? 'close' : 'check'} label={content}/>
</span>
</Tooltip>
}
const TYPE_LABELS = {
@ -51,22 +48,23 @@ function groupItemsByType(items) {
}));
}
function PreviewItemTable({items, negative, inverted}) {
function PreviewItemTable({items, negative}) {
if (!items.length) return null;
const columns = TYPE_COLUMNS[items[0].type] || [{key: 'type', header: 'Item'}];
return <Table compact basic='very' size='small' inverted={!!inverted} style={{marginBottom: '0.5em'}}>
<TableHeader>
<TableRow>
{columns.map(col => <TableHeaderCell key={col.key}>{col.header}</TableHeaderCell>)}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, i) => <TableRow key={i} negative={negative}>
return <Table style={{marginBottom: '0.5em'}}>
<Table.Header>
<Table.Row>
{columns.map(col => <Table.HeaderCell key={col.key}>{col.header}</Table.HeaderCell>)}
</Table.Row>
</Table.Header>
<Table.Body>
{/* Items being removed take the failed treatment: dashed, in every theme. */}
{items.map((item, i) => <Table.Row key={i} failed={negative}>
{columns.map(col =>
<TableCell key={col.key}>{item[col.key] || ''}</TableCell>
<Table.Cell key={col.key}>{item[col.key] || ''}</Table.Cell>
)}
</TableRow>)}
</TableBody>
</Table.Row>)}
</Table.Body>
</Table>;
}
@ -82,41 +80,44 @@ function BackupDateRow({date, previews, previewsLoaded, onSelect}) {
const mergeHasChanges = hasChanges(mergePreview);
const overwriteHasChanges = hasChanges(overwritePreview);
const noChanges = <span style={{color: 'var(--muted)', fontStyle: 'italic'}}>No changes</span>;
const previewFailed = <Tooltip label='Failed to load preview'>
<span style={{color: 'var(--danger)'}}><Icon name='warning sign' label='Failed to load preview'/></span>
</Tooltip>;
let mergeCell;
if (!mergeLoaded) {
mergeCell = <Loader size='mini' active inline/>;
mergeCell = <Loader size='xs'/>;
} else if (mergePreview === null) {
mergeCell = <Popup trigger={<Icon name='warning sign' color='red'/>} content='Failed to load preview'/>;
mergeCell = previewFailed;
} else if (mergeHasChanges) {
mergeCell = <Button size='mini' color='green' onClick={() => onSelect(date, 'merge', mergePreview)}>
<Icon name='plus'/> Merge
</Button>;
mergeCell = <Button size='xs' role='save' icon='plus'
onClick={() => onSelect(date, 'merge', mergePreview)}>Merge</Button>;
} else {
mergeCell = <span style={{color: 'grey', fontStyle: 'italic'}}>No changes</span>;
mergeCell = noChanges;
}
let overwriteCell;
if (!overwriteLoaded) {
overwriteCell = <Loader size='mini' active inline/>;
overwriteCell = <Loader size='xs'/>;
} else if (overwritePreview === null) {
overwriteCell = <Popup trigger={<Icon name='warning sign' color='red'/>} content='Failed to load preview'/>;
overwriteCell = previewFailed;
} else if (overwriteHasChanges) {
overwriteCell = <Button size='mini' color='orange' onClick={() => onSelect(date, 'overwrite', overwritePreview)}>
<Icon name='refresh'/> Overwrite
</Button>;
// Overwrite discards whatever is in the database, so it warns rather than saves.
overwriteCell = <Button size='xs' role='retry' icon='refresh'
onClick={() => onSelect(date, 'overwrite', overwritePreview)}>Overwrite</Button>;
} else {
overwriteCell = <span style={{color: 'grey', fontStyle: 'italic'}}>No changes</span>;
overwriteCell = noChanges;
}
return <TableRow>
<TableCell>{formatDate(date)}</TableCell>
<TableCell>{mergeCell}</TableCell>
<TableCell>{overwriteCell}</TableCell>
</TableRow>;
return <Table.Row>
<Table.Cell>{formatDate(date)}</Table.Cell>
<Table.Cell>{mergeCell}</Table.Cell>
<Table.Cell>{overwriteCell}</Table.Cell>
</Table.Row>;
}
function BackupsModal({open, onClose, fileName, fetchConfigs}) {
const {inverted} = useContext(ThemeContext);
const [dates, setDates] = useState([]);
const [datesLoading, setDatesLoading] = useState(true);
const [previews, setPreviews] = useState({});
@ -202,52 +203,45 @@ function BackupsModal({open, onClose, fileName, fetchConfigs}) {
</Modal.Header>
<Modal.Content>
{applying
? <div style={{textAlign: 'center', padding: '2em'}}>
<Loader active inline='centered'>Applying backup...</Loader>
</div>
? <Loading>Applying backup...</Loading>
: <>
{preview.add.length > 0 && <>
<Header as='h4' color='green'>
<Icon name='plus'/> {preview.add.length} item{preview.add.length !== 1 ? 's' : ''} to add
<Header as='h4' icon='plus' style={{color: 'var(--green)'}}>
{preview.add.length} item{preview.add.length !== 1 ? 's' : ''} to add
</Header>
<div style={{maxHeight: '300px', overflowY: 'auto', marginBottom: '1em'}}>
{groupItemsByType(preview.add).map(group =>
<React.Fragment key={group.type}>
<Header as='h5' inverted={!!inverted}
style={{marginBottom: '0.3em'}}>{group.label}</Header>
<PreviewItemTable items={group.items} inverted={inverted}/>
<Header as='h5' style={{marginBottom: '0.3em'}}>{group.label}</Header>
<PreviewItemTable items={group.items}/>
</React.Fragment>
)}
</div>
</>}
{preview.remove.length > 0 && <>
<Header as='h4' color='red'>
<Icon name='minus'/> {preview.remove.length} item{preview.remove.length !== 1 ? 's' : ''} to
remove
<Header as='h4' icon='minus' style={{color: 'var(--red)'}}>
{preview.remove.length} item{preview.remove.length !== 1 ? 's' : ''} to remove
</Header>
<div style={{maxHeight: '300px', overflowY: 'auto', marginBottom: '1em'}}>
{groupItemsByType(preview.remove).map(group =>
<React.Fragment key={group.type}>
<Header as='h5' inverted={!!inverted}
style={{marginBottom: '0.3em'}}>{group.label}</Header>
<PreviewItemTable items={group.items} negative inverted={inverted}/>
<Header as='h5' style={{marginBottom: '0.3em'}}>{group.label}</Header>
<PreviewItemTable items={group.items} negative/>
</React.Fragment>
)}
</div>
</>}
<p style={{color: 'grey'}}>{preview.unchanged} unchanged
<p style={{color: 'var(--muted)'}}>{preview.unchanged} unchanged
item{preview.unchanged !== 1 ? 's' : ''}</p>
</>
}
</Modal.Content>
<Modal.Actions>
{!applying && <>
<Button onClick={handleBack}>Back</Button>
<Button color='green' onClick={handleApply}>
<Icon name='check'/> Apply
</Button>
<Button role='cancel' onClick={handleBack}>Back</Button>
<Button role='save' icon='check' onClick={handleApply}>Apply</Button>
</>}
</Modal.Actions>
</Modal>;
@ -258,21 +252,19 @@ function BackupsModal({open, onClose, fileName, fetchConfigs}) {
<Modal.Header>Restore Backup: {fileName}</Modal.Header>
<Modal.Content>
{datesLoading
? <div style={{textAlign: 'center', padding: '2em'}}>
<Loader active inline='centered'>Loading backups...</Loader>
</div>
? <Loading>Loading backups...</Loading>
: dates.length === 0
? <p>No backups available for this config.</p>
: <div style={{maxHeight: '400px', overflowY: 'auto'}}>
<Table compact unstackable>
<TableHeader>
<TableRow>
<TableHeaderCell>Date</TableHeaderCell>
<TableHeaderCell>Merge</TableHeaderCell>
<TableHeaderCell>Overwrite</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Date</Table.HeaderCell>
<Table.HeaderCell>Merge</Table.HeaderCell>
<Table.HeaderCell>Overwrite</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{dates.map(date =>
<BackupDateRow
key={date}
@ -282,13 +274,13 @@ function BackupsModal({open, onClose, fileName, fetchConfigs}) {
onSelect={handleSelect}
/>
)}
</TableBody>
</Table.Body>
</Table>
</div>
}
</Modal.Content>
<Modal.Actions>
<Button onClick={handleClose}>Close</Button>
<Button role='cancel' onClick={handleClose}>Close</Button>
</Modal.Actions>
</Modal>;
}
@ -318,14 +310,14 @@ function ConfigContentModal({open, onClose, fileName}) {
<Modal.Header>{fileName}</Modal.Header>
<Modal.Content scrolling>
{loading
? <Loader active inline='centered'/>
? <Loading/>
: content
? <pre style={{whiteSpace: 'pre-wrap', wordBreak: 'break-word'}}>{content}</pre>
: <p>Failed to load config contents.</p>
}
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Close</Button>
<Button role='cancel' onClick={onClose}>Close</Button>
</Modal.Actions>
</Modal>;
}
@ -369,20 +361,21 @@ function ConfigTableRow({config, importConfig, saveConfig, fetchConfigs}) {
/>;
}
return <TableRow>
<TableCell><span style={{cursor: 'pointer'}} onClick={() => setContentModalOpen(true)}>{file_name}</span></TableCell>
<TableCell><WarningIcon ok={successful_import}/></TableCell>
<TableCell><WarningIcon ok={valid}/></TableCell>
<TableCell className="hide-on-mobile">
return <Table.Row>
<Table.Cell>
<span style={{cursor: 'pointer'}} onClick={() => setContentModalOpen(true)}>{file_name}</span>
</Table.Cell>
<Table.Cell><WarningIcon ok={successful_import}/></Table.Cell>
<Table.Cell><WarningIcon ok={valid}/></Table.Cell>
<Table.Cell className="hide-on-mobile">
{has_backup_import
? <Button icon size='mini' onClick={() => setBackupsModalOpen(true)}>
<Icon name='undo'/>
</Button>
? <IconButton icon='undo' label='Restore a backup'
onClick={() => setBackupsModalOpen(true)}/>
: null
}
</TableCell>
<TableCell>{importButton}</TableCell>
<TableCell>{saveButton}</TableCell>
</Table.Cell>
<Table.Cell>{importButton}</Table.Cell>
<Table.Cell>{saveButton}</Table.Cell>
{backupsModalOpen && <BackupsModal
open={backupsModalOpen}
onClose={() => setBackupsModalOpen(false)}
@ -394,24 +387,24 @@ function ConfigTableRow({config, importConfig, saveConfig, fetchConfigs}) {
onClose={() => setContentModalOpen(false)}
fileName={file_name}
/>}
</TableRow>
</Table.Row>
}
export function ConfigsTable({configs, loading, importConfig, saveConfig, fetchConfigs}) {
let body = <TableRow>
<TableCell colSpan={6}><TableRowPlaceholder/></TableCell>
</TableRow>
let body = <Table.Row>
<Table.Cell colSpan={6}><Placeholder lines={2}/></Table.Cell>
</Table.Row>
if (loading) {
// Configs are being fetched again.
} else if (configs === undefined) {
body = <TableRow>
<TableCell colSpan={6}>Failed to fetch configs</TableCell>
</TableRow>;
body = <Table.Row>
<Table.Cell colSpan={6}>Failed to fetch configs</Table.Cell>
</Table.Row>;
} else if (configs && Object.keys(configs).length === 0) {
body = <TableRow>
<TableCell colSpan={6}>No configs exist. Try tagging some files.</TableCell>
</TableRow>;
body = <Table.Row>
<Table.Cell colSpan={6}>No configs exist. Try tagging some files.</Table.Cell>
</Table.Row>;
} else if (configs) {
body = Object.entries(configs).map(([key, value]) =>
<ConfigTableRow
@ -424,20 +417,20 @@ export function ConfigsTable({configs, loading, importConfig, saveConfig, fetchC
);
}
return <Table unstackable striped compact>
<TableHeader>
<TableRow>
<TableHeaderCell width={8}>File Name</TableHeaderCell>
<TableHeaderCell width={1}>Imported</TableHeaderCell>
<TableHeaderCell width={1}>Valid</TableHeaderCell>
<TableHeaderCell width={3} className="hide-on-mobile">Restore</TableHeaderCell>
<TableHeaderCell width={1}>Import</TableHeaderCell>
<TableHeaderCell width={1}>Save</TableHeaderCell>
</TableRow>
</TableHeader>
return <Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File Name</Table.HeaderCell>
<Table.HeaderCell>Imported</Table.HeaderCell>
<Table.HeaderCell>Valid</Table.HeaderCell>
<Table.HeaderCell className="hide-on-mobile">Restore</Table.HeaderCell>
<Table.HeaderCell>Import</Table.HeaderCell>
<Table.HeaderCell>Save</Table.HeaderCell>
</Table.Row>
</Table.Header>
<TableBody>
<Table.Body>
{body}
</TableBody>
</Table.Body>
</Table>
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,9 @@
import React from 'react';
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {BrowserRouter} from 'react-router';
import {MantineProvider} from '@mantine/core';
import {ThemeContext, SettingsContext} from '../../contexts/contexts';
import {cssVariablesResolver, mantineTheme} from '../../themes/mantine';
import {ControllerPage} from './ControllerPage';
// Mock the controller API
@ -134,11 +136,13 @@ const defaultSettings = {
const renderControllerPage = () => {
return render(
<BrowserRouter>
<ThemeContext.Provider value={defaultTheme}>
<SettingsContext.Provider value={defaultSettings}>
<ControllerPage/>
</SettingsContext.Provider>
</ThemeContext.Provider>
<MantineProvider theme={mantineTheme} cssVariablesResolver={cssVariablesResolver}>
<ThemeContext.Provider value={defaultTheme}>
<SettingsContext.Provider value={defaultSettings}>
<ControllerPage/>
</SettingsContext.Provider>
</ThemeContext.Provider>
</MantineProvider>
</BrowserRouter>
);
};
@ -210,7 +214,7 @@ describe('ControllerPage', () => {
});
expect(screen.getByText('Hotspot Protocol')).toBeInTheDocument();
// The saved protocol is selected; WPA3 is offered because the device supports it.
// Semantic UI renders the selected value as both the dropdown text and an option.
// The select renders its chosen value as both the field's text and an option.
await waitFor(() => {
expect(screen.getAllByText('WPA2 (most compatible)').length).toBeGreaterThan(0);
expect(screen.getAllByText('WPA3').length).toBeGreaterThan(0);
@ -279,9 +283,11 @@ describe('DiskSection primary unmount', () => {
expect(screen.getByText(/API service will/)).toBeInTheDocument();
});
// Semantic's Confirm renders confirmButton='Unmount' as the last match.
const buttons = screen.getAllByText('Unmount');
fireEvent.click(buttons[buttons.length - 1]);
// The Confirm modal is portaled to the end of <body>, so it renders
// *before* the page content in DOM order -- scope the query to the
// dialog rather than assuming which match comes last.
const dialog = screen.getByRole('dialog');
fireEvent.click(within(dialog).getByText('Unmount'));
await waitFor(() => {
expect(controllerApi.unmountDisk).toHaveBeenCalledWith('/media/wrolpi');

View file

@ -27,20 +27,19 @@ import {
WROLModeMessage
} from "../Common";
import {
Button as SButton,
Button,
ButtonGroup,
Checkbox,
Header,
IconButton,
Label,
Loader,
PlaceholderLine,
TableBody,
TableCell,
TableFooter,
TableHeader,
TableHeaderCell,
TableRow
} from "semantic-ui-react";
import {Button, Header, Modal, Placeholder, Progress, Segment, Table} from "../Theme";
Modal,
Panel,
Placeholder,
Progress,
Table,
} from "../ui";
import {useDownloads} from "../../hooks/customHooks";
import {
EditArchiveDownloadForm,
@ -65,40 +64,37 @@ function DownloadProgressModal({progress, url}) {
<Button size='small' onClick={() => setOpen(true)} style={{marginLeft: '0.5em'}}>
{buttonLabel}
</Button>
<Modal closeIcon open={open} onClose={() => setOpen(false)} size='small'>
<Modal closeIcon open={open} onClose={() => setOpen(false)} size='fullscreen'>
<Modal.Header>Download Progress</Modal.Header>
<Modal.Content>
<Progress
percent={progress.percent}
progress
indicating
color={navColor}
>
{humanFileSize(progress.bytes_downloaded)} / {humanFileSize(progress.total_bytes)}
</Progress>
<Table definition>
<TableBody>
<TableRow>
<TableCell width={4}>Speed</TableCell>
<TableCell>{speedText}</TableCell>
</TableRow>
<TableRow>
<TableCell>ETA</TableCell>
<TableCell>{progress.eta || 'Calculating...'}</TableCell>
</TableRow>
<TableRow>
<TableCell>Downloaded</TableCell>
<TableCell>{humanFileSize(progress.bytes_downloaded)} / {humanFileSize(progress.total_bytes)}</TableCell>
</TableRow>
{url && <TableRow>
<TableCell>URL</TableCell>
<TableCell style={{wordBreak: 'break-all'}}>{url}</TableCell>
</TableRow>}
</TableBody>
label={`${humanFileSize(progress.bytes_downloaded)} / ${humanFileSize(progress.total_bytes)}`}
/>
<Table>
<Table.Body>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Speed</Table.Cell>
<Table.Cell>{speedText}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>ETA</Table.Cell>
<Table.Cell>{progress.eta || 'Calculating...'}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Downloaded</Table.Cell>
<Table.Cell>{humanFileSize(progress.bytes_downloaded)} / {humanFileSize(progress.total_bytes)}</Table.Cell>
</Table.Row>
{url && <Table.Row>
<Table.Cell style={{fontWeight: 600}}>URL</Table.Cell>
<Table.Cell style={{wordBreak: 'break-all'}}>{url}</Table.Cell>
</Table.Row>}
</Table.Body>
</Table>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setOpen(false)}>Close</Button>
<Button role='cancel' onClick={() => setOpen(false)}>Close</Button>
</Modal.Actions>
</Modal>
</>;
@ -154,7 +150,7 @@ function RetryDownloadsButton({callback, selectedIds, clearSelection}) {
const label = hasSelection ? `Retry (${selectedIds.length})` : 'Retry';
return <APIButton
color='green'
role='retry'
onClick={localRetryOnce}
obeyWROLMode={true}
>{label}</APIButton>
@ -184,7 +180,7 @@ function DeleteOnceDownloadsButton({callback, selectedIds, clearSelection}) {
: 'Are you sure you want to delete all downloads? Some may be retried!';
return <APIButton
color='red'
role='danger'
onClick={localDeleteOnce}
confirmContent={confirmContent}
confirmButton='Delete'
@ -228,27 +224,29 @@ function RecurringDownloadRow({download, fetchDownloads, onDelete}) {
(text) => <Link to={location}>{text}</Link> :
(text) => <a href={url} target='_blank' rel='noopener noreferrer'>{text}</a>;
const errorModal = <Modal
const errorModal = <Modal size='fullscreen'
closeIcon
onClose={handleErrorClose}
onOpen={handleErrorOpen}
open={errorModalOpen}
trigger={<Button icon='exclamation circle' color='orange'/>}
>
<Modal.Header>Download Error</Modal.Header>
<Modal.Content>
<pre style={{overflowX: 'scroll'}}>{error}</pre>
</Modal.Content>
<Modal.Actions>
<SButton onClick={handleEditClose}>Close</SButton>
<Button role='cancel' onClick={handleErrorClose}>Close</Button>
</Modal.Actions>
</Modal>;
const errorTrigger = <IconButton icon='exclamation triangle' label='Download error' color='danger'
onClick={handleErrorOpen}/>;
const hideEdit = downloader === Downloaders.MapCatalog || downloader === Downloaders.MapExtract;
const editButton = hideEdit ? null : <Button icon='edit' onClick={handleEditOpen}/>;
const editButton = hideEdit ? null :
<IconButton icon='edit' label='Edit download' onClick={handleEditOpen}/>;
const restartButton = <APIButton
color='green'
role='retry'
icon='redo'
confirmContent='Are you sure you want to restart this download?'
confirmButton='Restart'
@ -310,7 +308,7 @@ function RecurringDownloadRow({download, fetchDownloads, onDelete}) {
/>;
}
const editModal = <Modal closeIcon
const editModal = <Modal size='large' closeIcon
open={editModalOpen}
onClose={handleEditClose}
>
@ -320,24 +318,29 @@ function RecurringDownloadRow({download, fetchDownloads, onDelete}) {
</Modal.Content>
</Modal>;
return <TableRow>
<TableCell className='column-ellipsis'>
return <Table.Row failed={!!error && !download.progress}>
<Table.Cell className='column-ellipsis'>
{link(url)}
</TableCell>
<TableCell>{formatFrequency(frequency)}</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>{formatFrequency(frequency)}</Table.Cell>
<Table.Cell>
{last_successful_download && isoDatetimeToElapsedPopup(last_successful_download)}
{status === 'pending' && !download.progress && <Loader active inline size='tiny'/>}
{status === 'pending' && !download.progress && <Loader size='xs'/>}
{download.progress && <DownloadProgressModal progress={download.progress} url={download.url}/>}
</TableCell>
<TableCell>{next}</TableCell>
<TableCell textAlign='right'>
{error && !download.progress && errorModal}
{editButton}
{restartButton}
</TableCell>
</Table.Cell>
<Table.Cell>{next}</Table.Cell>
<Table.Cell>
{/* `justify-content` rather than the cell's `text-align: right`, which a flex row
does not answer to. These two are icon buttons and were sharing an edge. */}
<div className='wrolpi-button-row' style={{justifyContent: 'flex-end'}}>
{error && !download.progress && errorTrigger}
{editButton}
{restartButton}
</div>
</Table.Cell>
{errorModal}
{editModal}
</TableRow>
</Table.Row>
}
function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
@ -384,35 +387,36 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
(text) => <a href={location || url} target='_blank' rel='noopener noreferrer'>{text}</a>;
let completedAtCell = last_successful_download ? isoDatetimeToElapsedPopup(last_successful_download) : null;
let buttonCell = <TableCell/>;
let buttonCell = <Table.Cell/>;
if (status === 'pending' || status === 'new') {
buttonCell = (
<TableCell>
<Table.Cell>
<ButtonGroup>
<APIButton
color='red'
icon='stop circle'
role='danger'
icon='stop'
onClick={handleStop}
confirmContent='Are you sure you want to stop this download? It will not be retried.'
confirmButton='Stop'
obeyWROLMode={true}
/>
{status === 'new' && (downloader === Downloaders.Video || downloader === Downloaders.Archive) && (
<Button
<IconButton
icon='edit'
label='Edit download'
color='blue'
onClick={handleEditOpen}
/>
)}
</ButtonGroup>
</TableCell>
</Table.Cell>
);
} else if (status === 'failed' || status === 'deferred') {
buttonCell = (
<TableCell>
<Table.Cell>
<ButtonGroup>
<APIButton
color='red'
role='danger'
icon='trash'
onClick={handleDelete}
confirmContent='Are you sure you want to delete this download?'
@ -420,14 +424,15 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
obeyWROLMode={true}
/>
{(downloader === Downloaders.Video || downloader === Downloaders.Archive) && status !== 'pending' && (
<Button
<IconButton
icon='edit'
label='Edit download'
color='blue'
onClick={handleEditOpen}
/>
)}
<APIButton
color='green'
role='retry'
icon='redo'
confirmContent='Are you sure you want to restart this download?'
confirmButton='Start'
@ -435,35 +440,41 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
obeyWROLMode={true}
/>
</ButtonGroup>
</TableCell>
</Table.Cell>
);
} else if (status === 'complete' && location) {
buttonCell = (
<TableCell>
<Table.Cell>
{link('View')}
</TableCell>
</Table.Cell>
);
}
if (error && !download.progress) {
completedAtCell = (
<Modal
closeIcon
trigger={<Button icon='exclamation circle' color='red'/>}
>
<Modal.Header>Download Error</Modal.Header>
<Modal.Content>
<pre style={{overflowX: 'scroll'}}>{error}</pre>
</Modal.Content>
</Modal>
)
}
// Create edit modal for video or archive downloads
let editModal = null;
let errorModal = null;
const [errorModalOpen, setErrorModalOpen] = React.useState(false);
if (error && !download.progress) {
completedAtCell = (
<IconButton icon='exclamation triangle' label='Download error' color='danger'
onClick={() => setErrorModalOpen(true)}/>
)
errorModal = <Modal size='fullscreen'
closeIcon
open={errorModalOpen}
onClose={() => setErrorModalOpen(false)}
>
<Modal.Header>Download Error</Modal.Header>
<Modal.Content>
<pre style={{overflowX: 'scroll'}}>{error}</pre>
</Modal.Content>
</Modal>
}
if (downloader === Downloaders.Video) {
editModal = (
<Modal
<Modal size='large'
closeIcon
open={editModalOpen}
onClose={handleEditClose}
@ -482,7 +493,7 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
onDelete={handleDelete}
/>
{parentDownloadUrl && (
<p style={{marginTop: '1em', color: '#666'}}>
<p style={{marginTop: '1em', color: 'var(--muted)'}}>
From: <a href={parentDownloadUrl} target='_blank' rel='noopener noreferrer'>
{parentDownloadUrl}
</a>
@ -493,7 +504,7 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
);
} else if (downloader === Downloaders.Archive) {
editModal = (
<Modal
<Modal size='large'
closeIcon
open={editModalOpen}
onClose={handleEditClose}
@ -511,7 +522,7 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
onDelete={handleDelete}
/>
{parentDownloadUrl && (
<p style={{marginTop: '1em', color: '#666'}}>
<p style={{marginTop: '1em', color: 'var(--muted)'}}>
From: <a href={parentDownloadUrl} target='_blank' rel='noopener noreferrer'>
{parentDownloadUrl}
</a>
@ -522,24 +533,25 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
);
}
return <TableRow>
<TableCell collapsing>
return <Table.Row failed={status === 'failed' || status === 'deferred'}>
<Table.Cell>
<Checkbox
checked={isSelected}
onChange={() => onSelect(id)}
/>
</TableCell>
<TableCell className='column-ellipsis'>
</Table.Cell>
<Table.Cell className='column-ellipsis'>
{link(url)}
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
{completedAtCell}
{status === 'pending' && !download.progress ? <Loader active inline size='tiny'/> : null}
{status === 'pending' && !download.progress ? <Loader size='xs'/> : null}
{download.progress && <DownloadProgressModal progress={download.progress} url={download.url}/>}
</TableCell>
</Table.Cell>
{buttonCell}
{errorModal}
{editModal}
</TableRow>
</Table.Row>
}
export function OnceDownloadsTable({downloads, fetchDownloads}) {
@ -597,9 +609,10 @@ export function OnceDownloadsTable({downloads, fetchDownloads}) {
/>
);
const footer = <TableFooter>
<TableRow>
<TableHeaderCell colSpan={4}>
const footer = <Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan={4}>
<div className='wrolpi-button-row'>
<ClearDownloadsButton
callback={fetchDownloads}
selectedIds={selectedIds}
@ -615,9 +628,10 @@ export function OnceDownloadsTable({downloads, fetchDownloads}) {
selectedIds={selectedIds}
clearSelection={clearSelection}
/>
</TableHeaderCell>
</TableRow>
</TableFooter>;
</div>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>;
if (downloads && downloads.length >= 1) {
return <SortableTable
@ -629,29 +643,26 @@ export function OnceDownloadsTable({downloads, fetchDownloads}) {
tableProps={{className: 'table-ellipsis'}}
/>
} else if (downloads) {
return <Segment>No downloads are scheduled.</Segment>
return <Panel>No downloads are scheduled.</Panel>
} else if (downloads === undefined) {
return <ErrorMessage>Unable to fetch downloads</ErrorMessage>
}
return <Placeholder>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>
return <Placeholder lines={2}/>
}
export function RecurringDownloadsTable({downloads, fetchDownloads, onDelete}) {
if (downloads && downloads.length >= 1) {
return <Table compact className='table-ellipsis'>
<TableHeader>
<TableRow>
<TableHeaderCell width={8}>URL</TableHeaderCell>
<TableHeaderCell width={2}>Download Frequency</TableHeaderCell>
<TableHeaderCell width={2}>Completed At</TableHeaderCell>
<TableHeaderCell width={1}>Next</TableHeaderCell>
<TableHeaderCell width={3} textAlign='right'>Control</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
return <Table className='table-ellipsis'>
<Table.Header>
<Table.Row>
<Table.HeaderCell style={{width: '50%'}}>URL</Table.HeaderCell>
<Table.HeaderCell style={{width: '12.5%'}}>Download Frequency</Table.HeaderCell>
<Table.HeaderCell style={{width: '12.5%'}}>Completed At</Table.HeaderCell>
<Table.HeaderCell style={{width: '6.25%'}}>Next</Table.HeaderCell>
<Table.HeaderCell style={{width: '18.75%', textAlign: 'right'}}>Control</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{downloads.map(i => {
return <RecurringDownloadRow
key={i.id}
@ -660,17 +671,14 @@ export function RecurringDownloadsTable({downloads, fetchDownloads, onDelete}) {
onDelete={onDelete}
/>
})}
</TableBody>
</Table.Body>
</Table>
} else if (downloads) {
return <Segment>No downloads are scheduled.</Segment>
return <Panel>No downloads are scheduled.</Panel>
} else if (downloads === undefined) {
return <ErrorMessage>Unable to fetch downloads</ErrorMessage>
}
return <Placeholder>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>
return <Placeholder lines={2}/>
}
export function DownloadsPage() {
@ -679,7 +687,7 @@ export function DownloadsPage() {
const {onceDownloads, recurringDownloads, pendingOnceDownloads, fetchDownloads} = useDownloads();
const pendingOnceDownloadsSpan = pendingOnceDownloads > 0 ?
<Label color='violet' size='large'>{pendingOnceDownloads}</Label>
<Label color='violet'>{pendingOnceDownloads}</Label>
: null;
return <>

View file

@ -1,6 +1,6 @@
import React from "react";
import {Link} from "react-router";
import {Button, Icon, Message} from "semantic-ui-react";
import {Button, Message} from "../ui";
import {useLocalStorage} from "../Common";
import {useExtensionInstalled} from "../../hooks/customHooks";
import {isExtensionSupportedBrowser} from "./browserDetect";
@ -25,18 +25,19 @@ export function ExtensionInstallSuggestion() {
// Also skip on browsers that cannot install the extension (e.g. Safari).
if (installed !== false || dismissed || !isExtensionSupportedBrowser()) return null;
return <Message info icon onDismiss={() => setDismissed(true)}>
<Icon name='puzzle piece'/>
<Message.Content>
<Message.Header>Install the WROLPi browser extension</Message.Header>
<p>
Send pages, videos, and feeds to this WROLPi straight from any
tab. Open-source and not distributed via the official browser
stores.
</p>
<Button as={Link} to='/admin/extension' primary size='small'>
Open install page
</Button>
</Message.Content>
return <Message
kind='info'
icon='puzzle piece'
title='Install the WROLPi browser extension'
onDismiss={() => setDismissed(true)}
>
<p>
Send pages, videos, and feeds to this WROLPi straight from any
tab. Open-source and not distributed via the official browser
stores.
</p>
<Button component={Link} to='/admin/extension' role='primary' size='xs'>
Open install page
</Button>
</Message>;
}

View file

@ -1,7 +1,5 @@
import React from "react";
import {Button, Header, Icon, Segment} from "../Theme";
import {Input, Message} from "semantic-ui-react";
import {ThemeContext} from "../../contexts/contexts";
import {ActionInput, Button, Header, Message, Panel, Stack} from "../ui";
import {detectBrowser} from "./browserDetect";
// The WROLPi browser extension is distributed off-store: Chrome users
@ -34,49 +32,48 @@ function DestinationHint() {
// Fall through silently — the user can still copy manually.
}
};
return <Segment style={{marginBottom: '1em'}}>
<Header as='h4'>
<Icon name='globe'/>
This WROLPi's URL
</Header>
<p style={{color: 'grey'}}>
// No `marginBottom`: this Panel sits in a Mantine `Stack`, whose gap already spaces it.
// A margin here is added to that gap -- the compound the panel-margin approach caused.
return <Panel>
<Header as='h4' icon='globe'>This WROLPi's URL</Header>
<p style={{color: 'var(--muted)'}}>
After installing the extension, paste this URL into the
destination field on the extension's options page.
</p>
<Input
fluid
<ActionInput
readOnly
value={origin}
action={
<Button
icon={copied ? 'check' : 'copy'}
color={copied ? 'green' : null}
content={copied ? 'Copied' : 'Copy'}
role={copied ? 'save' : 'cancel'}
onClick={onCopy}
/>
>
{copied ? 'Copied' : 'Copy'}
</Button>
}
/>
</Segment>;
</Panel>;
}
function FirefoxCard({metadata, version}) {
const file = (metadata && metadata.files && metadata.files['wrolpi-firefox.xpi']) || {};
const available = !!file.available;
return <Segment>
<Header as='h3'>
<Icon name='firefox'/>
return <Panel>
<Header as='h3' icon='firefox'>
Firefox{version ? ` — v${version}` : ''}
</Header>
<p>One-click install. Mozilla has signed this build.</p>
<Button
primary
as='a'
role='primary'
component='a'
href={FIREFOX_XPI_PATH}
disabled={!available}
icon='download'
content={available ? `Install for Firefox (${formatSize(file.size_bytes)})` : 'Not yet available'}
/>
<p style={{marginTop: '1em', color: 'grey'}}>
>
{available ? `Install for Firefox (${formatSize(file.size_bytes)})` : 'Not yet available'}
</Button>
<p style={{marginTop: '1em', color: 'var(--muted)'}}>
Firefox will ask you to confirm the install. Accept the prompt.
</p>
<Header as='h4' style={{marginTop: '1.5em'}}>After installing</Header>
@ -84,15 +81,14 @@ function FirefoxCard({metadata, version}) {
<li>Open the extension's <strong>Options</strong> page (right-click the toolbar icon &rarr; Manage Extension &rarr; Options) and add this WROLPi as a destination. Click <strong>Test</strong> to grant API access.</li>
<li>Click the extension icon in the toolbar &rarr; the permission dropdown &rarr; <strong>Always Allow on this site</strong>. Firefox requires this second click in addition to the Test grant — without it, the extension can talk to your WROLPi but can't mark its tabs as connected.</li>
</ol>
</Segment>;
</Panel>;
}
function ChromiumCard({metadata, version}) {
const file = (metadata && metadata.files && metadata.files['wrolpi-chrome.zip']) || {};
const available = !!file.available;
return <Segment>
<Header as='h3'>
<Icon name='chrome'/>
return <Panel>
<Header as='h3' icon='chrome'>
Chrome / Brave / Edge{version ? ` — v${version}` : ''}
</Header>
<p>
@ -100,14 +96,15 @@ function ChromiumCard({metadata, version}) {
outside the Web Store, so we sideload via the built-in developer flow.
</p>
<Button
primary
as='a'
role='primary'
component='a'
href={CHROME_ZIP_PATH}
disabled={!available}
icon='download'
content={available ? `Download .zip (${formatSize(file.size_bytes)})` : 'Not yet available'}
download
/>
>
{available ? `Download .zip (${formatSize(file.size_bytes)})` : 'Not yet available'}
</Button>
<Header as='h4' style={{marginTop: '1.5em'}}>Install steps</Header>
<ol style={{lineHeight: 1.7, paddingLeft: '1.2em'}}>
<li>Download the <code>.zip</code> above and extract it to a folder you'll keep around.</li>
@ -115,28 +112,27 @@ function ChromiumCard({metadata, version}) {
<li>Toggle <strong>Developer mode</strong> on (top-right).</li>
<li>Click <strong>Load unpacked</strong> and pick the extracted folder.</li>
</ol>
<p style={{color: 'grey'}}>
<p style={{color: 'var(--muted)'}}>
The extension stays installed across browser restarts. Don't delete the
extracted folder — the browser loads from it directly.
</p>
</Segment>;
</Panel>;
}
function UnknownCard() {
return <Segment>
return <Panel>
<Header as='h3'>Unsupported browser</Header>
<p>
The WROLPi extension supports Chrome, Brave, Edge, and Firefox. Open
this page in one of those browsers to install.
</p>
</Segment>;
</Panel>;
}
export function ExtensionPage() {
const [metadata, setMetadata] = React.useState(null);
const [error, setError] = React.useState(null);
const browser = detectBrowser();
const {s} = React.useContext(ThemeContext);
React.useEffect(() => {
let cancelled = false;
@ -148,47 +144,43 @@ export function ExtensionPage() {
}, []);
if (error) {
return <Segment>
<Message negative>
<Message.Header>Couldn't load extension info</Message.Header>
<p>{error}</p>
</Message>
</Segment>;
return <Message kind='error' title="Couldn't load extension info">{error}</Message>;
}
const versions = (metadata && metadata.versions) || {};
const anyAvailable = metadata && Object.values(metadata.files || {}).some(f => f.available);
return <>
<Header as='h1'>Browser Extension</Header>
<p {...s}>
Install the WROLPi browser extension to send pages, videos, and feeds to
this WROLPi from any tab. The extension is open-source and not distributed
via official browser stores — it ships with each WROLPi release.
</p>
return <Stack gap='md'>
<div>
<Header as='h1'>Browser Extension</Header>
<p>
Install the WROLPi browser extension to send pages, videos, and feeds to
this WROLPi from any tab. The extension is open-source and not distributed
via official browser stores — it ships with each WROLPi release.
</p>
</div>
<DestinationHint/>
{metadata && !anyAvailable && <Message warning>
<Message.Header>Extension binaries not yet installed</Message.Header>
<p>
The extension binaries aren't on this WROLPi yet. They ship with the
next release; pull the latest and they'll appear here.
</p>
{metadata && !anyAvailable && <Message kind='warning' title='Extension binaries not yet installed'>
The extension binaries aren't on this WROLPi yet. They ship with the
next release; pull the latest and they'll appear here.
</Message>}
{browser === 'firefox' && <FirefoxCard metadata={metadata} version={versions.firefox}/>}
{browser === 'chromium' && <ChromiumCard metadata={metadata} version={versions.chrome}/>}
{browser === 'unknown' && <UnknownCard/>}
<Segment basic style={{marginTop: '2em'}}>
<Header as='h4'>Both browsers, just in case</Header>
<p style={{color: 'grey'}}>
You can install for a different browser too — useful if you sync
between machines.
</p>
<Stack gap='sm' style={{marginTop: '1em'}}>
<div>
<Header as='h4' dividing>Both browsers, just in case</Header>
<p style={{color: 'var(--muted)'}}>
You can install for a different browser too — useful if you sync
between machines.
</p>
</div>
{browser !== 'firefox' && <FirefoxCard metadata={metadata} version={versions.firefox}/>}
{browser !== 'chromium' && <ChromiumCard metadata={metadata} version={versions.chrome}/>}
</Segment>
</>;
</Stack>
</Stack>;
}

View file

@ -56,7 +56,7 @@ describe('ExtensionPage', () => {
it('shows the not-yet-installed warning when no binaries are available', async () => {
// This test exists specifically to render the <Message warning> block,
// which catches missing imports from the Theme/Semantic-UI module.
// which catches missing imports from the component library.
setUserAgent(CHROME_UA);
global.fetch = mockFetch({
files: {

View file

@ -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,10 +13,42 @@ 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 {navColorHexMap} from "../Vars";
import {Link} from "react-router";
import {
Button,
Divider,
Grid,
Header,
Icon,
Loading,
Modal,
Panel,
Select,
ThemePicker,
toast,
PathInput,
TextInput,
} from "../ui";
function ThemeSegment() {
return <Panel>
<Header as='h2'>Theme</Header>
<p>Choose how WROLPi looks. The choice is saved in this browser, so each device can
differ.</p>
<ThemePicker/>
<Divider/>
<Header as='h3'>Preview</Header>
<p>See every part of the interface in the theme you picked.</p>
<Link to='/theme-sample'>
<Button icon='eye'>View the components</Button>
</Link>
</Panel>;
}
export function ShutdownButton() {
const dockerized = useDockerized();
@ -71,7 +91,7 @@ export function ShutdownButton() {
return <APIButton
size='huge'
color='red'
role='danger'
onClick={handleShutdown}
confirmContent='Are you sure you want to turn off your WROLPi?'
confirmButton='Shutdown'
@ -118,33 +138,32 @@ function UpgradeSegment() {
// Not available in Docker
if (dockerized) {
return <Segment id='upgrade'>
return <Panel id='upgrade'>
<Header as='h3'>System Upgrade</Header>
<p>Upgrades are not available in Docker environments. Please upgrade your Docker images manually.</p>
</Segment>;
</Panel>;
}
// No update available
if (!status?.update_available) {
return <Segment id='upgrade'>
return <Panel id='upgrade'>
<Header as='h3'>System Upgrade</Header>
<p>Your WROLPi is up to date.</p>
<p>Version: <strong>v{status?.version}</strong> on branch <strong>{status?.git_branch || 'unknown'}</strong>
</p>
<APIButton
color='blue'
role='primary'
onClick={handleCheckUpgrade}
disabled={checking}
>
{checking ? 'Checking...' : 'Check for Upgrades'}
</APIButton>
</Segment>;
</Panel>;
}
// Update available
return <Segment id='upgrade'>
<Header as='h3'>
<Icon name='arrow alternate circle up'/>
return <Panel id='upgrade'>
<Header as='h3' icon='arrow alternate circle up'>
Upgrade Available
</Header>
<p>Branch: <strong>{status?.git_branch}</strong></p>
@ -153,7 +172,7 @@ function UpgradeSegment() {
<p><strong>{status?.commits_behind}</strong> commit(s) behind</p>
<APIButton
color='green'
role='save'
size='big'
onClick={handleUpgrade}
disabled={upgrading}
@ -162,7 +181,7 @@ function UpgradeSegment() {
>
{upgrading ? 'Starting Upgrade...' : 'Upgrade Now'}
</APIButton>
</Segment>;
</Panel>;
}
export function RestartButton() {
@ -206,7 +225,7 @@ export function RestartButton() {
return <APIButton
size='huge'
color='yellow'
role='retry'
onClick={handleRestart}
confirmContent='Are you sure you want to reboot your WROLPi?'
confirmButton='Restart'
@ -270,16 +289,15 @@ function WROLModeSection() {
};
return (
<Segment>
<Header as='h3'>
<Icon name='shield'/>
<Panel danger>
<Header as='h3' icon='shield'>
WROL Mode
</Header>
<p>
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.
</p>
<p style={{fontSize: '0.9em', color: '#888'}}>
<p style={{fontSize: '0.9em', color: 'var(--muted)'}}>
Note: User settings and tags can still be modified.
</p>
<Toggle
@ -287,7 +305,7 @@ function WROLModeSection() {
onChange={toggleWROLMode}
label={wrolMode ? 'WROL Mode Enabled' : 'WROL Mode Disabled'}
/>
</Segment>
</Panel>
);
}
@ -474,28 +492,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)}
const navColorOptions = Object.keys(navColorHexMap).map(i => {
return {value: i, label: i.charAt(0).toUpperCase() + i.slice(1)}
});
const mediaDirectoryLabel = `${settings.media_directory}/`;
@ -506,94 +518,92 @@ export function SettingsPage() {
body = <>
<p>Any changes will be written to <i>{settings.media_directory}/config/wrolpi.yaml</i>.</p>
<Form id="settings">
<Grid columns={2} stackable>
<Grid.Row>
<Grid.Column>
<div style={{margin: '0.5em'}}>
<Toggle
label='CPU Power-save on Startup'
disabled={disabled || state.throttle_on_startup === null}
checked={state.throttle_on_startup === true}
onChange={checked => handleInputChange(null, 'throttle_on_startup', checked)}
/>
</div>
<div style={{margin: '0.5em'}}>
<Toggle
label='Ignore outdated Zims'
disabled={disabled || state.ignore_outdated_zims === null}
checked={state.ignore_outdated_zims === true}
onChange={checked => handleInputChange(null, 'ignore_outdated_zims', checked)}
/>
</div>
<div style={{margin: '0.5em'}}>
<Toggle
label='Check for upgrades hourly'
disabled={disabled || dockerized || state.check_for_upgrades === null}
checked={!dockerized && state.check_for_upgrades === true}
onChange={checked => handleInputChange(null, 'check_for_upgrades', checked)}
/>
</div>
<div style={{margin: '0.5em'}}>
<Toggle
label='Tags Directory'
disabled={disabled || state.tags_directory === null}
checked={state.tags_directory === true}
onChange={checked => handleInputChange(null, 'tags_directory', checked)}
info='When enabled, WROLPi creates a "tags" directory with hardlinks to all tagged files, organized by tag name.'
/>
</div>
<div style={{margin: '0.5em'}}>
<Toggle
label='Save FFprobe Cache Files'
disabled={disabled || state.save_ffprobe_json === null}
checked={state.save_ffprobe_json === true}
onChange={checked => 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).'
/>
</div>
<br/>
<label htmlFor='log_levels_input'>Log Level: {logLevelToName(state.log_level)}</label>
<br/>
<input type='range'
id='log_levels_input'
list='log_levels'
min='1'
max='5'
value={state.log_level}
onChange={e => setState({...state, log_level: parseInt(e.target.value)})}
style={{marginBottom: '1em'}}
<form id="settings" onSubmit={e => e.preventDefault()}>
<Grid>
<Grid.Col span={12}>
<div style={{margin: '0.5em'}}>
<Toggle
label='CPU Power-save on Startup'
disabled={disabled || state.throttle_on_startup === null}
checked={state.throttle_on_startup === true}
onChange={checked => handleInputChange('throttle_on_startup', checked)}
/>
<datalist id='log_levels'>
<option value='1'>Critical</option>
<option value='2'>Warning</option>
<option value='3'>Info</option>
<option value='4'>Debug</option>
<option value='5'>Trace</option>
</datalist>
</div>
<br/>
<div style={{margin: '0.5em'}}>
<Toggle
label='Ignore outdated Zims'
disabled={disabled || state.ignore_outdated_zims === null}
checked={state.ignore_outdated_zims === true}
onChange={checked => handleInputChange('ignore_outdated_zims', checked)}
/>
</div>
<ButtonGroup>
<Button color={state.nav_color} onClick={e => e.preventDefault()}>Navbar Color</Button>
<Dropdown
id='settings_navbar_color_dropdown'
className='button icon'
floating
options={navColorOptions}
onChange={(e, {value}) => setState({...state, nav_color: value})}
value={state.nav_color}
/>
</ButtonGroup>
<div style={{margin: '0.5em'}}>
<Toggle
label='Check for upgrades hourly'
disabled={disabled || dockerized || state.check_for_upgrades === null}
checked={!dockerized && state.check_for_upgrades === true}
onChange={checked => handleInputChange('check_for_upgrades', checked)}
/>
</div>
</Grid.Column>
</Grid.Row>
<div style={{margin: '0.5em'}}>
<Toggle
label='Tags Directory'
disabled={disabled || state.tags_directory === null}
checked={state.tags_directory === true}
onChange={checked => handleInputChange('tags_directory', checked)}
info='When enabled, WROLPi creates a "tags" directory with hardlinks to all tagged files, organized by tag name.'
/>
</div>
<div style={{margin: '0.5em'}}>
<Toggle
label='Save FFprobe Cache Files'
disabled={disabled || state.save_ffprobe_json === null}
checked={state.save_ffprobe_json === true}
onChange={checked => 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).'
/>
</div>
<br/>
<label htmlFor='log_levels_input'>Log Level: {logLevelToName(state.log_level)}</label>
<br/>
<input type='range'
id='log_levels_input'
list='log_levels'
min='1'
max='5'
value={state.log_level}
onChange={e => setState({...state, log_level: parseInt(e.target.value)})}
style={{marginBottom: '1em'}}
/>
<datalist id='log_levels'>
<option value='1'>Critical</option>
<option value='2'>Warning</option>
<option value='3'>Info</option>
<option value='4'>Debug</option>
<option value='5'>Trace</option>
</datalist>
<br/>
<div style={{display: 'flex', alignItems: 'center', gap: 0}}>
<Button color={state.nav_color} onClick={e => e.preventDefault()}>Navbar Color</Button>
<Select
id='settings_navbar_color_dropdown'
data={navColorOptions}
value={state.nav_color || ''}
onChange={value => setState({...state, nav_color: value})}
allowDeselect={false}
style={{width: '10em'}}
/>
</div>
</Grid.Col>
</Grid>
@ -606,7 +616,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)}
/>
</div>
@ -619,7 +629,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)}
/>
</div>
@ -632,107 +642,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)}
/>
</div>
<Form.Group inline>
<Form.Input
label={<>
<b>Wait Between Downloads</b>
<InfoPopup
content='Number of seconds to wait after each download completes. This helps download "nicely" by being polite to servers. Set to 0 to disable waiting.'/>
</>}
<div style={{margin: '0.5em 0'}}>
<label>
<b>Wait Between Downloads</b>
<InfoPopup
content='Number of seconds to wait after each download completes. This helps download "nicely" by being polite to servers. Set to 0 to disable waiting.'/>
</label>
<br/>
<TextInput
value={state.download_wait}
disabled={disabled || state.download_wait === null}
onChange={(e, i) => handleTimeoutChange(e, 'download_wait', i.value)}
onChange={e => handleTimeoutChange('download_wait', e.currentTarget.value)}
/>
</Form.Group>
<Form.Group inline>
<Form.Input
label={<>
<b>Download Timeout</b>
<InfoPopup content='Downloads will be stopped after this many seconds have elapsed.
Downloads will never timeout if this is empty.'/>
</>}
</div>
<div style={{margin: '0.5em 0'}}>
<label>
<b>Download Timeout</b>
<InfoPopup content='Downloads will be stopped after this many seconds have elapsed.
Downloads will never timeout if this is empty.'/>
</label>
<br/>
<TextInput
value={state.download_timeout}
disabled={disabled || state.download_timeout === null}
onChange={(e, i) => handleTimeoutChange(e, 'download_timeout', i.value)}
onChange={e => handleTimeoutChange('download_timeout', e.currentTarget.value)}
/>
</Form.Group>
</div>
<Form.Group inline>
<Form.Input
label={<>
<b>Daily Download Limit (per domain)</b>
<InfoPopup
content='Maximum number of downloads from any single domain per day. Recurring
Channel/RSS catalog updates do not count; only the videos/archives they create
and one-off downloads count. Leave empty or set to 0 for no limit.'/>
</>}
<div style={{margin: '0.5em 0'}}>
<label>
<b>Daily Download Limit (per domain)</b>
<InfoPopup
content='Maximum number of downloads from any single domain per day. Recurring
Channel/RSS catalog updates do not count; only the videos/archives they create
and one-off downloads count. Leave empty or set to 0 for no limit.'/>
</label>
<br/>
<TextInput
value={state.download_daily_limit_per_domain}
disabled={disabled}
onChange={(e, i) => handleTimeoutChange(e, 'download_daily_limit_per_domain', i.value)}
onChange={e => handleTimeoutChange('download_daily_limit_per_domain', e.currentTarget.value)}
/>
</Form.Group>
<Form.Group inline>
<Form.Input
label={<>
<b>Daily Download Limit (total)</b>
<InfoPopup
content='Maximum number of downloads across all domains per day. Recurring
Channel/RSS catalog updates do not count. Leave empty or set to 0 for no limit.'/>
</>}
</div>
<div style={{margin: '0.5em 0'}}>
<label>
<b>Daily Download Limit (total)</b>
<InfoPopup
content='Maximum number of downloads across all domains per day. Recurring
Channel/RSS catalog updates do not count. Leave empty or set to 0 for no limit.'/>
</label>
<br/>
<TextInput
value={state.download_daily_limit_global}
disabled={disabled}
onChange={(e, i) => handleTimeoutChange(e, 'download_daily_limit_global', i.value)}
onChange={e => handleTimeoutChange('download_daily_limit_global', e.currentTarget.value)}
/>
</Form.Group>
</div>
<Form.Group inline>
<Form.Input
label={<>
<b>Download Window Start</b>
<InfoPopup
content='Start time for allowed download window (HH:MM, 24-hour). Leave both empty for no restriction.'/>
</>}
<div style={{margin: '0.5em 0'}}>
<label>
<b>Download Window Start</b>
<InfoPopup
content='Start time for allowed download window (HH:MM, 24-hour). Leave both empty for no restriction.'/>
</label>
<br/>
<TextInput
type='time'
value={state.download_window_start}
disabled={disabled}
onChange={(e, i) => handleInputChange(e, 'download_window_start', i.value)}
onChange={e => handleInputChange('download_window_start', e.currentTarget.value)}
/>
</Form.Group>
</div>
<Form.Group inline>
<Form.Input
label={<>
<b>Download Window End</b>
<InfoPopup
content='End time for allowed download window (HH:MM, 24-hour). Leave both empty for no restriction.'/>
</>}
<div style={{margin: '0.5em 0'}}>
<label>
<b>Download Window End</b>
<InfoPopup
content='End time for allowed download window (HH:MM, 24-hour). Leave both empty for no restriction.'/>
</label>
<br/>
<TextInput
type='time'
value={state.download_window_end}
disabled={disabled}
onChange={(e, i) => handleInputChange(e, 'download_window_end', i.value)}
onChange={e => handleInputChange('download_window_end', e.currentTarget.value)}
/>
</Form.Group>
</div>
<Form.Field width={3}>
<div style={{margin: '0.5em 0', maxWidth: '20em'}}>
<label><b>Timezone</b>
<InfoPopup
content='IANA timezone (e.g. America/Denver). Leave empty to use system timezone.'/>
</label>
<FormDropdown
<Select
placeholder='System default'
options={timezoneOptions}
data={timezoneOptions}
value={state.timezone || ''}
disabled={disabled}
search
selection
searchable
clearable
onChange={(e, {value}) => setState({...state, timezone: value || ''})}
onChange={value => setState({...state, timezone: value || ''})}
/>
</Form.Field>
</div>
{status && status.local_time &&
<p>Current local time: <b>{status.local_time.slice(0, 16).replace('T', ' ')}</b></p>}
@ -742,139 +757,119 @@ export function SettingsPage() {
<p>WROLPi will save files to these directories (any video files will be saved to
the <i>videos</i> by default, etc.).</p>
<Grid stackable>
<GridRow columns={2}>
<GridColumn>
<Form.Field>
<label>
Archive Directory
<InfoPopup
wide='very'
content={<>
<p>Variables:</p>
<ul>
<li><code>%(domain)s</code> - Domain name (e.g., "example.com")</li>
<li><code>%(domain_tag)s</code> - Tag name for the domain (empty if no
tag)
</li>
<li><code>%(tag)s</code> - Alias for the tag name</li>
<li><code>%(name)s</code> - Alias for the domain name</li>
</ul>
</>}
position='top left'
/>
</label>
<Input
label={mediaDirectoryLabel}
value={state.archive_destination}
disabled={!editSpecialDirectories}
onChange={(e, d) => handleInputChange(e, 'archive_destination', d.value)}
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<PathInput
label={<>
Archive Directory
<InfoPopup
w={340}
content={<>
<p>Variables:</p>
<ul>
<li><code>%(domain)s</code> - Domain name (e.g., "example.com")</li>
<li><code>%(domain_tag)s</code> - Tag name for the domain (empty if no
tag)
</li>
<li><code>%(tag)s</code> - Alias for the tag name</li>
<li><code>%(name)s</code> - Alias for the domain name</li>
</ul>
</>}
position='top'
/>
</Form.Field>
</GridColumn>
<GridColumn>
<Form.Field>
<label>
Videos Directory
<InfoPopup
wide='very'
content={<>
<p>Variables:</p>
<ul>
<li><code>%(channel_name)s</code> - Channel name</li>
<li><code>%(channel_tag)s</code> - Tag name for the channel (empty if no
tag)
</li>
<li><code>%(channel_domain)s</code> - Domain from the channel URL</li>
<li><code>%(tag)s</code> - Alias for the tag name</li>
<li><code>%(name)s</code> - Alias for the channel name</li>
</ul>
</>}
position='top left'
/>
</label>
<Input
label={mediaDirectoryLabel}
value={state.videos_destination}
disabled={!editSpecialDirectories}
onChange={(e, d) => handleInputChange(e, 'videos_destination', d.value)}
</>}
prefix={mediaDirectoryLabel}
value={state.archive_destination}
disabled={!editSpecialDirectories}
onChange={e => handleInputChange('archive_destination', e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<PathInput
label={<>
Videos Directory
<InfoPopup
w={340}
content={<>
<p>Variables:</p>
<ul>
<li><code>%(channel_name)s</code> - Channel name</li>
<li><code>%(channel_tag)s</code> - Tag name for the channel (empty if no
tag)
</li>
<li><code>%(channel_domain)s</code> - Domain from the channel URL</li>
<li><code>%(tag)s</code> - Alias for the tag name</li>
<li><code>%(name)s</code> - Alias for the channel name</li>
</ul>
</>}
position='top'
/>
</Form.Field>
</GridColumn>
</GridRow>
<GridRow columns={2}>
<GridColumn>
<Form.Field>
<label>Map Directory</label>
<Input
label={mediaDirectoryLabel}
value={state.map_destination}
disabled={!editSpecialDirectories}
onChange={(e, d) => handleInputChange(e, 'map_destination', d.value)}
/>
</Form.Field>
</GridColumn>
<GridColumn>
<Form.Field>
<label>Zims Directory</label>
<Input
label={mediaDirectoryLabel}
value={state.zims_destination}
disabled={!editSpecialDirectories}
onChange={(e, d) => handleInputChange(e, 'zims_destination', d.value)}
/>
</Form.Field>
</GridColumn>
</GridRow>
<GridRow columns={2}>
<GridColumn>
<Form.Field>
<label>Playlists Directory</label>
<Input
label={mediaDirectoryLabel}
value={state.playlists_destination}
disabled={!editSpecialDirectories}
onChange={(e, d) => handleInputChange(e, 'playlists_destination', d.value)}
/>
</Form.Field>
</GridColumn>
</GridRow>
<GridRow columns={1}>
<GridColumn>
<Toggle
label='Edit Directories'
disabled={disabled}
checked={editSpecialDirectories === true}
onChange={checked => setEditSpecialDirectories(checked)}
/>
</GridColumn>
</GridRow>
</>}
prefix={mediaDirectoryLabel}
value={state.videos_destination}
disabled={!editSpecialDirectories}
onChange={e => handleInputChange('videos_destination', e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<PathInput
label='Map Directory'
prefix={mediaDirectoryLabel}
value={state.map_destination}
disabled={!editSpecialDirectories}
onChange={e => handleInputChange('map_destination', e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<PathInput
label='Zims Directory'
prefix={mediaDirectoryLabel}
value={state.zims_destination}
disabled={!editSpecialDirectories}
onChange={e => handleInputChange('zims_destination', e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<PathInput
label='Playlists Directory'
prefix={mediaDirectoryLabel}
value={state.playlists_destination}
disabled={!editSpecialDirectories}
onChange={e => handleInputChange('playlists_destination', e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col span={12}>
<Toggle
label='Edit Directories'
disabled={disabled}
checked={editSpecialDirectories === true}
onChange={checked => setEditSpecialDirectories(checked)}
/>
</Grid.Col>
</Grid>
<Divider/>
<APIButton
id='settings_save_button'
color='violet'
role='save'
size='big'
onClick={() => localSaveSettings(state)}
obeyWROLMode={true}
disabled={disabled}
>Save</APIButton>
<Dimmer active={pendingSave}>
<Loader active={pendingSave} size='large'/>
</Dimmer>
{pendingSave && <Loading>Saving settings...</Loading>}
</Form>
</form>
</>
} else if (ready === false) {
body = <Loader active inline='centered'/>
body = <Loading/>
} else {
body = <ErrorMessage>Unable to fetch settings</ErrorMessage>
}
const configsSegment = <Segment>
const configsSegment = <Panel>
<RefreshHeader
header='Configs'
popupContents='Check if configs are valid'
@ -902,17 +897,21 @@ export function SettingsPage() {
<p>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.</p>
<a href='/ca.crt' download='wrolpi-ca.crt'>
<Button primary><Icon name='download'/> Download Root CA Certificate</Button>
<Button role='primary' icon='download'>Download Root CA Certificate</Button>
</a>
</Segment>;
</Panel>;
return <Container fluid>
// `wrolpi-stack`: these panels sit in a wrapper of this page's own, so the stack on
// PageContainer does not reach them and this wrapper is what has to space them.
return <div className='wrolpi-stack'>
<WROLModeMessage content='Settings are disabled because WROL Mode is enabled.'/>
<Segment>
<Panel>
<Header as='h2'>Settings</Header>
{body}
</Segment>
</Panel>
<ThemeSegment/>
{configsSegment}
@ -920,10 +919,10 @@ export function SettingsPage() {
<WROLModeSection/>
<Segment>
<Panel>
<Header as='h1'>Browser Settings</Header>
<APIButton onClick={clearAll}>Show All Hints</APIButton>
</Segment>
</Panel>
{/* Trace Level Modal - shown when user selects trace level in native mode */}
<Modal
@ -943,7 +942,7 @@ export function SettingsPage() {
? `Switching to debug mode in ${traceCountdown} seconds...`
: 'Switching API services...'}
</p>
<p style={{color: '#888', fontSize: '0.9em'}}>
<p style={{color: 'var(--muted)', fontSize: '0.9em'}}>
The API will restart. This page may temporarily lose connection.
</p>
</>
@ -957,7 +956,7 @@ export function SettingsPage() {
This will save your settings and restart the API service. The page may temporarily
lose connection during the switch.
</p>
<p style={{color: '#888', fontSize: '0.9em'}}>
<p style={{color: 'var(--muted)', fontSize: '0.9em'}}>
You can switch back to production mode later from the Controller page.
</p>
</>
@ -965,21 +964,21 @@ export function SettingsPage() {
</Modal.Content>
<Modal.Actions>
<Button
color='grey'
role='cancel'
onClick={handleTraceModalCancel}
disabled={traceSwitching}
>
Cancel
</Button>
<Button
color='green'
role='save'
onClick={handleTraceModalConfirm}
disabled={traceSwitching}
icon='check'
>
<Icon name='check'/> Enable Debug Mode
Enable Debug Mode
</Button>
</Modal.Actions>
</Modal>
</Container>;
</div>;
}

View file

@ -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 <Progress progress
percent={percent}
label={`${mount} ${humanFileSize(used)} of ${humanFileSize(size)}`}
color={color}
key={mount}
return <Progress
percent={percent}
label={`${mount} ${humanFileSize(used)} of ${humanFileSize(size)}`}
color={color}
key={mount}
/>
}
@ -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 = <DiskBandwidthProgress
bytes_ps={bytes_write_ps}
total={max_write_ps}
label={`${name} write`}
size='tiny'
disabled={bytes_write_ps === 0}
/>;
return <Grid columns={2}>
<Grid.Row>
<Grid.Column>{read}</Grid.Column>
<Grid.Column>{write}</Grid.Column>
</Grid.Row>
return <Grid>
<Grid.Col span={6}>{read}</Grid.Col>
<Grid.Col span={6}>{write}</Grid.Col>
</Grid>
}
function CPUTemperatureStatistic({temperature, high_temperature, critical_temperature, ...props}) {
if (!temperature) {
return <Statistic label='Temp C°' value='?'/>
return <Statistic label='Temp C°' value='?' {...props}/>
}
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 <Statistic label='Temp C°' value={temperature} {...props}/>
return <Statistic label='Temp C°' value={temperature} color={color} {...props}/>
}
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 <Statistic label='IO Wait %' value='?'/>
return <Statistic label='IO Wait %' value='?' {...props}/>
}
let color;
if (percent_iowait >= 30) {
props['color'] = 'red';
color = 'red';
} else if (percent_iowait >= 10) {
props['color'] = 'orange';
color = 'orange';
}
return <Statistic label='IO Wait %' value={percent_iowait.toFixed(1)} {...props}/>
return <Statistic label='IO Wait %' value={percent_iowait.toFixed(1)} color={color} {...props}/>
}
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 <Progress percent={percent} label={label} disabled={disabled} {...props}/>
return <Progress percent={percent} label={label} color={color} {...props}/>
}
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 <Grid columns={2}>
<Grid.Row>
<Grid.Column>
{recv}
</Grid.Column>
<Grid.Column>
{sent}
</Grid.Column>
</Grid.Row>
return <Grid>
<Grid.Col span={6}>{recv}</Grid.Col>
<Grid.Col span={6}>{sent}</Grid.Col>
</Grid>
}
@ -189,10 +174,10 @@ export function BandwidthProgressCombined({bandwidth, ...props}) {
export function CPUUsageProgress({percent, label}) {
if (percent === null) {
return <Progress progress={0} color='grey' label='Average CPU Usage ERROR' disabled/>
return <Progress percent={0} color='grey' label='Average CPU Usage ERROR'/>
}
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 <Progress percent={percent} progress color={color} label={label}/>
return <Progress percent={percent} color={color} label={label}/>
}
export function MemoryUsageProgress({percent, label}) {
if (percent === null) {
return <Progress progress={0} color='grey' label='RAM Usage' disabled/>
return <Progress percent={0} color='grey' label='RAM Usage'/>
}
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 <Progress percent={percent} progress color={color} label={label} size={size}/>
return <Progress percent={percent} color={color} label={label}/>
}
function ProcessInfoRow({pid, command, percent_cpu, percent_mem}) {
const color = percent_cpu >= 80 ? 'orange' : null;
return <TableRow color={color}>
<TableCell className='column-ellipsis'>{command}</TableCell>
<TableCell textAlign='right'>{percent_cpu}</TableCell>
<TableCell textAlign='right'>{percent_mem}</TableCell>
<TableCell textAlign='right'>{pid}</TableCell>
</TableRow>
return <Table.Row failed={percent_cpu >= 80}>
<Table.Cell className='column-ellipsis'>{command}</Table.Cell>
<Table.Cell numeric>{percent_cpu}</Table.Cell>
<Table.Cell numeric>{percent_mem}</Table.Cell>
<Table.Cell numeric>{pid}</Table.Cell>
</Table.Row>
}
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 = <CPUUsageProgress percent={percent} label={`CPU Usage (${cores} cores)`}/>;
const memoryUsageProgress = <MemoryUsageProgress percent={memoryPercent} label={`RAM Usage (${memorySize})`}/>;
const handleAccordionClick = (e, {index}) => {
setActiveIndex(activeIndex === index ? -1 : index);
}
const noProcessesRow = <Table.Row>
<Table.Cell colSpan={4}>No top processes</Table.Cell>
</Table.Row>
const noProcessesRow = <TableRow>
<TableCell colSpan={4}>No top processes</TableCell>
</TableRow>
return <SegmentGroup>
return <>
<Media at='mobile'>
<Segment>
<Panel>
{cpuProgress}
{memoryUsageProgress}
<Statistic.Group size='mini'>
<LoadStatistic label='1 Min. Load' value={minute_1} cores={cores} style={{margin: '1em'}}/>
<LoadStatistic label='5 Min. Load' value={minute_5} cores={cores} style={{margin: '1m'}}/>
<LoadStatistic label='15 Min. Load' value={minute_15} cores={cores} style={{margin: '1em'}}/>
<StatisticGroup>
<LoadStatistic label='1 Min. Load' value={minute_1} cores={cores}/>
<LoadStatistic label='5 Min. Load' value={minute_5} cores={cores}/>
<LoadStatistic label='15 Min. Load' value={minute_15} cores={cores}/>
<CPUTemperatureStatistic
id='cpu_temperature_statistic'
temperature={temperature}
@ -325,14 +299,14 @@ export function StatusPage() {
<FanRPMStatistic fan_rpm={fan_rpm}/>
<IOWaitStatistic percent_iowait={percent_iowait}/>
<UptimeStatistic uptime_seconds={uptime_seconds}/>
</Statistic.Group>
</Segment>
</StatisticGroup>
</Panel>
</Media>
<Media greaterThanOrEqual='tablet'>
<Segment>
<Panel>
{cpuProgress}
{memoryUsageProgress}
<Statistic.Group>
<StatisticGroup>
<LoadStatistic label='1 Min. Load' value={minute_1} cores={cores}/>
<LoadStatistic label='5 Min.' value={minute_5} cores={cores}/>
<LoadStatistic label='15 Min.' value={minute_15} cores={cores}/>
@ -341,16 +315,15 @@ export function StatusPage() {
temperature={temperature}
high_temperature={high_temperature}
critical_temperature={critical_temperature}
style={{marginRight: 0}}
/>
<FanRPMStatistic fan_rpm={fan_rpm}/>
<IOWaitStatistic percent_iowait={percent_iowait}/>
<UptimeStatistic uptime_seconds={uptime_seconds}/>
</Statistic.Group>
</Segment>
</StatisticGroup>
</Panel>
</Media>
<Segment>
<Panel>
<InfoHeader
headerSize='h2'
headerContent='Drive Bandwidth'
@ -361,70 +334,68 @@ export function StatusPage() {
{!_.isEmpty(diskBandwidthStats) ?
Object.entries(diskBandwidthStats).map(([name, disk]) => <DiskBandwidth key={name} {...disk}/>)
: <ProgressPlaceholder/>}
</Segment>
</Panel>
<Segment>
<Panel>
<SizedHeader>Network Bandwidth</SizedHeader>
{!_.isEmpty(nicBandwidthStats) ?
Object.entries(nicBandwidthStats).map(([name, stats]) => <BandwidthProgressGroup key={name}
bandwidth={stats}/>)
: <ProgressPlaceholder/>}
</Segment>
</Panel>
<Segment>
<Panel>
<SizedHeader>Top Processes</SizedHeader>
<Table unstackable striped className='table-ellipsis'>
<TableHeader>
<TableRow>
<TableHeaderCell width={10}>Command</TableHeaderCell>
<TableHeaderCell width={2} textAlign='right'>CPU %</TableHeaderCell>
<TableHeaderCell width={2} textAlign='right'>Mem %</TableHeaderCell>
<TableHeaderCell width={2} textAlign='right'>PID</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
<Table className='table-ellipsis'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Command</Table.HeaderCell>
<Table.HeaderCell>CPU %</Table.HeaderCell>
<Table.HeaderCell>Mem %</Table.HeaderCell>
<Table.HeaderCell>PID</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{processesStats && processesStats.length === 0 ?
noProcessesRow :
processesStats && processesStats.length > 0 ?
processesStats.map(i => <ProcessInfoRow key={i.pid} {...i}/>)
: <TableRow><TableCell><ProgressPlaceholder/></TableCell></TableRow>
: <Table.Row><Table.Cell colSpan={4}><ProgressPlaceholder/></Table.Cell></Table.Row>
}
</TableBody>
</Table.Body>
</Table>
</Segment>
</Panel>
<Segment>
<Panel>
<SizedHeader>Drive Usage</SizedHeader>
{drivesStats && drivesStats.length > 0 ? drivesStats.map((drive) => <DriveInfo
key={drive['mount']} {...drive}/>)
: <ProgressPlaceholder/>}
</Segment>
</Panel>
<Segment>
<Panel>
<SizedHeader sizeMobile={'h2'} sizeTablet={'h3'}>Developer</SizedHeader>
<Accordion>
<AccordionTitle onClick={handleAccordionClick} index={0}>
<Icon name='dropdown'/>
Status Details
</AccordionTitle>
<AccordionContent active={activeIndex === 0}>
<pre {...s}>
{JSON.stringify(status, undefined, 1)}
</pre>
</AccordionContent>
<Accordion.Item value='status'>
<Accordion.Control>Status Details</Accordion.Control>
<Accordion.Panel>
<pre style={{background: 'var(--panel)', color: 'var(--text)', padding: '0.5em'}}>
{JSON.stringify(status, undefined, 1)}
</pre>
</Accordion.Panel>
</Accordion.Item>
<AccordionTitle onClick={handleAccordionClick} index={1}>
<Icon name='dropdown'/>
Settings Details
</AccordionTitle>
<AccordionContent active={activeIndex === 1}>
<pre {...s}>
{JSON.stringify(settings, undefined, 1)}
</pre>
</AccordionContent>
<Accordion.Item value='settings'>
<Accordion.Control>Settings Details</Accordion.Control>
<Accordion.Panel>
<pre style={{background: 'var(--panel)', color: 'var(--text)', padding: '0.5em'}}>
{JSON.stringify(settings, undefined, 1)}
</pre>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Segment>
</SegmentGroup>
</Panel>
</>
}

View file

@ -1,10 +1,8 @@
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 {ColoredInput} from "../Apps";
import {Button, ButtonGroup, ColoredInput, Grid, Group, Header, IconButton, NumberInput, Panel, Table} from "../ui";
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 +204,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 +393,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 <Group gap={0} wrap='nowrap' align='stretch' style={{marginBottom: '0.25em'}}>
<span style={{
display: 'flex', alignItems: 'center', padding: '0 10px', fontSize: '0.8125rem', fontWeight: 500,
whiteSpace: 'nowrap', background: hex, color: textColorFor(hex), border: `1px solid ${hex}`,
}}>{label}</span>
<NumberInput name={name} value={value} onChange={onChange} min={min} step={step}
onFocus={e => e.target.select()} hideControls style={{flex: 1}}/>
</Group>;
}
export function DriveCalculator() {
const [mode, setMode] = React.useState('pulley');
const [metric, setMetric] = useLocalStorage('drive_calculator_metric', false);
@ -433,20 +447,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) =>
<Input {...inputProps} {...extraProps} name={name} labelPosition='left' value={value} onChange={onChange}
label={<Label style={{backgroundColor: hex, color: textColorFor(hex), borderColor: hex}}>
{labelText}</Label>}/>;
// Auxiliary (non-solver) numeric input.
const auxInput = (label, value, setter, name) =>
<ColoredInput {...inputProps} name={name} label={label} value={value}
<ColoredInput fluid type='number' onSelect={e => 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 +472,47 @@ export function DriveCalculator() {
? surfaceSpeedDisplay(pitchDiameter(Number(speedEl.size)), Number(speedEl.rpm), metric)
: null;
const modeButtons = <Button.Group style={{marginBottom: '1em', flexWrap: 'wrap'}}>
const modeButtons = <ButtonGroup style={{marginBottom: '1em', flexWrap: 'wrap'}}>
{Object.entries(MODES).map(([key, m]) =>
<Button key={key} type='button' icon={m.icon} content={m.button}
primary={mode === key} onClick={() => setMode(key)}/>)}
</Button.Group>;
<Button key={key} type='button' icon={m.icon} role={mode === key ? 'primary' : 'cancel'}
onClick={() => setMode(key)}>{m.button}</Button>)}
</ButtonGroup>;
// 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: <WheelInput hex={hex} label={`#${i + 1} ${sizeLabel}${sizeUnit}`} value={el.size}
onChange={onSize} name={`size-${i}`} min={toothed ? 1 : undefined}
step={toothed ? 1 : undefined}/>,
rpmField: <WheelInput hex={hex} label={`#${i + 1} RPM`} value={el.rpm}
onChange={onField('rpm')} name={`rpm-${i}`}/>,
removeBtn: elements.length > MIN_ELEMENTS
? <Button type='button' basic color='red' icon='close' size='small'
aria-label={`Remove #${i + 1}`} onClick={() => dispatch({type: 'remove', index: i})}/>
? <IconButton type='button' role='danger' icon='close' size='sm'
label={`Remove #${i + 1}`} onClick={() => dispatch({type: 'remove', index: i})}/>
: null,
};
});
const addButton = <Button type='button' icon='plus' primary content={`Add ${button}`}
const addButton = <Button type='button' icon='plus' role='primary'
disabled={elements.length >= MAX_ELEMENTS}
onClick={() => dispatch({type: 'add'})}
style={{marginTop: '0.5em'}}/>;
style={{marginTop: '0.5em'}}>Add {button}</Button>;
// Small colored swatch matching a wheel, used to label its detail rows.
const swatch = (hex) => <span style={{
@ -514,10 +522,10 @@ export function DriveCalculator() {
// Per-wheel pitch-diameter rows (gear/sprocket only).
const pitchDiameterRows = toothed ? elements.map((el, i) =>
<TableRow key={i}>
<TableCell>{swatch(ELEMENT_COLORS[i % ELEMENT_COLORS.length])}#{i + 1} Pitch Diameter</TableCell>
<TableCell>{fmt(pitchDiameter(Number(el.size)), lengthUnit)}</TableCell>
</TableRow>) : null;
<Table.Row key={i}>
<Table.Cell>{swatch(ELEMENT_COLORS[i % ELEMENT_COLORS.length])}#{i + 1} Pitch Diameter</Table.Cell>
<Table.Cell>{fmt(pitchDiameter(Number(el.size)), lengthUnit)}</Table.Cell>
</Table.Row>) : 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 +534,38 @@ export function DriveCalculator() {
const belt = twoElements ? beltLength(firstSize, lastSize, Number(centerDistance)) : null;
detailRows = <>
{twoElements && <>
<TableRow>
<TableCell width={6}>Center Distance <InfoPopup content={CENTER_DISTANCE_INFO}/></TableCell>
<TableCell>{auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")}</TableCell>
</TableRow>
<TableRow>
<TableCell>Belt Length</TableCell>
<TableCell>{fmt(belt, lengthUnit)}</TableCell>
</TableRow>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Center Distance <InfoPopup content={CENTER_DISTANCE_INFO}/></Table.Cell>
<Table.Cell>{auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Belt Length</Table.Cell>
<Table.Cell>{fmt(belt, lengthUnit)}</Table.Cell>
</Table.Row>
</>}
<TableRow>
<TableCell width={6}>Belt Speed</TableCell>
<TableCell>{fmtSpeed(trainSpeed)}</TableCell>
</TableRow>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Belt Speed</Table.Cell>
<Table.Cell>{fmtSpeed(trainSpeed)}</Table.Cell>
</Table.Row>
</>;
} 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 = <>
<TableRow>
<TableCell width={6}>{paramLabel}</TableCell>
<TableCell>{auxInput(paramLabel, gearTooth, setGearTooth, "gearParam")}</TableCell>
</TableRow>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>{paramLabel}</Table.Cell>
<Table.Cell>{auxInput(paramLabel, gearTooth, setGearTooth, "gearParam")}</Table.Cell>
</Table.Row>
{pitchDiameterRows}
{twoElements && <TableRow>
<TableCell>Center Distance</TableCell>
<TableCell>{fmt(cd, lengthUnit)}</TableCell>
</TableRow>}
<TableRow>
<TableCell>Pitch-Line Velocity</TableCell>
<TableCell>{fmtSpeed(trainSpeed)}</TableCell>
</TableRow>
{twoElements && <Table.Row>
<Table.Cell style={{fontWeight: 600}}>Center Distance</Table.Cell>
<Table.Cell>{fmt(cd, lengthUnit)}</Table.Cell>
</Table.Row>}
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Pitch-Line Velocity</Table.Cell>
<Table.Cell>{fmtSpeed(trainSpeed)}</Table.Cell>
</Table.Row>
</>;
} else { // sprocket
const pitch = Number(chainPitch);
@ -565,27 +573,27 @@ export function DriveCalculator() {
? chainLengthPitchesEven(firstSize, lastSize, Number(centerDistance), pitch)
: null;
detailRows = <>
<TableRow>
<TableCell width={6}>Chain Pitch</TableCell>
<TableCell>{auxInput(`Chain Pitch (${lengthUnit})`, chainPitch, setChainPitch, "chainPitch")}</TableCell>
</TableRow>
{twoElements && <TableRow>
<TableCell>Center Distance <InfoPopup content={CENTER_DISTANCE_INFO}/></TableCell>
<TableCell>{auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")}</TableCell>
</TableRow>}
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Chain Pitch</Table.Cell>
<Table.Cell>{auxInput(`Chain Pitch (${lengthUnit})`, chainPitch, setChainPitch, "chainPitch")}</Table.Cell>
</Table.Row>
{twoElements && <Table.Row>
<Table.Cell style={{fontWeight: 600}}>Center Distance <InfoPopup content={CENTER_DISTANCE_INFO}/></Table.Cell>
<Table.Cell>{auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")}</Table.Cell>
</Table.Row>}
{pitchDiameterRows}
{twoElements && <TableRow>
<TableCell>Chain Length</TableCell>
<TableCell>{links === null ? '—' : `${links} pitches`}</TableCell>
</TableRow>}
<TableRow>
<TableCell>Chain Speed</TableCell>
<TableCell>{fmtSpeed(trainSpeed)}</TableCell>
</TableRow>
{twoElements && <Table.Row>
<Table.Cell style={{fontWeight: 600}}>Chain Length</Table.Cell>
<Table.Cell>{links === null ? '—' : `${links} pitches`}</Table.Cell>
</Table.Row>}
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Chain Speed</Table.Cell>
<Table.Cell>{fmtSpeed(trainSpeed)}</Table.Cell>
</Table.Row>
</>;
}
return <Form>
return <div>
<Header as='h1'>Drive Ratio</Header>
<div style={{marginBottom: '1em'}}>{modeButtons}</div>
@ -605,53 +613,52 @@ export function DriveCalculator() {
</Media>
<Media greaterThanOrEqual='tablet'>
<Grid verticalAlign='middle'>
{elementRows.map(({sizeField, rpmField, removeBtn}, i) =>
<GridRow key={i}>
<GridColumn width={7}>{sizeField}</GridColumn>
<GridColumn width={7}>{rpmField}</GridColumn>
<GridColumn width={2}>{removeBtn}</GridColumn>
</GridRow>)}
</Grid>
{elementRows.map(({sizeField, rpmField, removeBtn}, i) =>
<Grid key={i} align='center'>
<Grid.Col span={5}>{sizeField}</Grid.Col>
<Grid.Col span={5}>{rpmField}</Grid.Col>
<Grid.Col span={2}>{removeBtn}</Grid.Col>
</Grid>)}
</Media>
<div>{addButton}</div>
<Segment style={{textAlign: 'center', overflowX: 'auto'}}>
<Panel style={{textAlign: 'center', overflowX: 'auto'}}>
<DriveDiagram mode={mode} sizes={elements.map(el => el.size)}/>
</Segment>
</Panel>
<Table definition unstackable>
<TableBody>
<TableRow>
<TableCell width={6}>Drive Ratio{' '}
<Table>
<Table.Body>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Drive Ratio{' '}
<span style={{fontWeight: 'normal', opacity: 0.7}}>(#1 → #{elements.length})</span>
</TableCell>
<TableCell>{formatRatio(ratio)}</TableCell>
</TableRow>
<TableRow>
<TableCell>Torque Multiplier</TableCell>
<TableCell>{ratio === null ? '—' : `${roundDigits(ratio, 3)}×`}</TableCell>
</TableRow>
<TableRow>
<TableCell>Output Torque <InfoPopup content={TORQUE_INFO}/></TableCell>
<TableCell>
<ColoredInput {...inputProps} name="torqueIn" label={`Input Torque (${torqueUnit})`}
</Table.Cell>
<Table.Cell>{formatRatio(ratio)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Torque Multiplier</Table.Cell>
<Table.Cell>{ratio === null ? '—' : `${roundDigits(ratio, 3)}×`}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Output Torque <InfoPopup content={TORQUE_INFO}/></Table.Cell>
<Table.Cell>
<ColoredInput fluid type='number' onSelect={e => e.target.select()} autoComplete='off'
name="torqueIn" label={`Input Torque (${torqueUnit})`}
value={torqueIn}
onChange={e => setTorqueIn(e.target.value)}/>
{outTorque !== null && <span style={{marginLeft: '1em'}}>
= <b>{roundDigits(outTorque, 3)} {torqueUnit}</b>
</span>}
</TableCell>
</TableRow>
</TableBody>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
<Header as='h3' style={{marginTop: '1em'}}>{button} Details</Header>
<Table definition unstackable>
<TableBody>{detailRows}</TableBody>
<Table>
<Table.Body>{detailRows}</Table.Body>
</Table>
</Form>;
</div>;
}
// Exported for testing

View file

@ -1,23 +1,7 @@
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 {ColoredInput} from "../Apps";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import {Media, ThemeContext} from "../../contexts/contexts";
import {ColoredInput, Grid, Header, Message, Panel, Select, Stack, Table} from "../ui";
import {Media} from "../../contexts/contexts";
const initialState = {
volts: '',
@ -119,7 +103,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 +116,39 @@ export function OhmsLawCalculator({setVolts}) {
return <div>
<Header as='h1'>Ohm's Law</Header>
<Grid columns={2}>
<GridRow>
<GridColumn>
<ColoredInput {...inputProps}
value={state.volts}
onChange={e => dispatch({type: 'volts', value: e.target.value})}
label='Volts'
color={calculateColor('volts')}
/>
</GridColumn>
<GridColumn>
<ColoredInput {...inputProps}
value={state.amps}
onChange={e => dispatch({type: 'amps', value: e.target.value})}
label='Amps'
color={calculateColor('amps')}
/>
</GridColumn>
</GridRow>
<GridRow>
<GridColumn>
<ColoredInput {...inputProps}
value={state.ohms}
onChange={e => dispatch({type: 'ohms', value: e.target.value})}
label='Ohms'
color={calculateColor('ohms')}
/>
</GridColumn>
<GridColumn>
<ColoredInput {...inputProps}
value={state.watts}
onChange={e => dispatch({type: 'watts', value: e.target.value})}
label='Watts'
color={calculateColor('watts')}
/>
</GridColumn>
</GridRow>
<Grid>
<Grid.Col span={{base: 12, xs: 6}}>
<ColoredInput {...inputProps}
value={state.volts}
onChange={e => dispatch({type: 'volts', value: e.target.value})}
label='Volts'
color={calculateColor('volts')}
/>
</Grid.Col>
<Grid.Col span={{base: 12, xs: 6}}>
<ColoredInput {...inputProps}
value={state.amps}
onChange={e => dispatch({type: 'amps', value: e.target.value})}
label='Amps'
color={calculateColor('amps')}
/>
</Grid.Col>
<Grid.Col span={{base: 12, xs: 6}}>
<ColoredInput {...inputProps}
value={state.ohms}
onChange={e => dispatch({type: 'ohms', value: e.target.value})}
label='Ohms'
color={calculateColor('ohms')}
/>
</Grid.Col>
<Grid.Col span={{base: 12, xs: 6}}>
<ColoredInput {...inputProps}
value={state.watts}
onChange={e => dispatch({type: 'watts', value: e.target.value})}
label='Watts'
color={calculateColor('watts')}
/>
</Grid.Col>
</Grid>
</div>
}
@ -301,10 +280,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 +304,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 = <Input fluid
const voltsInput = <ColoredInput fluid
label='Volts'
labelPosition='right'
type='number'
value={volts}
onChange={(e, {value}) => setVolts(parseInt(value))}
onChange={e => setVolts(parseInt(e.target.value))}
autoComplete="off"
/>;
@ -346,91 +322,79 @@ const PowerLossCalculator = ({volts, setVolts, wireType, setWireType, isSAE, set
onChange={() => setIsSAE(!isSAE)}
/>;
const lengthInput = <Grid columns={2}>
<Grid.Row>
<Grid.Column mobile={12} tablet={12}>
<Input fluid
label={isSAE ? 'feet' : 'meters'}
labelPosition='right'
type='number'
value={length}
onChange={(e, {value}) => setLength(parseInt(value))}
autoComplete="off"
/>
</Grid.Column>
<Grid.Column mobile={4} tablet={2}>
<InfoPopup
content='Length is one way. This length will be doubled to account for return wire.'/>
</Grid.Column>
</Grid.Row>
const lengthInput = <Grid>
<Grid.Col span={10}>
<ColoredInput fluid
label={isSAE ? 'feet' : 'meters'}
labelPosition='right'
type='number'
value={length}
onChange={e => setLength(parseInt(e.target.value))}
autoComplete="off"
/>
</Grid.Col>
<Grid.Col span={2}>
<InfoPopup
content='Length is one way. This length will be doubled to account for return wire.'/>
</Grid.Col>
</Grid>;
const wireTypeDropdown = <Dropdown
const wireTypeDropdown = <Select
placeholder='Select Wire Type'
fluid
selection
options={wireTypeOptions}
data={wireTypeOptions}
value={wireType}
onChange={(e, {value}) => setWireType(value)}
onChange={value => setWireType(value)}
/>;
return <Form>
return <div>
<Header as='h1'>Power Loss</Header>
<Media at='mobile'>
<Grid columns={2} divided stackable>
<Grid.Row>
<Grid.Column>{voltsInput}</Grid.Column>
<Grid.Column>{lengthInput}</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>{wireTypeDropdown}</Grid.Column>
<Grid.Column>{saeToggle}</Grid.Column>
</Grid.Row>
<Grid>
<Grid.Col span={6}>{voltsInput}</Grid.Col>
<Grid.Col span={6}>{lengthInput}</Grid.Col>
<Grid.Col span={6}>{wireTypeDropdown}</Grid.Col>
<Grid.Col span={6}>{saeToggle}</Grid.Col>
</Grid>
</Media>
<Media greaterThan='mobile'>
<Grid columns={2} divided>
<Grid.Row>
<Grid.Column>{voltsInput}</Grid.Column>
<Grid.Column>{saeToggle}</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>{lengthInput}</Grid.Column>
<Grid.Column>{wireTypeDropdown}</Grid.Column>
</Grid.Row>
<Grid>
<Grid.Col span={6}>{voltsInput}</Grid.Col>
<Grid.Col span={6}>{saeToggle}</Grid.Col>
<Grid.Col span={6}>{lengthInput}</Grid.Col>
<Grid.Col span={6}>{wireTypeDropdown}</Grid.Col>
</Grid>
</Media>
<Table unstackable compact celled definition>
<TableHeader>
<TableRow>
<TableHeaderCell/>
{ampsRange.map(amp => <TableHeaderCell key={amp}>{amp}A</TableHeaderCell>)}
</TableRow>
</TableHeader>
<TableBody>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell/>
{ampsRange.map(amp => <Table.HeaderCell key={amp}>{amp}A</Table.HeaderCell>)}
</Table.Row>
</Table.Header>
<Table.Body>
{Object.entries(resistances).map(([size, resistence]) => (
<TableRow key={size}>
<TableCell>{size}</TableCell>
<Table.Row key={size}>
<Table.Cell style={{fontWeight: 600}}>{size}</Table.Cell>
{ampsRange.map(amp => {
const lossPercentage = calcPowerLossPercent(isSAE, volts, amp, resistence, length);
const style = lossPercentage > 2 ? {backgroundColor: warningBackgroundColor} : null;
return <TableCell key={`${size}-${amp}`} style={style}>
// 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 <Table.Cell key={`${size}-${amp}`} style={style}>
{lossPercentage >= 100 ? '-' : roundDigits(lossPercentage, 1) + '%'}
</TableCell>
</Table.Cell>
})}
</TableRow>
</Table.Row>
))}
</TableBody>
</Table.Body>
</Table>
<Message icon color='yellow'>
<Icon name='exclamation'/>
<Message.Content>
<Message.Header>Warning</Message.Header>
These numbers are only an estimate and are only applicable for bare wires.
</Message.Content>
<Message kind='warning' icon='warning' title='Warning'>
These numbers are only an estimate and are only applicable for bare wires.
</Message>
</Form>
</div>
};
// Resistance changes with temperature; the resistance tables are referenced at 20°C.
@ -479,7 +443,7 @@ const FourWireKelvinCalculator = ({wireType, isSAE, length}) => {
setTemperatureC(isSAE ? (temperature - 32) * 5 / 9 : temperature);
};
return <Form>
return <div>
<Header as='h1'>4-Wire Kelvin</Header>
<p>
Expected millivolt drop across {length} {isSAE ? 'feet' : 'meters'} of wire when driving a known
@ -488,48 +452,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.'/>
</p>
<Grid columns={2}>
<Grid.Row>
<Grid.Column mobile={12} tablet={12}>
<Input fluid
label={isSAE ? '°F' : '°C'}
labelPosition='right'
type='number'
value={displayTemperature}
onChange={(e, {value}) => handleTemperatureChange(value)}
autoComplete="off"
/>
</Grid.Column>
<Grid.Column mobile={4} tablet={2}>
<InfoPopup
content='Wire temperature. Resistances are referenced at 20°C (68°F) and corrected using the temperature coefficient of the wire material (copper 0.393% per °C, aluminum 0.403% per °C).'/>
</Grid.Column>
</Grid.Row>
<Grid>
<Grid.Col span={10}>
<ColoredInput fluid
label={isSAE ? '°F' : '°C'}
labelPosition='right'
type='number'
value={displayTemperature}
onChange={e => handleTemperatureChange(e.target.value)}
autoComplete="off"
/>
</Grid.Col>
<Grid.Col span={2}>
<InfoPopup
content='Wire temperature. Resistances are referenced at 20°C (68°F) and corrected using the temperature coefficient of the wire material (copper 0.393% per °C, aluminum 0.403% per °C).'/>
</Grid.Col>
</Grid>
<Table unstackable compact celled definition>
<TableHeader>
<TableRow>
<TableHeaderCell/>
{ampsRange.map(amp => <TableHeaderCell key={amp}>{amp}A</TableHeaderCell>)}
</TableRow>
</TableHeader>
<TableBody>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell/>
{ampsRange.map(amp => <Table.HeaderCell key={amp}>{amp}A</Table.HeaderCell>)}
</Table.Row>
</Table.Header>
<Table.Body>
{Object.entries(resistances).map(([size, resistance]) => (
<TableRow key={size}>
<TableCell>{size}</TableCell>
<Table.Row key={size}>
<Table.Cell style={{fontWeight: 600}}>{size}</Table.Cell>
{ampsRange.map(amp => {
const milliVolts = calcKelvinMilliVoltDrop(
amp, resistance, length, temperatureC, tempCoefficientForWireType(wireType));
return <TableCell key={`${size}-${amp}`}>
return <Table.Cell key={`${size}-${amp}`}>
{roundDigits(milliVolts, 1)} mV
</TableCell>
</Table.Cell>
})}
</TableRow>
</Table.Row>
))}
</TableBody>
</Table.Body>
</Table>
</Form>
</div>
};
export const ElectricalCalculators = () => {
@ -537,12 +499,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 <Container>
<Segment><OhmsLawCalculator setVolts={setVolts}/></Segment>
<Segment><PowerLossCalculator volts={volts} setVolts={setVolts}
return <Stack>
<Panel><OhmsLawCalculator setVolts={setVolts}/></Panel>
<Panel><PowerLossCalculator volts={volts} setVolts={setVolts}
wireType={wireType} setWireType={setWireType}
isSAE={isSAE} setIsSAE={setIsSAE}
length={length} setLength={setLength}/></Segment>
<Segment><FourWireKelvinCalculator wireType={wireType} isSAE={isSAE} length={length}/></Segment>
</Container>
length={length} setLength={setLength}/></Panel>
<Panel><FourWireKelvinCalculator wireType={wireType} isSAE={isSAE} length={length}/></Panel>
</Stack>
}

View file

@ -1,9 +1,6 @@
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 {ColoredInput, Grid, Header, Panel, Table, TextInput, Toggle} from "../ui";
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 +94,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 +112,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 +126,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}) =>
<Input {...inputProps} step={1} name={`count-${key}`} labelPosition='left' value={counts[key]}
onChange={e => setCounts({...counts, [key]: e.target.value})}
label={<Label style={labelStyle(color)}>{label}</Label>}/>;
<ColoredInput {...inputProps} step={1} name={`count-${key}`} label={label} color={color}
value={counts[key]} onChange={e => setCounts({...counts, [key]: e.target.value})}/>;
const shareInput = ({key, label, color}) =>
<Input {...inputProps} step={1} name={`share-${key}`} labelPosition='left' value={shares[key]}
onChange={e => setShares({...shares, [key]: e.target.value})}
label={<Label style={labelStyle(color)}>{label}</Label>}/>;
<ColoredInput {...inputProps} step={1} name={`share-${key}`} label={label} color={color}
value={shares[key]} onChange={e => setShares({...shares, [key]: e.target.value})}/>;
const rows = FOOD_CATEGORIES.map(c => {
const perYear = amounts[c.key] ?? '';
@ -155,26 +141,26 @@ export function FoodStorageCalculator() {
});
const grandTotal = rows.reduce((s, r) => s + r.total, 0);
return <Form>
return <div>
<Header as='h1'>Food Storage</Header>
<p {...t}>Estimate how much food to store for your household over time.</p>
<p>Estimate how much food to store for your household over time.</p>
<div style={{marginBottom: '1em'}}>
<Toggle label={metric ? 'Metric (kg)' : 'Imperial (lb)'} checked={metric} onChange={toggleMetric}/>
</div>
<Header as='h3'>People</Header>
<Grid stackable columns={3}>
{PEOPLE_META.map(meta => <GridColumn key={meta.key}>{countInput(meta)}</GridColumn>)}
<Grid>
{PEOPLE_META.map(meta => <Grid.Col key={meta.key} span={{base: 12, sm: 4}}>{countInput(meta)}</Grid.Col>)}
</Grid>
<Header as='h4' style={{marginTop: '1em', marginBottom: '0.5em'}}>
Ration share (% of an adult man) <InfoPopup content={SHARES_INFO}/>
</Header>
<Grid stackable columns={3}>
{PEOPLE_META.map(meta => <GridColumn key={meta.key}>{shareInput(meta)}</GridColumn>)}
<Grid>
{PEOPLE_META.map(meta => <Grid.Col key={meta.key} span={{base: 12, sm: 4}}>{shareInput(meta)}</Grid.Col>)}
</Grid>
<p {...t} style={{...t.style, marginTop: '0.5em', opacity: 0.8}}>
<p style={{marginTop: '0.5em', opacity: 0.8}}>
≈ {roundDigits(equivalents, 2)} adult-equivalent{equivalents === 1 ? '' : 's'}
</p>
@ -183,40 +169,40 @@ export function FoodStorageCalculator() {
<input type='range' min={0} max={MONTH_STOPS.length - 1} step={1} value={monthStop}
aria-label='Duration' onChange={e => setMonthsIndex(Number(e.target.value))}
style={{width: '100%'}}/>
<Header as='h2' textAlign='center' style={{marginTop: '0.25em'}}>{formatMonths(months)}</Header>
<Header as='h2' style={{marginTop: '0.25em', textAlign: 'center'}}>{formatMonths(months)}</Header>
</div>
<Header as='h3' style={{marginTop: '1em'}}>
Amounts ({unit} per person per year) <InfoPopup content={CATEGORY_INFO}/>
</Header>
<Segment>
<Table unstackable>
<TableHeader>
<TableRow>
<TableHeaderCell width={7}>Category</TableHeaderCell>
<TableHeaderCell width={5}>{unit === 'kg' ? 'Kg' : 'Lbs'} / person / year</TableHeaderCell>
<TableHeaderCell width={4}>Total needed</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{rows.map(r => <TableRow key={r.key}>
<TableCell>{r.label}</TableCell>
<TableCell>
<Input fluid type='number' min={0} value={r.perYear}
aria-label={`${r.label} per person per year`}
onChange={e => setAmounts({...amounts, [r.key]: e.target.value})}/>
</TableCell>
<TableCell>{fmt(r.total, unit)}</TableCell>
</TableRow>)}
</TableBody>
<TableFooter>
<TableRow>
<TableHeaderCell>Total</TableHeaderCell>
<TableHeaderCell/>
<TableHeaderCell>{fmt(grandTotal, unit)}</TableHeaderCell>
</TableRow>
</TableFooter>
<Panel>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Category</Table.HeaderCell>
<Table.HeaderCell>{unit === 'kg' ? 'Kg' : 'Lbs'} / person / year</Table.HeaderCell>
<Table.HeaderCell>Total needed</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map(r => <Table.Row key={r.key}>
<Table.Cell>{r.label}</Table.Cell>
<Table.Cell>
<TextInput type='number' min={0} value={r.perYear} style={{width: '100%'}}
aria-label={`${r.label} per person per year`}
onChange={e => setAmounts({...amounts, [r.key]: e.target.value})}/>
</Table.Cell>
<Table.Cell>{fmt(r.total, unit)}</Table.Cell>
</Table.Row>)}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell>Total</Table.HeaderCell>
<Table.HeaderCell/>
<Table.HeaderCell>{fmt(grandTotal, unit)}</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
</Segment>
</Form>;
</Panel>
</div>;
}

View file

@ -1,9 +1,6 @@
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 {ColoredInput, Grid, Header, Table} from "../ui";
import {roundDigits} from "../Common";
import {ColoredInput} from "../Apps";
// ---------------------------------------------------------------------------
// Pure antenna math
@ -212,9 +209,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 +220,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 +255,74 @@ export function DipoleAntennaCalculator() {
return <>
<Header as='h2'>Dipole Antenna</Header>
<Grid columns={2}>
<Grid.Row>
<Grid.Column>
<ColoredInput fluid
label='MHz'
labelPosition='right'
value={mhzInputValue}
onChange={handleMhzChange}
onSelect={e => handleClick(e, 'mhz')}
color={mhzSelected ? null : 'grey'}
/>
</Grid.Column>
<Grid.Column>
<ColoredInput fluid
label='Wavelength'
labelPosition='right'
value={waveInputValue}
onChange={handleWaveChange}
onSelect={e => handleClick(e, 'wave')}
color={mhzSelected ? 'grey' : null}
/>
</Grid.Column>
</Grid.Row>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<ColoredInput fluid
label='MHz'
labelPosition='right'
value={mhzInputValue}
onChange={e => handleMhzChange(e.target.value)}
onSelect={e => handleClick(e, 'mhz')}
color={mhzSelected ? null : 'grey'}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<ColoredInput fluid
label='Wavelength'
labelPosition='right'
value={waveInputValue}
onChange={e => handleWaveChange(e.target.value)}
onSelect={e => handleClick(e, 'wave')}
color={mhzSelected ? 'grey' : null}
/>
</Grid.Col>
</Grid>
{ANTENNA_FRACTIONS.map(i => {
const [feet_, inches_] = convertFeetToFeetAndInches(feet * i.multiplier);
const [half_feet, half_inches] = convertFeetToFeetAndInches(feet * i.multiplier / 2);
return <Table key={i.name} size='large' striped unstackable>
<TableHeader>
<TableRow>
<TableHeaderCell>{i.name}</TableHeaderCell>
<TableHeaderCell>Leg Length</TableHeaderCell>
</TableRow>
</TableHeader>
return <Table key={i.name} style={{marginTop: '1em'}}>
<Table.Header>
<Table.Row>
<Table.HeaderCell>{i.name}</Table.HeaderCell>
<Table.HeaderCell>Leg Length</Table.HeaderCell>
</Table.Row>
</Table.Header>
<TableBody>
<TableRow>
<TableCell>
<Table.Body>
<Table.Row>
<Table.Cell>
{feet_} ft. {inches_} in.
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
{half_feet} ft. {half_inches} in.
</TableCell>
</TableRow>
<TableRow>
<TableCell>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>
{roundDigits(inches * i.multiplier)} inches
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
{roundDigits(inches * i.multiplier / 2)} inches
</TableCell>
</TableRow>
<TableRow>
<TableCell>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>
{roundDigits(meters * i.multiplier)} meters
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
{roundDigits(meters * i.multiplier / 2)} meters
</TableCell>
</TableRow>
<TableRow>
<TableCell>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>
{roundDigits(cm * i.multiplier)} cm.
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
{roundDigits(cm * i.multiplier / 2)} cm.
</TableCell>
</TableRow>
</TableBody>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
})}
</>

View file

@ -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 <>
<Header as='h2'>Encrypt</Header>
<SegmentGroup>
<Segment>
<Stack gap='sm'>
<Panel>
<h3>Key</h3>
<TextArea
<Textarea
name='otp'
className='otp'
style={{width: '100%'}}
value={otp}
onChange={handleInputChange}
onChange={e => setOtp(e.currentTarget.value)}
placeholder='The random letters from your One-Time Pad'
/>
</Segment>
<Segment>
</Panel>
<Panel>
<h3>Plaintext</h3>
<TextArea
<Textarea
name='plaintext'
className='otp'
style={{width: '100%'}}
value={plaintext}
onChange={handleInputChange}
onChange={e => setPlaintext(e.currentTarget.value)}
placeholder='The message you want to send'
/>
</Segment>
<Segment>
</Panel>
<Panel>
<h3>Ciphertext</h3>
{error
? <ErrorMessage>{error}</ErrorMessage>
: <pre>{ciphertext || (chars ? 'Enter your message above' : 'Set valid characters in Advanced options')}</pre>}
</Segment>
</SegmentGroup>
</Panel>
</Stack>
</>
}
@ -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 <>
<Header as='h2'>Decrypt</Header>
<SegmentGroup>
<Segment>
<Stack gap='sm'>
<Panel>
<h3>Key</h3>
<TextArea
<Textarea
name='otp'
className='otp'
style={{width: '100%'}}
value={otp}
onChange={handleInputChange}
onChange={e => setOtp(e.currentTarget.value)}
placeholder='The random letters from your One-Time Pad'
/>
</Segment>
<Segment>
</Panel>
<Panel>
<h3>Ciphertext</h3>
<TextArea
<Textarea
name='ciphertext'
className='otp'
style={{width: '100%'}}
value={ciphertext}
onChange={handleInputChange}
onChange={e => setCiphertext(e.currentTarget.value)}
placeholder='The message you received'
/>
</Segment>
<Segment>
</Panel>
<Panel>
<h3>Plaintext</h3>
{checksumValid !== null && <p>
<Icon name={checksumValid ? 'check' : 'times'} color={checksumValid ? 'green' : 'red'}/>
{checksumValid ? 'Checksum is valid' : 'Checksum is invalid'}
</p>}
{checksumValid !== null &&
<Status kind={checksumValid ? 'complete' : 'failed'}>
{checksumValid ? 'Checksum is valid' : 'Checksum is invalid'}
</Status>}
{error
? <ErrorMessage>{error}</ErrorMessage>
: <pre>{plaintext || (chars ? 'Enter the encrypted message above' : 'Set valid characters in Advanced options')}</pre>}
</Segment>
</SegmentGroup>
</Panel>
</Stack>
</>
}
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,30 @@ export function OneTimePadCalculator() {
<Header as='h1'>One-Time Pad</Header>
<Header as='h4'>One-Time Pads can be used to encrypt your communications. This can be done by hand
(yes, really) or in this app.</Header>
<p {...t}>These messages are never stored and cannot be retrieved.</p>
<Button color='violet' disabled={!activeChars} onClick={handleGenerateNewPad}>Generate New Pad</Button>
<Button secondary href={cheatSheetURL}>Cheat Sheet PDF</Button>
<p>These messages are never stored and cannot be retrieved.</p>
<div className='wrolpi-button-row'>
<Button color='violet' disabled={!activeChars} onClick={handleGenerateNewPad}>
Generate New Pad
</Button>
<Button role='cancel' component='a' href={cheatSheetURL}>Cheat Sheet PDF</Button>
</div>
<SimpleAccordion title='Advanced'>
<Header as='h4'>Pad characters</Header>
<Form>
<Form.Field>
<Radio label='A–Z' name='charset' value='alpha'
checked={charsetKind === 'alpha'} onChange={() => setCharsetKind('alpha')}/>
</Form.Field>
<Form.Field>
<Radio label='A–Z and 0–9' name='charset' value='alphanumeric'
checked={charsetKind === 'alphanumeric'} onChange={() => setCharsetKind('alphanumeric')}/>
</Form.Field>
<Form.Field>
<Radio label='Custom' name='charset' value='custom'
checked={charsetKind === 'custom'} onChange={() => setCharsetKind('custom')}/>
</Form.Field>
</Form>
<Stack gap={4}>
<Radio label='A–Z' name='charset' value='alpha'
checked={charsetKind === 'alpha'} onChange={() => setCharsetKind('alpha')}/>
<Radio label='A–Z and 0–9' name='charset' value='alphanumeric'
checked={charsetKind === 'alphanumeric'} onChange={() => setCharsetKind('alphanumeric')}/>
<Radio label='Custom' name='charset' value='custom'
checked={charsetKind === 'custom'} onChange={() => setCharsetKind('custom')}/>
</Stack>
{charsetKind === 'custom' && <>
<Input
fluid
<TextInput
style={{width: '100%'}}
value={customChars}
placeholder='e.g. ABCDEF0123'
onChange={(e, {value}) => setCustomChars(value)}
onChange={e => setCustomChars(e.currentTarget.value)}
/>
{charsetError && <ErrorMessage>{charsetError}</ErrorMessage>}
</>}

View file

@ -1,50 +1,36 @@
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import React from "react";
import {Header} from "../Theme";
import QRCode from "react-qr-code";
import {Input} from "semantic-ui-react";
import {Header, Stack, TextInput} from "../ui";
import {Media} from "../../contexts/contexts";
export function QRCodeCalculator() {
const [value, setValue] = React.useState('');
return <Grid>
<Grid.Row>
<Grid.Column>
<Header as='h1'>QR Code Calculator</Header>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<Input fluid value={value} onChange={e => setValue(e.target.value)} placeholder='Enter link, etc.'/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<Media at='mobile'>
<div style={{
height: "auto",
margin: "0 auto",
width: '332px',
background: 'white',
padding: '16px'
}}>
<QRCode value={value} size={300}/>
</div>
</Media>
<Media greaterThanOrEqual='tablet'>
<div style={{
height: "auto",
margin: "0 auto",
maxWidth: '632px',
width: "100%",
background: 'white',
padding: '16px'
}}>
<QRCode value={value} size={600}/>
</div>
</Media>
</Grid.Column>
</Grid.Row>
</Grid>
}
return <Stack>
<Header as='h1'>QR Code Calculator</Header>
<TextInput value={value} onChange={e => setValue(e.target.value)} placeholder='Enter link, etc.'/>
<Media at='mobile'>
<div className='media' style={{
height: "auto",
margin: "0 auto",
width: '332px',
background: 'white',
padding: '16px'
}}>
<QRCode value={value} size={300}/>
</div>
</Media>
<Media greaterThanOrEqual='tablet'>
<div className='media' style={{
height: "auto",
margin: "0 auto",
maxWidth: '632px',
width: "100%",
background: 'white',
padding: '16px'
}}>
<QRCode value={value} size={600}/>
</div>
</Media>
</Stack>
}

View file

@ -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) => (
<div className="ui fluid labeled input" style={{marginBottom: '0.25em'}}>
<div className={`ui label ${color}`}>{label}</div>
<input
<Group gap={0} wrap='nowrap' align='stretch' style={{marginBottom: '0.25em'}}>
<Label color={color || 'grey'}>{label}</Label>
<NumberInput
id={name}
name={name}
type="number"
ref={ref}
value={unitToInputValue(value)}
onChange={e => dispatch(e)}
onChange={val => dispatch({name, value: val})}
onKeyDown={onKeyDown}
onFocus={e => e.target.select()}
hideControls
style={{flex: 1}}
/>
{unitOptions &&
<Dropdown
options={unitOptions}
className='label'
onChange={(_, data) => dispatch(data)}
<Select
data={unitOptions.map(o => ({value: o.value, label: o.text}))}
onChange={val => dispatch({name: unitName, value: val})}
name={unitName}
value={unitValue}
style={{minWidth: 150}}
/>}
</div>
</Group>
));
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 = <Dropdown selection
fluid
placeholder='Base Units'
options={baseOptions}
value={state.base}
name='base'
onChange={(_, data) => dispatch(data)}
style={{marginBottom: '1em'}}
const baseDropdown = <Select
placeholder='Base Units'
data={baseOptions}
value={state.base ?? ''}
name='base'
onChange={val => dispatch({name: 'base', value: val || null})}
style={{marginBottom: '1em'}}
/>;
const handleInputChange = (e) => {
@ -318,7 +321,7 @@ const RatioCalculator = () => {
}
}, []);
return <Form>
return <div>
<Header as='h1'>Ratio</Header>
<Header as='h2'>A : B = C : D</Header>
@ -335,37 +338,36 @@ const RatioCalculator = () => {
</Media>
<Media greaterThanOrEqual='tablet'>
<Table unstackable className='ratio-table'>
<TableBody>
<TableRow>
<TableCell className='equal-width'>{inputA}</TableCell>
<TableCell className='min-width'/>
<TableCell className='equal-width'>{inputC}</TableCell>
</TableRow>
<TableRow>
<TableCell className='equal-width'>
<Table className='ratio-table'>
<Table.Body>
<Table.Row>
<Table.Cell className='equal-width'>{inputA}</Table.Cell>
<Table.Cell className='min-width'/>
<Table.Cell className='equal-width'>{inputC}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell className='equal-width'>
<hr/>
</TableCell>
<TableCell textAlign='center' className='min-width'>
</Table.Cell>
<Table.Cell style={{textAlign: 'center'}} className='min-width'>
<Header as='h1'>=</Header>
</TableCell>
<TableCell className='equal-width'>
</Table.Cell>
<Table.Cell className='equal-width'>
<hr/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='equal-width'>{inputB}</TableCell>
<TableCell className='min-width'/>
<TableCell className='equal-width'>{inputD}</TableCell>
</TableRow>
</TableBody>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell className='equal-width'>{inputB}</Table.Cell>
<Table.Cell className='min-width'/>
<Table.Cell className='equal-width'>{inputD}</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</Media>
<HandPointMessage storageName='hint_ratio_calculator'>
<MessageHeader>Tip</MessageHeader>
You can change inputs using the <b>a</b>, <b>b</b>, <b>c</b>, and <b>d</b> keys.
<strong>Tip</strong> You can change inputs using the <b>a</b>, <b>b</b>, <b>c</b>, and <b>d</b> keys.
</HandPointMessage>
</Form>
</div>
}
export const RatioCalculators = () => {

View file

@ -1,12 +1,9 @@
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, ColoredInput, Grid, Header, Table} from "../ui";
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 +78,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 +92,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 +122,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}) =>
<Input {...inputProps} min={0} step={1} name={`count-${key}`} labelPosition='left' value={counts[key]}
onChange={e => setCounts({...counts, [key]: e.target.value})}
label={<Label style={{backgroundColor: color, color: textColorFor(color), borderColor: color}}>
{label}</Label>}/>;
<ColoredInput {...inputProps} min={0} step={1} name={`count-${key}`} label={label} color={color}
value={counts[key]} onChange={e => setCounts({...counts, [key]: e.target.value})}/>;
const rateInput = ({key, label, color}) =>
<Input {...inputProps} min={0} step={50} name={`rate-${key}`} labelPosition='left' value={rates[key]}
onChange={e => setRates({...rates, [key]: e.target.value})}
label={<Label style={{backgroundColor: color, color: textColorFor(color), borderColor: color}}>
{label}</Label>}/>;
<ColoredInput {...inputProps} min={0} step={50} name={`rate-${key}`} label={label} color={color}
value={rates[key]} onChange={e => setRates({...rates, [key]: e.target.value})}/>;
return <Form>
return <div>
<Header as='h3'>People</Header>
<Grid stackable columns={3}>
{CATEGORY_META.map(meta => <GridColumn key={meta.key}>{countInput(meta)}</GridColumn>)}
<Grid>
{CATEGORY_META.map(meta =>
<Grid.Col key={meta.key} span={{base: 12, sm: 4}}>{countInput(meta)}</Grid.Col>)}
</Grid>
<Header as='h3' style={{marginTop: '1em'}}>
Daily Calories (per person) <InfoPopup content={DEMAND_INFO}/>
</Header>
<div style={{marginBottom: '1em'}}>
<div style={{marginBottom: '1em', display: 'flex', flexWrap: 'wrap', gap: '0.5em'}}>
{Object.entries(RATION_PRESETS).map(([key, p]) => (
<button key={key} type='button' onClick={() => 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',
}}>
<Button key={key} role={preset === key ? 'primary' : 'cancel'} onClick={() => setPreset(key)}>
<strong>{p.label}</strong> — {p.description}
</button>
</Button>
))}
</div>
<Grid stackable columns={3}>
{CATEGORY_META.map(meta => <GridColumn key={meta.key}>{rateInput(meta)}</GridColumn>)}
<Grid>
{CATEGORY_META.map(meta =>
<Grid.Col key={meta.key} span={{base: 12, sm: 4}}>{rateInput(meta)}</Grid.Col>)}
</Grid>
<Table definition unstackable style={{marginTop: '1em'}}>
<TableBody>
<TableRow>
<TableCell width={6}>Total Stored Calories</TableCell>
<TableCell>{fmt(total, 'kcal')}</TableCell>
</TableRow>
<TableRow>
<TableCell>Daily Demand</TableCell>
<TableCell>{fmt(daily, 'kcal/day')}</TableCell>
</TableRow>
<TableRow>
<TableCell>Food Lasts</TableCell>
<TableCell>{days ? formatDuration(days) : '—'}</TableCell>
</TableRow>
</TableBody>
<Table style={{marginTop: '1em'}}>
<Table.Body>
<Table.Row>
<Table.Cell>Total Stored Calories</Table.Cell>
<Table.Cell>{fmt(total, 'kcal')}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Daily Demand</Table.Cell>
<Table.Cell>{fmt(daily, 'kcal/day')}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Food Lasts</Table.Cell>
<Table.Cell>{days ? formatDuration(days) : '—'}</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
<SupplyPlan name={name} items={items} fields={fields} caloriesKey={caloriesKey} countKey={countKey}
currentDays={days} total={total} daily={daily}/>
</Form>;
</div>;
}
// Print only the supply-plan shopping list (not the whole-inventory print block): toggle a body class that the
@ -218,7 +204,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,63 +237,63 @@ 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) =>
<TableHeaderCell sorted={sortDir(key)} onClick={() => toggleSort(key)} style={{cursor: 'pointer'}}>
{label}
</TableHeaderCell>;
<Table.HeaderCell sorted={sortDir(key)} onSort={() => toggleSort(key)}>{label}</Table.HeaderCell>;
return <div style={{marginTop: '2em'}}>
<Header as='h3'>Plan More Supply <InfoPopup content={PLAN_INFO}/></Header>
{!countKey
? <p {...t}>Add a <strong>Count</strong> field to this inventory to plan purchases.</p>
? <p>Add a <strong>Count</strong> field to this inventory to plan purchases.</p>
: <>
<div style={{padding: '0 0.5em'}}>
<input type='range' min={minMonths} max={maxMonths} step={1} value={targetMonths}
aria-label='Target duration (months)' style={{width: '100%'}}
onChange={e => setTargetMonths(Number(e.target.value))}/>
<Header as='h2' textAlign='center' style={{marginTop: '0.25em'}}>
<Header as='h2' style={{marginTop: '0.25em', textAlign: 'center'}}>
Target: {formatDuration(targetDays)}
</Header>
<p {...t} style={{...t.style, textAlign: 'center', opacity: 0.8}}>
<p style={{textAlign: 'center', opacity: 0.8}}>
Currently {formatDuration(currentDays)}
</p>
</div>
{rows.length === 0
? <p {...t}>Drag the slider above your current estimate to see a shopping list.</p>
? <p>Drag the slider above your current estimate to see a shopping list.</p>
: <>
<Table celled unstackable sortable>
<TableHeader>
<TableRow>
<Table className='shopping-list-table'>
<Table.Header>
<Table.Row>
{headerCell('name', 'Item')}
{headerCell('current', 'Have')}
{headerCell('additional', 'Buy')}
{headerCell('target', 'New Total')}
</TableRow>
</TableHeader>
<TableBody>
{sortedRows.map((r, i) => <TableRow key={`${r.name}-${i}`}>
<TableCell>{r.name}</TableCell>
<TableCell>{r.current}</TableCell>
<TableCell><strong>+{r.additional}</strong></TableCell>
<TableCell>{r.target}</TableCell>
</TableRow>)}
</TableBody>
</Table.Row>
</Table.Header>
<Table.Body>
{sortedRows.map((r, i) => <Table.Row key={`${r.name}-${i}`}>
<Table.Cell>{r.name}</Table.Cell>
<Table.Cell>{r.current}</Table.Cell>
<Table.Cell><strong>+{r.additional}</strong></Table.Cell>
<Table.Cell>{r.target}</Table.Cell>
</Table.Row>)}
</Table.Body>
</Table>
<p {...t}>
<p>
Buy <strong>{totalToBuy.toLocaleString()}</strong> additional package{totalToBuy === 1 ? '' : 's'}
{' '}across <strong>{rows.length}</strong> item{rows.length === 1 ? '' : 's'}
{projectedDays ? <> to reach about <strong>{formatDuration(projectedDays)}</strong> of food.</> : '.'}
</p>
<Button primary
onClick={() => downloadCSV(
inventoryExportFilename(`${name || 'inventory'} shopping list`, 'csv'),
shoppingListCSV(sortedRows))}>
<Icon name='download'/> Download CSV
</Button>
<Button onClick={printShoppingList}>
<Icon name='print'/> Print / Save as PDF
</Button>
<div className='wrolpi-button-row'>
<Button role='primary' icon='download'
onClick={() => downloadCSV(
inventoryExportFilename(`${name || 'inventory'} shopping list`, 'csv'),
shoppingListCSV(sortedRows))}>
Download CSV
</Button>
<Button icon='print' onClick={printShoppingList}>
Print / Save as PDF
</Button>
</div>
{/* Hidden printable block — `printShoppingList` makes this the only thing printed. */}
<ShoppingListPrint name={name} rows={sortedRows}

View file

@ -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 {
dailyCalorieDemand, daysOfFood, findCaloriesKey, findCountKey, RationEstimatePanel, totalCalories,
} from "./RationCalculator";
@ -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']);
});

View file

@ -1,7 +1,6 @@
import React from "react";
import {roundDigits} from "../Common";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {GridColumn, GridRow, Input} from "semantic-ui-react";
import {Grid, NumberInput} from "../ui";
export function TemperatureCalculator() {
const [state, setState] = React.useState({
@ -10,14 +9,15 @@ export function TemperatureCalculator() {
kelvin: '',
})
const handleInputChange = (e, {name, value}) => {
if (value === '') {
const handleInputChange = (name, value) => {
if (value === '' || value === undefined || value === null) {
// User has cleared the input;
setState({celsius: '', fahrenheit: '', kelvin: ''});
return
}
const numValue = parseFloat(value.endsWith('.') ? `${value}0` : value);
const stringValue = `${value}`;
const numValue = parseFloat(stringValue.endsWith('.') ? `${stringValue}0` : stringValue);
console.debug(`Calculating ${value} ${name}`);
let newCelsius;
let newKelvin;
@ -52,25 +52,30 @@ export function TemperatureCalculator() {
e.target.select();
}
const commonInputProps = {
fluid: true,
labelPosition: 'right',
type: 'number',
onChange: handleInputChange,
onClick: handleClick
};
return <Grid columns={3}>
<GridRow>
<GridColumn>
<Input value={state.celsius} label='°C' name='celsius' {...commonInputProps}/>
</GridColumn>
<GridColumn>
<Input value={state.fahrenheit} label='°F' name='fahrenheit' {...commonInputProps}/>
</GridColumn>
<GridColumn>
<Input value={state.kelvin} label='K' name='kelvin' {...commonInputProps}/>
</GridColumn>
</GridRow>
return <Grid>
<Grid.Col span={4}>
<NumberInput
value={state.celsius}
rightSection='°C'
onChange={value => handleInputChange('celsius', value)}
onClick={handleClick}
/>
</Grid.Col>
<Grid.Col span={4}>
<NumberInput
value={state.fahrenheit}
rightSection='°F'
onChange={value => handleInputChange('fahrenheit', value)}
onClick={handleClick}
/>
</Grid.Col>
<Grid.Col span={4}>
<NumberInput
value={state.kelvin}
rightSection='K'
onChange={value => handleInputChange('kelvin', value)}
onClick={handleClick}
/>
</Grid.Col>
</Grid>
}

View file

@ -1,37 +1,37 @@
import React, {useContext} from 'react';
import {Input, TableCell, TableRow} from "semantic-ui-react";
import {ThemeContext} from "../../contexts/contexts";
import React from 'react';
import {useVINDecoder} from "../../hooks/customHooks";
import {Header, Table} from "../Theme";
import {Header, Table, TextInput} from "../ui";
export function VinDecoderCalculator() {
const {t} = useContext(ThemeContext);
const basicKeys = ['Country', 'Manufacturer', 'Region', 'Years'];
const detailsKeys = ['Body', 'Engine', 'Model', 'Plant', 'Transmission', 'Serial'];
const {value, setValue, vin} = useVINDecoder();
let body = <p {...t}>Enter a VIN number above</p>;
let body = <p>Enter a VIN number above</p>;
if (value && !vin) {
body = <p {...t}>VIN number is invalid</p>;
body = <p>VIN number is invalid</p>;
}
if (vin && vin['country']) {
let details = <p {...t}>No details</p>;
let details = <p>No details</p>;
if (vin['body']) {
details = <Table celled columns={2} {...t}>
{detailsKeys.map(i => <TableRow key={i}>
<TableCell width={5}><b>{i}</b></TableCell>
<TableCell width={11}>{vin[i.toLowerCase()] || '(Unknown)'}</TableCell>
</TableRow>)}
details = <Table>
<Table.Body>
{detailsKeys.map(i => <Table.Row key={i}>
<Table.Cell width={5}><b>{i}</b></Table.Cell>
<Table.Cell width={11}>{vin[i.toLowerCase()] || '(Unknown)'}</Table.Cell>
</Table.Row>)}
</Table.Body>
</Table>;
}
body = <>
<Table celled columns={2} {...t}>
{basicKeys.map(i => <TableRow key={i}>
<TableCell width={5}><b>{i}</b></TableCell>
<TableCell width={11}>{vin[i.toLowerCase()]}</TableCell>
</TableRow>)}
<Table>
<Table.Body>
{basicKeys.map(i => <Table.Row key={i}>
<Table.Cell width={5}><b>{i}</b></Table.Cell>
<Table.Cell width={11}>{vin[i.toLowerCase()]}</Table.Cell>
</Table.Row>)}
</Table.Body>
</Table>
<Header as='h3'>Details</Header>
{details}
@ -40,8 +40,8 @@ export function VinDecoderCalculator() {
return <>
<Header as='h1'>VIN Number Decoder</Header>
<Input
size='large'
<TextInput
size='lg'
label='VIN'
value={value}
onChange={e => setValue(e.target.value)}

Some files were not shown because too many files have changed in this diff Show more