mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Merge the token-driven component library into master
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:
commit
cafa6ac019
180 changed files with 26865 additions and 9830 deletions
|
|
@ -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:
|
||||
|
|
|
|||
6
.github/workflows/controller-tests.yml
vendored
6
.github/workflows/controller-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
.github/workflows/jest-tests.yml
vendored
8
.github/workflows/jest-tests.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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$/);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
66
app/cypress/e2e/map-themes.cy.js
Normal file
66
app/cypress/e2e/map-themes.cy.js
Normal 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
79
app/cypress/e2e/settings/special-directories.cy.js
Normal file
79
app/cypress/e2e/settings/special-directories.cy.js
Normal 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/');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
1102
app/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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/`);
|
||||
|
|
|
|||
645
app/src/App.css
645
app/src/App.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}/>
|
||||
|
|
|
|||
|
|
@ -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']));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
280
app/src/Tags.js
280
app/src/Tags.js
|
|
@ -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
207
app/src/Tags.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
93
app/src/babel-pins.test.js
Normal file
93
app/src/babel-pins.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 & Add</Button>
|
||||
: <Button primary disabled={!selected} loading={adding} onClick={handleAddExisting}>Add</Button>}
|
||||
? <Button role='primary' loading={adding} onClick={handleCreateAndAdd}>Create & 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}/>
|
||||
</>;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
—
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'},
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ? <> 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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)}/>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 — from your computer or from your WROLPi's media directory —
|
||||
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 — from your computer or from your WROLPi's media
|
||||
directory — 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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
158
app/src/components/Map.test.js
Normal file
158
app/src/components/Map.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} <SIcon name='lock'/></> : name;
|
||||
const {i} = React.useContext(ThemeContext);
|
||||
const topNavText = wrolModeEnabled ? <>{name} <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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
233
app/src/components/Nav.test.js
Normal file
233
app/src/components/Nav.test.js
Normal 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:/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
371
app/src/components/ThemeProvider.test.js
Normal file
371
app/src/components/ThemeProvider.test.js
Normal 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));
|
||||
});
|
||||
});
|
||||
1063
app/src/components/ThemeSamplePage.js
Normal file
1063
app/src/components/ThemeSamplePage.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'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 "Use Current Browser" 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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
75
app/src/components/Zim.test.js
Normal file
75
app/src/components/Zim.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 <>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 → Manage Extension → 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 → the permission dropdown → <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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
})}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
</>}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue