release: publish Multi-Debrid Downloader v2.0.14
Add live English and German localization, queue availability and metadata resolution, responsive package controls, polished navigation and drag interactions, clearer history and account states, and a rebuilt public README. Harden Windows packaging with verified icons and version metadata, archive inspection, and expanded release tests.
This commit is contained in:
+109
@@ -0,0 +1,109 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||||
|
|
||||||
|
## [2.0.14] - 2026-08-10
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- Added immediate English/German language switching across the complete interface. New installations now start in English, while existing installations retain their previous language behavior until changed.
|
||||||
|
- Added RapidGator file-size discovery before downloads start and accelerated large queue checks with bounded parallel requests.
|
||||||
|
- Added package and file availability states with aligned online counters, clear colors, and automatic migration of existing column layouts.
|
||||||
|
- Reworked the Downloads workspace for substantially denser queues, smoother interaction, clearer selection, and responsive column sizing.
|
||||||
|
- Rebuilt the project README with current documentation and a sanitized screenshot of the application.
|
||||||
|
|
||||||
|
### Downloads workspace
|
||||||
|
|
||||||
|
- Reduced package rows to 40 pixels and file rows to 38 pixels so more queue entries fit on screen.
|
||||||
|
- Removed the previous blue package-card treatment in favor of flat rows and restrained separators.
|
||||||
|
- Changed progress fills to green and unified package/file value contrast.
|
||||||
|
- Added stable minimum widths and proportional column distribution so labels and values remain readable at normal window sizes.
|
||||||
|
- Centered all data columns while preserving left alignment for package and file names.
|
||||||
|
- Increased the action column width and reserved scrollbar space to prevent clipping.
|
||||||
|
- Normalized expand buttons to a fixed 30 × 30 pixel footprint.
|
||||||
|
- Added a 300 ms measured expand/collapse animation for package files.
|
||||||
|
- Added smooth, pointer-based full-column reordering. The dragged header and every visible cell move together while neighboring columns glide aside.
|
||||||
|
- Added persistent column-order migration with the new Availability column.
|
||||||
|
- Added RapidGator and DDownload hoster icons with full-name tooltips and text fallback for unknown hosters.
|
||||||
|
|
||||||
|
### Availability and metadata
|
||||||
|
|
||||||
|
- Added package-level online counters with separate online, partial, offline, and checking states.
|
||||||
|
- Added file-level Online and Offline indicators.
|
||||||
|
- Aligned availability symbols, counts, separators, and labels on fixed axes.
|
||||||
|
- Read RapidGator file names and sizes from the public file page without creating a debrid download.
|
||||||
|
- Replaced sequential link checks with bounded concurrency and duplicate-request sharing.
|
||||||
|
- Improved retry behavior for transient RapidGator metadata failures.
|
||||||
|
- Updated known file and package sizes while the queue is checked.
|
||||||
|
- Corrected the sidebar Total value to represent the current queue instead of the historical all-time download counter.
|
||||||
|
|
||||||
|
### Selection and package controls
|
||||||
|
|
||||||
|
- Fixed global Select all and Deselect all synchronization for memoized package rows.
|
||||||
|
- Increased selection checkboxes to 18 × 18 pixels.
|
||||||
|
- Added a green selected-row background and a clear left selection edge.
|
||||||
|
- Added Shift range selection across visible package and file rows.
|
||||||
|
- Added Escape handling to clear the complete selection, including when a checkbox has focus.
|
||||||
|
- Removed the redundant package activation checkbox; activation remains available through context actions.
|
||||||
|
- Preserved additive Ctrl/Command selection and bulk actions.
|
||||||
|
|
||||||
|
### Navigation and animation
|
||||||
|
|
||||||
|
- Added a shared sliding selection indicator to the main navigation.
|
||||||
|
- Added matching sliding indicators to Downloads filters, Settings sections, History ranges, and Statistics periods.
|
||||||
|
- Fixed initial category selection so newly opened views appear selected immediately instead of fading in from an empty state.
|
||||||
|
- Preserved intentional interface animations when the operating system requests reduced motion.
|
||||||
|
- Added slide-in and slide-out transitions to the File, Settings, and Help menus.
|
||||||
|
- Kept menu panels within the application window at narrow widths.
|
||||||
|
- Added rolling number transitions for package, link, session, total-size, and hoster statistics.
|
||||||
|
|
||||||
|
### Interface and layout
|
||||||
|
|
||||||
|
- Added the application icon and a bordered product identity area to the main header.
|
||||||
|
- Standardized sidebar geometry across Link collector, Settings, History, and Statistics.
|
||||||
|
- Reworked Downloads sidebar actions as permanently recognizable bordered buttons.
|
||||||
|
- Moved Monitor clipboard directly above the statistics panel, added the same button surface, and reduced the lower gap.
|
||||||
|
- Made clipboard toggling optimistic so the checkbox responds immediately and safely rolls back on an IPC failure.
|
||||||
|
- Hid the redundant Files display mode and retained a focused package workflow.
|
||||||
|
- Improved header, menu, account-table, progress-value, and status text contrast.
|
||||||
|
- Unified the speed indicator with the rest of the application shell.
|
||||||
|
- Fixed duplicated Speed wording in the Downloads sidebar summary.
|
||||||
|
|
||||||
|
### Localization
|
||||||
|
|
||||||
|
- Added persistent `English` and `Deutsch` language settings.
|
||||||
|
- Localized navigation, menus, settings, dialogs, notifications, tooltips, empty states, status labels, history entries, pagination, and update flows.
|
||||||
|
- Fixed remaining German history statuses appearing while English was selected.
|
||||||
|
- Kept existing installations compatible while setting English as the default for new installations.
|
||||||
|
- Localized manual update checks and the GitHub release changelog display.
|
||||||
|
|
||||||
|
### History, settings, and accounts
|
||||||
|
|
||||||
|
- Matched History action buttons to the Downloads action-button design.
|
||||||
|
- Centered History column headings except Package / File.
|
||||||
|
- Changed removed history entries to red and completed entries to green.
|
||||||
|
- Unified account-table header text with the primary high-contrast text color.
|
||||||
|
- Kept Account Overview and Usage Rules within a stable viewport layout.
|
||||||
|
- Aligned the update-check action to the right and kept Save settings visibly disabled when no changes are pending.
|
||||||
|
|
||||||
|
### Development and reliability
|
||||||
|
|
||||||
|
- Assigned the downloader development server a fixed port to prevent another Electron project from being opened accidentally.
|
||||||
|
- Added strict development URL resolution for the correct Vite instance.
|
||||||
|
- Packaged the application icon as an unpacked resource and resolved it from the correct development or installed path for window and tray use.
|
||||||
|
- Improved settings migration for language and column-order versions.
|
||||||
|
- Added regression coverage for localization, availability, column dragging, navigation indicators, queue totals, metadata checks, selection, clipboard behavior, and responsive layout contracts.
|
||||||
|
|
||||||
|
## [2.0.13] - 2026-08-10
|
||||||
|
|
||||||
|
- Rebuilt the primary desktop workspace across Downloads, Link collector, Settings, History, and Statistics.
|
||||||
|
- Added compact navigation, contextual sidebars, dense data tables, account management improvements, and responsive layouts.
|
||||||
|
- Improved update visibility, dialogs, context menus, notifications, keyboard focus, error recovery, and theme behavior.
|
||||||
|
|
||||||
|
## [2.0.12] - 2026-08-10
|
||||||
|
|
||||||
|
- Prevented static Settings text from being selected while keeping editable fields selectable.
|
||||||
|
|
||||||
|
## [2.0.11] - 2026-08-10
|
||||||
|
|
||||||
|
- Stabilized the account-management layout when switching between Overview and Usage Rules.
|
||||||
@@ -1,367 +1,204 @@
|
|||||||
# Multi Debrid Downloader
|
# Multi-Debrid Downloader
|
||||||
|
|
||||||
Desktop downloader for Windows with package-based queue management, multi-provider fallback, automatic extraction, auto-rename, provider statistics, and built-in updates.
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
## Changelog
|
Fast package-based downloading for Windows with multiple debrid services, automatic fallback, link availability checks, extraction, queue recovery, history, statistics, and built-in updates.
|
||||||
|
|
||||||
### 2.0.12
|
[](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest)
|
||||||
|
[](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest)
|
||||||
|
[](https://www.electronjs.org/)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
- Prevent static headings, labels, hints, and card text from being selected throughout Settings.
|

|
||||||
- Keep editable fields and text areas selectable for normal copy and paste.
|
|
||||||
|
|
||||||
### 2.0.11
|
## Overview
|
||||||
|
|
||||||
- Keep Account Overview and Usage Rules inside the same fixed layout frame.
|
Multi-Debrid Downloader combines a desktop download queue with provider routing, package management, automatic extraction, and persistent recovery. Add direct hoster links, organize them into packages, choose your debrid accounts, and let the application handle retries, fallback, progress, cleanup, and history.
|
||||||
- Prevent sizing and scroll-mode changes when switching between the account tabs.
|
|
||||||
|
|
||||||
### 2.0.10
|
The interface is available in English and German. New installations start in English, and the language can be changed immediately without restarting the application.
|
||||||
|
|
||||||
- Add locally bundled provider logos throughout account management.
|
## Download
|
||||||
- Reorder the account table to Hoster, Status, Download Traffic Remaining, Username, Expiry Date, and Password / Access.
|
|
||||||
- Increase account-row and status text sizes while keeping the table compact.
|
|
||||||
- Show full account usernames while continuing to protect passwords and API keys.
|
|
||||||
- Replace the provider-wide edit form with a compact editor for the selected account or API key.
|
|
||||||
- Preserve sibling accounts, usage counters, limits, and status metadata during account-specific edits.
|
|
||||||
|
|
||||||
### 2.0.9
|
The latest Windows release is available on the [GitHub Releases page](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest).
|
||||||
|
|
||||||
- Rebuild the Accounts view as a viewport-filling compact table with flat rows and bottom-aligned actions.
|
| Package | Recommended for |
|
||||||
- Separate account usage rules from the overview so rotation and routing controls no longer reduce the visible account list.
|
| --- | --- |
|
||||||
- Redesign the account dialog with one-line provider rows, an immediate default selection, compact credential fields, and split cancel/save actions.
|
| `Real-Debrid-Downloader-Setup-<version>.exe` | Normal installation with Start menu and optional desktop shortcut |
|
||||||
- Add a global account enable/disable control while preserving unrelated provider state.
|
| `Real-Debrid-Downloader-<version>-portable.exe` | Portable use without installation |
|
||||||
|
| `SHA256SUMS.txt` | Verifying downloaded files |
|
||||||
|
|
||||||
### 2.0.8
|
Windows 10 or Windows 11 is required. Release executables are currently unsigned, so Windows may show a SmartScreen warning on first launch.
|
||||||
|
|
||||||
- Add a compact account-management table with traffic, status, expiry, username, and credential columns.
|
## Highlights
|
||||||
- Add account actions through right-click and the row action menu.
|
|
||||||
- Support quick edit, status checks, enable/disable, and removal without expanding the full account form.
|
|
||||||
|
|
||||||
### 2.0.7
|
- Dense package queue with expandable file rows, search, filters, bulk selection, Shift range selection, and keyboard clearing with Escape.
|
||||||
|
- RapidGator availability and file-size discovery before a download starts.
|
||||||
|
- Package totals calculated from known link sizes and updated while links are checked.
|
||||||
|
- Multi-account provider routing with configurable priority, hoster overrides, traffic limits, and automatic fallback.
|
||||||
|
- Resumable parallel downloads, retries, reconnect handling, schedules, bandwidth limits, and live speed history.
|
||||||
|
- Automatic extraction with nested archives, password reuse, conflict handling, disk-space checks, and optional cleanup.
|
||||||
|
- Persistent sessions, history, all-time statistics, queue backup import/export, and recovery after restart.
|
||||||
|
- English and German interface with localized dialogs, statuses, menus, settings, and update information.
|
||||||
|
- Built-in GitHub update checks with release changelogs and installer download support.
|
||||||
|
|
||||||
- Improve account filter readability in dark and light themes.
|
## Supported services
|
||||||
|
|
||||||
### 2.0.6
|
| Service | Access modes |
|
||||||
|
| --- | --- |
|
||||||
|
| Real-Debrid | API token |
|
||||||
|
| AllDebrid | API token, browser login |
|
||||||
|
| BestDebrid | API token, cookie import |
|
||||||
|
| Debrid-Link | Multiple API keys |
|
||||||
|
| Mega-Debrid | API, web login |
|
||||||
|
| LinkSnappy | Account login |
|
||||||
|
| DDownload | Account login |
|
||||||
|
| 1fichier | API key |
|
||||||
|
|
||||||
- Keeps the download speed history alive when switching between tabs.
|
Provider availability and supported hosters can change independently of the application. A valid account for at least one configured service is required for debrid downloads.
|
||||||
- Adds API and Web-Login filters to the account picker.
|
|
||||||
- Uses clearer Web-Login account labels and validates supported credentials before saving.
|
|
||||||
|
|
||||||
## Why this tool?
|
## Download queue
|
||||||
|
|
||||||
- Package-based workflow with progress, extraction, history, and clean post-processing.
|
|
||||||
- Multiple debrid accounts in one app, including provider order, automatic fallback, and per-hoster routing.
|
|
||||||
- Built for large queues with session persistence, retries, reconnect handling, resume support, and integrity checks.
|
|
||||||
- Includes an in-app updater for releases published on GitHub.
|
|
||||||
|
|
||||||
## Supported providers
|
|
||||||
|
|
||||||
- AllDebrid API
|
|
||||||
- AllDebrid Web via browser login
|
|
||||||
- BestDebrid API
|
|
||||||
- BestDebrid Web via cookie import
|
|
||||||
- Debrid-Link with multi-key support
|
|
||||||
- DDownload login
|
|
||||||
- 1fichier API
|
|
||||||
- LinkSnappy login
|
|
||||||
- Mega-Debrid API
|
|
||||||
- Mega-Debrid Web
|
|
||||||
- Real-Debrid
|
|
||||||
|
|
||||||
## Core features
|
|
||||||
|
|
||||||
### Queue and package handling
|
|
||||||
|
|
||||||
- Package-based queue with item status, retries, ETA, speed, provider, and account label.
|
|
||||||
- Start, pause, stop, cancel, reset, rename, and delete for packages and items.
|
|
||||||
- Ctrl+Click multi-select and bulk actions.
|
|
||||||
- Queue backup import/export as JSON.
|
|
||||||
- Context-menu export for selected packages or selected items as structured TXT re-import files.
|
|
||||||
- Duplicate handling when adding links: keep, skip, or overwrite.
|
|
||||||
- Optional start scheduling for a specific time.
|
|
||||||
- Session recovery after restart with optional auto-resume.
|
|
||||||
- Optional auto-sorting by progress.
|
|
||||||
|
|
||||||
### Link collection
|
|
||||||
|
|
||||||
- Paste links directly into the collector.
|
|
||||||
- Import `.txt` export files that preserve package names and optional per-file names.
|
|
||||||
- Clipboard watcher with automatic link detection.
|
|
||||||
- `.dlc` import via file picker and drag-and-drop.
|
|
||||||
- Drag-and-drop of plain links, `.txt` export files, and supported container files.
|
|
||||||
|
|
||||||
### Provider routing and fallback
|
|
||||||
|
|
||||||
- Configurable provider order with primary, secondary, and tertiary fallback.
|
|
||||||
- Optional automatic provider fallback on unrestrict/download failures.
|
|
||||||
- Per-hoster routing override, so specific hosters can always use a specific provider.
|
|
||||||
- Providers can be disabled without deleting stored account data.
|
|
||||||
- Daily traffic limits per provider.
|
|
||||||
- Debrid-Link per-key daily limits and per-key daily usage tracking.
|
|
||||||
|
|
||||||
### Accounts and provider tools
|
|
||||||
|
|
||||||
- Central Accounts view with account type, status, info, access data, and actions.
|
|
||||||
- BestDebrid cookie import directly from a Netscape cookies file.
|
|
||||||
- AllDebrid browser-login flow and in-app Rapidgator host status display.
|
|
||||||
- Debrid-Link multi-key management with optional detailed line-by-line key display.
|
|
||||||
- Debrid-Link API-key statistics popup with per-key Rapidgator traffic quota, link quota, reset, activate/deactivate, and click-to-copy masked keys.
|
|
||||||
|
|
||||||
### Download engine
|
|
||||||
|
|
||||||
- Parallel downloads with resumable transfers when supported.
|
|
||||||
- Reconnect handling with configurable wait time.
|
|
||||||
- Circuit-breaker style cooldown and retry handling for provider issues.
|
|
||||||
- Global speed limit or per-download speed limit mode.
|
|
||||||
- Bandwidth schedules with time windows and speed caps.
|
|
||||||
- Live bandwidth chart and session statistics.
|
|
||||||
- Persistent all-time download counter.
|
|
||||||
|
|
||||||
### Extraction and post-processing
|
|
||||||
|
|
||||||
- Automatic extraction after download.
|
|
||||||
- Extraction can continue even when the session is stopped or after app restart.
|
|
||||||
- Hybrid download + extract workflow.
|
|
||||||
- Extraction backend using native tools by default, with JVM sidecar support available.
|
|
||||||
- Supports common archive formats including RAR, ZIP, and 7z.
|
|
||||||
- Nested extraction for archives found inside extracted output.
|
|
||||||
- Conflict handling: overwrite, skip, rename, or ask.
|
|
||||||
- Disk-space validation before extraction.
|
|
||||||
- Package-scoped password reuse for multi-archive sets.
|
|
||||||
- Optional cleanup of downloaded archives after extraction.
|
|
||||||
- Optional cleanup of link artifacts and sample files after extraction.
|
|
||||||
- Optional flat MKV collection folder after package completion.
|
|
||||||
|
|
||||||
### Auto-rename and media cleanup
|
|
||||||
|
|
||||||
- Auto-rename for extracted scene-style files based on folder/source naming.
|
|
||||||
- Multi-episode token parsing.
|
|
||||||
- Handles compact episode tokens like `s02e01` directly attached to the title.
|
|
||||||
- Optional skip of already extracted packages on start.
|
|
||||||
|
|
||||||
### Integrity, history, and backup
|
|
||||||
|
|
||||||
- Optional integrity verification with `CRC32`, `MD5`, and `SHA1`.
|
|
||||||
- Download history with package details, duration, size, provider, and target folder.
|
|
||||||
- Backup export/import for restoring app state.
|
|
||||||
- Persistent config, session, and history files in the Electron `userData` directory.
|
|
||||||
|
|
||||||
### UI and desktop integration
|
|
||||||
|
|
||||||
- Downloads, history, statistics, and settings tabs.
|
|
||||||
- Progress bars for packages and single items.
|
|
||||||
- Hoster/provider display showing both source and effective debrid account.
|
|
||||||
- Minimize-to-tray support.
|
|
||||||
- Dark/light theme setting.
|
|
||||||
- Long path support on Windows.
|
|
||||||
- Default startup window size of `1920x1080`.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Prebuilt releases
|
|
||||||
|
|
||||||
1. Download the latest installer or portable build from the releases page.
|
|
||||||
2. Start the app.
|
|
||||||
3. Add your provider credentials in `Settings > Accounts`.
|
|
||||||
|
|
||||||
Releases: [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases)
|
|
||||||
|
|
||||||
### Build from source
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
|
|
||||||
- Node.js `20+`
|
|
||||||
- npm
|
|
||||||
- Windows `10/11`
|
|
||||||
- Java Runtime `8+` for the optional JVM extraction backend
|
|
||||||
- Optional native extraction tools: 7-Zip / WinRAR / UnRAR
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## NPM scripts
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
| --- | --- |
|
|
||||||
| `npm run dev` | Starts Vite, tsup watchers, and Electron in development mode |
|
|
||||||
| `npm run build` | Builds main and renderer bundles |
|
|
||||||
| `npm run start` | Starts the built app in production mode |
|
|
||||||
| `npm test` | Runs Vitest unit tests |
|
|
||||||
| `npm run self-check` | Runs integrated self-checks |
|
|
||||||
| `npm run verify:release -- --verify-archives --seven-zip <path-to-7z.exe>` | Verifies metadata, unpacked redistribution files, and the nested Setup/Portable payloads |
|
|
||||||
| `npm run release:win` | Builds Windows installer and portable EXE |
|
|
||||||
|
|
||||||
## Typical workflow
|
|
||||||
|
|
||||||
1. Add one or more provider accounts in `Settings > Accounts`.
|
|
||||||
2. Configure provider order, fallback, and optional hoster routing.
|
|
||||||
3. Paste links or import `.dlc` files.
|
|
||||||
4. Adjust package names, target folders, extraction, and cleanup settings if needed.
|
|
||||||
5. Start the queue and monitor downloads, extraction, and provider status.
|
|
||||||
6. Review history and statistics after completion.
|
|
||||||
|
|
||||||
## Link export format
|
|
||||||
|
|
||||||
Selected packages or items can be exported from the context menu as a structured text file. Re-importing that file restores the original package grouping, even if it only contains a subset of items from a larger package.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
# rd-link-export: 1
|
|
||||||
# package: Dave Staffel 1
|
|
||||||
# file: Dave.S01E01.rar
|
|
||||||
https://example.com/e01
|
|
||||||
# file: Dave.S01E02.rar
|
|
||||||
https://example.com/e02
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported import sources:
|
|
||||||
|
|
||||||
- collector text input
|
|
||||||
- `Datei importieren`
|
|
||||||
- drag-and-drop of `.txt` and `.json`
|
|
||||||
|
|
||||||
The optional `# file:` marker preserves the original item name so the imported subset can be rebuilt with the same package name and per-item filename hints.
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
- `src/main` - Electron main process, download engine, provider clients, updater, storage
|
|
||||||
- `src/preload` - secure IPC bridge
|
|
||||||
- `src/renderer` - React UI
|
|
||||||
- `src/shared` - shared types and IPC contracts
|
|
||||||
- `tests` - unit and integration-style tests
|
|
||||||
- `resources/extractor-jvm` - optional JVM extraction runtime
|
|
||||||
- `scripts` - release and build helpers
|
|
||||||
|
|
||||||
## Data and logs
|
|
||||||
|
|
||||||
Runtime files are stored in Electron's `userData` directory, including:
|
|
||||||
|
|
||||||
- `rd_downloader_config.json`
|
|
||||||
- `rd_session_state.json`
|
|
||||||
- `rd_history.json`
|
|
||||||
- `rd_downloader.log`
|
|
||||||
- `audit.log`
|
|
||||||
- `rename.log`
|
|
||||||
- `debug_support_manifest.json`
|
|
||||||
- `trace.log`
|
|
||||||
- `trace_config.json`
|
|
||||||
- `session-logs/session_*.txt`
|
|
||||||
- `package-logs/package_*.txt`
|
|
||||||
- `item-logs/item_*.txt`
|
|
||||||
|
|
||||||
`audit.log`, `rename.log`, and `trace.log` are rotated automatically. The current file is kept plus one `.old` backup, and outdated backups are purged automatically.
|
|
||||||
|
|
||||||
### Remote debug server
|
|
||||||
|
|
||||||
For headless or server-style troubleshooting, the app can expose a small authenticated HTTP debug API with live status and log tails.
|
|
||||||
|
|
||||||
Enable it by creating these files in the same runtime folder that contains `rd_downloader.log`:
|
|
||||||
|
|
||||||
- `debug_token.txt`
|
|
||||||
Example: a long random token such as `rd-debug-please-change-me`
|
|
||||||
- `debug_port.txt`
|
|
||||||
Example: `9868`
|
|
||||||
- `debug_host.txt` (optional)
|
|
||||||
Default is `127.0.0.1`. Set `0.0.0.0` only if you really want remote access and protect it with firewall, VPN, or reverse proxy.
|
|
||||||
|
|
||||||
After startup, the app also writes `debug_support_manifest.json` into the same runtime folder. This file lists all available endpoints, the authentication method, related runtime files, and the requirements for remote access.
|
|
||||||
|
|
||||||
If you want extra support detail during a flaky or hard-to-reproduce issue, the app also maintains a `trace.log` plus `trace_config.json`. You can enable or disable the support trace from the app menu or remotely via the debug API. By default, the support trace now auto-disables again after 2 hours so it does not stay enabled forever by accident.
|
|
||||||
|
|
||||||
The app menu under `Help` also includes a `Check debug setup` action. It verifies the current host/port/token/support-manifest/trace setup locally and now also reports free disk space, current support-log sizes, and an estimated support-bundle size.
|
|
||||||
|
|
||||||
Available endpoints after restart:
|
|
||||||
|
|
||||||
- `GET /health`
|
|
||||||
- `GET /meta`
|
|
||||||
- `GET /debug/setup`
|
|
||||||
- `GET /self-check`
|
|
||||||
- `GET /host/diagnostics`
|
|
||||||
- `GET /status`
|
|
||||||
- `GET /settings`
|
|
||||||
- `GET /accounts`
|
|
||||||
- `GET /stats`
|
|
||||||
- `GET /history?limit=50&status=completed`
|
|
||||||
- `GET /packages?package=Release&includeItems=1`
|
|
||||||
- `GET /items?status=downloading&package=Release`
|
|
||||||
- `GET /session?package=Release`
|
|
||||||
- `GET /log?lines=100&grep=keyword`
|
|
||||||
- `GET /logs/main?lines=100&grep=keyword`
|
|
||||||
- `GET /logs/audit?lines=100&grep=keyword`
|
|
||||||
- `GET /logs/rename?lines=100&grep=keyword`
|
|
||||||
- `GET /logs/trace?lines=100&grep=keyword`
|
|
||||||
- `GET /logs/session?lines=100&grep=keyword`
|
|
||||||
- `GET /logs/package?package=Release&lines=100&grep=keyword`
|
|
||||||
- `GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword`
|
|
||||||
- `GET /trace/config?enable=1¬e=support&durationMinutes=120`
|
|
||||||
- `GET /support/bundle`
|
|
||||||
- `GET /diagnostics?package=Release&lines=150`
|
|
||||||
|
|
||||||
Authentication works with either:
|
|
||||||
|
|
||||||
- header: `Authorization: Bearer <token>`
|
|
||||||
- query param: `?token=<token>`
|
|
||||||
|
|
||||||
Example from PowerShell:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Invoke-RestMethod "http://SERVER:9868/diagnostics?token=YOUR_TOKEN&package=Release"
|
|
||||||
Invoke-RestMethod "http://SERVER:9868/settings?token=YOUR_TOKEN"
|
|
||||||
Invoke-RestMethod "http://SERVER:9868/accounts?token=YOUR_TOKEN"
|
|
||||||
Invoke-RestMethod "http://SERVER:9868/stats?token=YOUR_TOKEN"
|
|
||||||
Invoke-RestMethod "http://SERVER:9868/history?token=YOUR_TOKEN&limit=20"
|
|
||||||
Invoke-RestMethod "http://SERVER:9868/debug/setup?token=YOUR_TOKEN"
|
|
||||||
Invoke-RestMethod "http://SERVER:9868/self-check?token=YOUR_TOKEN"
|
|
||||||
Invoke-RestMethod "http://SERVER:9868/logs/audit?token=YOUR_TOKEN&lines=200"
|
|
||||||
Invoke-RestMethod "http://SERVER:9868/logs/rename?token=YOUR_TOKEN&lines=200"
|
|
||||||
Invoke-RestMethod "http://SERVER:9868/logs/trace?token=YOUR_TOKEN&lines=200"
|
|
||||||
Invoke-RestMethod "http://SERVER:9868/trace/config?token=YOUR_TOKEN&enable=1¬e=support&durationMinutes=120"
|
|
||||||
Invoke-RestMethod "http://SERVER:9868/logs/package?token=YOUR_TOKEN&package=Release&lines=200"
|
|
||||||
Invoke-RestMethod "http://SERVER:9868/logs/item?token=YOUR_TOKEN&item=episode.part2.rar&lines=200"
|
|
||||||
Invoke-RestMethod "http://SERVER:9868/host/diagnostics?token=YOUR_TOKEN"
|
|
||||||
Invoke-WebRequest "http://SERVER:9868/support/bundle?token=YOUR_TOKEN" -OutFile ".\\rd-support-bundle.zip"
|
|
||||||
```
|
|
||||||
|
|
||||||
This makes it easy to share one URL plus token during support, so current package status, session state, history, redacted account/settings state, audit actions, rename/MKV move traces, trace data, package/session/item logs, host-side Windows crash hints, disk space, support-log volume, support-bundle size estimates, and even a full ZIP support bundle can be inspected remotely.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- Provider does not work: verify credentials, enabled state, provider order, and daily limits.
|
|
||||||
- Debrid-Link quota looks wrong: open the API-key statistics popup and check the Rapidgator quota for the affected key.
|
|
||||||
- Extraction fails: verify passwords and installed extraction tools. The native backend is the default; JVM extraction is optional.
|
|
||||||
- Downloads stall: check active speed limits, bandwidth schedules, reconnect settings, and provider health.
|
|
||||||
- Accounts table looks misaligned on one machine: use `Reset columns` in the Accounts view to clear the locally stored column widths.
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
Detailed release history is published on [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases).
|
The Downloads workspace is optimized for large queues:
|
||||||
|
|
||||||
### 2.0.5
|
- Compact package and file rows with progress, downloaded size, total size, hoster, service, priority, status, speed, and availability.
|
||||||
|
- RapidGator and DDownload hoster icons with full names in tooltips.
|
||||||
|
- Online counters for packages and clear online, partial, checking, and offline states.
|
||||||
|
- Drag-reorderable columns with a smooth full-column preview and persisted layout.
|
||||||
|
- Green progress indicators, animated package expansion, and clear selected-row highlighting.
|
||||||
|
- Context menus for package and file actions without redundant activation controls.
|
||||||
|
- Start, pause, stop, schedule, reorder, rename, remove, enable, disable, reset, and retry actions.
|
||||||
|
- Animated summary values for packages, links, session bytes, queue size, and hoster count.
|
||||||
|
|
||||||
- Move the default download, extraction, and video collection directories to the desktop under `Multi-Debrid-Downloader`.
|
## Link collector
|
||||||
- Migrate untouched legacy default directories from `Downloads\RealDebrid` to the new desktop location.
|
|
||||||
|
|
||||||
### 2.0.4
|
- Paste one or many supported links into collections before adding them to the queue.
|
||||||
|
- Import structured text exports, JSON queue backups, and DLC containers.
|
||||||
|
- Drop plain links or supported files directly into the application.
|
||||||
|
- Monitor the clipboard with an immediate, persistent toggle.
|
||||||
|
- Preserve package and optional file names when exporting and re-importing selected links.
|
||||||
|
|
||||||
- Rename the online backup action from "create" to "export" for clearer wording.
|
## Accounts and routing
|
||||||
|
|
||||||
### 2.0.3
|
- Manage several accounts or API keys for the same provider.
|
||||||
|
- Enable or disable individual accounts without deleting their saved data.
|
||||||
|
- Check account status, remaining traffic, username, expiry, and access type.
|
||||||
|
- Configure primary, secondary, and tertiary provider fallback.
|
||||||
|
- Route individual hosters through a specific provider.
|
||||||
|
- Apply daily provider limits and per-key Debrid-Link limits.
|
||||||
|
- Keep passwords, API keys, cookies, and tokens masked in the interface.
|
||||||
|
|
||||||
- Keep online backup keys on a single line in the export and import dialog.
|
## Download engine
|
||||||
- Widen the online backup dialog on larger windows while keeping it responsive on smaller screens.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
The project is licensed under the MIT License. See `LICENSE`.
|
|
||||||
|
|
||||||
Bundled JVM extractor licenses and redistribution notices are available in `resources/extractor-jvm`.
|
- Parallel downloads with configurable concurrency.
|
||||||
|
- Resume support when the source and provider allow ranged requests.
|
||||||
|
- Automatic retries with cooldown and reconnect handling.
|
||||||
|
- Global or per-download speed limits and time-based bandwidth schedules.
|
||||||
|
- Package speed, ETA, progress, and live bandwidth statistics.
|
||||||
|
- Duplicate handling with keep, skip, or overwrite choices.
|
||||||
|
- Optional scheduled queue start and automatic resume after restart.
|
||||||
|
|
||||||
Bundled provider icon notices are available in `THIRD_PARTY_NOTICES.md`.
|
## Extraction and cleanup
|
||||||
|
|
||||||
|
- Automatic RAR, ZIP, and 7z extraction after download.
|
||||||
|
- Nested archive extraction and package-scoped password reuse.
|
||||||
|
- Conflict policies for overwrite, skip, rename, or confirmation.
|
||||||
|
- Disk-space validation before extraction.
|
||||||
|
- Optional archive, link-artifact, and sample-file cleanup.
|
||||||
|
- Optional flat MKV collection after a package completes.
|
||||||
|
- Extraction recovery after restart and optional extraction while downloads are stopped.
|
||||||
|
|
||||||
|
## History and statistics
|
||||||
|
|
||||||
|
- Filterable history with completed, failed, removed, and active outcomes.
|
||||||
|
- Package details including provider, target folder, size, duration, and completion state.
|
||||||
|
- Session, daily, seven-day, 30-day, and all-time statistics.
|
||||||
|
- Persistent all-time counters and bandwidth history.
|
||||||
|
- Localized status colors and consistent table actions.
|
||||||
|
|
||||||
|
## Settings and updates
|
||||||
|
|
||||||
|
- Immediate English/German language switching.
|
||||||
|
- Dark, light, and system theme options.
|
||||||
|
- Configurable download folder, queue behavior, history retention, notifications, extraction, cleanup, and bandwidth.
|
||||||
|
- GitHub update checks with localized release notes and download actions.
|
||||||
|
- Backup export/import for settings and optional queue data.
|
||||||
|
- Optional minimize-to-tray and desktop notifications.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
1. Download the installer or portable executable from [Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/latest).
|
||||||
|
2. Open **Settings → Accounts** and add at least one supported debrid account.
|
||||||
|
3. Configure provider order and optional hoster routing under **Usage rules**.
|
||||||
|
4. Add links from **Downloads** or prepare collections in **Link collector**.
|
||||||
|
5. Review package names, destination, extraction, and cleanup settings.
|
||||||
|
6. Start the queue.
|
||||||
|
|
||||||
|
## Link export format
|
||||||
|
|
||||||
|
Selected packages or files can be exported as structured text and imported again without losing package grouping.
|
||||||
|
|
||||||
|
```text
|
||||||
|
# rd-link-export: 1
|
||||||
|
# package: Example Series S01
|
||||||
|
# file: Example.Series.S01E01.part1.rar
|
||||||
|
https://example.com/file-1
|
||||||
|
# file: Example.Series.S01E01.part2.rar
|
||||||
|
https://example.com/file-2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Node.js 20 or newer
|
||||||
|
- npm
|
||||||
|
- Windows 10 or Windows 11 for release packaging
|
||||||
|
- Optional Java Runtime 8 or newer for the JVM extraction backend
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm ci
|
||||||
|
npm test
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the Windows installer and portable executable:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run release:win
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run dev` | Start the main-process watcher, Vite renderer, and Electron |
|
||||||
|
| `npm test` | Run client and backup API tests |
|
||||||
|
| `npm run self-check` | Run the integrated application self-check |
|
||||||
|
| `npm run build` | Build the main and renderer bundles |
|
||||||
|
| `npm run release:win` | Build the Windows installer and portable executable |
|
||||||
|
| `npm run verify:release` | Verify release metadata and packaged redistribution files |
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main Electron main process and download engine
|
||||||
|
src/preload Secure IPC bridge
|
||||||
|
src/renderer React interface
|
||||||
|
src/shared Shared types and contracts
|
||||||
|
resources/extractor-jvm Optional extraction runtime
|
||||||
|
services/backup-api Backup API service
|
||||||
|
scripts Build and release verification tools
|
||||||
|
tests Unit and integration tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data, privacy, and diagnostics
|
||||||
|
|
||||||
|
Configuration, credentials, queue state, history, and logs are stored locally in Electron's `userData` directory. Secrets are not included in public source or release archives. Provider credentials are only sent to the configured provider endpoints required for account and download operations.
|
||||||
|
|
||||||
|
The application can generate support diagnostics and expose an optional authenticated local debug API. Remote access is disabled by default. Do not expose diagnostic endpoints publicly without a firewall, VPN, or reverse proxy, and always use a strong unique token.
|
||||||
|
|
||||||
|
## Updates and changelog
|
||||||
|
|
||||||
|
See [CHANGELOG.md](CHANGELOG.md) for the complete release history. The application also displays the current GitHub release notes when an update is available.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Multi-Debrid Downloader is released under the [MIT License](LICENSE). Third-party notices are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||||
|
|||||||
+13
-12
@@ -1,14 +1,15 @@
|
|||||||
# Third-Party Notices
|
# Third-Party Notices
|
||||||
|
|
||||||
The provider names, logos, and favicons bundled with this application are trademarks or brand assets of their respective owners. They are included unchanged solely to identify the supported services and are not covered by this project's MIT license. Inclusion does not imply endorsement or affiliation.
|
The provider names, logos, and favicons bundled with this application are trademarks or brand assets of their respective owners. They are included unchanged solely to identify the supported services and are not covered by this project's MIT license. Inclusion does not imply endorsement or affiliation.
|
||||||
|
|
||||||
| Provider | Source | Retrieved | SHA-256 |
|
| Provider | Source | Retrieved | SHA-256 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Real-Debrid | https://fcdn.real-debrid.com/0831/favicons/favicon-32x32.png | 2026-08-09 | `542D80BDDB948F240B100C1872E83B0228BC1BFE5C46E8FFD95AFA419CB11F52` |
|
| Real-Debrid | https://fcdn.real-debrid.com/0831/favicons/favicon-32x32.png | 2026-08-09 | `542D80BDDB948F240B100C1872E83B0228BC1BFE5C46E8FFD95AFA419CB11F52` |
|
||||||
| Mega-Debrid | https://cdn.mega-debrid.eu/images/favicon.png | 2026-08-09 | `12D3DE939965D13F852853A2A5BB873B0D7A89D411BEAFA02518B48356803AE4` |
|
| Mega-Debrid | https://cdn.mega-debrid.eu/images/favicon.png | 2026-08-09 | `12D3DE939965D13F852853A2A5BB873B0D7A89D411BEAFA02518B48356803AE4` |
|
||||||
| BestDebrid | https://bestdebrid.com/en/files/assets/images/favicon.ico | 2026-08-09 | `11E54B1B0DCBBB8F5BCAA21D0EDC0CF182276C513ED733ACEEEBC35120CD9F33` |
|
| BestDebrid | https://bestdebrid.com/en/files/assets/images/favicon.ico | 2026-08-09 | `11E54B1B0DCBBB8F5BCAA21D0EDC0CF182276C513ED733ACEEEBC35120CD9F33` |
|
||||||
| AllDebrid | https://cdn.alldebrid.com/lib/images/default/favicon.png | 2026-08-09 | `AE296FBE43A6CCEABC85033E5C063BFCA01932EB3677FB679AF63C23535CD9EF` |
|
| AllDebrid | https://cdn.alldebrid.com/lib/images/default/favicon.png | 2026-08-09 | `AE296FBE43A6CCEABC85033E5C063BFCA01932EB3677FB679AF63C23535CD9EF` |
|
||||||
| DDownload | https://ddownload.com/images/favicon.ico | 2026-08-09 | `E0413F73F8A2A5AD85BE8082B0897E82AE3B354FE2490884D7A992BB18F34DF3` |
|
| DDownload | https://ddownload.com/images/favicon.ico | 2026-08-09 | `E0413F73F8A2A5AD85BE8082B0897E82AE3B354FE2490884D7A992BB18F34DF3` |
|
||||||
|
| RapidGator | https://rapidgator.net/favicon.ico | 2026-08-10 | `9CD57803D93FBD183DE40D1007606A3C30F90FC82A205C7ABF4C12D322B7C144` |
|
||||||
| 1Fichier | https://img.1fichier.com/favicon.png | 2026-08-09 | `C3567E0299879FE67924C6119BF082BE128A5E8962FC0D61A0D8AAA0B1304CB5` |
|
| 1Fichier | https://img.1fichier.com/favicon.png | 2026-08-09 | `C3567E0299879FE67924C6119BF082BE128A5E8962FC0D61A0D8AAA0B1304CB5` |
|
||||||
| Debrid-Link | https://cdn.debrid-link.com/img/fav/favicon.ico?i=2 | 2026-08-09 | `1AB89ADA0EB9CB665F5E05209F27B50869FCA1972620C90D57BF682E87F98FA4` |
|
| Debrid-Link | https://cdn.debrid-link.com/img/fav/favicon.ico?i=2 | 2026-08-09 | `1AB89ADA0EB9CB665F5E05209F27B50869FCA1972620C90D57BF682E87F98FA4` |
|
||||||
| LinkSnappy | https://linksnappy.com/templates/v3/img/favicon.png | 2026-08-09 | `D9DB0F5E4BDB2BF659ADD6D666F8464455196EC47201F27B126083754126497E` |
|
| LinkSnappy | https://linksnappy.com/templates/v3/img/favicon.png | 2026-08-09 | `D9DB0F5E4BDB2BF659ADD6D666F8464455196EC47201F27B126083754126497E` |
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.13",
|
"version": "2.0.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.13",
|
"version": "2.0.14",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+105
-101
@@ -1,105 +1,109 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.13",
|
"version": "2.0.14",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -k \"npm:dev:main:watch\" \"npm:dev:renderer\" \"npm:dev:electron\"",
|
"dev": "concurrently -k \"npm:dev:main:watch\" \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||||
"dev:renderer": "vite",
|
"dev:renderer": "vite --port 5180 --strictPort",
|
||||||
"visual:dev": "vite --config tests/visual/vite.config.mts --host 127.0.0.1 --port 5174 --strictPort",
|
"visual:dev": "vite --config tests/visual/vite.config.mts --host 127.0.0.1 --port 5174 --strictPort",
|
||||||
"dev:main:watch": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap --watch",
|
"dev:main:watch": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap --watch",
|
||||||
"dev:electron": "wait-on tcp:5173 file:build/main/main/main.js && cross-env NODE_ENV=development electron .",
|
"dev:electron": "wait-on tcp:5180 file:build/main/main/main.js && cross-env NODE_ENV=development DEV_SERVER_PORT=5180 electron .",
|
||||||
"build": "npm run build:main && npm run build:renderer",
|
"build": "npm run build:main && npm run build:renderer",
|
||||||
"build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap",
|
"build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap",
|
||||||
"build:renderer": "vite build",
|
"build:renderer": "vite build",
|
||||||
"start": "cross-env NODE_ENV=production electron .",
|
"start": "cross-env NODE_ENV=production electron .",
|
||||||
"test": "npm run test:client && npm run test:backup-api",
|
"test": "npm run test:client && npm run test:backup-api",
|
||||||
"test:client": "vitest run",
|
"test:client": "vitest run",
|
||||||
"test:backup-api": "npm --prefix services/backup-api test",
|
"test:backup-api": "npm --prefix services/backup-api test",
|
||||||
"start:backup-api": "npm --prefix services/backup-api start",
|
"start:backup-api": "npm --prefix services/backup-api start",
|
||||||
"self-check": "tsx tests/self-check.ts",
|
"self-check": "tsx tests/self-check.ts",
|
||||||
"release:win": "npm run build && electron-builder --publish never --win nsis portable",
|
"release:win": "npm run build && electron-builder --publish never --win nsis portable",
|
||||||
"verify:release": "node scripts/verify_public_release.mjs"
|
"verify:release": "node scripts/verify_public_release.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"uuid": "11.1.1"
|
"uuid": "11.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^24.0.13",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"electron": "^31.7.7",
|
"electron": "^31.7.7",
|
||||||
"electron-builder": "^25.1.8",
|
"electron-builder": "^25.1.8",
|
||||||
"rcedit": "^5.0.2",
|
"rcedit": "^5.0.2",
|
||||||
"tsup": "^8.3.6",
|
"tsup": "^8.3.6",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
"vitest": "^2.1.8",
|
"vitest": "^2.1.8",
|
||||||
"wait-on": "^8.0.1",
|
"wait-on": "^8.0.1",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.sucukdeluxe.realdebrid",
|
"appId": "com.sucukdeluxe.realdebrid",
|
||||||
"productName": "Real-Debrid-Downloader",
|
"productName": "Real-Debrid-Downloader",
|
||||||
"directories": {
|
"directories": {
|
||||||
"buildResources": "assets",
|
"buildResources": "assets",
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
"publish": {
|
"publish": {
|
||||||
"provider": "github",
|
"provider": "github",
|
||||||
"owner": "Sucukdeluxe",
|
"owner": "Sucukdeluxe",
|
||||||
"repo": "multi-debrid-downloader"
|
"repo": "multi-debrid-downloader"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"build/main/**/*",
|
"build/main/**/*",
|
||||||
"build/renderer/**/*",
|
"build/renderer/**/*",
|
||||||
"resources/extractor-jvm/**/*",
|
"resources/extractor-jvm/**/*",
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
"THIRD_PARTY_NOTICES.md",
|
"THIRD_PARTY_NOTICES.md",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "LICENSE",
|
"from": "LICENSE",
|
||||||
"to": "LICENSE"
|
"to": "LICENSE"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "THIRD_PARTY_NOTICES.md",
|
"from": "THIRD_PARTY_NOTICES.md",
|
||||||
"to": "THIRD_PARTY_NOTICES.md"
|
"to": "THIRD_PARTY_NOTICES.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "assets/app_icon.ico",
|
||||||
|
"to": "assets/app_icon.ico"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"resources/extractor-jvm/**/*"
|
"resources/extractor-jvm/**/*"
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
"nsis",
|
"nsis",
|
||||||
"portable"
|
"portable"
|
||||||
],
|
],
|
||||||
"icon": "assets/app_icon.ico",
|
"icon": "assets/app_icon.ico",
|
||||||
"signAndEditExecutable": false
|
"signAndEditExecutable": false
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"artifactName": "${productName}-Setup-${version}.${ext}",
|
"artifactName": "${productName}-Setup-${version}.${ext}",
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"perMachine": false,
|
"perMachine": false,
|
||||||
"allowToChangeInstallationDirectory": true,
|
"allowToChangeInstallationDirectory": true,
|
||||||
"createDesktopShortcut": true
|
"createDesktopShortcut": true
|
||||||
},
|
},
|
||||||
"portable": {
|
"portable": {
|
||||||
"artifactName": "${productName}-${version}-portable.${ext}"
|
"artifactName": "${productName}-${version}-portable.${ext}"
|
||||||
},
|
},
|
||||||
"afterPack": "scripts/afterPack.cjs"
|
"afterPack": "scripts/afterPack.cjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-15
@@ -1,18 +1,32 @@
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { rcedit } = require("rcedit");
|
const { rcedit } = require("rcedit");
|
||||||
|
|
||||||
module.exports = async function afterPack(context) {
|
module.exports = async function afterPack(context) {
|
||||||
const productFilename = context.packager?.appInfo?.productFilename;
|
const productFilename = context.packager?.appInfo?.productFilename;
|
||||||
if (!productFilename) {
|
const productName = context.packager?.appInfo?.productName;
|
||||||
console.warn(" • rcedit: skipped — productFilename not available");
|
const version = context.packager?.appInfo?.version;
|
||||||
return;
|
if (!productFilename) {
|
||||||
}
|
throw new Error("rcedit: productFilename not available");
|
||||||
const exePath = path.join(context.appOutDir, `${productFilename}.exe`);
|
}
|
||||||
const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico");
|
if (!productName) {
|
||||||
console.log(` • rcedit: patching icon → ${exePath}`);
|
throw new Error("rcedit: productName not available");
|
||||||
try {
|
}
|
||||||
await rcedit(exePath, { icon: iconPath });
|
if (!version) {
|
||||||
} catch (error) {
|
throw new Error("rcedit: version not available");
|
||||||
console.warn(` • rcedit: failed — ${String(error)}`);
|
}
|
||||||
}
|
const exePath = path.join(context.appOutDir, `${productFilename}.exe`);
|
||||||
};
|
const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico");
|
||||||
|
console.log(` • rcedit: patching metadata and icon → ${exePath}`);
|
||||||
|
await rcedit(exePath, {
|
||||||
|
icon: iconPath,
|
||||||
|
"file-version": version,
|
||||||
|
"product-version": version,
|
||||||
|
"version-string": {
|
||||||
|
CompanyName: "Sucukdeluxe",
|
||||||
|
FileDescription: "Multi-Debrid-Downloader",
|
||||||
|
InternalFilename: productFilename,
|
||||||
|
OriginalFilename: `${productFilename}.exe`,
|
||||||
|
ProductName: productName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
+407
-372
@@ -1,398 +1,433 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { parse } from "yaml";
|
import { parse } from "yaml";
|
||||||
|
|
||||||
const EXPECTED_PUBLISH = Object.freeze({
|
const EXPECTED_PUBLISH = Object.freeze({
|
||||||
provider: "github",
|
provider: "github",
|
||||||
owner: "Sucukdeluxe",
|
owner: "Sucukdeluxe",
|
||||||
repo: "multi-debrid-downloader"
|
repo: "multi-debrid-downloader"
|
||||||
});
|
});
|
||||||
const EXPECTED_PRODUCT_NAME = "Real-Debrid-Downloader";
|
const EXPECTED_PRODUCT_NAME = "Real-Debrid-Downloader";
|
||||||
const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}";
|
const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}";
|
||||||
const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}";
|
const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}";
|
||||||
const REQUIRED_BUILD_FILES = Object.freeze([
|
const REQUIRED_BUILD_FILES = Object.freeze([
|
||||||
"resources/extractor-jvm/**/*",
|
"resources/extractor-jvm/**/*",
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
"THIRD_PARTY_NOTICES.md"
|
"THIRD_PARTY_NOTICES.md"
|
||||||
]);
|
]);
|
||||||
const EXPECTED_EXTRA_RESOURCE = Object.freeze({
|
const EXPECTED_EXTRA_RESOURCE = Object.freeze({
|
||||||
from: "LICENSE",
|
from: "LICENSE",
|
||||||
to: "LICENSE"
|
to: "LICENSE"
|
||||||
});
|
});
|
||||||
const REDISTRIBUTION_FILES = Object.freeze([
|
const EXPECTED_ICON_RESOURCE = Object.freeze({
|
||||||
Object.freeze({
|
from: "assets/app_icon.ico",
|
||||||
sourcePath: "LICENSE",
|
to: "assets/app_icon.ico"
|
||||||
packagedPath: "resources/LICENSE",
|
});
|
||||||
sha256: "f2c1bc02d9ba5235cc67dfea734e7dc90559b00d8cb2d142bad7a984ff96d3f6"
|
const REDISTRIBUTION_FILES = Object.freeze([
|
||||||
}),
|
Object.freeze({
|
||||||
Object.freeze({
|
sourcePath: "LICENSE",
|
||||||
sourcePath: "THIRD_PARTY_NOTICES.md",
|
packagedPath: "resources/LICENSE",
|
||||||
packagedPath: "resources/THIRD_PARTY_NOTICES.md",
|
sha256: "f2c1bc02d9ba5235cc67dfea734e7dc90559b00d8cb2d142bad7a984ff96d3f6"
|
||||||
sha256: "b5d923900e9bf932fbf173191973d0892a091ac5afdc4907b8b9f4f846e8126c"
|
}),
|
||||||
}),
|
Object.freeze({
|
||||||
Object.freeze({
|
sourcePath: "THIRD_PARTY_NOTICES.md",
|
||||||
sourcePath: "resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
packagedPath: "resources/THIRD_PARTY_NOTICES.md",
|
||||||
packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
sha256: "28f9d1f8692811758c41584e59d0db6cbf26654b4b93e335626921bef86bdf76"
|
||||||
sha256: "20e50fe7aae3e56378ebf0417d9de904f55a0e61e4df315333e632a4d3555d95"
|
}),
|
||||||
}),
|
Object.freeze({
|
||||||
Object.freeze({
|
sourcePath: "resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
||||||
sourcePath: "resources/extractor-jvm/licenses/7-Zip-license.txt",
|
packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
||||||
packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/7-Zip-license.txt",
|
sha256: "20e50fe7aae3e56378ebf0417d9de904f55a0e61e4df315333e632a4d3555d95"
|
||||||
sha256: "477e15d4033026edb25d36c9f078bb0beafc9318f6505473648972a536ece263"
|
}),
|
||||||
}),
|
Object.freeze({
|
||||||
Object.freeze({
|
sourcePath: "resources/extractor-jvm/licenses/7-Zip-license.txt",
|
||||||
sourcePath: "resources/extractor-jvm/licenses/Apache-2.0.txt",
|
packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/7-Zip-license.txt",
|
||||||
packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/Apache-2.0.txt",
|
sha256: "477e15d4033026edb25d36c9f078bb0beafc9318f6505473648972a536ece263"
|
||||||
sha256: "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"
|
}),
|
||||||
}),
|
Object.freeze({
|
||||||
Object.freeze({
|
sourcePath: "resources/extractor-jvm/licenses/Apache-2.0.txt",
|
||||||
sourcePath: "resources/extractor-jvm/THIRD_PARTY_NOTICES.txt",
|
packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/Apache-2.0.txt",
|
||||||
packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/THIRD_PARTY_NOTICES.txt",
|
sha256: "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"
|
||||||
sha256: "d4ab6ee9ba293f9d25d22e0506a465d79794259274a0ec944d3bb3d001b46ca9"
|
}),
|
||||||
})
|
Object.freeze({
|
||||||
]);
|
sourcePath: "resources/extractor-jvm/THIRD_PARTY_NOTICES.txt",
|
||||||
|
packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/THIRD_PARTY_NOTICES.txt",
|
||||||
function readRequiredFile(filePath) {
|
sha256: "d4ab6ee9ba293f9d25d22e0506a465d79794259274a0ec944d3bb3d001b46ca9"
|
||||||
requireNonEmptyFile(filePath, "required file");
|
})
|
||||||
return fs.readFileSync(filePath, "utf8");
|
]);
|
||||||
}
|
|
||||||
|
function readRequiredFile(filePath) {
|
||||||
function parseYamlDocument(content, sourceName) {
|
requireNonEmptyFile(filePath, "required file");
|
||||||
let value;
|
return fs.readFileSync(filePath, "utf8");
|
||||||
try {
|
}
|
||||||
value = parse(content, { uniqueKeys: true });
|
|
||||||
} catch (error) {
|
function parseYamlDocument(content, sourceName) {
|
||||||
throw new Error(`${sourceName} contains invalid YAML: ${String(error?.message || error)}`);
|
let value;
|
||||||
}
|
try {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
value = parse(content, { uniqueKeys: true });
|
||||||
throw new Error(`${sourceName} must contain a YAML mapping`);
|
} catch (error) {
|
||||||
}
|
throw new Error(`${sourceName} contains invalid YAML: ${String(error?.message || error)}`);
|
||||||
return value;
|
}
|
||||||
}
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new Error(`${sourceName} must contain a YAML mapping`);
|
||||||
function assertEqual(actual, expected, label) {
|
}
|
||||||
if (actual !== expected) {
|
return value;
|
||||||
throw new Error(`${label} must be ${expected}, received ${String(actual)}`);
|
}
|
||||||
}
|
|
||||||
}
|
function assertEqual(actual, expected, label) {
|
||||||
|
if (actual !== expected) {
|
||||||
function resolveReleaseDir(rootDir) {
|
throw new Error(`${label} must be ${expected}, received ${String(actual)}`);
|
||||||
const directLatest = path.join(rootDir, "latest.yml");
|
}
|
||||||
if (fs.existsSync(directLatest)) {
|
}
|
||||||
return rootDir;
|
|
||||||
}
|
function resolveReleaseDir(rootDir) {
|
||||||
return path.join(rootDir, "release");
|
const directLatest = path.join(rootDir, "latest.yml");
|
||||||
}
|
if (fs.existsSync(directLatest)) {
|
||||||
|
return rootDir;
|
||||||
function requireNonEmptyFile(filePath, label) {
|
}
|
||||||
let stat;
|
return path.join(rootDir, "release");
|
||||||
try {
|
}
|
||||||
stat = fs.lstatSync(filePath);
|
|
||||||
} catch (error) {
|
function requireNonEmptyFile(filePath, label) {
|
||||||
if (error?.code === "ENOENT") {
|
let stat;
|
||||||
throw new Error(`Missing ${label}: ${filePath}`);
|
try {
|
||||||
}
|
stat = fs.lstatSync(filePath);
|
||||||
throw error;
|
} catch (error) {
|
||||||
}
|
if (error?.code === "ENOENT") {
|
||||||
if (stat.isSymbolicLink()) {
|
throw new Error(`Missing ${label}: ${filePath}`);
|
||||||
throw new Error(`${label} must be a regular file, not a symbolic link: ${filePath}`);
|
}
|
||||||
}
|
throw error;
|
||||||
if (!stat.isFile() || stat.size === 0) {
|
}
|
||||||
throw new Error(`${label} must be a non-empty regular file: ${filePath}`);
|
if (stat.isSymbolicLink()) {
|
||||||
}
|
throw new Error(`${label} must be a regular file, not a symbolic link: ${filePath}`);
|
||||||
return stat;
|
}
|
||||||
}
|
if (!stat.isFile() || stat.size === 0) {
|
||||||
|
throw new Error(`${label} must be a non-empty regular file: ${filePath}`);
|
||||||
function sha256NormalizedText(filePath) {
|
}
|
||||||
const normalized = fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
return stat;
|
||||||
return crypto.createHash("sha256").update(normalized, "utf8").digest("hex");
|
}
|
||||||
}
|
|
||||||
|
function sha256NormalizedText(filePath) {
|
||||||
function verifyRedistributionFiles(baseDir, pathKey, label) {
|
const normalized = fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
for (const file of REDISTRIBUTION_FILES) {
|
return crypto.createHash("sha256").update(normalized, "utf8").digest("hex");
|
||||||
const relativePath = file[pathKey];
|
}
|
||||||
const filePath = path.join(baseDir, ...relativePath.split("/"));
|
|
||||||
requireNonEmptyFile(filePath, `${label} redistribution file`);
|
function verifyRedistributionFiles(baseDir, pathKey, label) {
|
||||||
const actualDigest = sha256NormalizedText(filePath);
|
for (const file of REDISTRIBUTION_FILES) {
|
||||||
if (actualDigest !== file.sha256) {
|
const relativePath = file[pathKey];
|
||||||
throw new Error(`${label} redistribution content mismatch for ${relativePath}`);
|
const filePath = path.join(baseDir, ...relativePath.split("/"));
|
||||||
}
|
requireNonEmptyFile(filePath, `${label} redistribution file`);
|
||||||
}
|
const actualDigest = sha256NormalizedText(filePath);
|
||||||
}
|
if (actualDigest !== file.sha256) {
|
||||||
|
throw new Error(`${label} redistribution content mismatch for ${relativePath}`);
|
||||||
function hasExpectedExtraResource(extraResources) {
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExtraResource(extraResources, expected) {
|
||||||
const entries = Array.isArray(extraResources) ? extraResources : [extraResources];
|
const entries = Array.isArray(extraResources) ? extraResources : [extraResources];
|
||||||
return entries.some((entry) => (
|
return entries.some((entry) => (
|
||||||
entry
|
entry
|
||||||
&& typeof entry === "object"
|
&& typeof entry === "object"
|
||||||
&& !Array.isArray(entry)
|
&& !Array.isArray(entry)
|
||||||
&& entry.from === EXPECTED_EXTRA_RESOURCE.from
|
&& entry.from === expected.from
|
||||||
&& entry.to === EXPECTED_EXTRA_RESOURCE.to
|
&& entry.to === expected.to
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCommand(command, args) {
|
function sha256File(filePath) {
|
||||||
const result = spawnSync(command, args, {
|
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||||
encoding: "utf8",
|
|
||||||
windowsHide: true
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
status: result.status,
|
|
||||||
stdout: String(result.stdout || ""),
|
|
||||||
stderr: String(result.stderr || ""),
|
|
||||||
error: result.error
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function listFiles(rootDir) {
|
function verifyPackagedIcon(sourceRoot, packagedRoot, label) {
|
||||||
const files = [];
|
const sourcePath = path.join(sourceRoot, "assets", "app_icon.ico");
|
||||||
const pending = [rootDir];
|
const packagedPath = path.join(packagedRoot, "resources", "assets", "app_icon.ico");
|
||||||
while (pending.length > 0) {
|
requireNonEmptyFile(sourcePath, "source application icon");
|
||||||
const current = pending.pop();
|
requireNonEmptyFile(packagedPath, `${label} application icon`);
|
||||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
if (sha256File(sourcePath) !== sha256File(packagedPath)) {
|
||||||
const entryPath = path.join(current, entry.name);
|
throw new Error(`${label} application icon content mismatch for resources/assets/app_icon.ico`);
|
||||||
const stat = fs.lstatSync(entryPath);
|
|
||||||
if (stat.isSymbolicLink()) {
|
|
||||||
files.push(entryPath);
|
|
||||||
} else if (stat.isDirectory()) {
|
|
||||||
pending.push(entryPath);
|
|
||||||
} else if (stat.isFile()) {
|
|
||||||
files.push(entryPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractArchive(archivePath, outputDir, sevenZipPath, commandRunner) {
|
|
||||||
fs.mkdirSync(outputDir, { recursive: true });
|
|
||||||
const args = ["x", archivePath, `-o${outputDir}`, "-y", "-bb0", "-bd"];
|
|
||||||
const result = commandRunner(sevenZipPath, args);
|
|
||||||
if (result?.error || result?.status !== 0) {
|
|
||||||
const detail = String(result?.error?.message || result?.stderr || result?.stdout || "unknown error").trim();
|
|
||||||
throw new Error(`7-Zip command failed for ${archivePath}: ${detail}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractArchiveTree(archivePath, outputDir, sevenZipPath, commandRunner, depth = 0) {
|
function runCommand(command, args) {
|
||||||
if (depth > 5) {
|
const result = spawnSync(command, args, {
|
||||||
throw new Error(`Archive nesting limit exceeded for ${archivePath}`);
|
encoding: "utf8",
|
||||||
|
windowsHide: true
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
stdout: String(result.stdout || ""),
|
||||||
|
stderr: String(result.stderr || ""),
|
||||||
|
error: result.error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listFiles(rootDir) {
|
||||||
|
const files = [];
|
||||||
|
const pending = [rootDir];
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const current = pending.pop();
|
||||||
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
|
const entryPath = path.join(current, entry.name);
|
||||||
|
const stat = fs.lstatSync(entryPath);
|
||||||
|
if (stat.isSymbolicLink()) {
|
||||||
|
files.push(entryPath);
|
||||||
|
} else if (stat.isDirectory()) {
|
||||||
|
pending.push(entryPath);
|
||||||
|
} else if (stat.isFile()) {
|
||||||
|
files.push(entryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractArchive(archivePath, outputDir, sevenZipPath, commandRunner) {
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
const args = ["x", archivePath, `-o${outputDir}`, "-y", "-bb0", "-bd"];
|
||||||
|
const result = commandRunner(sevenZipPath, args);
|
||||||
|
if (result?.error || result?.status !== 0) {
|
||||||
|
const detail = String(result?.error?.message || result?.stderr || result?.stdout || "unknown error").trim();
|
||||||
|
throw new Error(`7-Zip command failed for ${archivePath}: ${detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractArchiveTree(archivePath, outputDir, sevenZipPath, commandRunner, depth = 0) {
|
||||||
|
if (depth > 5) {
|
||||||
|
throw new Error(`Archive nesting limit exceeded for ${archivePath}`);
|
||||||
|
}
|
||||||
|
extractArchive(archivePath, outputDir, sevenZipPath, commandRunner);
|
||||||
|
const nestedArchives = listFiles(outputDir).filter((filePath) => /\.7z$/i.test(filePath));
|
||||||
|
for (let index = 0; index < nestedArchives.length; index += 1) {
|
||||||
|
const nestedArchive = nestedArchives[index];
|
||||||
|
const nestedOutput = path.join(outputDir, `.nested-${depth}-${index}`);
|
||||||
|
extractArchiveTree(nestedArchive, nestedOutput, sevenZipPath, commandRunner, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelativePath(rootDir, filePath) {
|
||||||
|
return path.relative(rootDir, filePath).split(path.sep).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyArchiveRedistributionFiles(extractionRoot, archiveName, sourceRoot) {
|
||||||
|
const extractedFiles = listFiles(extractionRoot);
|
||||||
|
for (const file of REDISTRIBUTION_FILES) {
|
||||||
|
const suffix = file.packagedPath.toLowerCase();
|
||||||
|
const matches = extractedFiles.filter((filePath) => {
|
||||||
|
const relativePath = normalizeRelativePath(extractionRoot, filePath).toLowerCase();
|
||||||
|
return relativePath === suffix || relativePath.endsWith(`/${suffix}`);
|
||||||
|
});
|
||||||
|
if (matches.length === 0) {
|
||||||
|
throw new Error(`Missing redistribution file ${file.packagedPath} in archive ${archiveName}`);
|
||||||
|
}
|
||||||
|
for (const filePath of matches) {
|
||||||
|
requireNonEmptyFile(filePath, `${archiveName} redistribution file`);
|
||||||
|
const actualDigest = sha256NormalizedText(filePath);
|
||||||
|
if (actualDigest !== file.sha256) {
|
||||||
|
throw new Error(`Archive redistribution content mismatch for ${file.packagedPath} in ${archiveName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
extractArchive(archivePath, outputDir, sevenZipPath, commandRunner);
|
const iconMatches = extractedFiles.filter((filePath) => normalizeRelativePath(extractionRoot, filePath).toLowerCase().endsWith("resources/assets/app_icon.ico"));
|
||||||
const nestedArchives = listFiles(outputDir).filter((filePath) => /\.7z$/i.test(filePath));
|
if (iconMatches.length === 0) {
|
||||||
for (let index = 0; index < nestedArchives.length; index += 1) {
|
throw new Error(`Missing application icon resources/assets/app_icon.ico in archive ${archiveName}`);
|
||||||
const nestedArchive = nestedArchives[index];
|
|
||||||
const nestedOutput = path.join(outputDir, `.nested-${depth}-${index}`);
|
|
||||||
extractArchiveTree(nestedArchive, nestedOutput, sevenZipPath, commandRunner, depth + 1);
|
|
||||||
}
|
}
|
||||||
}
|
const sourceIconPath = path.join(sourceRoot, "assets", "app_icon.ico");
|
||||||
|
requireNonEmptyFile(sourceIconPath, "source application icon");
|
||||||
function normalizeRelativePath(rootDir, filePath) {
|
const sourceDigest = sha256File(sourceIconPath);
|
||||||
return path.relative(rootDir, filePath).split(path.sep).join("/");
|
for (const iconPath of iconMatches) {
|
||||||
}
|
requireNonEmptyFile(iconPath, `${archiveName} application icon`);
|
||||||
|
if (sha256File(iconPath) !== sourceDigest) {
|
||||||
function verifyArchiveRedistributionFiles(extractionRoot, archiveName) {
|
throw new Error(`Archive application icon content mismatch in ${archiveName}`);
|
||||||
const extractedFiles = listFiles(extractionRoot);
|
|
||||||
for (const file of REDISTRIBUTION_FILES) {
|
|
||||||
const suffix = file.packagedPath.toLowerCase();
|
|
||||||
const matches = extractedFiles.filter((filePath) => {
|
|
||||||
const relativePath = normalizeRelativePath(extractionRoot, filePath).toLowerCase();
|
|
||||||
return relativePath === suffix || relativePath.endsWith(`/${suffix}`);
|
|
||||||
});
|
|
||||||
if (matches.length === 0) {
|
|
||||||
throw new Error(`Missing redistribution file ${file.packagedPath} in archive ${archiveName}`);
|
|
||||||
}
|
|
||||||
for (const filePath of matches) {
|
|
||||||
requireNonEmptyFile(filePath, `${archiveName} redistribution file`);
|
|
||||||
const actualDigest = sha256NormalizedText(filePath);
|
|
||||||
if (actualDigest !== file.sha256) {
|
|
||||||
throw new Error(`Archive redistribution content mismatch for ${file.packagedPath} in ${archiveName}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPackageVersion(rootDir) {
|
function readPackageVersion(rootDir) {
|
||||||
const packageJson = JSON.parse(readRequiredFile(path.join(rootDir, "package.json")));
|
const packageJson = JSON.parse(readRequiredFile(path.join(rootDir, "package.json")));
|
||||||
const version = String(packageJson.version || "").trim();
|
const version = String(packageJson.version || "").trim();
|
||||||
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
|
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
|
||||||
throw new Error(`package.json contains invalid version ${version || "<empty>"}`);
|
throw new Error(`package.json contains invalid version ${version || "<empty>"}`);
|
||||||
}
|
}
|
||||||
return { packageJson, version };
|
return { packageJson, version };
|
||||||
}
|
}
|
||||||
|
|
||||||
function sha512Base64(filePath) {
|
function sha512Base64(filePath) {
|
||||||
const hash = crypto.createHash("sha512");
|
const hash = crypto.createHash("sha512");
|
||||||
const handle = fs.openSync(filePath, "r");
|
const handle = fs.openSync(filePath, "r");
|
||||||
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||||
try {
|
try {
|
||||||
let bytesRead;
|
let bytesRead;
|
||||||
do {
|
do {
|
||||||
bytesRead = fs.readSync(handle, buffer, 0, buffer.length, null);
|
bytesRead = fs.readSync(handle, buffer, 0, buffer.length, null);
|
||||||
if (bytesRead > 0) {
|
if (bytesRead > 0) {
|
||||||
hash.update(buffer.subarray(0, bytesRead));
|
hash.update(buffer.subarray(0, bytesRead));
|
||||||
}
|
}
|
||||||
} while (bytesRead > 0);
|
} while (bytesRead > 0);
|
||||||
} finally {
|
} finally {
|
||||||
fs.closeSync(handle);
|
fs.closeSync(handle);
|
||||||
}
|
}
|
||||||
return hash.digest("base64");
|
return hash.digest("base64");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyPublicRelease(rootDir = process.cwd()) {
|
export function verifyPublicRelease(rootDir = process.cwd()) {
|
||||||
const absoluteRoot = path.resolve(rootDir);
|
const absoluteRoot = path.resolve(rootDir);
|
||||||
const { packageJson, version } = readPackageVersion(absoluteRoot);
|
const { packageJson, version } = readPackageVersion(absoluteRoot);
|
||||||
|
|
||||||
const build = packageJson.build || {};
|
const build = packageJson.build || {};
|
||||||
const publish = build.publish || {};
|
const publish = build.publish || {};
|
||||||
assertEqual(build.productName, EXPECTED_PRODUCT_NAME, "package.json build.productName");
|
assertEqual(build.productName, EXPECTED_PRODUCT_NAME, "package.json build.productName");
|
||||||
assertEqual(publish.provider, EXPECTED_PUBLISH.provider, "package.json publish provider");
|
assertEqual(publish.provider, EXPECTED_PUBLISH.provider, "package.json publish provider");
|
||||||
assertEqual(publish.owner, EXPECTED_PUBLISH.owner, "package.json publish owner");
|
assertEqual(publish.owner, EXPECTED_PUBLISH.owner, "package.json publish owner");
|
||||||
assertEqual(publish.repo, EXPECTED_PUBLISH.repo, "package.json publish repo");
|
assertEqual(publish.repo, EXPECTED_PUBLISH.repo, "package.json publish repo");
|
||||||
assertEqual(build.nsis?.artifactName, EXPECTED_NSIS_ARTIFACT_NAME, "package.json NSIS artifactName");
|
assertEqual(build.nsis?.artifactName, EXPECTED_NSIS_ARTIFACT_NAME, "package.json NSIS artifactName");
|
||||||
assertEqual(build.portable?.artifactName, EXPECTED_PORTABLE_ARTIFACT_NAME, "package.json portable artifactName");
|
assertEqual(build.portable?.artifactName, EXPECTED_PORTABLE_ARTIFACT_NAME, "package.json portable artifactName");
|
||||||
const buildFiles = Array.isArray(build.files) ? build.files : [];
|
const buildFiles = Array.isArray(build.files) ? build.files : [];
|
||||||
const missingBuildFiles = REQUIRED_BUILD_FILES.filter((entry) => !buildFiles.includes(entry));
|
const missingBuildFiles = REQUIRED_BUILD_FILES.filter((entry) => !buildFiles.includes(entry));
|
||||||
if (missingBuildFiles.length > 0) {
|
if (missingBuildFiles.length > 0) {
|
||||||
throw new Error(`package.json build.files omits redistribution content: ${missingBuildFiles.join(", ")}`);
|
throw new Error(`package.json build.files omits redistribution content: ${missingBuildFiles.join(", ")}`);
|
||||||
}
|
}
|
||||||
if (!hasExpectedExtraResource(build.extraResources)) {
|
if (!hasExtraResource(build.extraResources, EXPECTED_EXTRA_RESOURCE)) {
|
||||||
throw new Error("package.json build.extraResources must copy LICENSE to LICENSE");
|
throw new Error("package.json build.extraResources must copy LICENSE to LICENSE");
|
||||||
}
|
}
|
||||||
|
if (!hasExtraResource(build.extraResources, EXPECTED_ICON_RESOURCE)) {
|
||||||
const releaseDir = resolveReleaseDir(absoluteRoot);
|
throw new Error("package.json build.extraResources must copy assets/app_icon.ico to assets/app_icon.ico");
|
||||||
const latestFields = parseYamlDocument(
|
|
||||||
readRequiredFile(path.join(releaseDir, "latest.yml")),
|
|
||||||
"latest.yml"
|
|
||||||
);
|
|
||||||
const appUpdateFields = parseYamlDocument(
|
|
||||||
readRequiredFile(path.join(releaseDir, "win-unpacked", "resources", "app-update.yml")),
|
|
||||||
"app-update.yml"
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEqual(latestFields.version, version, "latest.yml version");
|
|
||||||
assertEqual(appUpdateFields.provider, EXPECTED_PUBLISH.provider, "app-update.yml provider");
|
|
||||||
assertEqual(appUpdateFields.owner, EXPECTED_PUBLISH.owner, "app-update.yml owner");
|
|
||||||
assertEqual(appUpdateFields.repo, EXPECTED_PUBLISH.repo, "app-update.yml repo");
|
|
||||||
|
|
||||||
const expectedSetup = `${EXPECTED_PRODUCT_NAME}-Setup-${version}.exe`;
|
|
||||||
const expectedPortable = `${EXPECTED_PRODUCT_NAME}-${version}-portable.exe`;
|
|
||||||
const expectedBlockmap = `${expectedSetup}.blockmap`;
|
|
||||||
assertEqual(latestFields.path, expectedSetup, "latest.yml path");
|
|
||||||
|
|
||||||
const requiredArtifacts = [expectedSetup, expectedPortable, expectedBlockmap];
|
|
||||||
const missingArtifacts = requiredArtifacts.filter((fileName) => !fs.existsSync(path.join(releaseDir, fileName)));
|
|
||||||
if (missingArtifacts.length > 0) {
|
|
||||||
throw new Error(`Missing release artifacts: ${missingArtifacts.join(", ")}`);
|
|
||||||
}
|
}
|
||||||
const artifactStats = new Map();
|
|
||||||
for (const fileName of requiredArtifacts) {
|
const releaseDir = resolveReleaseDir(absoluteRoot);
|
||||||
artifactStats.set(fileName, requireNonEmptyFile(path.join(releaseDir, fileName), "release artifact"));
|
const latestFields = parseYamlDocument(
|
||||||
}
|
readRequiredFile(path.join(releaseDir, "latest.yml")),
|
||||||
|
"latest.yml"
|
||||||
if (!Array.isArray(latestFields.files) || latestFields.files.length !== 1) {
|
);
|
||||||
throw new Error("latest.yml files must contain exactly one canonical installer entry");
|
const appUpdateFields = parseYamlDocument(
|
||||||
}
|
readRequiredFile(path.join(releaseDir, "win-unpacked", "resources", "app-update.yml")),
|
||||||
const installerEntry = latestFields.files[0];
|
"app-update.yml"
|
||||||
if (!installerEntry || typeof installerEntry !== "object" || Array.isArray(installerEntry)) {
|
);
|
||||||
throw new Error("latest.yml files entry must be a mapping");
|
|
||||||
}
|
assertEqual(latestFields.version, version, "latest.yml version");
|
||||||
assertEqual(installerEntry.url, expectedSetup, "latest.yml files url");
|
assertEqual(appUpdateFields.provider, EXPECTED_PUBLISH.provider, "app-update.yml provider");
|
||||||
const expectedSetupSize = artifactStats.get(expectedSetup).size;
|
assertEqual(appUpdateFields.owner, EXPECTED_PUBLISH.owner, "app-update.yml owner");
|
||||||
assertEqual(installerEntry.size, expectedSetupSize, "latest.yml files size");
|
assertEqual(appUpdateFields.repo, EXPECTED_PUBLISH.repo, "app-update.yml repo");
|
||||||
const expectedSetupDigest = sha512Base64(path.join(releaseDir, expectedSetup));
|
|
||||||
assertEqual(installerEntry.sha512, expectedSetupDigest, "latest.yml files SHA512 digest");
|
const expectedSetup = `${EXPECTED_PRODUCT_NAME}-Setup-${version}.exe`;
|
||||||
assertEqual(latestFields.sha512, expectedSetupDigest, "latest.yml SHA512 digest");
|
const expectedPortable = `${EXPECTED_PRODUCT_NAME}-${version}-portable.exe`;
|
||||||
|
const expectedBlockmap = `${expectedSetup}.blockmap`;
|
||||||
|
assertEqual(latestFields.path, expectedSetup, "latest.yml path");
|
||||||
|
|
||||||
|
const requiredArtifacts = [expectedSetup, expectedPortable, expectedBlockmap];
|
||||||
|
const missingArtifacts = requiredArtifacts.filter((fileName) => !fs.existsSync(path.join(releaseDir, fileName)));
|
||||||
|
if (missingArtifacts.length > 0) {
|
||||||
|
throw new Error(`Missing release artifacts: ${missingArtifacts.join(", ")}`);
|
||||||
|
}
|
||||||
|
const artifactStats = new Map();
|
||||||
|
for (const fileName of requiredArtifacts) {
|
||||||
|
artifactStats.set(fileName, requireNonEmptyFile(path.join(releaseDir, fileName), "release artifact"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(latestFields.files) || latestFields.files.length !== 1) {
|
||||||
|
throw new Error("latest.yml files must contain exactly one canonical installer entry");
|
||||||
|
}
|
||||||
|
const installerEntry = latestFields.files[0];
|
||||||
|
if (!installerEntry || typeof installerEntry !== "object" || Array.isArray(installerEntry)) {
|
||||||
|
throw new Error("latest.yml files entry must be a mapping");
|
||||||
|
}
|
||||||
|
assertEqual(installerEntry.url, expectedSetup, "latest.yml files url");
|
||||||
|
const expectedSetupSize = artifactStats.get(expectedSetup).size;
|
||||||
|
assertEqual(installerEntry.size, expectedSetupSize, "latest.yml files size");
|
||||||
|
const expectedSetupDigest = sha512Base64(path.join(releaseDir, expectedSetup));
|
||||||
|
assertEqual(installerEntry.sha512, expectedSetupDigest, "latest.yml files SHA512 digest");
|
||||||
|
assertEqual(latestFields.sha512, expectedSetupDigest, "latest.yml SHA512 digest");
|
||||||
|
|
||||||
verifyRedistributionFiles(absoluteRoot, "sourcePath", "source");
|
verifyRedistributionFiles(absoluteRoot, "sourcePath", "source");
|
||||||
verifyRedistributionFiles(path.join(releaseDir, "win-unpacked"), "packagedPath", "win-unpacked");
|
verifyRedistributionFiles(path.join(releaseDir, "win-unpacked"), "packagedPath", "win-unpacked");
|
||||||
|
verifyPackagedIcon(absoluteRoot, path.join(releaseDir, "win-unpacked"), "win-unpacked");
|
||||||
return {
|
|
||||||
publish: {
|
return {
|
||||||
provider: publish.provider,
|
publish: {
|
||||||
owner: publish.owner,
|
provider: publish.provider,
|
||||||
repo: publish.repo
|
owner: publish.owner,
|
||||||
},
|
repo: publish.repo
|
||||||
latestArtifact: latestFields.path,
|
},
|
||||||
missingArtifacts
|
latestArtifact: latestFields.path,
|
||||||
};
|
missingArtifacts
|
||||||
}
|
};
|
||||||
|
}
|
||||||
export function verifyReleaseArchives(rootDir = process.cwd(), options = {}) {
|
|
||||||
const absoluteRoot = path.resolve(rootDir);
|
export function verifyReleaseArchives(rootDir = process.cwd(), options = {}) {
|
||||||
const { version } = readPackageVersion(absoluteRoot);
|
const absoluteRoot = path.resolve(rootDir);
|
||||||
const releaseDir = resolveReleaseDir(absoluteRoot);
|
const { version } = readPackageVersion(absoluteRoot);
|
||||||
const sevenZipPath = String(options.sevenZipPath || process.env.SEVEN_ZIP_PATH || "7z").trim();
|
const releaseDir = resolveReleaseDir(absoluteRoot);
|
||||||
const commandRunner = options.runCommand || runCommand;
|
const sevenZipPath = String(options.sevenZipPath || process.env.SEVEN_ZIP_PATH || "7z").trim();
|
||||||
const archiveNames = [
|
const commandRunner = options.runCommand || runCommand;
|
||||||
`${EXPECTED_PRODUCT_NAME}-Setup-${version}.exe`,
|
const archiveNames = [
|
||||||
`${EXPECTED_PRODUCT_NAME}-${version}-portable.exe`
|
`${EXPECTED_PRODUCT_NAME}-Setup-${version}.exe`,
|
||||||
];
|
`${EXPECTED_PRODUCT_NAME}-${version}-portable.exe`
|
||||||
const verifiedArchives = [];
|
];
|
||||||
|
const verifiedArchives = [];
|
||||||
for (const archiveName of archiveNames) {
|
|
||||||
const archivePath = path.join(releaseDir, archiveName);
|
for (const archiveName of archiveNames) {
|
||||||
requireNonEmptyFile(archivePath, "release archive");
|
const archivePath = path.join(releaseDir, archiveName);
|
||||||
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-archive-"));
|
requireNonEmptyFile(archivePath, "release archive");
|
||||||
try {
|
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-archive-"));
|
||||||
extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner);
|
try {
|
||||||
verifyArchiveRedistributionFiles(extractionRoot, archiveName);
|
extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner);
|
||||||
verifiedArchives.push(archiveName);
|
verifyArchiveRedistributionFiles(extractionRoot, archiveName, absoluteRoot);
|
||||||
} finally {
|
verifiedArchives.push(archiveName);
|
||||||
fs.rmSync(extractionRoot, { recursive: true, force: true });
|
} finally {
|
||||||
}
|
fs.rmSync(extractionRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return { verifiedArchives };
|
|
||||||
}
|
return { verifiedArchives };
|
||||||
|
}
|
||||||
function parseCliArgs(argv) {
|
|
||||||
let rootDir = "";
|
function parseCliArgs(argv) {
|
||||||
let verifyArchives = false;
|
let rootDir = "";
|
||||||
let sevenZipPath = "";
|
let verifyArchives = false;
|
||||||
for (let index = 0; index < argv.length; index += 1) {
|
let sevenZipPath = "";
|
||||||
const arg = argv[index];
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
if (arg === "--verify-archives") {
|
const arg = argv[index];
|
||||||
verifyArchives = true;
|
if (arg === "--verify-archives") {
|
||||||
} else if (arg === "--seven-zip") {
|
verifyArchives = true;
|
||||||
index += 1;
|
} else if (arg === "--seven-zip") {
|
||||||
sevenZipPath = String(argv[index] || "").trim();
|
index += 1;
|
||||||
if (!sevenZipPath) {
|
sevenZipPath = String(argv[index] || "").trim();
|
||||||
throw new Error("--seven-zip requires an executable path");
|
if (!sevenZipPath) {
|
||||||
}
|
throw new Error("--seven-zip requires an executable path");
|
||||||
} else if (arg.startsWith("--")) {
|
}
|
||||||
throw new Error(`Unknown argument: ${arg}`);
|
} else if (arg.startsWith("--")) {
|
||||||
} else if (!rootDir) {
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
rootDir = arg;
|
} else if (!rootDir) {
|
||||||
} else {
|
rootDir = arg;
|
||||||
throw new Error(`Unexpected argument: ${arg}`);
|
} else {
|
||||||
}
|
throw new Error(`Unexpected argument: ${arg}`);
|
||||||
}
|
}
|
||||||
if (sevenZipPath && !verifyArchives) {
|
}
|
||||||
throw new Error("--seven-zip requires --verify-archives");
|
if (sevenZipPath && !verifyArchives) {
|
||||||
}
|
throw new Error("--seven-zip requires --verify-archives");
|
||||||
return {
|
}
|
||||||
rootDir: rootDir || process.cwd(),
|
return {
|
||||||
verifyArchives,
|
rootDir: rootDir || process.cwd(),
|
||||||
sevenZipPath: sevenZipPath || process.env.SEVEN_ZIP_PATH || "7z"
|
verifyArchives,
|
||||||
};
|
sevenZipPath: sevenZipPath || process.env.SEVEN_ZIP_PATH || "7z"
|
||||||
}
|
};
|
||||||
|
}
|
||||||
const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
||||||
if (isCli) {
|
const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||||
try {
|
if (isCli) {
|
||||||
const args = parseCliArgs(process.argv.slice(2));
|
try {
|
||||||
const result = verifyPublicRelease(args.rootDir);
|
const args = parseCliArgs(process.argv.slice(2));
|
||||||
const archiveResult = args.verifyArchives
|
const result = verifyPublicRelease(args.rootDir);
|
||||||
? verifyReleaseArchives(args.rootDir, { sevenZipPath: args.sevenZipPath })
|
const archiveResult = args.verifyArchives
|
||||||
: { verifiedArchives: [] };
|
? verifyReleaseArchives(args.rootDir, { sevenZipPath: args.sevenZipPath })
|
||||||
process.stdout.write(`${JSON.stringify({ ...result, ...archiveResult })}\n`);
|
: { verifiedArchives: [] };
|
||||||
} catch (error) {
|
process.stdout.write(`${JSON.stringify({ ...result, ...archiveResult })}\n`);
|
||||||
process.stderr.write(`${String(error?.message || error)}\n`);
|
} catch (error) {
|
||||||
process.exitCode = 1;
|
process.stderr.write(`${String(error?.message || error)}\n`);
|
||||||
}
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function resolveAppIconPath(isPackaged: boolean, appPath: string, resourcesPath: string): string {
|
||||||
|
const basePath = isPackaged ? resourcesPath : appPath;
|
||||||
|
return path.join(basePath, "assets", "app_icon.ico");
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ export const ONLINE_BACKUP_API_URL = "https://downloader.24-music.de/backup-api"
|
|||||||
export function defaultSettings(): AppSettings {
|
export function defaultSettings(): AppSettings {
|
||||||
const baseDir = path.join(os.homedir(), "Desktop", "Multi-Debrid-Downloader");
|
const baseDir = path.join(os.homedir(), "Desktop", "Multi-Debrid-Downloader");
|
||||||
return {
|
return {
|
||||||
|
language: "en",
|
||||||
token: "",
|
token: "",
|
||||||
realDebridUseWebLogin: false,
|
realDebridUseWebLogin: false,
|
||||||
megaLogin: "",
|
megaLogin: "",
|
||||||
@@ -120,7 +121,8 @@ export function defaultSettings(): AppSettings {
|
|||||||
totalCompletedFilesAllTime: 0,
|
totalCompletedFilesAllTime: 0,
|
||||||
totalRuntimeAllTimeMs: 0,
|
totalRuntimeAllTimeMs: 0,
|
||||||
bandwidthSchedules: [],
|
bandwidthSchedules: [],
|
||||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"],
|
||||||
|
columnOrderVersion: 3,
|
||||||
extractCpuPriority: "high",
|
extractCpuPriority: "high",
|
||||||
autoExtractWhenStopped: true,
|
autoExtractWhenStopped: true,
|
||||||
disabledProviders: [],
|
disabledProviders: [],
|
||||||
|
|||||||
+17
-47
@@ -1697,13 +1697,23 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
|
|||||||
export interface RapidgatorCheckResult {
|
export interface RapidgatorCheckResult {
|
||||||
online: boolean;
|
online: boolean;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
fileSize: string | null;
|
fileSizeBytes: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RG_FILE_ID_RE = /\/file\/([a-z0-9]{32}|\d+)/i;
|
const RG_FILE_ID_RE = /\/file\/([a-z0-9]{32}|\d+)/i;
|
||||||
const RG_FILE_NOT_FOUND_RE = />\s*404\s*File not found/i;
|
const RG_FILE_NOT_FOUND_RE = />\s*404\s*File not found/i;
|
||||||
const RG_FILESIZE_RE = /File\s*size:\s*<strong>([^<>"]+)<\/strong>/i;
|
const RG_FILESIZE_RE = /File\s*size:\s*<strong>([^<>"]+)<\/strong>/i;
|
||||||
|
|
||||||
|
export function parseRapidgatorFileSize(value: string | null | undefined): number | null {
|
||||||
|
const match = String(value ?? "").trim().match(/^([\d.,]+)\s*(B|KB|KIB|MB|MIB|GB|GIB|TB|TIB)$/i);
|
||||||
|
if (!match) return null;
|
||||||
|
const amount = Number(match[1].replace(/,/g, "."));
|
||||||
|
if (!Number.isFinite(amount) || amount < 0) return null;
|
||||||
|
const powers: Record<string, number> = { B: 0, KB: 1, KIB: 1, MB: 2, MIB: 2, GB: 3, GIB: 3, TB: 4, TIB: 4 };
|
||||||
|
const bytes = Math.round(amount * (1024 ** powers[match[2].toUpperCase()]));
|
||||||
|
return Number.isSafeInteger(bytes) ? bytes : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkRapidgatorOnline(
|
export async function checkRapidgatorOnline(
|
||||||
link: string,
|
link: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
@@ -1723,47 +1733,7 @@ export async function checkRapidgatorOnline(
|
|||||||
"Accept-Language": "en-US,en;q=0.9,de;q=0.8"
|
"Accept-Language": "en-US,en;q=0.9,de;q=0.8"
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
|
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
|
||||||
try {
|
|
||||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
|
||||||
|
|
||||||
const response = await fetch(link, {
|
|
||||||
method: "HEAD",
|
|
||||||
redirect: "follow",
|
|
||||||
headers,
|
|
||||||
signal: withTimeoutSignal(signal, 15000)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 404) {
|
|
||||||
return { online: false, fileName: "", fileSize: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const finalUrl = response.url || link;
|
|
||||||
if (!finalUrl.includes(fileId)) {
|
|
||||||
return { online: false, fileName: "", fileSize: null };
|
|
||||||
}
|
|
||||||
const fileName = filenameFromRapidgatorUrlPath(link);
|
|
||||||
return { online: true, fileName, fileSize: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
|
|
||||||
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
} catch (error) {
|
|
||||||
const errorText = compactErrorText(error);
|
|
||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
|
|
||||||
if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await sleepWithSignal(retryDelay(attempt), signal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
|
|
||||||
try {
|
try {
|
||||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
if (signal?.aborted) throw new Error("aborted:debrid");
|
||||||
|
|
||||||
@@ -1776,7 +1746,7 @@ export async function checkRapidgatorOnline(
|
|||||||
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
try { await response.body?.cancel(); } catch { }
|
try { await response.body?.cancel(); } catch { }
|
||||||
return { online: false, fileName: "", fileSize: null };
|
return { online: false, fileName: "", fileSizeBytes: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1791,20 +1761,20 @@ export async function checkRapidgatorOnline(
|
|||||||
const finalUrl = response.url || link;
|
const finalUrl = response.url || link;
|
||||||
if (!finalUrl.includes(fileId)) {
|
if (!finalUrl.includes(fileId)) {
|
||||||
try { await response.body?.cancel(); } catch { }
|
try { await response.body?.cancel(); } catch { }
|
||||||
return { online: false, fileName: "", fileSize: null };
|
return { online: false, fileName: "", fileSizeBytes: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal);
|
const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal);
|
||||||
|
|
||||||
if (RG_FILE_NOT_FOUND_RE.test(html)) {
|
if (RG_FILE_NOT_FOUND_RE.test(html)) {
|
||||||
return { online: false, fileName: "", fileSize: null };
|
return { online: false, fileName: "", fileSizeBytes: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = extractRapidgatorFilenameFromHtml(html) || filenameFromRapidgatorUrlPath(link);
|
const fileName = extractRapidgatorFilenameFromHtml(html) || filenameFromRapidgatorUrlPath(link);
|
||||||
const sizeMatch = html.match(RG_FILESIZE_RE);
|
const sizeMatch = html.match(RG_FILESIZE_RE);
|
||||||
const fileSize = sizeMatch ? sizeMatch[1].trim() : null;
|
const fileSizeBytes = parseRapidgatorFileSize(sizeMatch?.[1]);
|
||||||
|
|
||||||
return { online: true, fileName, fileSize };
|
return { online: true, fileName, fileSizeBytes };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorText = compactErrorText(error);
|
const errorText = compactErrorText(error);
|
||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
|
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const DEV_SERVER_PORT = process.env.DEV_SERVER_PORT || "5180";
|
||||||
|
export const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
||||||
@@ -605,7 +605,7 @@ export function getAuthoritativeRealDebridTotal(
|
|||||||
if (!resumeHardResetUsed || source !== "content-length") {
|
if (!resumeHardResetUsed || source !== "content-length") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1696,14 +1696,27 @@ function formatExtractFailureLabel(reason: string, archiveName = ""): string {
|
|||||||
: `Entpack-Fehler: ${summary}`;
|
: `Entpack-Fehler: ${summary}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryDelayWithJitter(attempt: number, baseMs: number): number {
|
function retryDelayWithJitter(attempt: number, baseMs: number): number {
|
||||||
const exponential = baseMs * Math.pow(1.5, Math.min(attempt - 1, 14));
|
const exponential = baseMs * Math.pow(1.5, Math.min(attempt - 1, 14));
|
||||||
const capped = Math.min(exponential, 120000);
|
const capped = Math.min(exponential, 120000);
|
||||||
const jitter = capped * (0.5 + Math.random() * 0.5);
|
const jitter = capped * (0.5 + Math.random() * 0.5);
|
||||||
return Math.floor(jitter);
|
return Math.floor(jitter);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DownloadManager extends EventEmitter {
|
export async function runWithLimitedConcurrency<T>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
|
||||||
|
const workerCount = Math.min(items.length, Math.max(1, Math.floor(concurrency)));
|
||||||
|
let nextIndex = 0;
|
||||||
|
const workers = Array.from({ length: workerCount }, async () => {
|
||||||
|
while (nextIndex < items.length) {
|
||||||
|
const item = items[nextIndex];
|
||||||
|
nextIndex += 1;
|
||||||
|
await worker(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DownloadManager extends EventEmitter {
|
||||||
private settings: AppSettings;
|
private settings: AppSettings;
|
||||||
|
|
||||||
private session: SessionState;
|
private session: SessionState;
|
||||||
@@ -3278,31 +3291,28 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState();
|
this.emitState();
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkedUrls = new Map<string, Awaited<ReturnType<typeof checkRapidgatorOnline>>>();
|
const checkedUrls = new Map<string, ReturnType<typeof checkRapidgatorOnline>>();
|
||||||
|
|
||||||
for (const { itemId, url } of itemsToCheck) {
|
await runWithLimitedConcurrency(itemsToCheck, 8, async ({ itemId, url }) => {
|
||||||
const item = this.session.items[itemId];
|
const item = this.session.items[itemId];
|
||||||
if (!item) continue;
|
if (!item) return;
|
||||||
|
|
||||||
if (checkedUrls.has(url)) {
|
let check = checkedUrls.get(url);
|
||||||
const cached = checkedUrls.get(url);
|
if (!check) {
|
||||||
if (cached !== undefined) {
|
check = checkRapidgatorOnline(url);
|
||||||
this.applyRapidgatorCheckResult(item, cached);
|
checkedUrls.set(url, check);
|
||||||
}
|
}
|
||||||
this.emitState();
|
|
||||||
continue;
|
try {
|
||||||
}
|
const result = await check;
|
||||||
|
this.applyRapidgatorCheckResult(item, result);
|
||||||
try {
|
} catch (err) {
|
||||||
const result = await checkRapidgatorOnline(url);
|
logger.warn(`checkRapidgatorOnline Fehler für ${url}: ${compactErrorText(err)}`);
|
||||||
checkedUrls.set(url, result);
|
item.onlineStatus = undefined;
|
||||||
this.applyRapidgatorCheckResult(item, result);
|
}
|
||||||
} catch (err) {
|
this.persistSoon();
|
||||||
logger.warn(`checkRapidgatorOnline Fehler für ${url}: ${compactErrorText(err)}`);
|
this.emitState();
|
||||||
item.onlineStatus = undefined;
|
});
|
||||||
}
|
|
||||||
this.emitState();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
}
|
}
|
||||||
@@ -3354,8 +3364,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (result.fileName && looksLikeOpaqueFilename(item.fileName)) {
|
if (result.fileName && looksLikeOpaqueFilename(item.fileName)) {
|
||||||
item.fileName = sanitizeFilename(result.fileName);
|
item.fileName = sanitizeFilename(result.fileName);
|
||||||
this.assignItemTargetPath(item, path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, item.fileName));
|
this.assignItemTargetPath(item, path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, item.fileName));
|
||||||
}
|
}
|
||||||
item.onlineStatus = "online";
|
if (result.fileSizeBytes !== null && result.fileSizeBytes > 0) {
|
||||||
|
item.totalBytes = result.fileSizeBytes;
|
||||||
|
}
|
||||||
|
item.onlineStatus = "online";
|
||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3364,7 +3377,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const uncheckedIds: string[] = [];
|
const uncheckedIds: string[] = [];
|
||||||
for (const item of Object.values(this.session.items)) {
|
for (const item of Object.values(this.session.items)) {
|
||||||
if (item.status !== "queued") continue;
|
if (item.status !== "queued") continue;
|
||||||
if (item.onlineStatus) continue;
|
if (item.onlineStatus === "offline") continue;
|
||||||
|
if (item.onlineStatus === "online" && item.totalBytes !== null && item.totalBytes > 0) continue;
|
||||||
try {
|
try {
|
||||||
const host = new URL(item.url).hostname.toLowerCase();
|
const host = new URL(item.url).hostname.toLowerCase();
|
||||||
if (host !== "rapidgator.net" && !host.endsWith(".rapidgator.net") && host !== "rg.to" && !host.endsWith(".rg.to")) continue;
|
if (host !== "rapidgator.net" && !host.endsWith(".rapidgator.net") && host !== "rg.to" && !host.endsWith(".rg.to")) continue;
|
||||||
|
|||||||
+5
-3
@@ -11,6 +11,8 @@ import { APP_NAME } from "./constants";
|
|||||||
import { extractHttpLinksFromText } from "./utils";
|
import { extractHttpLinksFromText } from "./utils";
|
||||||
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
|
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
|
||||||
import { revealHistoryEntry } from "./history-reveal";
|
import { revealHistoryEntry } from "./history-reveal";
|
||||||
|
import { DEV_SERVER_URL } from "./dev-server-url";
|
||||||
|
import { resolveAppIconPath } from "./app-icon";
|
||||||
|
|
||||||
function validateString(value: unknown, name: string): string {
|
function validateString(value: unknown, name: string): string {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@@ -115,7 +117,7 @@ function createWindow(): BrowserWindow {
|
|||||||
minHeight: 760,
|
minHeight: 760,
|
||||||
backgroundColor: "#070b14",
|
backgroundColor: "#070b14",
|
||||||
title: `${APP_NAME} - v${controller.getVersion()}`,
|
title: `${APP_NAME} - v${controller.getVersion()}`,
|
||||||
icon: path.join(app.getAppPath(), "assets", "app_icon.ico"),
|
icon: resolveAppIconPath(app.isPackaged, app.getAppPath(), process.resourcesPath),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
@@ -140,7 +142,7 @@ function createWindow(): BrowserWindow {
|
|||||||
window.setAutoHideMenuBar(true);
|
window.setAutoHideMenuBar(true);
|
||||||
|
|
||||||
if (isDevMode()) {
|
if (isDevMode()) {
|
||||||
void window.loadURL("http://localhost:5173");
|
void window.loadURL(DEV_SERVER_URL);
|
||||||
} else {
|
} else {
|
||||||
void window.loadFile(path.join(app.getAppPath(), "build", "renderer", "index.html"));
|
void window.loadFile(path.join(app.getAppPath(), "build", "renderer", "index.html"));
|
||||||
}
|
}
|
||||||
@@ -212,7 +214,7 @@ function createTray(): void {
|
|||||||
if (tray) {
|
if (tray) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico");
|
const iconPath = resolveAppIconPath(app.isPackaged, app.getAppPath(), process.resourcesPath);
|
||||||
try {
|
try {
|
||||||
tray = new Tray(iconPath);
|
tray = new Tray(iconPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+22
-13
@@ -135,10 +135,10 @@ function migrateLegacyDefaultDirectories(settings: AppSettings, defaults: AppSet
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"];
|
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"];
|
||||||
const ALL_VALID_COLUMNS = new Set([...DEFAULT_COLUMN_ORDER, "added"]);
|
const ALL_VALID_COLUMNS = new Set([...DEFAULT_COLUMN_ORDER, "added"]);
|
||||||
|
|
||||||
function normalizeColumnOrder(raw: unknown): string[] {
|
function normalizeColumnOrder(raw: unknown, version: unknown): string[] {
|
||||||
if (!Array.isArray(raw) || raw.length === 0) {
|
if (!Array.isArray(raw) || raw.length === 0) {
|
||||||
return [...DEFAULT_COLUMN_ORDER];
|
return [...DEFAULT_COLUMN_ORDER];
|
||||||
}
|
}
|
||||||
@@ -151,10 +151,14 @@ function normalizeColumnOrder(raw: unknown): string[] {
|
|||||||
result.push(col);
|
result.push(col);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!seen.has("name")) {
|
if (!seen.has("name")) {
|
||||||
result.unshift("name");
|
result.unshift("name");
|
||||||
}
|
}
|
||||||
return result;
|
if (version !== 3 && !seen.has("availability")) {
|
||||||
|
const speedIndex = result.indexOf("speed");
|
||||||
|
result.splice(speedIndex >= 0 ? speedIndex + 1 : result.length, 0, "availability");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPreferredMegaDebridProvider(megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): DebridProvider {
|
function getPreferredMegaDebridProvider(megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): DebridProvider {
|
||||||
@@ -421,6 +425,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
);
|
);
|
||||||
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
|
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
|
||||||
const normalized: AppSettings = {
|
const normalized: AppSettings = {
|
||||||
|
language: settings.language === "de" ? "de" : "en",
|
||||||
token: asText(settings.token),
|
token: asText(settings.token),
|
||||||
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
|
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
|
||||||
megaLogin,
|
megaLogin,
|
||||||
@@ -506,7 +511,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
|
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
|
||||||
theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
|
theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
|
||||||
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules),
|
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules),
|
||||||
columnOrder: normalizeColumnOrder(settings.columnOrder),
|
columnOrder: normalizeColumnOrder(settings.columnOrder, settings.columnOrderVersion),
|
||||||
|
columnOrderVersion: 3,
|
||||||
extractCpuPriority: settings.extractCpuPriority,
|
extractCpuPriority: settings.extractCpuPriority,
|
||||||
autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped,
|
autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped,
|
||||||
disabledProviders: normalizeDisabledProviders(settings.disabledProviders),
|
disabledProviders: normalizeDisabledProviders(settings.disabledProviders),
|
||||||
@@ -677,10 +683,13 @@ function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
|
|||||||
function readSettingsFile(filePath: string): AppSettings | null {
|
function readSettingsFile(filePath: string): AppSettings | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings;
|
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings;
|
||||||
const merged = normalizeSettings({
|
const migratedLanguage = (parsed as Partial<AppSettings>).language === undefined ? "de" : parsed.language;
|
||||||
...defaultSettings(),
|
const merged = normalizeSettings({
|
||||||
...migrateLegacyMegaEnableFlags(parsed)
|
...defaultSettings(),
|
||||||
});
|
...migrateLegacyMegaEnableFlags(parsed),
|
||||||
|
language: migratedLanguage,
|
||||||
|
columnOrderVersion: parsed.columnOrderVersion
|
||||||
|
});
|
||||||
return sanitizeCredentialPersistence(merged);
|
return sanitizeCredentialPersistence(merged);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = (error as NodeJS.ErrnoException)?.code || "";
|
const code = (error as NodeJS.ErrnoException)?.code || "";
|
||||||
|
|||||||
+131
-68
@@ -1,4 +1,5 @@
|
|||||||
import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||||
import { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
|
||||||
import type {
|
import type {
|
||||||
@@ -36,13 +37,14 @@ import {
|
|||||||
getProviderUsageDayKey
|
getProviderUsageDayKey
|
||||||
} from "../shared/provider-daily-limits";
|
} from "../shared/provider-daily-limits";
|
||||||
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
||||||
import { pruneSelection } from "./selection";
|
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
|
||||||
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
|
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
|
||||||
import type { AccountModeFilter } from "./account-ui";
|
import type { AccountModeFilter } from "./account-ui";
|
||||||
import { applyAccountEdit, buildAccountEditCheckSettings, createAccountEditState, getAccountEditExpectedStatusId, removeAccountTarget, validateAccountEdit, validateAccountEditStatuses } from "./account-edit";
|
import { applyAccountEdit, buildAccountEditCheckSettings, createAccountEditState, getAccountEditExpectedStatusId, removeAccountTarget, validateAccountEdit, validateAccountEditStatuses } from "./account-edit";
|
||||||
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
||||||
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
||||||
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
|
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
|
||||||
|
import { createUiLocalizer, normalizeLanguage } from "./i18n";
|
||||||
import type { DownloadSpeedHistoryState } from "./download-speed-state";
|
import type { DownloadSpeedHistoryState } from "./download-speed-state";
|
||||||
import { extractHoster, formatDateTime, formatSpeedMbps, humanSize, providerLabels } from "./download-format";
|
import { extractHoster, formatDateTime, formatSpeedMbps, humanSize, providerLabels } from "./download-format";
|
||||||
import { AppShell } from "./shell/AppShell";
|
import { AppShell } from "./shell/AppShell";
|
||||||
@@ -88,8 +90,9 @@ import {
|
|||||||
StatisticsSidebarStatus,
|
StatisticsSidebarStatus,
|
||||||
type StatisticsViewActions
|
type StatisticsViewActions
|
||||||
} from "./views/statistics/StatisticsView";
|
} from "./views/statistics/StatisticsView";
|
||||||
import { buildDownloadsViewModel, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
import { buildDownloadsViewModel, getDownloadQueueTotalBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
||||||
import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
|
import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
|
||||||
|
import { beginDownloadColumnDrag, clearDownloadColumnDrag, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession } from "./views/downloads/column-drag";
|
||||||
import {
|
import {
|
||||||
DownloadsContent,
|
DownloadsContent,
|
||||||
DownloadsFooter,
|
DownloadsFooter,
|
||||||
@@ -1024,8 +1027,8 @@ const emptyStats = (): DownloadStats => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const emptySnapshot = (): UiSnapshot => ({
|
const emptySnapshot = (): UiSnapshot => ({
|
||||||
settings: {
|
settings: {
|
||||||
token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "",
|
language: "en", token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "",
|
||||||
debridLinkDisabledKeyIds: [],
|
debridLinkDisabledKeyIds: [],
|
||||||
archivePasswordList: "",
|
archivePasswordList: "",
|
||||||
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
|
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
|
||||||
@@ -1041,7 +1044,8 @@ const emptySnapshot = (): UiSnapshot => ({
|
|||||||
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
|
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
|
||||||
accountListShowDetailedDebridLinkKeys: false,
|
accountListShowDetailedDebridLinkKeys: false,
|
||||||
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
||||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"],
|
||||||
|
columnOrderVersion: 3,
|
||||||
autoExtractWhenStopped: true,
|
autoExtractWhenStopped: true,
|
||||||
disabledProviders: [],
|
disabledProviders: [],
|
||||||
hosterRouting: {},
|
hosterRouting: {},
|
||||||
@@ -1647,8 +1651,8 @@ function computePackageProgress(pkg: PackageEntry | undefined, items: Record<str
|
|||||||
return totalSize > 0 ? totalDown / totalSize : 0;
|
return totalSize > 0 ? totalDown / totalSize : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"];
|
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"];
|
||||||
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "added"];
|
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability", "added"];
|
||||||
const COLUMN_DEFS = downloadColumnDefinitions;
|
const COLUMN_DEFS = downloadColumnDefinitions;
|
||||||
|
|
||||||
function sameStringArray(a: string[], b: string[]): boolean {
|
function sameStringArray(a: string[], b: string[]): boolean {
|
||||||
@@ -1742,7 +1746,12 @@ export function App(): ReactElement {
|
|||||||
const [runtimeNow, setRuntimeNow] = useState(() => Date.now());
|
const [runtimeNow, setRuntimeNow] = useState(() => Date.now());
|
||||||
const updateCheckGenerationRef = useRef(0);
|
const updateCheckGenerationRef = useRef(0);
|
||||||
const settingsDirtyRef = useRef(false);
|
const settingsDirtyRef = useRef(false);
|
||||||
const settingsDraftRevisionRef = useRef(0);
|
const settingsDraftRevisionRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const localizer = createUiLocalizer(document, normalizeLanguage(settingsDraft.language));
|
||||||
|
return () => localizer.disconnect();
|
||||||
|
}, [settingsDraft.language]);
|
||||||
const panelDirtyRevisionRef = useRef(0);
|
const panelDirtyRevisionRef = useRef(0);
|
||||||
const latestStateRef = useRef<UiSnapshot | null>(null);
|
const latestStateRef = useRef<UiSnapshot | null>(null);
|
||||||
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
||||||
@@ -1829,11 +1838,16 @@ export function App(): ReactElement {
|
|||||||
const [debridLinkHostLimitsLoading, setDebridLinkHostLimitsLoading] = useState(false);
|
const [debridLinkHostLimitsLoading, setDebridLinkHostLimitsLoading] = useState(false);
|
||||||
const [debridLinkHostLimitsError, setDebridLinkHostLimitsError] = useState("");
|
const [debridLinkHostLimitsError, setDebridLinkHostLimitsError] = useState("");
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set<string>; dontAsk: boolean } | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set<string>; dontAsk: boolean } | null>(null);
|
||||||
const [columnOrder, setColumnOrder] = useState<string[]>(() => DEFAULT_COLUMN_ORDER);
|
const [columnOrder, setColumnOrder] = useState<string[]>(() => DEFAULT_COLUMN_ORDER);
|
||||||
const [dragColId, setDragColId] = useState<string | null>(null);
|
const columnDragSessionRef = useRef<DownloadColumnDragSession | null>(null);
|
||||||
const [dropTargetCol, setDropTargetCol] = useState<string | null>(null);
|
const columnDragSettleTimerRef = useRef<number | null>(null);
|
||||||
const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null);
|
const suppressColumnSortRef = useRef(false);
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (columnDragSettleTimerRef.current !== null) window.clearTimeout(columnDragSettleTimerRef.current);
|
||||||
|
if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current);
|
||||||
|
}, []);
|
||||||
|
const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null);
|
||||||
const colHeaderCtxRef = useRef<HTMLDivElement>(null);
|
const colHeaderCtxRef = useRef<HTMLDivElement>(null);
|
||||||
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
||||||
const historyEntriesRef = useRef<HistoryEntry[]>([]);
|
const historyEntriesRef = useRef<HistoryEntry[]>([]);
|
||||||
@@ -4488,9 +4502,10 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: globalThis.KeyboardEvent): void => {
|
const onKey = (e: globalThis.KeyboardEvent): void => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
|
const inputType = target.tagName === "INPUT" ? (target as HTMLInputElement).type : "";
|
||||||
|
if (shouldClearDownloadSelectionOnEscape(target.tagName, inputType)) {
|
||||||
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
|
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
|
||||||
if (tabRef.current === "downloads") setSelectedIds(new Set());
|
if (tabRef.current === "downloads") setSelectedIds(new Set());
|
||||||
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
|
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
|
||||||
@@ -4503,11 +4518,11 @@ export function App(): ReactElement {
|
|||||||
requestDeleteSelection();
|
requestDeleteSelection();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onDown = (e: MouseEvent): void => {
|
const onDown = (e: MouseEvent): void => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest(".package-card") || target.closest(".ctx-menu") || target.closest(".modal-backdrop") || target.closest(".modal-card")) return;
|
if (!shouldClearDownloadSelection(target)) return;
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
window.addEventListener("mousedown", onDown);
|
window.addEventListener("mousedown", onDown);
|
||||||
return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("mousedown", onDown); };
|
return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("mousedown", onDown); };
|
||||||
@@ -4896,6 +4911,34 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
}, [askConfirmPrompt, performQuickAction]);
|
}, [askConfirmPrompt, performQuickAction]);
|
||||||
|
|
||||||
|
const setClipboardWatcherActive = useCallback((active: boolean): void => {
|
||||||
|
const update = (current: UiSnapshot): UiSnapshot => ({
|
||||||
|
...current,
|
||||||
|
clipboardActive: active,
|
||||||
|
settings: { ...current.settings, clipboardWatch: active }
|
||||||
|
});
|
||||||
|
const next = update(snapshotRef.current);
|
||||||
|
snapshotRef.current = next;
|
||||||
|
masterSnapshotRef.current = masterSnapshotRef.current ? update(masterSnapshotRef.current) : next;
|
||||||
|
if (latestStateRef.current) {
|
||||||
|
latestStateRef.current = update(latestStateRef.current);
|
||||||
|
}
|
||||||
|
setSnapshot(next);
|
||||||
|
setSettingsDraft((current) => ({ ...current, clipboardWatch: active }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleClipboardWatcher = useCallback((): void => {
|
||||||
|
const previous = snapshotRef.current.clipboardActive;
|
||||||
|
const next = !previous;
|
||||||
|
setClipboardWatcherActive(next);
|
||||||
|
void window.rd.toggleClipboard()
|
||||||
|
.then(setClipboardWatcherActive)
|
||||||
|
.catch((error) => {
|
||||||
|
setClipboardWatcherActive(previous);
|
||||||
|
showToast(`Zwischenablage konnte nicht umgeschaltet werden: ${String(error)}`, 2600);
|
||||||
|
});
|
||||||
|
}, [setClipboardWatcherActive, showToast]);
|
||||||
|
|
||||||
const activateDownloadSchedule = useCallback((): void => {
|
const activateDownloadSchedule = useCallback((): void => {
|
||||||
if (!scheduleTimeInput) return;
|
if (!scheduleTimeInput) return;
|
||||||
const [hours, minutes] = scheduleTimeInput.split(":").map(Number);
|
const [hours, minutes] = scheduleTimeInput.split(":").map(Number);
|
||||||
@@ -4918,6 +4961,7 @@ export function App(): ReactElement {
|
|||||||
}, [downloadsViewCore.actionableSelectedIds, executeDeleteSelection, settingsDraft.confirmDeleteSelection]);
|
}, [downloadsViewCore.actionableSelectedIds, executeDeleteSelection, settingsDraft.confirmDeleteSelection]);
|
||||||
|
|
||||||
const downloadPackageSpeeds = useMemo(() => Object.fromEntries(packageSpeedMap), [packageSpeedMap]);
|
const downloadPackageSpeeds = useMemo(() => Object.fromEntries(packageSpeedMap), [packageSpeedMap]);
|
||||||
|
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
|
||||||
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
|
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
|
||||||
...downloadsViewCore,
|
...downloadsViewCore,
|
||||||
running: snapshot.session.running,
|
running: snapshot.session.running,
|
||||||
@@ -4944,12 +4988,14 @@ export function App(): ReactElement {
|
|||||||
packages: snapshot.stats.totalPackages,
|
packages: snapshot.stats.totalPackages,
|
||||||
links: Object.keys(snapshot.session.items).length,
|
links: Object.keys(snapshot.session.items).length,
|
||||||
session: humanSize(snapshot.stats.totalDownloaded),
|
session: humanSize(snapshot.stats.totalDownloaded),
|
||||||
total: humanSize(snapshot.stats.totalDownloadedAllTime),
|
sessionBytes: snapshot.stats.totalDownloaded,
|
||||||
|
total: humanSize(downloadQueueTotalBytes),
|
||||||
|
totalBytes: downloadQueueTotalBytes,
|
||||||
hosters: providerStats.length,
|
hosters: providerStats.length,
|
||||||
speed: snapshot.speedText,
|
speed: snapshot.speedText,
|
||||||
eta: snapshot.etaText
|
eta: snapshot.etaText
|
||||||
}
|
}
|
||||||
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.speedText, snapshot.stats.totalDownloaded, snapshot.stats.totalDownloadedAllTime, snapshot.stats.totalPackages]);
|
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.speedText, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
||||||
|
|
||||||
const downloadsActions: DownloadsViewActions = {
|
const downloadsActions: DownloadsViewActions = {
|
||||||
onDisplayModeChange: setDownloadDisplayMode,
|
onDisplayModeChange: setDownloadDisplayMode,
|
||||||
@@ -4988,7 +5034,7 @@ export function App(): ReactElement {
|
|||||||
if (entry) onPackageStartEdit(entry.id, entry.name);
|
if (entry) onPackageStartEdit(entry.id, entry.name);
|
||||||
},
|
},
|
||||||
onRemoveSelection: removeActionableDownloads,
|
onRemoveSelection: removeActionableDownloads,
|
||||||
onToggleClipboardWatcher: () => { void performQuickAction(() => window.rd.toggleClipboard()); },
|
onToggleClipboardWatcher: toggleClipboardWatcher,
|
||||||
onClearAll: clearDownloadQueue,
|
onClearAll: clearDownloadQueue,
|
||||||
onToggleAllPackages: () => {
|
onToggleAllPackages: () => {
|
||||||
const targetState = !allPackagesCollapsed;
|
const targetState = !allPackagesCollapsed;
|
||||||
@@ -5022,7 +5068,6 @@ export function App(): ReactElement {
|
|||||||
onToggleSelection: onSelectId,
|
onToggleSelection: onSelectId,
|
||||||
onSelectionMouseDown: onSelectMouseDown,
|
onSelectionMouseDown: onSelectMouseDown,
|
||||||
onSelectionMouseEnter: onSelectMouseEnter,
|
onSelectionMouseEnter: onSelectMouseEnter,
|
||||||
onTogglePackage: onPackageToggle,
|
|
||||||
onTogglePackageCollapse: onPackageToggleCollapse,
|
onTogglePackageCollapse: onPackageToggleCollapse,
|
||||||
onStartPackageRename: onPackageStartEdit,
|
onStartPackageRename: onPackageStartEdit,
|
||||||
onPackageRenameChange: setEditingName,
|
onPackageRenameChange: setEditingName,
|
||||||
@@ -5042,35 +5087,53 @@ export function App(): ReactElement {
|
|||||||
const item = snapshot.session.items[id];
|
const item = snapshot.session.items[id];
|
||||||
onPackageContextMenu(packageId ?? item?.packageId ?? id, item?.id, x, y);
|
onPackageContextMenu(packageId ?? item?.packageId ?? id, item?.id, x, y);
|
||||||
},
|
},
|
||||||
onSortColumn: sortDownloadsByColumn,
|
onSortColumn: (column) => {
|
||||||
onColumnDragStart: (column, event) => {
|
if (suppressColumnSortRef.current) return;
|
||||||
event.dataTransfer.effectAllowed = "move";
|
sortDownloadsByColumn(column);
|
||||||
setDragColId(column);
|
|
||||||
},
|
},
|
||||||
onColumnDragOver: (column, event) => {
|
onColumnPointerDown: (column, event) => {
|
||||||
if (dragColId && dragColId !== column) {
|
if (columnDragSettleTimerRef.current !== null) {
|
||||||
event.preventDefault();
|
window.clearTimeout(columnDragSettleTimerRef.current);
|
||||||
setDropTargetCol(column);
|
columnDragSettleTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current);
|
||||||
|
columnDragSessionRef.current = beginDownloadColumnDrag(event.currentTarget, column, event.pointerId, event.clientX);
|
||||||
},
|
},
|
||||||
onColumnDragLeave: () => setDropTargetCol(null),
|
onColumnPointerMove: (_column, event) => {
|
||||||
onColumnDrop: (column, event) => {
|
const session = columnDragSessionRef.current;
|
||||||
event.preventDefault();
|
if (!session || session.pointerId !== event.pointerId) return;
|
||||||
setDropTargetCol(null);
|
if (updateDownloadColumnDrag(session, event.clientX)) event.preventDefault();
|
||||||
if (!dragColId || dragColId === column) return;
|
|
||||||
const next = [...columnOrder];
|
|
||||||
const fromIndex = next.indexOf(dragColId);
|
|
||||||
const toIndex = next.indexOf(column);
|
|
||||||
if (fromIndex < 0 || toIndex < 0) return;
|
|
||||||
next.splice(fromIndex, 1);
|
|
||||||
next.splice(toIndex, 0, dragColId);
|
|
||||||
setColumnOrder(next);
|
|
||||||
setDragColId(null);
|
|
||||||
void window.rd.updateSettings({ columnOrder: next }).catch(() => {});
|
|
||||||
},
|
},
|
||||||
onColumnDragEnd: () => {
|
onColumnPointerUp: (_column, event) => {
|
||||||
setDragColId(null);
|
const session = columnDragSessionRef.current;
|
||||||
setDropTargetCol(null);
|
if (!session || session.pointerId !== event.pointerId) return;
|
||||||
|
const next = settleDownloadColumnDrag(session, false);
|
||||||
|
if (!next) {
|
||||||
|
columnDragSessionRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suppressColumnSortRef.current = true;
|
||||||
|
window.setTimeout(() => { suppressColumnSortRef.current = false; }, 0);
|
||||||
|
const changed = next.join("|") !== session.measurements.map((measurement) => measurement.id).join("|");
|
||||||
|
columnDragSettleTimerRef.current = window.setTimeout(() => {
|
||||||
|
if (changed) {
|
||||||
|
flushSync(() => setColumnOrder(next));
|
||||||
|
void window.rd.updateSettings({ columnOrder: next }).catch(() => {});
|
||||||
|
}
|
||||||
|
clearDownloadColumnDrag(session);
|
||||||
|
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
|
||||||
|
columnDragSettleTimerRef.current = null;
|
||||||
|
}, 220);
|
||||||
|
},
|
||||||
|
onColumnPointerCancel: (_column, event) => {
|
||||||
|
const session = columnDragSessionRef.current;
|
||||||
|
if (!session || session.pointerId !== event.pointerId) return;
|
||||||
|
settleDownloadColumnDrag(session, true);
|
||||||
|
columnDragSettleTimerRef.current = window.setTimeout(() => {
|
||||||
|
clearDownloadColumnDrag(session);
|
||||||
|
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
|
||||||
|
columnDragSettleTimerRef.current = null;
|
||||||
|
}, 220);
|
||||||
},
|
},
|
||||||
onColumnContextMenu: (_column, x, y) => setColHeaderCtx({ x, y })
|
onColumnContextMenu: (_column, x, y) => setColHeaderCtx({ x, y })
|
||||||
};
|
};
|
||||||
@@ -5616,15 +5679,16 @@ export function App(): ReactElement {
|
|||||||
/>
|
/>
|
||||||
<nav aria-label="Anwendungsmenü" className="md-application-menu-tree">
|
<nav aria-label="Anwendungsmenü" className="md-application-menu-tree">
|
||||||
<div className="menu-bar-item">
|
<div className="menu-bar-item">
|
||||||
<button
|
<button
|
||||||
className={`menu-bar-trigger${openMenu === "datei" ? " open" : ""}`}
|
aria-expanded={openMenu === "datei"}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
className={`menu-bar-trigger${openMenu === "datei" ? " open" : ""}`}
|
||||||
onClick={() => setOpenMenu(openMenu === "datei" ? null : "datei")}
|
onClick={() => setOpenMenu(openMenu === "datei" ? null : "datei")}
|
||||||
onMouseEnter={() => { if (openMenu && openMenu !== "datei") { setOpenMenu("datei"); setOpenSubmenu(null); } }}
|
onMouseEnter={() => { if (openMenu && openMenu !== "datei") { setOpenMenu("datei"); setOpenSubmenu(null); } }}
|
||||||
>
|
>
|
||||||
Datei
|
Datei
|
||||||
</button>
|
</button>
|
||||||
{openMenu === "datei" && (
|
<div aria-hidden={openMenu !== "datei"} className={`menu-dropdown${openMenu === "datei" ? " is-open" : ""}`}>
|
||||||
<div className="menu-dropdown">
|
|
||||||
<button className="menu-dropdown-item" onClick={() => { closeMenus(); setTab("collector"); }}>
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); setTab("collector"); }}>
|
||||||
<span>Text mit Links analysieren</span>
|
<span>Text mit Links analysieren</span>
|
||||||
<span className="shortcut">Strg+L</span>
|
<span className="shortcut">Strg+L</span>
|
||||||
@@ -5662,19 +5726,19 @@ export function App(): ReactElement {
|
|||||||
<span>Beenden</span>
|
<span>Beenden</span>
|
||||||
<span className="shortcut">Strg+Q</span>
|
<span className="shortcut">Strg+Q</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="menu-bar-item">
|
<div className="menu-bar-item">
|
||||||
<button
|
<button
|
||||||
className={`menu-bar-trigger${openMenu === "einstellungen" ? " open" : ""}`}
|
aria-expanded={openMenu === "einstellungen"}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
className={`menu-bar-trigger${openMenu === "einstellungen" ? " open" : ""}`}
|
||||||
onClick={() => setOpenMenu(openMenu === "einstellungen" ? null : "einstellungen")}
|
onClick={() => setOpenMenu(openMenu === "einstellungen" ? null : "einstellungen")}
|
||||||
onMouseEnter={() => { if (openMenu && openMenu !== "einstellungen") { setOpenMenu("einstellungen"); setOpenSubmenu(null); } }}
|
onMouseEnter={() => { if (openMenu && openMenu !== "einstellungen") { setOpenMenu("einstellungen"); setOpenSubmenu(null); } }}
|
||||||
>
|
>
|
||||||
Einstellungen
|
Einstellungen
|
||||||
</button>
|
</button>
|
||||||
{openMenu === "einstellungen" && (
|
<div aria-hidden={openMenu !== "einstellungen"} className={`menu-dropdown${openMenu === "einstellungen" ? " is-open" : ""}`}>
|
||||||
<div className="menu-dropdown">
|
|
||||||
<button className="menu-dropdown-item" onClick={() => { closeMenus(); setTab("settings"); }}>
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); setTab("settings"); }}>
|
||||||
<span>Einstellungen</span>
|
<span>Einstellungen</span>
|
||||||
<span className="shortcut">Strg+P</span>
|
<span className="shortcut">Strg+P</span>
|
||||||
@@ -5772,19 +5836,19 @@ export function App(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<span className="menu-speed-unit">MB/s</span>
|
<span className="menu-speed-unit">MB/s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="menu-bar-item">
|
<div className="menu-bar-item">
|
||||||
<button
|
<button
|
||||||
className={`menu-bar-trigger${openMenu === "hilfe" ? " open" : ""}`}
|
aria-expanded={openMenu === "hilfe"}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
className={`menu-bar-trigger${openMenu === "hilfe" ? " open" : ""}`}
|
||||||
onClick={() => setOpenMenu(openMenu === "hilfe" ? null : "hilfe")}
|
onClick={() => setOpenMenu(openMenu === "hilfe" ? null : "hilfe")}
|
||||||
onMouseEnter={() => { if (openMenu && openMenu !== "hilfe") { setOpenMenu("hilfe"); setOpenSubmenu(null); } }}
|
onMouseEnter={() => { if (openMenu && openMenu !== "hilfe") { setOpenMenu("hilfe"); setOpenSubmenu(null); } }}
|
||||||
>
|
>
|
||||||
Hilfe
|
Hilfe
|
||||||
</button>
|
</button>
|
||||||
{openMenu === "hilfe" && (
|
<div aria-hidden={openMenu !== "hilfe"} className={`menu-dropdown${openMenu === "hilfe" ? " is-open" : ""}`}>
|
||||||
<div className="menu-dropdown">
|
|
||||||
<div
|
<div
|
||||||
className="menu-submenu"
|
className="menu-submenu"
|
||||||
onMouseEnter={() => setOpenSubmenu("hilfe-log")}
|
onMouseEnter={() => setOpenSubmenu("hilfe-log")}
|
||||||
@@ -5836,8 +5900,7 @@ export function App(): ReactElement {
|
|||||||
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}>
|
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}>
|
||||||
<span>Suche Aktualisierungen</span>
|
<span>Suche Aktualisierungen</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="md-avatar-anchor">
|
<div className="md-avatar-anchor">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AudioStripSummary, DebridProvider } from "../shared/types";
|
import type { AudioStripSummary, DebridProvider } from "../shared/types";
|
||||||
|
import { hosterIconSources } from "./hoster-icons";
|
||||||
|
|
||||||
export const providerLabels: Record<DebridProvider, string> = {
|
export const providerLabels: Record<DebridProvider, string> = {
|
||||||
realdebrid: "Real-Debrid",
|
realdebrid: "Real-Debrid",
|
||||||
@@ -48,6 +49,13 @@ export function extractHoster(url: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatHosterLabel(hoster: string): { compact: string; title: string; iconSrc?: string } {
|
||||||
|
const normalized = hoster.trim().toLowerCase();
|
||||||
|
if (normalized === "rapidgator") return { compact: "RG", title: "RapidGator", iconSrc: hosterIconSources.rapidgator };
|
||||||
|
if (normalized === "ddownload") return { compact: "DD", title: "DDownload", iconSrc: hosterIconSources.ddownload };
|
||||||
|
return { compact: hoster, title: hoster };
|
||||||
|
}
|
||||||
|
|
||||||
export function formatAudioStripSummary(summary: AudioStripSummary): { text: string; tooltip: string; attention: boolean } {
|
export function formatAudioStripSummary(summary: AudioStripSummary): { text: string; tooltip: string; attention: boolean } {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
const ok = summary.remuxed + summary.keptSingle;
|
const ok = summary.remuxed + summary.keptSingle;
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const hosterIconSources: Readonly<Record<string, string>> = {
|
||||||
|
rapidgator: "data:image/x-icon;base64,AAABAAIAEBAAAAEAGABoAwAAJgAAACAgAAABAAgAqAgAAI4DAAAoAAAAEAAAACAAAAABABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHVV8LEFQNTU2NTU2NTU2NTU2NTU2NTU2NTU2NTU2MDtDIk9vAAAAAAAAAAAALj5KKFaKHHXZFoP9FoP9FoP9FoP9FoP9FoP9FoP9FoP9GXzrJlqUMzpCmsDaHFd/KFiNGIX/GIX/GIX/GIX/GIX/GIX/LUpqLUpqLUpqIWq7GIX/GIX/Jl+gLT9NLEFPH3jcGob/Gob/Gob/Gob/Gob/Gob/NDQ0NDQ0NDQ0NDQ0JGWuGob/Gob/NTU2MTpAHoLyHIf/HIf/HIf/HIf/HIf/HIf/HIf/HIf/InHJNDQ0NDQ0IXbWHIf/NTU2MTpAIILyHoj/Hoj/Hoj/Hoj/IX3kLVB4NDQ0NDQ0JHLJLkpqNDQ0JHLJHoj/NTU2MTpAIYPyIIn/IIn/IIn/IYPxMT9PNDQ0MEVdL0tqJHjWL0tqNDQ0JXLJIIn/NTU2MTpAI4TyL0tqNDQ0J3PJKmKgNDQ0LlF4Ior/Ior/Ior/L0tqNDQ0J3PJIor/NTU2MTpAJYXyMEtqNDQ0KHTJK2KgNDQ0LV2TJIv/JIv/JIv/MEtqNDQ0KHTJJIv/NTU2MTpAKIbyMUtqNDQ0KnXJKnrWNDQ0NDQ0Ll2TLWOgLWOgMkBPNDQ0KnXJJ4z/NTU2MTpAKojyMUxqNDQ0LHbJKY7/LWquMzpCNDQ0NDQ0NDQ0NDQ0NDQ0LHbJKY7/NTU2MTpALInyMkxqNDQ0MFiFK4//K4//LInxLXfJLXfJLXfJLXfJLXfJLInxK4//NTU2Lz1HLobpL3G7NDQ0NDQ0M0ZdMk1qMk1qMGuuLZD/LZD/LZD/LZD/LZD/LZD/NTU2IFFzMl+TLpD+MGutNDQ0NDQ0NDQ0NDQ0MWWgLpD+LpD+LpD+LpD+LpD+MW60MTpAAAAANDc6MWKYLofrLoHfMHG6MHG6MHG6L3zTLofrLofrLofrLofrMWywM0hgFGKYAAAAo8ffKUZbMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpALz1HE2OZAAAAwAMAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAgAEAACgAAAAgAAAAQAAAAAEACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1NTYAo8ffAClGWwAxOkAALz1HABNjmQAAfdIACnC1ADQ3OgAxYpgALofrAC6B3wAwcboAL3zTADFssAAzSGAAFGKYACBRcwAyX5MALpD+ADBrrQA0NDQAMWWgADFutAAuhukAL3G7ADNGXQAyTWoAMGuuAC2Q/wA1NTYALInyADJMagAwWIUAK4//ACyJ8QAtd8kAKojyADFMagAsdskAKY7/AC1qrgAzOkIAKIbyADFLagAqdckAKnrWAC5dkwAtY6AAMkBPACeM/wAlhfIAMEtqACh0yQArYqAALV2TACSL/wAjhPIAL0tqACdzyQAqYqAALlF4ACKK/wAhg/IAIIn/ACGD8QAxP08AMEVdACR41gAlcskAIILyAB6I/wAhfeQALVB4ACRyyQAuSmoAHoLyAByH/wAicckAIXbWACxBTwAfeNwAGob/ACRlrgAcV38AKFiNABiF/wAtSmoAIWq7ACZfoAAtP00AB3S+AC4+SgAoVooAHHXZABaD/QAZfOsAJlqUAJrA2gAFdsIAHVV8ACxBUAAwO0MAIk9vAAtusQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpZGUeHh4eHh4eHmZnaWlpaWlpaWlpaWlpaWlpaWlpaVxdXl9fX19fX19fYGEqYmlpaWlpaWlpaWlpaWlpaWlUVUBAQEBAQFdXV1hWVllaaWlpaWlpaWlpaWlpaWlpaVBRQEBAQEBAFRUVFVNSUh5paWlpaWlpaWlpaWlpaWlpBEZAQEBAQEBAQEoVFU84HmlpaWlpaWlpaWlpaWlpaWkERkBAQEBISRUVSjQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlQEBAQUIVQzpENBUnOB5paWlpaWlpaWlpaWlpaWlpBCUgFSc2FT04ODg0FSc4HmlpaWlpaWlpaWlpaWlpaWkEJSAVJzYVNzg4ODQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlIBUnLhUVLzAwMRUnEx5paWlpaWlpaWlpaWlpaWlpBCUgFSciKSoVFRUVFScTHmlpaWlpaWlpaWlpaWlpaWkEGCAVISIiIyQkJCQkIxMeaWlpaWlpaWlpaWlpaWlpaQQYGRUVGhsbHBMTExMTEx5paWlpaWlpaWlpaWlpaWlpERITFBUVFRUWExMTExMXA2lpaWlpaWlpaWlpaWlpaWlpCAkKCwwMDA0KCgoKDg8QaWlpaWlpaWlpaWlpaWlpaWkBAgMDAwMDAwMDAwMDBWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaf//////////////////////wAP//4AA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//+AAP//gAH/////////////////////////////////////////////////////////////////",
|
||||||
|
ddownload: "./provider-icons/ddownload.ico"
|
||||||
|
};
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import type { AppLanguage } from "../shared/types";
|
||||||
|
|
||||||
|
const pairs = [
|
||||||
|
["Downloads", "Downloads"], ["Linksammler", "Link collector"], ["Einstellungen", "Settings"], ["Verlauf", "History"], ["Statistiken", "Statistics"],
|
||||||
|
["Datei", "File"], ["Hilfe", "Help"], ["Kontomenü", "Account menu"], ["Allgemein", "General"], ["Accounts", "Accounts"],
|
||||||
|
["Hauptnavigation", "Main navigation"], ["Globale Aktionen", "Global actions"], ["Anwendungsmenü", "Application menu"], ["Seitenleiste einklappen", "Collapse sidebar"], ["Seitenleiste ausklappen", "Expand sidebar"],
|
||||||
|
["Aktuelle Download-Geschwindigkeit (geglättet)", "Current download speed (smoothed)"], ["Einstellungsbereich", "Settings area"],
|
||||||
|
["Entpacken", "Extraction"], ["Geschwindigkeit", "Speed"], ["Bereinigung", "Cleanup"], ["Updates", "Updates"],
|
||||||
|
["Einstellungen speichern", "Save settings"], ["Gespeichert", "Saved"], ["Ungespeicherte Änderungen", "Unsaved changes"], ["Wird gespeichert…", "Saving…"], ["Speichern fehlgeschlagen", "Save failed"],
|
||||||
|
["Sprache", "Language"], ["Speicherort", "Storage location"], ["Download-Verhalten", "Download behavior"], ["Oberfläche und Bedienung", "Interface and controls"], ["Discord-Benachrichtigungen", "Discord notifications"],
|
||||||
|
["Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.", "Storage location, download behavior, history, interface and notifications."],
|
||||||
|
["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"],
|
||||||
|
["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."],
|
||||||
|
["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Dauerhaft", "Permanent"],
|
||||||
|
["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"],
|
||||||
|
["Nach Fortschritt sortieren", "Sort by progress"], ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"],
|
||||||
|
["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"],
|
||||||
|
["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"],
|
||||||
|
["Quelle und Zeitpunkt der Update-Prüfung.", "Update source and check timing."], ["Aktualisierung", "Update"], ["Beim Start nach Updates suchen", "Check for updates on startup"], ["Update-Quelle", "Update source"],
|
||||||
|
["Jetzt nach einer neuen Version suchen", "Check for a new version now"], ["Nach Updates suchen", "Check for updates"], ["Quelle im Format Benutzer/Repository.", "Source in owner/repository format."],
|
||||||
|
["Update verfügbar", "Update available"], ["Eine neue Version ist bereit. Klicke hier, um sie zu installieren.", "A new version is ready. Click here to install it."], ["Update installieren", "Install update"],
|
||||||
|
["Später", "Later"], ["Jetzt aktualisieren", "Update now"], ["Changelog anzeigen", "Show changelog"], ["Update wird vorbereitet...", "Preparing update..."], ["Update-Fortschritt", "Update progress"],
|
||||||
|
["Pakete", "Packages"], ["Dateien", "Files"], ["Alle", "All"], ["Aktiv", "Active"], ["Wartend", "Queued"], ["Pausiert", "Paused"], ["Fertig", "Completed"], ["Fehler", "Errors"],
|
||||||
|
["Downloadansicht", "Download view"], ["Downloadfilter", "Download filters"], ["Service filtern", "Filter service"], ["Downloads durchsuchen", "Search downloads"], ["Downloadstatus", "Download status"],
|
||||||
|
["Seitenleiste einklappen", "Collapse sidebar"], ["Seitenleiste ausklappen", "Expand sidebar"], ["Informationen", "Information"], ["Alle sichtbaren Downloads auswählen", "Select all visible downloads"],
|
||||||
|
["Links hinzufügen", "Add links"], ["Start", "Start"], ["Pause", "Pause"], ["Stop", "Stop"], ["Zeitplan", "Schedule"], ["Nach oben", "Move up"], ["Nach unten", "Move down"],
|
||||||
|
["Umbenennen", "Rename"], ["Entfernen", "Remove"], ["Name", "Name"], ["Geladen / Größe", "Downloaded / size"], ["Fortschritt", "Progress"], ["Hoster", "Hoster"], ["Service", "Service"],
|
||||||
|
["Priorität", "Priority"], ["Status", "Status"], ["Aktion", "Action"], ["Alle Services", "All services"], ["Paket, Datei oder Service", "Package, file or service"], ["Alle ein-/ausklappen", "Expand/collapse all"],
|
||||||
|
["Hoch", "High"], ["Normal", "Normal"], ["Niedrig", "Low"], ["In Warteschlange", "Queued"], ["Abgeschlossen", "Completed"], ["Entpackt", "Extracted"], ["Automatisch entpacken", "Extract automatically"],
|
||||||
|
["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"],
|
||||||
|
["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."],
|
||||||
|
["Accounts zum Herunterladen verwenden", "Use accounts for downloads"], ["Download-Traffic übrig", "Download traffic remaining"], ["Benutzername", "Username"], ["Verfallsdatum", "Expiration date"], ["Passwort/Zugang", "Password/access"],
|
||||||
|
["Account hinzufügen", "Add account"], ["Ausgewählte prüfen", "Check selected"], ["Ausgewählte entfernen", "Remove selected"], ["Aktivieren", "Enable"], ["Deaktivieren", "Disable"], ["Noch nicht geprüft", "Not checked yet"],
|
||||||
|
["Aktiviert", "Enabled"], ["Aktionen", "Actions"], ["Deaktiviert", "Disabled"], ["Premium aktiv", "Premium active"], ["API-Key aktiv", "API key active"], ["API-Account", "API account"], ["API-Key", "API key"],
|
||||||
|
["Ungültiger API-Key (nicht autorisiert)", "Invalid API key (not authorized)"], ["Free Account", "Free account"], ["Unbeschränkt", "Unlimited"], ["Keine Accounts eingerichtet", "No accounts configured"],
|
||||||
|
["Ablauf, Ziel, Tonspur, Ablageform und Leistung.", "Workflow, destination, audio track, layout and performance."], ["Ziel und Ablauf", "Destination and workflow"], ["Entpacken nach", "Extract to"],
|
||||||
|
["Zielordner für entpackte Dateien.", "Destination folder for extracted files."], ["Benötigt ffmpeg.", "Requires ffmpeg."],
|
||||||
|
["Wählen", "Choose"], ["Automatisch entpacken", "Extract automatically"], ["Bereits Entpacktes überspringen", "Skip already extracted files"], ["Entpackte Einträge ausblenden", "Hide extracted entries"],
|
||||||
|
["Entpacken auch ohne laufende Sitzung", "Extract without an active session"], ["Deutsche Tonspur", "German audio track"], ["Nur deutsche Tonspur behalten", "Keep German audio track only"], ["Welche Tonspur behalten", "Audio track to keep"],
|
||||||
|
["Ablageform", "Output layout"], ["Automatisch umbenennen", "Rename automatically"], ["In Paket-Unterordner ablegen", "Store in package subfolder"], ["Videos in Sammelordner verschieben", "Move videos to library folder"],
|
||||||
|
["Video-Sammelordner", "Video library folder"], ["Leistung", "Performance"], ["Hybrid-Entpacken", "Hybrid extraction"], ["Gleichzeitige Entpackungen", "Concurrent extractions"], ["CPU-Priorität beim Entpacken", "CPU priority during extraction"],
|
||||||
|
["Passwörter", "Passwords"], ["Passwortliste für Archive", "Archive password list"], ["Ein Passwort pro Zeile", "One password per line"], ["Hoch (80% CPU)", "High (80% CPU)"], ["Mittel (50% CPU)", "Medium (50% CPU)"], ["Niedrig (25% CPU)", "Low (25% CPU)"],
|
||||||
|
["Tempo, Wiederverbindung und zeitgesteuerte Bandbreitenregeln.", "Speed, reconnection and scheduled bandwidth rules."], ["Tempo-Begrenzung", "Speed limit"], ["Geschwindigkeit begrenzen", "Limit speed"],
|
||||||
|
["Höchstgeschwindigkeit (MB/s)", "Maximum speed (MB/s)"], ["Limit gilt für", "Limit applies to"], ["Verbindung", "Connection"], ["Automatisch neu verbinden", "Reconnect automatically"], ["Wartezeit vor neuem Versuch (Sek.)", "Wait before retry (sec.)"],
|
||||||
|
["Von (Stunde)", "From (hour)"], ["Bis (Stunde)", "To (hour)"], ["Limit (MB/s)", "Limit (MB/s)"], ["Zeitregel aktiviert", "Schedule rule enabled"], ["Zeitregel entfernen", "Remove schedule rule"],
|
||||||
|
["Bandbreitenplanung", "Bandwidth schedule"], ["Jede Regel legt für ein Zeitfenster ein eigenes Limit fest.", "Each rule sets a separate limit for a time window."], ["Weitere Zeitregel", "Additional schedule rule"], ["Zeitregel hinzufügen", "Add schedule rule"],
|
||||||
|
["Integritätsprüfung und Aufräumen nach Downloads und Entpacken.", "Integrity checks and cleanup after downloading and extraction."], ["Prüfung", "Verification"], ["Dateien auf Fehler prüfen", "Check files for errors"],
|
||||||
|
["Nach dem Entpacken", "After extraction"], ["Link-Dateien danach entfernen", "Remove link files afterward"], ["Vorschau-Dateien danach entfernen", "Remove sample files afterward"], ["Archive nach dem Entpacken", "Archives after extraction"],
|
||||||
|
["Fertige Downloads und Konflikte", "Completed downloads and conflicts"], ["Fertige Downloads aus der Liste", "Completed downloads in the list"], ["Bei gleichnamigen Dateien", "When files have the same name"],
|
||||||
|
["Hinzufügen", "Add"], ["Aktualisieren", "Refresh"], ["+ Hinzufügen", "+ Add"], ["− Entfernen", "− Remove"], ["↻ Aktualisieren", "↻ Refresh"], ["Provider-Reihenfolge", "Provider order"], ["Lege fest, in welcher Reihenfolge verfügbare Provider verwendet werden.", "Choose the order in which available providers are used."],
|
||||||
|
["Automatischer Fallback", "Automatic fallback"], ["Zugangsdaten lokal speichern", "Store credentials locally"], ["Hoster-Routing", "Hoster routing"], ["Eigene Zuordnungen überschreiben für den jeweiligen Hoster die Standardreihenfolge.", "Custom assignments override the default order for each hoster."],
|
||||||
|
["Rotations-Verlauf", "Rotation history"], ["Sammlung", "Collection"], ["URL oder Rohzeile", "URL or raw line"], ["Zeile", "Line"], ["Paket / Datei", "Package / file"], ["Größe", "Size"], ["Gestartet", "Started"], ["Beendet", "Finished"],
|
||||||
|
["Alle Einträge", "All entries"], ["Heute", "Today"], ["Letzte 7 Tage", "Last 7 days"], ["Älter", "Older"], ["Gelöscht", "Deleted"], ["Fehlgeschlagen", "Failed"],
|
||||||
|
["Dauer", "Duration"], ["Durchschnitt", "Average"], ["Zielordner", "Destination folder"], ["Verlaufsfilter", "History filters"], ["Verlauf leeren", "Clear history"], ["Verlaufsaktionen", "History actions"],
|
||||||
|
["Einträge", "Entries"], ["Erneut hinzufügen", "Add again"], ["Im Ordner zeigen", "Show in folder"], ["Auswahl löschen", "Clear selection"], ["Verlauf durchsuchen", "Search history"], ["Name, Pfad, Hoster oder Provider", "Name, path, hoster or provider"],
|
||||||
|
["Verlaufstabelle", "History table"], ["Verlauf wird geladen", "Loading history"], ["Die gespeicherten Einträge werden geladen.", "Saved entries are being loaded."], ["Noch kein Verlauf", "No history yet"], ["Keine passenden Einträge", "No matching entries"],
|
||||||
|
["Abgeschlossene und gelöschte Pakete erscheinen hier.", "Completed and deleted packages appear here."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Öffne die Ansicht erneut, um es noch einmal zu versuchen.", "Open the view again to retry."],
|
||||||
|
["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"],
|
||||||
|
["Sichtbar:", "Visible:"], ["pro Seite", "per page"],
|
||||||
|
["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"],
|
||||||
|
["Keine Downloads", "No downloads"], ["Keine passenden Downloads", "No matching downloads"], ["Füge Links hinzu, um Downloads vorzubereiten.", "Add links to prepare downloads."], ["Passe Filter oder Suche an.", "Adjust the filter or search."],
|
||||||
|
["Keine Links gesammelt", "No links collected"], ["Keine passenden Links", "No matching links"], ["Füge Links oder Text ein, um sie zu sammeln.", "Paste links or text to collect them."], ["Links durchsuchen", "Search links"],
|
||||||
|
["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"],
|
||||||
|
["Sitzungszähler und Ergebnisse der aktuellen Queue werden angezeigt.", "Session counters and results for the current queue are shown."], ["Sitzung zurücksetzen", "Reset session"], ["Gesamt zurücksetzen", "Reset total"], ["Fehler zurücksetzen", "Reset errors"],
|
||||||
|
["Bandbreitenverlauf", "Bandwidth history"], ["Provider", "Provider"], ["Daten", "Data"], ["Ergebnisse", "Results"],
|
||||||
|
["Nie", "Never"], ["Sofort", "Immediately"], ["Beim App-Start", "On app startup"], ["Sobald Paket fertig ist", "When package completes"], ["Überschreiben", "Overwrite"], ["Überspringen", "Skip"], ["Nachfragen", "Ask"],
|
||||||
|
["Abbrechen", "Cancel"], ["Speichern", "Save"], ["Schließen", "Close"], ["Löschen", "Delete"], ["Suchen", "Search"], ["Zurücksetzen", "Reset"], ["Testen", "Test"], ["Öffnen", "Open"],
|
||||||
|
["Noch keine Downloads", "No downloads yet"], ["Füge Links hinzu, um den ersten Download zu starten.", "Add links to start the first download."], ["Keine passenden Downloads", "No matching downloads"], ["Alle anzeigen", "Show all"],
|
||||||
|
["Neue Sammlung", "New collection"], ["Linksammler-Aktionen", "Link collector actions"], ["Links erfassen", "Capture links"], ["DLC importieren", "Import DLC"], ["Datei importieren", "Import file"],
|
||||||
|
["Sammlung verarbeiten", "Process collection"], ["Queue exportieren", "Export queue"], ["An Downloads übergeben", "Send to downloads"], ["Auswahl entfernen", "Remove selection"], ["Gesammelte Links", "Collected links"],
|
||||||
|
["Auswahl", "Selection"], ["Links werden verarbeitet", "Processing links"], ["Die laufende Aktion wird abgeschlossen.", "The current action is being completed."], ["Die lokale Sammlung bleibt unverändert.", "The local collection remains unchanged."],
|
||||||
|
["Passe die Suche an oder lösche den Filter.", "Adjust the search or clear the filter."], ["Füge Links hinzu oder importiere eine vorhandene Liste.", "Add links or import an existing list."], ["Keine passenden Links", "No matching links"], ["Noch keine Links", "No links yet"],
|
||||||
|
["Link auswählen", "Select link"], ["Lokal", "Local"], ["Übernehmen", "Apply"], ["Eine URL oder Rohzeile pro Zeile", "One URL or raw line per line"],
|
||||||
|
["Die Accountdaten werden aktualisiert.", "Account data is being updated."], ["Accounts werden geladen", "Loading accounts"], ["Die gespeicherten Accounts bleiben unverändert.", "Saved accounts remain unchanged."],
|
||||||
|
["Füge einen Account hinzu, um Downloads über einen Anbieter zu starten.", "Add an account to start downloads through a provider."], ["Noch keine Accounts", "No accounts yet"], ["Keine Provider konfiguriert.", "No providers configured."],
|
||||||
|
["Keine eigenen Zuordnungen.", "No custom assignments."], ["Hoster-Routing hinzufügen", "Add hoster routing"], ["Hoster hinzufügen…", "Add hoster…"], ["Eigener Hoster…", "Custom hoster…"], ["Noch keine Rotations-Ereignisse.", "No rotation events yet."],
|
||||||
|
["Prüfen und speichern", "Check and save"], ["Wähle einen Dienst und trage die passenden Zugangsdaten ein.", "Choose a service and enter the matching credentials."], ["Accounts durchsuchen", "Search accounts"],
|
||||||
|
["Dienst oder Zugangstyp suchen", "Search service or access type"], ["Account-Typ filtern", "Filter account type"], ["Verfügbare Account-Typen", "Available account types"], ["Keine passenden Account-Typen.", "No matching account types."],
|
||||||
|
["Prüfen", "Check"], ["Bearbeite ausschließlich den ausgewählten Account.", "Edit only the selected account."], ["Account bearbeiten", "Edit account"], ["Account aktiviert", "Account enabled"],
|
||||||
|
["Immer erste Tonspur", "Always first audio track"], ["Pro Download", "Per download"], ["Keine Archive löschen", "Do not delete archives"], ["Archive in Papierkorb", "Move archives to recycle bin"], ["Archive löschen", "Delete archives"],
|
||||||
|
["Accounts und Verwendungsregeln.", "Accounts and usage rules."], ["Premium Account", "Premium account"], ["Zugang ungültig", "Invalid access"], ["Prüft…", "Checking…"], ["Geschützter Zugang", "Protected access"],
|
||||||
|
["Der ausgewählte Mega-Debrid-Account wurde nicht gefunden.", "The selected Mega-Debrid account was not found."], ["Der ausgewählte Debrid-Link-Key wurde nicht gefunden.", "The selected Debrid-Link key was not found."],
|
||||||
|
["Das Tageslimit muss eine positive Zahl oder 0 sein.", "The daily limit must be a positive number or 0."], ["Login und Passwort werden benötigt.", "Login and password are required."],
|
||||||
|
["Der Login darf keinen Doppelpunkt oder Zeilenumbruch enthalten.", "The login must not contain a colon or line break."], ["Das Passwort darf keinen Zeilenumbruch enthalten.", "The password must not contain a line break."],
|
||||||
|
["Dieser Mega-Debrid-Login ist bereits vorhanden.", "This Mega-Debrid login already exists."], ["Der API-Key wird benötigt.", "The API key is required."], ["Beim Bearbeiten ist genau ein API-Key erlaubt.", "Exactly one API key is allowed when editing."],
|
||||||
|
["Dieser Debrid-Link-Key ist bereits vorhanden.", "This Debrid-Link key already exists."], ["Der Zugangstoken wird benötigt.", "The access token is required."], ["Die Prüfung hat keinen Account zurückgegeben.", "The check returned no account."],
|
||||||
|
["Die Prüfung hat mehr als den ausgewählten Account zurückgegeben.", "The check returned more than the selected account."], ["Die Prüfung hat den falschen Account zurückgegeben.", "The check returned the wrong account."], ["Zugangsdaten ungültig", "Invalid credentials"],
|
||||||
|
["Für diesen Zeitraum werden noch keine historischen Daten gespeichert.", "Historical data is not stored for this time range yet."], ["Keine abgeschlossenen oder fehlgeschlagenen Ergebnisse", "No completed or failed results"],
|
||||||
|
["Heutige Daten stammen aus den lokalen Provider-Nutzungszählern des aktuellen Kalendertags.", "Today's data comes from the local provider usage counters for the current calendar day."],
|
||||||
|
["Provider-Nutzung heute", "Provider usage today"], ["Dateianzahlen werden nicht tagesweise gespeichert", "File counts are not stored by day"], ["Ergebnisse werden nicht tagesweise gespeichert", "Results are not stored by day"],
|
||||||
|
["Durchschnittsgeschwindigkeit wird nicht tagesweise gespeichert", "Average speed is not stored by day"], ["Fehler werden nicht tagesweise gespeichert", "Errors are not stored by day"],
|
||||||
|
["Gesamtwerte stammen aus dauerhaft gespeicherten Zählern. Ergebnisse und Geschwindigkeiten werden nicht historisch gespeichert.", "Totals come from permanently stored counters. Results and speeds are not stored historically."],
|
||||||
|
["Gesamtzähler", "Total counter"], ["Ergebnisse werden nicht dauerhaft gespeichert", "Results are not stored permanently"], ["Durchschnittsgeschwindigkeit wird nicht dauerhaft gespeichert", "Average speed is not stored permanently"],
|
||||||
|
["Fehler werden nicht dauerhaft gespeichert", "Errors are not stored permanently"], ["Aktuelle Queue", "Current queue"], ["Letzter beendeter Lauf", "Last completed run"],
|
||||||
|
["Während des laufenden Durchgangs nicht als Gesamtwert verfügbar", "Unavailable as a total while the run is active"], ["Kein beendeter Lauf mit Ergebnissen verfügbar", "No completed run with results available"],
|
||||||
|
["Sitzungszähler und Ergebnisse des zuletzt beendeten Laufs werden angezeigt.", "Session counters and results from the last completed run are shown."], ["Nicht verfügbar", "Unavailable"],
|
||||||
|
["Heute wurden noch keine Providerbytes erfasst.", "No provider bytes have been recorded today."], ["Noch keine gespeicherten Providerbytes vorhanden.", "No stored provider bytes available yet."],
|
||||||
|
["In der aktuellen Queue sind noch keine Providerwerte vorhanden.", "No provider values are available in the current queue yet."], ["Statistik-Zeitraum", "Statistics time range"], ["Statistik-Dashboard", "Statistics dashboard"],
|
||||||
|
["Statistiken zurücksetzen", "Reset statistics"], ["Erfolgsquote", "Success rate"], ["Live aus der aktuellen Renderer-Sitzung", "Live from the current renderer session"],
|
||||||
|
["Kontextmenü", "Context menu"], ["Die Oberfläche hat einen Fehler ausgelöst", "The interface encountered an error"],
|
||||||
|
["Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.", "The interface was stopped to prevent data loss. Downloads running in the background are not affected. The error was written to the log."],
|
||||||
|
["Oberfläche neu laden", "Reload interface"], ["Unbekannter Fehler", "Unknown error"],
|
||||||
|
["Direkter Zugriff über API-Token.", "Direct access via API token."], ["Login über Browserfenster statt Token.", "Login through a browser window instead of a token."],
|
||||||
|
["Login:Passwort-Paare für Mega-Debrid (API). Mehrere Accounts zeilenweise für Multi-Account.", "Login:password pairs for Mega-Debrid (API). Enter multiple accounts on separate lines for multi-account."],
|
||||||
|
["Login:Passwort-Paare für Mega-Debrid (Web). Mehrere Accounts zeilenweise für Multi-Account.", "Login:password pairs for Mega-Debrid (Web). Enter multiple accounts on separate lines for multi-account."],
|
||||||
|
["Cookie-Import aus dem Browser statt API-Token.", "Import cookies from the browser instead of using an API token."], ["Direkter Zugriff über API-Key.", "Direct access via API key."],
|
||||||
|
["Login über Browserfenster für reCAPTCHA.", "Login through a browser window for reCAPTCHA."], ["Direkter Login für ddownload.com und ddl.to.", "Direct login for ddownload.com and ddl.to."],
|
||||||
|
["API-Key für 1fichier.com.", "API key for 1fichier.com."], ["API-Key(s) für debrid-link.com. Mehrere Keys zeilenweise für Multi-Account.", "API key(s) for debrid-link.com. Enter multiple keys on separate lines for multi-account."],
|
||||||
|
["Login für linksnappy.com mit Benutzername und Passwort.", "Login for linksnappy.com with username and password."], ["Login:Passwort (API)", "Login:password (API)"], ["Login:Passwort (Web)", "Login:password (Web)"],
|
||||||
|
["Login + Passwort", "Login + password"], ["Login gespeichert", "Login saved"], ["API-Key gespeichert", "API key saved"], ["Zugang gespeichert", "Access saved"], ["Nicht hinterlegt", "Not stored"],
|
||||||
|
["Bitte zuerst einen Account-Typ auswählen.", "Select an account type first."], ["Nicht angegeben", "Not specified"], ["Nur per API-Key sichtbar", "Visible only via API key"],
|
||||||
|
["abgebrochen (fataler Fehler)", "cancelled (fatal error)"], ["übersprungen (bis zum Tagesreset gesperrt)", "skipped (blocked until daily reset)"], ["übersprungen (Cooldown aktiv)", "skipped (cooldown active)"],
|
||||||
|
["übersprungen (deaktiviert)", "skipped (disabled)"], ["übersprungen (Tageslimit erreicht)", "skipped (daily limit reached)"], ["übersprungen (Host-Cooldown)", "skipped (host cooldown)"],
|
||||||
|
["Provider-weiter Fehler, restliche Keys übersprungen", "Provider-wide error, remaining keys skipped"], ["Netzwerk-Kaskade, restliche Keys übersprungen", "Network cascade, remaining keys skipped"],
|
||||||
|
["Key ist manuell deaktiviert.", "Key is manually disabled."], ["Lokales Tageslimit erreicht.", "Local daily limit reached."], ["Live-Status wird geladen.", "Loading live status."],
|
||||||
|
["Der Hoster ist laut Debrid-Link aktuell offline.", "The hoster is currently offline according to Debrid-Link."], ["Key ist nutzbar.", "Key is usable."], ["Download starten für Statistiken", "Start a download for statistics"],
|
||||||
|
["Download fertig | Prüfe Integrität...", "Download complete | Checking integrity..."], ["Starte Installer...", "Starting installer..."], ["Installer gestartet", "Installer started"],
|
||||||
|
["Verlauf konnte nicht geladen werden", "History could not be loaded"], ["Debrid-Link ist nicht konfiguriert", "Debrid-Link is not configured"], ["Entpacken -", "Extraction -"],
|
||||||
|
["Nur API aktiv. Kein Web-Fallback.", "API only. No web fallback."], ["Nur Web aktiv. Kein API-Fallback.", "Web only. No API fallback."], ["Login kann bei Bedarf direkt aus der Liste geöffnet werden.", "The login can be opened directly from the list when needed."],
|
||||||
|
["Cookie-Import lässt sich direkt aus der Liste erneut starten.", "Cookie import can be started again directly from the list."], ["Lade Status", "Load status"], ["Rapidgator-Status wird aktualisiert.", "RapidGator status is being updated."],
|
||||||
|
["Rapidgator-Status kann direkt aus der Liste geladen werden.", "RapidGator status can be loaded directly from the list."], ["Status basiert auf den zuletzt gespeicherten AllDebrid-Daten.", "Status is based on the last saved AllDebrid data."],
|
||||||
|
["Update wird vorbereitet", "Preparing update"], ["Stilles Update gestartet - App wird neu gestartet", "Silent update started - the app will restart"], ["Einstellungen gespeichert", "Settings saved"],
|
||||||
|
["Real-Debrid Login-Fenster geöffnet", "Real-Debrid login window opened"], ["AllDebrid Login-Fenster geöffnet", "AllDebrid login window opened"], ["Keine Cookie-Datei ausgewählt", "No cookie file selected"],
|
||||||
|
["Keine Mega-Debrid-/Debrid-Link-Accounts zum Prüfen konfiguriert.", "No Mega-Debrid or Debrid-Link accounts configured for checking."], ["Account aktiviert", "Account enabled"], ["Account deaktiviert", "Account disabled"],
|
||||||
|
["Account entfernen", "Remove account"], ["Account entfernt", "Account removed"], ["Key entfernen", "Remove key"], ["Key entfernt", "Key removed"], ["Accounts aktiviert", "Accounts enabled"], ["Accounts deaktiviert", "Accounts disabled"],
|
||||||
|
["Für diesen Account ist keine direkte Statusprüfung verfügbar.", "Direct status checking is not available for this account."], ["Keine gespeicherten Links vorhanden", "No saved links available"], ["Keine Links hinzugefügt", "No links added"],
|
||||||
|
["Fehler beim Hinzufügen", "Error while adding"], ["Verlaufseintrag entfernen", "Remove history entry"], ["Verlaufseinträge entfernen", "Remove history entries"], ["Diesen Eintrag aus dem Verlauf entfernen?", "Remove this entry from history?"],
|
||||||
|
["Einige Verlaufseinträge konnten nicht entfernt werden", "Some history entries could not be removed"], ["Wirklich alle Einträge aus dem Verlauf entfernen?", "Remove all entries from history?"], ["Verlauf geleert", "History cleared"],
|
||||||
|
["Verlauf konnte nicht geleert werden", "History could not be cleared"], ["Zielordner geöffnet", "Destination folder opened"], ["Verlaufseintrag wurde nicht gefunden", "History entry was not found"],
|
||||||
|
["Der gespeicherte Zielordner ist ungültig", "The saved destination folder is invalid"], ["Der gespeicherte Zielordner existiert nicht mehr", "The saved destination folder no longer exists"],
|
||||||
|
["Das gespeicherte Ziel ist kein Ordner", "The saved destination is not a folder"], ["Zielordner konnte nicht geöffnet werden", "Destination folder could not be opened"], ["Bitte zuerst mindestens einen Hoster-Account eintragen", "Add at least one hoster account first"],
|
||||||
|
["Start abgebrochen", "Start cancelled"], ["Keine gültigen Links gefunden", "No valid links found"], ["Keine gültigen Links in den DLC-Dateien gefunden", "No valid links found in the DLC files"],
|
||||||
|
["Keine gültigen Links in den Import-Dateien gefunden", "No valid links found in the import files"], ["Links per Drag-and-Drop eingefügt", "Links added by drag and drop"], ["Queue exportiert", "Queue exported"],
|
||||||
|
["Keine gültigen Links in der Datei gefunden", "No valid links found in the file"], ["Sicherung exportiert", "Backup exported"], ["Online-Schlüssel erstellt", "Online key created"],
|
||||||
|
["Online-Sicherung konnte nicht erstellt werden.", "Online backup could not be created."], ["Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen.", "Online backup could not be loaded. Check the key and try again."],
|
||||||
|
["Online-Schlüssel kopiert", "Online key copied"], ["Schlüssel konnte nicht kopiert werden", "Key could not be copied"], ["Support-Bundle exportiert", "Support bundle exported"],
|
||||||
|
["Support-Trace für 2 Stunden aktiviert", "Support trace enabled for 2 hours"], ["Support-Trace deaktiviert", "Support trace disabled"], ["Keine akuten Warnungen", "No current warnings"],
|
||||||
|
["Remote-fähig konfiguriert", "Configured for remote access"], ["Debug-Setup prüfen", "Check debug setup"], ["Letzte Fehler", "Recent errors"], ["Keine Fehler oder Warnungen seit dem App-Start aufgezeichnet.", "No errors or warnings recorded since the app started."],
|
||||||
|
["In Zwischenablage kopieren", "Copy to clipboard"], ["Einträge anzeigen", "Show entries"], ["Fehlerliste kopiert", "Error list copied"], ["Debug-Token rotieren", "Rotate debug token"],
|
||||||
|
["Das aktuelle Debug-Token wird ersetzt. Externe Debug-Links mit altem Token funktionieren danach nicht mehr.", "The current debug token will be replaced. External debug links using the old token will stop working."],
|
||||||
|
["Token rotieren", "Rotate token"], ["Netzwerkmodus braucht mindestens eine IP oder CIDR in der Allowlist", "Network mode requires at least one IP address or CIDR in the allowlist"],
|
||||||
|
["Ferndiagnose aktiv", "Remote diagnostics active"], ["Ferndiagnose konfiguriert", "Remote diagnostics configured"], ["Ferndiagnose deaktiviert", "Remote diagnostics disabled"],
|
||||||
|
["Neues Token - alter Verbindungscode ist ungueltig", "New token - old connection code is invalid"], ["Verbindungscode kopiert", "Connection code copied"], ["Kopieren fehlgeschlagen", "Copy failed"],
|
||||||
|
["Queue löschen", "Delete queue"], ["Wirklich alle Einträge aus der Queue löschen?", "Delete all entries from the queue?"], ["Alles löschen", "Delete all"], ["Session-Statistik zurückgesetzt", "Session statistics reset"],
|
||||||
|
["Gesamt-Downloadstatistik zurückgesetzt", "Total download statistics reset"], ["Hoster-Domain eingeben:", "Enter hoster domain:"], ["Test-Nachricht gesendet", "Test message sent"], ["Test fehlgeschlagen", "Test failed"],
|
||||||
|
["Login / E-Mail", "Login / email"], ["Token / API-Key", "Token / API key"], ["Tageslimit (GB, optional)", "Daily limit (GB, optional)"], ["Kein Limit", "No limit"],
|
||||||
|
["Der Zähler wird täglich um 00:00 Uhr zurückgesetzt.", "The counter resets every day at 00:00."], ["Account erfolgreich geprüft", "Account checked successfully"], ["Für diesen Dienst ist keine direkte Statusprüfung verfügbar.", "Direct status checking is not available for this service."],
|
||||||
|
["Paket- und Linkstatus werden laufend aktualisiert. Auswahl, Reihenfolge und aktive Filter bleiben beim Ansichtswechsel erhalten.", "Package and link status are updated continuously. Selection, order and active filters remain in place when switching views."],
|
||||||
|
["Rohzeilen bleiben lokal in der gewählten Sammlung, bis sie über „An Downloads übergeben“ an die Queue gesendet werden. Strg+L öffnet den Linksammler, Strg+O lädt DLC-Dateien.", "Raw lines remain local in the selected collection until they are sent to the queue using “Send to downloads”. Ctrl+L opens the link collector, Ctrl+O loads DLC files."],
|
||||||
|
["Abgeschlossene und gelöschte Pakete bleiben hier durchsuchbar. Details zeigen Zielordner, Provider und gespeicherte Linkadressen.", "Completed and deleted packages remain searchable here. Details show the destination folder, provider and saved link addresses."],
|
||||||
|
["Für sieben und 30 Tage bleiben Kennzahlen leer, solange keine historischen Buckets gespeichert werden.", "Metrics remain empty for seven and 30 days while no historical buckets are stored."],
|
||||||
|
["Text mit Links analysieren", "Analyze text containing links"], ["Online-Schlüssel exportieren", "Export online key"], ["Online-Schlüssel importieren", "Import online key"], ["Logs öffnen", "Open logs"],
|
||||||
|
["Support-Bundle exportieren", "Export support bundle"], ["Support-Trace deaktivieren", "Disable support trace"], ["Support-Trace aktivieren", "Enable support trace"], ["Letzte Fehler anzeigen", "Show recent errors"],
|
||||||
|
["Einträge:", "Entries:"], ["Ausgewählt:", "Selected:"], ["Online-Schlüssel", "Online key"],
|
||||||
|
["Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten wieder her. Bewahre ihn wie ein Passwort auf.", "This key restores your settings, including saved credentials. Keep it as secure as a password."],
|
||||||
|
["Füge den vollständigen MDD2-Schlüssel ein. Die aktuellen Einstellungen werden durch die gespeicherte Version ersetzt.", "Paste the complete MDD2 key. The current settings will be replaced by the saved version."],
|
||||||
|
["Online-Sicherung wird verschlüsselt und gespeichert …", "Online backup is being encrypted and saved …"], ["Online-Sicherungsschlüssel", "Online backup key"], ["Online-Sicherungsschlüssel eingeben", "Enter online backup key"], ["Wird geladen …", "Loading …"],
|
||||||
|
["Ermöglicht einer vertrauenswürdigen Support-Stelle den geschützten Lesezugriff auf Status, Logs und Fehler. Der Verbindungscode enthält das Zugriffstoken und ist wie ein Passwort zu behandeln.", "Allows a trusted support contact protected read access to status, logs and errors. The connection code contains the access token and must be treated like a password."],
|
||||||
|
["Oeffentliche Adresse (fuer den Verbindungscode)", "Public address (for the connection code)"], ["Allowlist - erlaubte IPs/CIDR (eine pro Zeile)", "Allowlist - permitted IPs/CIDRs (one per line)"],
|
||||||
|
["Pflicht im Netzwerkmodus. Nur diese Quell-IPs duerfen verbinden (zusaetzlich zum Token). Loopback ist immer erlaubt.", "Required in network mode. Only these source IPs may connect (in addition to the token). Loopback is always allowed."],
|
||||||
|
["Token neu (Code ungueltig machen)", "New token (invalidate code)"], ["Enthaelt das Zugriffstoken - wie ein Passwort behandeln. Token neu = alter Code wird sofort ungueltig.", "Contains the access token - treat it like a password. New token = old code becomes invalid immediately."],
|
||||||
|
["Möchtest Du wirklich diese Aufräumaktion(en) durchführen?", "Do you really want to perform these cleanup action(s)?"], ["Ausgewählte Links löschen", "Delete selected links"], ["Nicht mehr anzeigen", "Do not show again"],
|
||||||
|
["Paket bereits entpackt", "Package already extracted"], ["ist im Ziel bereits vorhanden.", "already exists at the destination."], ["Für alle weiteren Pakete dieselbe Auswahl verwenden", "Use the same selection for all remaining packages"],
|
||||||
|
["Entpacktes überspringen", "Skip extracted content"], ["Links, .dlc oder Export-Dateien hier ablegen", "Drop links, .dlc or export files here"], ["Account prüfen", "Check account"],
|
||||||
|
["Account aktivieren", "Enable account"], ["Account deaktivieren", "Disable account"], ["Ausgewählte Downloads starten", "Start selected downloads"], ["Alle Downloads starten", "Start all downloads"],
|
||||||
|
["Linkadressen anzeigen", "Show link addresses"], ["Paket exportieren", "Export package"], ["Log öffnen", "Open log"], ["Item-Log öffnen", "Open item log"], ["Jetzt entpacken", "Extract now"],
|
||||||
|
["API-Key Statistik", "API key statistics"], ["Status unbekannt", "Status unknown"], ["RG Links", "RG links"], ["Alle Namen kopiert", "All names copied"], ["Alle Namen kopieren", "Copy all names"],
|
||||||
|
["Alle Links kopiert", "All links copied"], ["Alle Links kopieren", "Copy all links"],
|
||||||
|
["nicht gesetzt", "not set"], ["Schätzwert", "Estimate"], ["ist verfügbar. Installierte Version:", "is available. Installed version:"],
|
||||||
|
["Real-Debrid Web-Login", "Real-Debrid web login"], ["Mega-Debrid Web-Login", "Mega-Debrid web login"], ["BestDebrid Web-Login", "BestDebrid web login"], ["AllDebrid Web-Login", "AllDebrid web login"],
|
||||||
|
["DDownload Login", "DDownload login"], ["Debrid-Link API", "Debrid-Link API"], ["LinkSnappy Web-Login", "LinkSnappy web login"],
|
||||||
|
["Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster.", "Daily limit reached. New links will switch to the next hoster."], ["Mega-Debrid: Bitte Login und Passwort eintragen.", "Mega-Debrid: Enter a login and password."],
|
||||||
|
["Dieser Mega-Debrid-Account ist bereits vorhanden.", "This Mega-Debrid account already exists."], ["Debrid-Link: Bitte genau einen API-Key eintragen.", "Debrid-Link: Enter exactly one API key."],
|
||||||
|
["Die Prüfung hat nicht genau den neuen Account bestätigt.", "The check did not confirm exactly the new account."], ["Nur lokal gebunden", "Bound locally only"], ["(nur lokal)", "(local only)"], ["Nur lokal", "Local only"],
|
||||||
|
["Bindet nur an 127.0.0.1. Fernzugriff nur ueber einen Tunnel (z.B. Tailscale/SSH) - die sicherste Variante.", "Binds only to 127.0.0.1. Remote access only through a tunnel (such as Tailscale/SSH) - the safest option."],
|
||||||
|
["Bindet an 0.0.0.0. Erreichbar im Netzwerk, erfordert eine Allowlist. Nur in vertrauenswuerdigen Netzen/VPN nutzen.", "Binds to 0.0.0.0. Reachable on the network and requires an allowlist. Use only on trusted networks or VPNs."],
|
||||||
|
["127.0.0.1 oder Tunnel-Adresse", "127.0.0.1 or tunnel address"], ["Server-IP oder DNS-Name", "Server IP or DNS name"], ["löschen ?", "delete?"], ["Link(s) verbleiben!", "link(s) remaining!"],
|
||||||
|
["Bei \"überspringen\" wird nur das erneute Entpacken übersprungen - offene Downloads bleiben in der Queue.", "With \"skip\", only repeated extraction is skipped - open downloads remain in the queue."],
|
||||||
|
["überschreiben", "overwrite"], ["überspringen", "skip"], ["Priorität >", "Priority >"], ["Keys · Heute:", "Keys · Today:"], ["· Rapidgator-Quota wird geladen (", "· Loading RapidGator quota ("],
|
||||||
|
["· API-Quota konnte nicht geladen werden", "· API quota could not be loaded"], ["Name kopiert", "Name copied"], ["Link kopiert", "Link copied"],
|
||||||
|
["Download-Ziel", "Download destination"], ["Support-Logs:", "Support logs:"], ["Session-Logs", "Session logs"], ["Paket-Logs", "Package logs"], ["Support-Bundle:", "Support bundle:"], ["Warnungen:", "Warnings:"],
|
||||||
|
["Web-Login", "Web login"], ["API-Token", "API token"], ["erfolgreich", "successful"], ["Unbekannt", "Unknown"], ["Abgebrochen", "Cancelled"], ["Passwort", "Password"],
|
||||||
|
["Sicherung", "Backup"], ["Session-Log", "Session log"], ["Remote-Support", "Remote support"], ["Kopieren", "Copy"], ["Verbindungscode", "Connection code"], ["deaktiviert", "disabled"]
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const deToEn = new Map<string, string>(pairs);
|
||||||
|
const enToDe = new Map<string, string>(pairs.map(([de, en]) => [en, de]));
|
||||||
|
const attributes = ["aria-label", "placeholder", "title"] as const;
|
||||||
|
const prefixedPairs = [
|
||||||
|
["Status: ", "Status: "], ["Debug-Server aktiv: ", "Debug server active: "], ["Runtime-Ordner: ", "Runtime folder: "], ["Token-Datei: ", "Token file: "], ["Support-Manifest: ", "Support manifest: "], ["Trace aktiv: ", "Trace active: "],
|
||||||
|
["Unbekannter Account-Typ: ", "Unknown account type: "], ["Update-Download: ", "Update download: "], ["Update: ", "Update: "], ["Warnungen: ", "Warnings: "], ["Debug-Token rotiert: ", "Debug token rotated: "],
|
||||||
|
["AllDebrid Status fehlgeschlagen: ", "AllDebrid status failed: "], ["Debrid-Link Quota fehlgeschlagen: ", "Debrid-Link quota failed: "], ["Snapshot konnte nicht geladen werden: ", "Snapshot could not be loaded: "],
|
||||||
|
["Update-Check fehlgeschlagen: ", "Update check failed: "], ["Update-Fehler: ", "Update error: "], ["Auto-Update fehlgeschlagen: ", "Automatic update failed: "],
|
||||||
|
["Einstellungen konnten nicht gespeichert werden: ", "Settings could not be saved: "], ["Real-Debrid Login fehlgeschlagen: ", "Real-Debrid login failed: "], ["AllDebrid Login fehlgeschlagen: ", "AllDebrid login failed: "],
|
||||||
|
["BestDebrid Cookie-Import fehlgeschlagen: ", "BestDebrid cookie import failed: "], ["Account-Check fehlgeschlagen: ", "Account check failed: "], ["Prüfung fehlgeschlagen: ", "Check failed: "],
|
||||||
|
["Account konnte nicht gespeichert werden: ", "Account could not be saved: "], ["Reset fehlgeschlagen: ", "Reset failed: "], ["Umschalten fehlgeschlagen: ", "Toggle failed: "],
|
||||||
|
["Entfernen fehlgeschlagen: ", "Removal failed: "], ["Account konnte nicht entfernt werden: ", "Account could not be removed: "], ["Accounts konnten nicht umgeschaltet werden: ", "Accounts could not be toggled: "],
|
||||||
|
["Fehler beim Hinzufügen: ", "Error while adding: "], ["Fehler beim DLC-Import: ", "DLC import error: "], ["Export fehlgeschlagen: ", "Export failed: "], ["Fehler bei Drag-and-Drop: ", "Drag-and-drop error: "],
|
||||||
|
["Import fehlgeschlagen: ", "Import failed: "], ["Fehler: ", "Error: "], ["Sortierung fehlgeschlagen: ", "Sorting failed: "], ["Umbenennen fehlgeschlagen: ", "Rename failed: "],
|
||||||
|
["Paket-Löschung fehlgeschlagen: ", "Package deletion failed: "], ["Paket-Umschalten fehlgeschlagen: ", "Package toggle failed: "], ["Sicherung fehlgeschlagen: ", "Backup failed: "],
|
||||||
|
["Sicherung laden fehlgeschlagen: ", "Loading backup failed: "], ["Support-Bundle fehlgeschlagen: ", "Support bundle failed: "], ["Support-Trace fehlgeschlagen: ", "Support trace failed: "],
|
||||||
|
["Debug-Setup-Check fehlgeschlagen: ", "Debug setup check failed: "], ["Fehler-Ansicht fehlgeschlagen: ", "Error view failed: "], ["Token-Rotation fehlgeschlagen: ", "Token rotation failed: "],
|
||||||
|
["Ferndiagnose-Status fehlgeschlagen: ", "Remote diagnostics status failed: "], ["Aktivieren fehlgeschlagen: ", "Enabling failed: "], ["Deaktivieren fehlgeschlagen: ", "Disabling failed: "],
|
||||||
|
["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "]
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function normalizeLanguage(value: unknown): AppLanguage {
|
||||||
|
return value === "de" ? "de" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateDynamic(value: string, language: AppLanguage): string {
|
||||||
|
for (const [german, english] of prefixedPairs) {
|
||||||
|
const source = language === "en" ? german : english;
|
||||||
|
if (value.startsWith(source)) return `${language === "en" ? english : german}${value.slice(source.length)}`;
|
||||||
|
}
|
||||||
|
if (language === "en") {
|
||||||
|
const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/);
|
||||||
|
if (update) return `${update[1]} is available. Installed version: ${update[2]}.`;
|
||||||
|
const pagination = value.match(/^(\d+\s*[–-]\s*\d+) von (\d+)$/);
|
||||||
|
if (pagination) return `${pagination[1]} of ${pagination[2]}`;
|
||||||
|
const schedule = value.match(/^Zeitregel (\d+)$/);
|
||||||
|
if (schedule) return `Schedule rule ${schedule[1]}`;
|
||||||
|
const scheduled = value.match(/^Geplant: (.+)$/);
|
||||||
|
if (scheduled) return `Scheduled: ${scheduled[1].replace(/^Heute\b/, "Today")}`;
|
||||||
|
const cancelled = value.match(/^(\d+) abgebrochen$/);
|
||||||
|
if (cancelled) return `${cancelled[1]} cancelled`;
|
||||||
|
const labelledCount = value.match(/^(Einträge|Sichtbar|Ausgewählt): (\d+)$/);
|
||||||
|
if (labelledCount) return `${({ Einträge: "Entries", Sichtbar: "Visible", Ausgewählt: "Selected" } as const)[labelledCount[1] as "Einträge" | "Sichtbar" | "Ausgewählt"]}: ${labelledCount[2]}`;
|
||||||
|
const perPage = value.match(/^(\d+) pro Seite$/);
|
||||||
|
if (perPage) return `${perPage[1]} per page`;
|
||||||
|
const filter = value.match(/^(Alle|Aktiv|Wartend|Pausiert|Fertig|Fehler) (\d+)$/);
|
||||||
|
if (filter) return `${deToEn.get(filter[1]) ?? filter[1]} ${filter[2]}`;
|
||||||
|
const remaining = value.match(/^(.+) von (.+) übrig$/);
|
||||||
|
if (remaining) return `${remaining[1]} of ${remaining[2]} remaining`;
|
||||||
|
const actionsFor = value.match(/^Aktionen für (.+)$/);
|
||||||
|
if (actionsFor) return `Actions for ${actionsFor[1]}`;
|
||||||
|
const assignment = value.match(/^(.+) Zuordnung entfernen$/);
|
||||||
|
if (assignment) return `Remove ${assignment[1]} assignment`;
|
||||||
|
const providerFor = value.match(/^Provider für (.+)$/);
|
||||||
|
if (providerFor) return `Provider for ${providerFor[1]}`;
|
||||||
|
const move = value.match(/^(.+) nach (oben|unten)$/);
|
||||||
|
if (move) return `Move ${move[1]} ${move[2] === "oben" ? "up" : "down"}`;
|
||||||
|
const audio = value.match(/^Tonspur: (.+)$/);
|
||||||
|
if (audio) return `Audio track: ${audio[1].replace(/ohne DE-Tag/g, "without DE tag").replace(/ffmpeg fehlt/g, "ffmpeg missing").replace(/(\d+) Fehler/g, "$1 errors")}`;
|
||||||
|
const result = value.match(/^(\d+\/\d+) fertig(.*)$/);
|
||||||
|
if (result) return `${result[1]} completed${result[2].replace(/(\d+) Fehler/g, "$1 errors").replace(/(\d+) abgebrochen/g, "$1 cancelled")}`;
|
||||||
|
const extracting = value.match(/^Entpacken (\d+%)$/);
|
||||||
|
if (extracting) return `Extracting ${extracting[1]}`;
|
||||||
|
const checkedUntil = value.match(/^Account geprüft — (.+) bis (.+)$/);
|
||||||
|
if (checkedUntil) return `Account checked — ${checkedUntil[1]} until ${checkedUntil[2]}`;
|
||||||
|
const checked = value.match(/^Account geprüft — (.+)$/);
|
||||||
|
if (checked) return `Account checked — ${checked[1]}`;
|
||||||
|
const invalid = value.match(/^Account ungültig — (.+)$/);
|
||||||
|
if (invalid) return `Invalid account — ${invalid[1]}`;
|
||||||
|
const queueLinks = value.match(/^(\d+) Link\(s\) zur Queue hinzugefügt$/);
|
||||||
|
if (queueLinks) return `${queueLinks[1]} link(s) added to the queue`;
|
||||||
|
const packagesAndLinks = value.match(/^(\d+) Paket\(e\), (\d+) Link\(s\) (hinzugefügt|importiert|exportiert)$/);
|
||||||
|
if (packagesAndLinks) return `${packagesAndLinks[1]} package(s), ${packagesAndLinks[2]} link(s) ${{ hinzugefügt: "added", importiert: "imported", exportiert: "exported" }[packagesAndLinks[3] as "hinzugefügt" | "importiert" | "exportiert"]}`;
|
||||||
|
const selected = value.match(/^(\d+) ausgewählt$/);
|
||||||
|
if (selected) return `${selected[1]} selected`;
|
||||||
|
const ago = value.match(/^vor (\d+) (Min|Std|Tag(?:en)?)$/);
|
||||||
|
if (ago) return `${ago[1]} ${ago[2] === "Min" ? "min" : ago[2] === "Std" ? "hr" : ago[2].startsWith("Tag") ? "day" : ago[2]} ago`;
|
||||||
|
const validation = value.match(/^(.+): (Mindestens einen Account hinzufügen|Bitte Zugangstoken eintragen|Bitte Login oder E-Mail eintragen|Bitte Passwort eintragen|Tageslimit muss eine Zahl >= 0 sein|Bitte mindestens einen gültigen API-Key eintragen)\.$/);
|
||||||
|
if (validation) {
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
"Mindestens einen Account hinzufügen": "Add at least one account",
|
||||||
|
"Bitte Zugangstoken eintragen": "Enter an access token",
|
||||||
|
"Bitte Login oder E-Mail eintragen": "Enter a login or email address",
|
||||||
|
"Bitte Passwort eintragen": "Enter a password",
|
||||||
|
"Tageslimit muss eine Zahl >= 0 sein": "The daily limit must be a number >= 0",
|
||||||
|
"Bitte mindestens einen gültigen API-Key eintragen": "Enter at least one valid API key"
|
||||||
|
};
|
||||||
|
return `${validation[1]}: ${messages[validation[2]]}.`;
|
||||||
|
}
|
||||||
|
const clipboard = value.match(/^Zwischenablage: (\d+) Link\(s\) erkannt$/);
|
||||||
|
if (clipboard) return `Clipboard: ${clipboard[1]} link(s) detected`;
|
||||||
|
const disabledKeys = value.match(/^(\d+\/\d+) API-Keys deaktiviert\.$/);
|
||||||
|
if (disabledKeys) return `${disabledKeys[1]} API keys disabled.`;
|
||||||
|
const accountCheck = value.match(/^Account-Check: (\d+\/\d+) Login gültig, (\d+) mit Premium\.$/);
|
||||||
|
if (accountCheck) return `Account check: ${accountCheck[1]} logins valid, ${accountCheck[2]} with premium.`;
|
||||||
|
const removeAccount = value.match(/^Soll (.+) wirklich aus der Accountliste entfernt werden\?$/);
|
||||||
|
if (removeAccount) return `Remove ${removeAccount[1]} from the account list?`;
|
||||||
|
const resolved = value.match(/^Konflikte gelöst: (\d+) überschrieben, (\d+) übersprungen$/);
|
||||||
|
if (resolved) return `Conflicts resolved: ${resolved[1]} overwritten, ${resolved[2]} skipped`;
|
||||||
|
const importedDlc = value.match(/^DLC importiert: (\d+) Paket\(e\), (\d+) Link\(s\)$/);
|
||||||
|
if (importedDlc) return `DLC imported: ${importedDlc[1]} package(s), ${importedDlc[2]} link(s)`;
|
||||||
|
const imported = value.match(/^(Importiert|Drag-and-Drop): (\d+) Paket\(e\), (\d+) Link\(s\)$/);
|
||||||
|
if (imported) return `${imported[1] === "Importiert" ? "Imported" : "Drag and drop"}: ${imported[2]} package(s), ${imported[3]} link(s)`;
|
||||||
|
const warnings = value.match(/^(\d+) Fehler, (\d+) Warnungen \(letzte (\d+)\)$/);
|
||||||
|
if (warnings) return `${warnings[1]} errors, ${warnings[2]} warnings (latest ${warnings[3]})`;
|
||||||
|
const capture = value.match(/^Links für (.+) lokal erfassen\.$/);
|
||||||
|
if (capture) return `Capture links locally for ${capture[1]}.`;
|
||||||
|
const copy = value.match(/^(.+) Klicken zum Kopieren$/);
|
||||||
|
if (copy) return `Click to copy ${copy[1]}`;
|
||||||
|
const cancelledPart = value.match(/^· (\d+) abgebrochen$/);
|
||||||
|
if (cancelledPart) return `· ${cancelledPart[1]} cancelled`;
|
||||||
|
const quotaUnknown = value.match(/^(.+): unbekannt \((.+)\)$/);
|
||||||
|
if (quotaUnknown) return `${quotaUnknown[1]}: unknown (${quotaUnknown[2]})`;
|
||||||
|
const quota = value.match(/^(.+): (.+) frei von (.+) \((.+)% frei\) \| (.+)$/);
|
||||||
|
if (quota) return `${quota[1]}: ${quota[2]} free of ${quota[3]} (${quota[4]}% free) | ${quota[5]}`;
|
||||||
|
const namedLimit = value.match(/^(.+): (.+) Limit muss eine Zahl >= 0 sein\.$/);
|
||||||
|
if (namedLimit) return `${namedLimit[1]}: ${namedLimit[2]} limit must be a number >= 0.`;
|
||||||
|
const dailyBlocked = value.match(/^Tageslimit erreicht, bis zum Tagesreset gesperrt(.*)$/);
|
||||||
|
if (dailyBlocked) return `Daily limit reached, blocked until the daily reset${dailyBlocked[1]}`;
|
||||||
|
const failed = value.match(/^fehlgeschlagen(.*)$/);
|
||||||
|
if (failed) return `failed${failed[1]}`;
|
||||||
|
const timeout = value.match(/^Timeout\/Abbruch(.*) → nächster Account beim Retry$/);
|
||||||
|
if (timeout) return `Timeout/cancellation${timeout[1]} → next account on retry`;
|
||||||
|
const localLimit = value.match(/^Lokales Tageslimit erreicht \((.+) \/ (.+)\)\.$/);
|
||||||
|
if (localLimit) return `Local daily limit reached (${localLimit[1]} / ${localLimit[2]}).`;
|
||||||
|
const noUpdate = value.match(/^Kein Update verfügbar \(v(.+)\)$/);
|
||||||
|
if (noUpdate) return `No update available (v${noUpdate[1]})`;
|
||||||
|
const cookies = value.match(/^(\d+) BestDebrid-Cookies importiert$/);
|
||||||
|
if (cookies) return `${cookies[1]} BestDebrid cookies imported`;
|
||||||
|
const saved = value.match(/^(.+) gespeichert$/);
|
||||||
|
if (saved) return `${saved[1]} saved`;
|
||||||
|
const removeMega = value.match(/^Soll der Mega-Debrid-Account (.+) wirklich entfernt werden\?$/);
|
||||||
|
if (removeMega) return `Remove the Mega-Debrid account ${removeMega[1]}?`;
|
||||||
|
const removeKey = value.match(/^Soll der Debrid-Link-Key (.+) wirklich entfernt werden\?$/);
|
||||||
|
if (removeKey) return `Remove the Debrid-Link key ${removeKey[1]}?`;
|
||||||
|
const counterReset = value.match(/^(.+): Tageszähler zurückgesetzt$/);
|
||||||
|
if (counterReset) return `${counterReset[1]}: Daily counter reset`;
|
||||||
|
const actionFailed = value.match(/^(.+): (Reset|Umschalten|Aktion) fehlgeschlagen: (.+)$/);
|
||||||
|
if (actionFailed) return `${actionFailed[1]}: ${{ Reset: "Reset", Umschalten: "Toggle", Aktion: "Action" }[actionFailed[2] as "Reset" | "Umschalten" | "Aktion"]} failed: ${actionFailed[3]}`;
|
||||||
|
const toggleFailed = value.match(/^(.+) konnte nicht umgeschaltet werden: (.+)$/);
|
||||||
|
if (toggleFailed) return `${toggleFailed[1]} could not be toggled: ${toggleFailed[2]}`;
|
||||||
|
const historyQuestion = value.match(/^(\d+) Einträge aus dem Verlauf entfernen\?$/);
|
||||||
|
if (historyQuestion) return `Remove ${historyQuestion[1]} entries from history?`;
|
||||||
|
const historyRemoved = value.match(/^(\d+) Verlaufseinträge entfernt$/);
|
||||||
|
if (historyRemoved) return `${historyRemoved[1]} history entries removed`;
|
||||||
|
const packageCount = value.match(/^(\d+) Paket\(e\)$/);
|
||||||
|
if (packageCount) return `${packageCount[1]} package(s)`;
|
||||||
|
const linkCount = value.match(/^(\d+) Link\(s\)$/);
|
||||||
|
if (linkCount) return `${linkCount[1]} link(s)`;
|
||||||
|
const exportSelected = value.match(/^Ausgewählte (Pakete|Dateien) exportieren \((\d+)\)$/);
|
||||||
|
if (exportSelected) return `Export selected ${exportSelected[1] === "Pakete" ? "packages" : "files"} (${exportSelected[2]})`;
|
||||||
|
const toggleAll = value.match(/^Alle (.+) umschalten$/);
|
||||||
|
if (toggleAll) return `Toggle all ${toggleAll[1]}`;
|
||||||
|
const removeSelected = value.match(/^Ausgewählte entfernen \((\d+)\)$/);
|
||||||
|
if (removeSelected) return `Remove selected (${removeSelected[1]})`;
|
||||||
|
const copied = value.match(/^(.+) kopiert$/);
|
||||||
|
if (copied) return `${copied[1]} copied`;
|
||||||
|
const suffixes: Array<[RegExp, string]> = [
|
||||||
|
[/^(.+) auswählen$/, "Select $1"], [/^(.+) einklappen$/, "Collapse $1"], [/^(.+) ausklappen$/, "Expand $1"],
|
||||||
|
[/^(.+) aktivieren$/, "Enable $1"], [/^(.+) deaktivieren$/, "Disable $1"], [/^(.+) Aktionen$/, "$1 actions"], [/^(.+) entfernen$/, "Remove $1"]
|
||||||
|
];
|
||||||
|
for (const [pattern, replacement] of suffixes) if (pattern.test(value)) return value.replace(pattern, replacement);
|
||||||
|
return value
|
||||||
|
.replace(/Automatisch entpacken/g, "Extract automatically")
|
||||||
|
.replace(/Wartet auf Wiederholung/g, "Waiting to retry")
|
||||||
|
.replace(/Fehlgeschlagen nach (\d+) Versuchen/g, "Failed after $1 attempts")
|
||||||
|
.replace(/(\d+) Fehler/g, "$1 errors");
|
||||||
|
} else {
|
||||||
|
const update = value.match(/^(.+) is available\. Installed version: (.+)\.$/);
|
||||||
|
if (update) return `${update[1]} ist verfügbar. Installierte Version: ${update[2]}.`;
|
||||||
|
const pagination = value.match(/^(\d+\s*[–-]\s*\d+) of (\d+)$/);
|
||||||
|
if (pagination) return `${pagination[1]} von ${pagination[2]}`;
|
||||||
|
const schedule = value.match(/^Schedule rule (\d+)$/);
|
||||||
|
if (schedule) return `Zeitregel ${schedule[1]}`;
|
||||||
|
const scheduled = value.match(/^Scheduled: (.+)$/);
|
||||||
|
if (scheduled) return `Geplant: ${scheduled[1].replace(/^Today\b/, "Heute")}`;
|
||||||
|
const cancelled = value.match(/^(\d+) cancelled$/);
|
||||||
|
if (cancelled) return `${cancelled[1]} abgebrochen`;
|
||||||
|
const labelledCount = value.match(/^(Entries|Visible|Selected): (\d+)$/);
|
||||||
|
if (labelledCount) return `${({ Entries: "Einträge", Visible: "Sichtbar", Selected: "Ausgewählt" } as const)[labelledCount[1] as "Entries" | "Visible" | "Selected"]}: ${labelledCount[2]}`;
|
||||||
|
const perPage = value.match(/^(\d+) per page$/);
|
||||||
|
if (perPage) return `${perPage[1]} pro Seite`;
|
||||||
|
const filter = value.match(/^(All|Active|Queued|Paused|Completed|Errors) (\d+)$/);
|
||||||
|
if (filter) return `${enToDe.get(filter[1]) ?? filter[1]} ${filter[2]}`;
|
||||||
|
const remaining = value.match(/^(.+) of (.+) remaining$/);
|
||||||
|
if (remaining) return `${remaining[1]} von ${remaining[2]} übrig`;
|
||||||
|
const actionsFor = value.match(/^Actions for (.+)$/);
|
||||||
|
if (actionsFor) return `Aktionen für ${actionsFor[1]}`;
|
||||||
|
const assignment = value.match(/^Remove (.+) assignment$/);
|
||||||
|
if (assignment) return `${assignment[1]} Zuordnung entfernen`;
|
||||||
|
const providerFor = value.match(/^Provider for (.+)$/);
|
||||||
|
if (providerFor) return `Provider für ${providerFor[1]}`;
|
||||||
|
const move = value.match(/^Move (.+) (up|down)$/);
|
||||||
|
if (move) return `${move[1]} nach ${move[2] === "up" ? "oben" : "unten"}`;
|
||||||
|
const audio = value.match(/^Audio track: (.+)$/);
|
||||||
|
if (audio) return `Tonspur: ${audio[1].replace(/without DE tag/g, "ohne DE-Tag").replace(/ffmpeg missing/g, "ffmpeg fehlt").replace(/(\d+) errors/g, "$1 Fehler")}`;
|
||||||
|
const result = value.match(/^(\d+\/\d+) completed(.*)$/);
|
||||||
|
if (result) return `${result[1]} fertig${result[2].replace(/(\d+) errors/g, "$1 Fehler").replace(/(\d+) cancelled/g, "$1 abgebrochen")}`;
|
||||||
|
const extracting = value.match(/^Extracting (\d+%)$/);
|
||||||
|
if (extracting) return `Entpacken ${extracting[1]}`;
|
||||||
|
const checkedUntil = value.match(/^Account checked — (.+) until (.+)$/);
|
||||||
|
if (checkedUntil) return `Account geprüft — ${checkedUntil[1]} bis ${checkedUntil[2]}`;
|
||||||
|
const checked = value.match(/^Account checked — (.+)$/);
|
||||||
|
if (checked) return `Account geprüft — ${checked[1]}`;
|
||||||
|
const invalid = value.match(/^Invalid account — (.+)$/);
|
||||||
|
if (invalid) return `Account ungültig — ${invalid[1]}`;
|
||||||
|
const queueLinks = value.match(/^(\d+) link\(s\) added to the queue$/);
|
||||||
|
if (queueLinks) return `${queueLinks[1]} Link(s) zur Queue hinzugefügt`;
|
||||||
|
const packagesAndLinks = value.match(/^(\d+) package\(s\), (\d+) link\(s\) (added|imported|exported)$/);
|
||||||
|
if (packagesAndLinks) return `${packagesAndLinks[1]} Paket(e), ${packagesAndLinks[2]} Link(s) ${{ added: "hinzugefügt", imported: "importiert", exported: "exportiert" }[packagesAndLinks[3] as "added" | "imported" | "exported"]}`;
|
||||||
|
const selected = value.match(/^(\d+) selected$/);
|
||||||
|
if (selected) return `${selected[1]} ausgewählt`;
|
||||||
|
const ago = value.match(/^(\d+) (min|hr|day) ago$/);
|
||||||
|
if (ago) return `vor ${ago[1]} ${ago[2] === "min" ? "Min" : ago[2] === "hr" ? "Std" : "Tag"}`;
|
||||||
|
const validation = value.match(/^(.+): (Add at least one account|Enter an access token|Enter a login or email address|Enter a password|The daily limit must be a number >= 0|Enter at least one valid API key)\.$/);
|
||||||
|
if (validation) {
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
"Add at least one account": "Mindestens einen Account hinzufügen",
|
||||||
|
"Enter an access token": "Bitte Zugangstoken eintragen",
|
||||||
|
"Enter a login or email address": "Bitte Login oder E-Mail eintragen",
|
||||||
|
"Enter a password": "Bitte Passwort eintragen",
|
||||||
|
"The daily limit must be a number >= 0": "Tageslimit muss eine Zahl >= 0 sein",
|
||||||
|
"Enter at least one valid API key": "Bitte mindestens einen gültigen API-Key eintragen"
|
||||||
|
};
|
||||||
|
return `${validation[1]}: ${messages[validation[2]]}.`;
|
||||||
|
}
|
||||||
|
const clipboard = value.match(/^Clipboard: (\d+) link\(s\) detected$/);
|
||||||
|
if (clipboard) return `Zwischenablage: ${clipboard[1]} Link(s) erkannt`;
|
||||||
|
const disabledKeys = value.match(/^(\d+\/\d+) API keys disabled\.$/);
|
||||||
|
if (disabledKeys) return `${disabledKeys[1]} API-Keys deaktiviert.`;
|
||||||
|
const accountCheck = value.match(/^Account check: (\d+\/\d+) logins valid, (\d+) with premium\.$/);
|
||||||
|
if (accountCheck) return `Account-Check: ${accountCheck[1]} Login gültig, ${accountCheck[2]} mit Premium.`;
|
||||||
|
const removeAccount = value.match(/^Remove (.+) from the account list\?$/);
|
||||||
|
if (removeAccount) return `Soll ${removeAccount[1]} wirklich aus der Accountliste entfernt werden?`;
|
||||||
|
const resolved = value.match(/^Conflicts resolved: (\d+) overwritten, (\d+) skipped$/);
|
||||||
|
if (resolved) return `Konflikte gelöst: ${resolved[1]} überschrieben, ${resolved[2]} übersprungen`;
|
||||||
|
const importedDlc = value.match(/^DLC imported: (\d+) package\(s\), (\d+) link\(s\)$/);
|
||||||
|
if (importedDlc) return `DLC importiert: ${importedDlc[1]} Paket(e), ${importedDlc[2]} Link(s)`;
|
||||||
|
const imported = value.match(/^(Imported|Drag and drop): (\d+) package\(s\), (\d+) link\(s\)$/);
|
||||||
|
if (imported) return `${imported[1] === "Imported" ? "Importiert" : "Drag-and-Drop"}: ${imported[2]} Paket(e), ${imported[3]} Link(s)`;
|
||||||
|
const warnings = value.match(/^(\d+) errors, (\d+) warnings \(latest (\d+)\)$/);
|
||||||
|
if (warnings) return `${warnings[1]} Fehler, ${warnings[2]} Warnungen (letzte ${warnings[3]})`;
|
||||||
|
const capture = value.match(/^Capture links locally for (.+)\.$/);
|
||||||
|
if (capture) return `Links für ${capture[1]} lokal erfassen.`;
|
||||||
|
const copy = value.match(/^Click to copy (.+)$/);
|
||||||
|
if (copy) return `${copy[1]} Klicken zum Kopieren`;
|
||||||
|
const cancelledPart = value.match(/^· (\d+) cancelled$/);
|
||||||
|
if (cancelledPart) return `· ${cancelledPart[1]} abgebrochen`;
|
||||||
|
const quotaUnknown = value.match(/^(.+): unknown \((.+)\)$/);
|
||||||
|
if (quotaUnknown) return `${quotaUnknown[1]}: unbekannt (${quotaUnknown[2]})`;
|
||||||
|
const quota = value.match(/^(.+): (.+) free of (.+) \((.+)% free\) \| (.+)$/);
|
||||||
|
if (quota) return `${quota[1]}: ${quota[2]} frei von ${quota[3]} (${quota[4]}% frei) | ${quota[5]}`;
|
||||||
|
const namedLimit = value.match(/^(.+): (.+) limit must be a number >= 0\.$/);
|
||||||
|
if (namedLimit) return `${namedLimit[1]}: ${namedLimit[2]} Limit muss eine Zahl >= 0 sein.`;
|
||||||
|
const dailyBlocked = value.match(/^Daily limit reached, blocked until the daily reset(.*)$/);
|
||||||
|
if (dailyBlocked) return `Tageslimit erreicht, bis zum Tagesreset gesperrt${dailyBlocked[1]}`;
|
||||||
|
const failed = value.match(/^failed(.*)$/);
|
||||||
|
if (failed) return `fehlgeschlagen${failed[1]}`;
|
||||||
|
const timeout = value.match(/^Timeout\/cancellation(.*) → next account on retry$/);
|
||||||
|
if (timeout) return `Timeout/Abbruch${timeout[1]} → nächster Account beim Retry`;
|
||||||
|
const localLimit = value.match(/^Local daily limit reached \((.+) \/ (.+)\)\.$/);
|
||||||
|
if (localLimit) return `Lokales Tageslimit erreicht (${localLimit[1]} / ${localLimit[2]}).`;
|
||||||
|
const noUpdate = value.match(/^No update available \(v(.+)\)$/);
|
||||||
|
if (noUpdate) return `Kein Update verfügbar (v${noUpdate[1]})`;
|
||||||
|
const cookies = value.match(/^(\d+) BestDebrid cookies imported$/);
|
||||||
|
if (cookies) return `${cookies[1]} BestDebrid-Cookies importiert`;
|
||||||
|
const saved = value.match(/^(.+) saved$/);
|
||||||
|
if (saved) return `${saved[1]} gespeichert`;
|
||||||
|
const removeMega = value.match(/^Remove the Mega-Debrid account (.+)\?$/);
|
||||||
|
if (removeMega) return `Soll der Mega-Debrid-Account ${removeMega[1]} wirklich entfernt werden?`;
|
||||||
|
const removeKey = value.match(/^Remove the Debrid-Link key (.+)\?$/);
|
||||||
|
if (removeKey) return `Soll der Debrid-Link-Key ${removeKey[1]} wirklich entfernt werden?`;
|
||||||
|
const counterReset = value.match(/^(.+): Daily counter reset$/);
|
||||||
|
if (counterReset) return `${counterReset[1]}: Tageszähler zurückgesetzt`;
|
||||||
|
const actionFailed = value.match(/^(.+): (Reset|Toggle|Action) failed: (.+)$/);
|
||||||
|
if (actionFailed) return `${actionFailed[1]}: ${{ Reset: "Reset", Toggle: "Umschalten", Action: "Aktion" }[actionFailed[2] as "Reset" | "Toggle" | "Action"]} fehlgeschlagen: ${actionFailed[3]}`;
|
||||||
|
const toggleFailed = value.match(/^(.+) could not be toggled: (.+)$/);
|
||||||
|
if (toggleFailed) return `${toggleFailed[1]} konnte nicht umgeschaltet werden: ${toggleFailed[2]}`;
|
||||||
|
const historyQuestion = value.match(/^Remove (\d+) entries from history\?$/);
|
||||||
|
if (historyQuestion) return `${historyQuestion[1]} Einträge aus dem Verlauf entfernen?`;
|
||||||
|
const historyRemoved = value.match(/^(\d+) history entries removed$/);
|
||||||
|
if (historyRemoved) return `${historyRemoved[1]} Verlaufseinträge entfernt`;
|
||||||
|
const packageCount = value.match(/^(\d+) package\(s\)$/);
|
||||||
|
if (packageCount) return `${packageCount[1]} Paket(e)`;
|
||||||
|
const linkCount = value.match(/^(\d+) link\(s\)$/);
|
||||||
|
if (linkCount) return `${linkCount[1]} Link(s)`;
|
||||||
|
const exportSelected = value.match(/^Export selected (packages|files) \((\d+)\)$/);
|
||||||
|
if (exportSelected) return `Ausgewählte ${exportSelected[1] === "packages" ? "Pakete" : "Dateien"} exportieren (${exportSelected[2]})`;
|
||||||
|
const toggleAll = value.match(/^Toggle all (.+)$/);
|
||||||
|
if (toggleAll) return `Alle ${toggleAll[1]} umschalten`;
|
||||||
|
const removeSelected = value.match(/^Remove selected \((\d+)\)$/);
|
||||||
|
if (removeSelected) return `Ausgewählte entfernen (${removeSelected[1]})`;
|
||||||
|
const copied = value.match(/^(.+) copied$/);
|
||||||
|
if (copied) return `${copied[1]} kopiert`;
|
||||||
|
const suffixes: Array<[RegExp, string]> = [
|
||||||
|
[/^Select (.+)$/, "$1 auswählen"], [/^Collapse (.+)$/, "$1 einklappen"], [/^Expand (.+)$/, "$1 ausklappen"],
|
||||||
|
[/^Enable (.+)$/, "$1 aktivieren"], [/^Disable (.+)$/, "$1 deaktivieren"], [/^(.+) actions$/, "$1 Aktionen"], [/^Remove (.+)$/, "$1 entfernen"]
|
||||||
|
];
|
||||||
|
for (const [pattern, replacement] of suffixes) if (pattern.test(value)) return value.replace(pattern, replacement);
|
||||||
|
return value
|
||||||
|
.replace(/Extract automatically/g, "Automatisch entpacken")
|
||||||
|
.replace(/Waiting to retry/g, "Wartet auf Wiederholung")
|
||||||
|
.replace(/Failed after (\d+) attempts/g, "Fehlgeschlagen nach $1 Versuchen")
|
||||||
|
.replace(/(\d+) errors/g, "$1 Fehler");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateUiText(value: string, language: AppLanguage): string {
|
||||||
|
const leading = value.match(/^\s*/)?.[0] ?? "";
|
||||||
|
const trailing = value.match(/\s*$/)?.[0] ?? "";
|
||||||
|
const core = value.slice(leading.length, value.length - trailing.length);
|
||||||
|
if (!core) return value;
|
||||||
|
const translated = (language === "en" ? deToEn : enToDe).get(core) ?? translateDynamic(core, language);
|
||||||
|
return `${leading}${translated}${trailing}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Localizer = { setLanguage: (language: AppLanguage) => void; disconnect: () => void };
|
||||||
|
|
||||||
|
export function createUiLocalizer(documentRef: Document, initialLanguage: AppLanguage): Localizer {
|
||||||
|
let language = initialLanguage;
|
||||||
|
const sourceText = new WeakMap<Text, string>();
|
||||||
|
const renderedText = new WeakMap<Text, string>();
|
||||||
|
const sourceAttributes = new WeakMap<Element, Map<string, string>>();
|
||||||
|
const renderedAttributes = new WeakMap<Element, Map<string, string>>();
|
||||||
|
|
||||||
|
const ignored = (node: Node): boolean => node.parentElement?.closest('[data-i18n-ignore="true"]') !== null;
|
||||||
|
const localizeText = (node: Text): void => {
|
||||||
|
if (ignored(node)) return;
|
||||||
|
const current = node.data;
|
||||||
|
const lastRendered = renderedText.get(node);
|
||||||
|
if (!sourceText.has(node) || (lastRendered !== undefined && current !== lastRendered)) sourceText.set(node, current);
|
||||||
|
const next = translateUiText(sourceText.get(node) ?? current, language);
|
||||||
|
renderedText.set(node, next);
|
||||||
|
if (current !== next) node.data = next;
|
||||||
|
};
|
||||||
|
const localizeElement = (element: Element): void => {
|
||||||
|
if (element.closest('[data-i18n-ignore="true"]')) return;
|
||||||
|
let sources = sourceAttributes.get(element);
|
||||||
|
let rendered = renderedAttributes.get(element);
|
||||||
|
if (!sources) { sources = new Map(); sourceAttributes.set(element, sources); }
|
||||||
|
if (!rendered) { rendered = new Map(); renderedAttributes.set(element, rendered); }
|
||||||
|
for (const attribute of attributes) {
|
||||||
|
const current = element.getAttribute(attribute);
|
||||||
|
if (current === null) continue;
|
||||||
|
const lastRendered = rendered.get(attribute);
|
||||||
|
if (!sources.has(attribute) || (lastRendered !== undefined && current !== lastRendered)) sources.set(attribute, current);
|
||||||
|
const next = translateUiText(sources.get(attribute) ?? current, language);
|
||||||
|
rendered.set(attribute, next);
|
||||||
|
if (current !== next) element.setAttribute(attribute, next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const localizeTree = (root: Node): void => {
|
||||||
|
if (root.nodeType === Node.TEXT_NODE) localizeText(root as Text);
|
||||||
|
if (root.nodeType === Node.ELEMENT_NODE) localizeElement(root as Element);
|
||||||
|
const walker = documentRef.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
|
||||||
|
let node = walker.nextNode();
|
||||||
|
while (node) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) localizeText(node as Text);
|
||||||
|
else localizeElement(node as Element);
|
||||||
|
node = walker.nextNode();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
if (mutation.type === "characterData") localizeText(mutation.target as Text);
|
||||||
|
else if (mutation.type === "attributes") localizeElement(mutation.target as Element);
|
||||||
|
else for (const node of mutation.addedNodes) localizeTree(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const start = (): void => {
|
||||||
|
documentRef.documentElement.lang = language;
|
||||||
|
localizeTree(documentRef.body);
|
||||||
|
observer.observe(documentRef.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] });
|
||||||
|
};
|
||||||
|
start();
|
||||||
|
return {
|
||||||
|
setLanguage(nextLanguage) {
|
||||||
|
language = nextLanguage;
|
||||||
|
documentRef.documentElement.lang = language;
|
||||||
|
localizeTree(documentRef.body);
|
||||||
|
},
|
||||||
|
disconnect() { observer.disconnect(); }
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
import type { SessionState } from "../shared/types";
|
import type { SessionState } from "../shared/types";
|
||||||
|
|
||||||
/**
|
const DOWNLOAD_SELECTION_PRESERVE_SELECTOR = ".downloads-package-card, .downloads-item-row, .downloads-selection-cell, .package-card, .ctx-menu, .modal-backdrop, .modal-card";
|
||||||
|
|
||||||
|
export function shouldClearDownloadSelection(target: Pick<Element, "closest">): boolean {
|
||||||
|
return !target.closest(DOWNLOAD_SELECTION_PRESERVE_SELECTOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldClearDownloadSelectionOnEscape(tagName: string, inputType = ""): boolean {
|
||||||
|
const normalizedTag = tagName.toUpperCase();
|
||||||
|
if (normalizedTag === "TEXTAREA") return false;
|
||||||
|
if (normalizedTag !== "INPUT") return true;
|
||||||
|
return ["checkbox", "radio", "button"].includes(inputType.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
* Drop selected ids whose package OR item no longer exists in the session.
|
* Drop selected ids whose package OR item no longer exists in the session.
|
||||||
* The selection set mixes package and item ids; when entries vanish (delta
|
* The selection set mixes package and item ids; when entries vanish (delta
|
||||||
* removal, backup-driven session swap, completed-cleanup) a stale id would
|
* removal, backup-driven session swap, completed-cleanup) a stale id would
|
||||||
|
|||||||
@@ -1,38 +1,71 @@
|
|||||||
import type { ReactElement, ReactNode } from "react";
|
import { useEffect, useLayoutEffect, useRef, type ReactElement, type ReactNode } from "react";
|
||||||
|
import appIconUrl from "../../../assets/app_icon.png";
|
||||||
import { Icon } from "../ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
import { buildMainNavigation, type MainView } from "./shell-model";
|
import { buildMainNavigation, type MainView } from "./shell-model";
|
||||||
|
|
||||||
export interface AppHeaderProps {
|
export interface AppHeaderProps {
|
||||||
activeView: MainView;
|
activeView: MainView;
|
||||||
onViewChange: (view: MainView) => void;
|
onViewChange: (view: MainView) => void;
|
||||||
actions: ReactNode;
|
actions: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useHeaderLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||||
|
|
||||||
export function AppHeader({ activeView, onViewChange, actions }: AppHeaderProps): ReactElement {
|
export function AppHeader({ activeView, onViewChange, actions }: AppHeaderProps): ReactElement {
|
||||||
const navigation = buildMainNavigation(activeView);
|
const navigation = buildMainNavigation(activeView);
|
||||||
|
const navigationRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
return (
|
useHeaderLayoutEffect(() => {
|
||||||
<header className="md-shell-header" data-ui-region="header">
|
const element = navigationRef.current;
|
||||||
<div className="md-shell-brand">Multi-Debrid-Downloader</div>
|
if (!element) return;
|
||||||
<nav aria-label="Hauptnavigation" className="md-shell-navigation" role="tablist">
|
let frame = 0;
|
||||||
{navigation.map((item) => (
|
const sync = (): void => {
|
||||||
<button
|
cancelAnimationFrame(frame);
|
||||||
aria-current={item.active ? "page" : undefined}
|
frame = requestAnimationFrame(() => {
|
||||||
aria-selected={item.active}
|
const activeItem = element.querySelector<HTMLElement>(`.md-shell-navigation-item[data-main-view="${activeView}"]`);
|
||||||
|
if (!activeItem) return;
|
||||||
|
const navigationRect = element.getBoundingClientRect();
|
||||||
|
const itemRect = activeItem.getBoundingClientRect();
|
||||||
|
element.style.setProperty("--md-navigation-active-x", `${itemRect.left - navigationRect.left}px`);
|
||||||
|
element.style.setProperty("--md-navigation-active-y", `${itemRect.top - navigationRect.top}px`);
|
||||||
|
element.style.setProperty("--md-navigation-active-width", `${itemRect.width}px`);
|
||||||
|
element.style.setProperty("--md-navigation-active-height", `${itemRect.height}px`);
|
||||||
|
element.classList.add("has-active-indicator");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const observer = new ResizeObserver(sync);
|
||||||
|
observer.observe(element);
|
||||||
|
element.querySelectorAll<HTMLElement>(".md-shell-navigation-item").forEach((item) => observer.observe(item));
|
||||||
|
sync();
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [activeView]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="md-shell-header" data-ui-region="header">
|
||||||
|
<div className="md-shell-brand"><img alt="" className="md-shell-brand-mark" src={appIconUrl} /><span>Multi-Debrid Downloader</span></div>
|
||||||
|
<nav aria-label="Hauptnavigation" className="md-shell-navigation" ref={navigationRef} role="tablist">
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<button
|
||||||
|
aria-current={item.active ? "page" : undefined}
|
||||||
|
aria-selected={item.active}
|
||||||
className={`md-shell-navigation-item${item.active ? " is-active" : ""}`}
|
className={`md-shell-navigation-item${item.active ? " is-active" : ""}`}
|
||||||
data-visual-active-view={item.active ? item.id : undefined}
|
data-main-view={item.id}
|
||||||
key={item.id}
|
data-visual-active-view={item.active ? item.id : undefined}
|
||||||
onClick={() => onViewChange(item.id)}
|
key={item.id}
|
||||||
role="tab"
|
onClick={() => onViewChange(item.id)}
|
||||||
title={item.label}
|
role="tab"
|
||||||
type="button"
|
title={item.label}
|
||||||
>
|
type="button"
|
||||||
<Icon name={item.icon} size={18} />
|
>
|
||||||
<span>{item.label}</span>
|
<Icon name={item.icon} size={18} />
|
||||||
</button>
|
<span>{item.label}</span>
|
||||||
))}
|
</button>
|
||||||
</nav>
|
))}
|
||||||
{actions ? <div aria-label="Globale Aktionen" className="md-shell-header-actions" role="group">{actions}</div> : null}
|
</nav>
|
||||||
</header>
|
{actions ? <div aria-label="Globale Aktionen" className="md-shell-header-actions" role="group">{actions}</div> : null}
|
||||||
);
|
</header>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function UpdateExperience({
|
|||||||
{releaseNotes.trim() ? (
|
{releaseNotes.trim() ? (
|
||||||
<details className="md-update-release-notes">
|
<details className="md-update-release-notes">
|
||||||
<summary>Changelog anzeigen</summary>
|
<summary>Changelog anzeigen</summary>
|
||||||
<pre>{releaseNotes}</pre>
|
<pre data-i18n-ignore="true">{releaseNotes}</pre>
|
||||||
</details>
|
</details>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+860
-775
File diff suppressed because it is too large
Load Diff
+84
-25
@@ -1,24 +1,24 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: "Manrope", "Segoe UI Variable", "Segoe UI", sans-serif;
|
font-family: "Manrope", "Segoe UI Variable", "Segoe UI", sans-serif;
|
||||||
--bg: #050810;
|
--bg: #0f0f0f;
|
||||||
--bg-glow: #1e1730;
|
--bg-glow: #181818;
|
||||||
--surface: #0c1322;
|
--surface: #232323;
|
||||||
--card: #12192c;
|
--card: #232323;
|
||||||
--field: #090f1c;
|
--field: #1b1b1b;
|
||||||
--border: #283447;
|
--border: #3d3d3d;
|
||||||
--text: #ece6dd;
|
--text: #ffffff;
|
||||||
--muted: #a59c8e;
|
--muted: #a5a5a5;
|
||||||
--accent: #f2942d;
|
--accent: #4a4a4a;
|
||||||
--accent-2: #ff7a5c;
|
--accent-2: #636363;
|
||||||
--danger: #f4564b;
|
--danger: #f4564b;
|
||||||
--button-bg: #0d1a2c;
|
--button-bg: #2b2b2b;
|
||||||
--button-bg-hover: #12243d;
|
--button-bg-hover: #373737;
|
||||||
--tab-bg: #0b1321;
|
--tab-bg: #1b1b1b;
|
||||||
--tab-active: #14253e;
|
--tab-active: #383838;
|
||||||
--toast-bg: #0f1f33;
|
--toast-bg: #262626;
|
||||||
--toast-border: #2a4f78;
|
--toast-border: #4a4a4a;
|
||||||
--progress-track: #0b1628;
|
--progress-track: #1b1b1b;
|
||||||
--overlay: rgba(8, 13, 23, 0.85);
|
--overlay: rgba(0, 0, 0, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
@@ -48,9 +48,54 @@
|
|||||||
--overlay: rgba(229, 238, 252, 0.88);
|
--overlay: rgba(229, 238, 252, 0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-sliding-selection {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-sliding-selection::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: var(--ui-sliding-selection-width, 0px);
|
||||||
|
height: var(--ui-sliding-selection-height, 0px);
|
||||||
|
border: 1px solid var(--ui-sliding-selection-border, var(--ui-border));
|
||||||
|
border-radius: var(--ui-sliding-selection-radius, 6px);
|
||||||
|
background: var(--ui-sliding-selection-background, var(--ui-active));
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate3d(var(--ui-sliding-selection-x, 0px), var(--ui-sliding-selection-y, 0px), 0);
|
||||||
|
transition: transform var(--ui-sliding-selection-duration, 0ms) cubic-bezier(0.22, 0.76, 0.22, 1), width var(--ui-sliding-selection-duration, 0ms) cubic-bezier(0.22, 0.76, 0.22, 1), height var(--ui-sliding-selection-duration, 0ms) cubic-bezier(0.22, 0.76, 0.22, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-sliding-selection > [data-sliding-selection-item="true"] {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-sliding-selection-horizontal::before {
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
height: 2px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
background: var(--ui-accent);
|
||||||
|
transform: translate3d(var(--ui-sliding-selection-x, 0px), 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ui-sliding-selection::before {
|
||||||
|
transition-property: transform, width, height !important;
|
||||||
|
transition-duration: var(--ui-sliding-selection-duration, 0ms), var(--ui-sliding-selection-duration, 0ms), var(--ui-sliding-selection-duration, 0ms) !important;
|
||||||
|
transition-timing-function: cubic-bezier(0.22, 0.76, 0.22, 1), cubic-bezier(0.22, 0.76, 0.22, 1), cubic-bezier(0.22, 0.76, 0.22, 1) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
@@ -101,7 +146,7 @@ body,
|
|||||||
background: var(--button-bg-hover);
|
background: var(--button-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-dropdown {
|
.menu-dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -111,9 +156,23 @@ body,
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
white-space: nowrap;
|
opacity: 0;
|
||||||
}
|
transform: translateY(-8px);
|
||||||
|
transform-origin: top;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 150ms ease, transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), visibility 0s linear 180ms;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-dropdown.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
.menu-dropdown-item {
|
.menu-dropdown-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+394
-392
@@ -1,392 +1,394 @@
|
|||||||
:root,
|
:root,
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
--ui-canvas: #0F0F0F;
|
--ui-canvas: #0F0F0F;
|
||||||
--ui-surface: #232323;
|
--ui-surface: #232323;
|
||||||
--ui-input: #2B2B2B;
|
--ui-input: #2B2B2B;
|
||||||
--ui-table-header: #313131;
|
--ui-table-header: #313131;
|
||||||
--ui-active: #333436;
|
--ui-active: #333436;
|
||||||
--ui-hover: #373535;
|
--ui-hover: #373535;
|
||||||
--ui-tooltip: #4F4D4D;
|
--ui-tooltip: #4F4D4D;
|
||||||
--ui-border: #3D3D3D;
|
--ui-border: #3D3D3D;
|
||||||
--ui-text: #FFFFFF;
|
--ui-text: #FFFFFF;
|
||||||
--ui-text-secondary: #EAEDF3;
|
--ui-text-secondary: #EAEDF3;
|
||||||
--ui-text-muted: #919191;
|
--ui-text-muted: #919191;
|
||||||
--ui-primary: #BAD0FC;
|
--ui-primary: #D6D6D6;
|
||||||
--ui-primary-hover: #8AA5DC;
|
--ui-primary-hover: #E6E6E6;
|
||||||
--ui-accent: #3886FF;
|
--ui-accent: #4A4A4A;
|
||||||
--ui-warning: #F1C786;
|
--ui-success: #4ADE80;
|
||||||
--ui-danger: #F06464;
|
--ui-warning: #F1C786;
|
||||||
--ui-error-action-text: #181A1F;
|
--ui-danger: #F06464;
|
||||||
--ui-modal-secondary: #35383D;
|
--ui-error-action-text: #181A1F;
|
||||||
--ui-overlay: rgba(0, 0, 0, 0.60);
|
--ui-modal-secondary: #35383D;
|
||||||
color-scheme: dark;
|
--ui-overlay: rgba(0, 0, 0, 0.60);
|
||||||
}
|
color-scheme: dark;
|
||||||
|
}
|
||||||
:root[data-theme="light"] {
|
|
||||||
--ui-canvas: #F3F4F6;
|
:root[data-theme="light"] {
|
||||||
--ui-surface: #FFFFFF;
|
--ui-canvas: #F3F4F6;
|
||||||
--ui-input: #F7F8FA;
|
--ui-surface: #FFFFFF;
|
||||||
--ui-table-header: #E7E9ED;
|
--ui-input: #F7F8FA;
|
||||||
--ui-active: #DEE6F5;
|
--ui-table-header: #E7E9ED;
|
||||||
--ui-hover: #E8ECF3;
|
--ui-active: #DEE6F5;
|
||||||
--ui-tooltip: #35383D;
|
--ui-hover: #E8ECF3;
|
||||||
--ui-border: #D0D4DB;
|
--ui-tooltip: #35383D;
|
||||||
--ui-text: #181A1F;
|
--ui-border: #D0D4DB;
|
||||||
--ui-text-secondary: #343842;
|
--ui-text: #181A1F;
|
||||||
--ui-text-muted: #667085;
|
--ui-text-secondary: #343842;
|
||||||
--ui-primary: #A9C2F3;
|
--ui-text-muted: #667085;
|
||||||
--ui-primary-hover: #8AA5DC;
|
--ui-primary: #3A3A3A;
|
||||||
--ui-accent: #256FDB;
|
--ui-primary-hover: #202020;
|
||||||
--ui-warning: #E8B85D;
|
--ui-accent: #5E5E5E;
|
||||||
--ui-danger: #D94747;
|
--ui-success: #1E9E55;
|
||||||
--ui-error-action-text: #181A1F;
|
--ui-warning: #E8B85D;
|
||||||
--ui-modal-secondary: #E7E9ED;
|
--ui-danger: #D94747;
|
||||||
--ui-overlay: rgba(0, 0, 0, 0.45);
|
--ui-error-action-text: #181A1F;
|
||||||
color-scheme: light;
|
--ui-modal-secondary: #E7E9ED;
|
||||||
}
|
--ui-overlay: rgba(0, 0, 0, 0.45);
|
||||||
|
color-scheme: light;
|
||||||
*,
|
}
|
||||||
*::before,
|
|
||||||
*::after {
|
*,
|
||||||
box-sizing: border-box;
|
*::before,
|
||||||
}
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
html,
|
}
|
||||||
body,
|
|
||||||
#root {
|
html,
|
||||||
min-width: 0;
|
body,
|
||||||
min-height: 100%;
|
#root {
|
||||||
}
|
min-width: 0;
|
||||||
|
min-height: 100%;
|
||||||
body {
|
}
|
||||||
margin: 0;
|
|
||||||
background: var(--ui-canvas);
|
body {
|
||||||
color: var(--ui-text);
|
margin: 0;
|
||||||
font-family: Inter, "Segoe UI Variable", "Segoe UI", sans-serif;
|
background: var(--ui-canvas);
|
||||||
font-size: 14px;
|
color: var(--ui-text);
|
||||||
font-weight: 400;
|
font-family: Inter, "Segoe UI Variable", "Segoe UI", sans-serif;
|
||||||
line-height: 20px;
|
font-size: 14px;
|
||||||
text-rendering: optimizeLegibility;
|
font-weight: 400;
|
||||||
user-select: none;
|
line-height: 20px;
|
||||||
}
|
text-rendering: optimizeLegibility;
|
||||||
|
user-select: none;
|
||||||
button,
|
}
|
||||||
input,
|
|
||||||
select,
|
button,
|
||||||
textarea {
|
input,
|
||||||
font: inherit;
|
select,
|
||||||
}
|
textarea {
|
||||||
|
font: inherit;
|
||||||
button {
|
}
|
||||||
color: inherit;
|
|
||||||
}
|
button {
|
||||||
|
color: inherit;
|
||||||
input,
|
}
|
||||||
textarea,
|
|
||||||
[contenteditable="true"] {
|
input,
|
||||||
user-select: text;
|
textarea,
|
||||||
}
|
[contenteditable="true"] {
|
||||||
|
user-select: text;
|
||||||
* {
|
}
|
||||||
scrollbar-color: var(--ui-border) transparent;
|
|
||||||
scrollbar-width: thin;
|
* {
|
||||||
}
|
scrollbar-color: var(--ui-border) transparent;
|
||||||
|
scrollbar-width: thin;
|
||||||
*::-webkit-scrollbar {
|
}
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
*::-webkit-scrollbar {
|
||||||
}
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
*::-webkit-scrollbar-thumb {
|
}
|
||||||
border: 2px solid transparent;
|
|
||||||
border-radius: 999px;
|
*::-webkit-scrollbar-thumb {
|
||||||
background: var(--ui-border);
|
border: 2px solid transparent;
|
||||||
background-clip: padding-box;
|
border-radius: 999px;
|
||||||
}
|
background: var(--ui-border);
|
||||||
|
background-clip: padding-box;
|
||||||
*::-webkit-scrollbar-track {
|
}
|
||||||
background: transparent;
|
|
||||||
}
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
.ui-icon {
|
}
|
||||||
display: block;
|
|
||||||
flex: 0 0 auto;
|
.ui-icon {
|
||||||
pointer-events: none;
|
display: block;
|
||||||
}
|
flex: 0 0 auto;
|
||||||
|
pointer-events: none;
|
||||||
.ui-input {
|
}
|
||||||
min-width: 0;
|
|
||||||
height: 36px;
|
.ui-input {
|
||||||
border: 1px solid var(--ui-border);
|
min-width: 0;
|
||||||
border-radius: 6px;
|
height: 36px;
|
||||||
outline: none;
|
border: 1px solid var(--ui-border);
|
||||||
background: var(--ui-input);
|
border-radius: 6px;
|
||||||
color: var(--ui-text);
|
outline: none;
|
||||||
}
|
background: var(--ui-input);
|
||||||
|
color: var(--ui-text);
|
||||||
.ui-input::placeholder {
|
}
|
||||||
color: var(--ui-text-muted);
|
|
||||||
opacity: 1;
|
.ui-input::placeholder {
|
||||||
}
|
color: var(--ui-text-muted);
|
||||||
|
opacity: 1;
|
||||||
.ui-input:disabled {
|
}
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.55;
|
.ui-input:disabled {
|
||||||
}
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
:where(
|
}
|
||||||
button,
|
|
||||||
[href],
|
:where(
|
||||||
input,
|
button,
|
||||||
select,
|
[href],
|
||||||
textarea,
|
input,
|
||||||
summary,
|
select,
|
||||||
[tabindex]:not([tabindex="-1"])
|
textarea,
|
||||||
):focus-visible {
|
summary,
|
||||||
outline: 2px solid var(--ui-accent);
|
[tabindex]:not([tabindex="-1"])
|
||||||
outline-offset: 2px;
|
):focus-visible {
|
||||||
}
|
outline: 2px solid var(--ui-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
.ui-toolbar {
|
}
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
.ui-toolbar {
|
||||||
min-height: 59px;
|
display: flex;
|
||||||
align-items: center;
|
min-width: 0;
|
||||||
gap: 8px;
|
min-height: 59px;
|
||||||
padding: 11px 12px 12px;
|
align-items: center;
|
||||||
border-bottom: 1px solid var(--ui-border);
|
gap: 8px;
|
||||||
background: var(--ui-surface);
|
padding: 11px 12px 12px;
|
||||||
}
|
border-bottom: 1px solid var(--ui-border);
|
||||||
|
background: var(--ui-surface);
|
||||||
.ui-toolbar-group {
|
}
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
.ui-toolbar-group {
|
||||||
align-items: center;
|
display: flex;
|
||||||
gap: 4px;
|
min-width: 0;
|
||||||
}
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
.ui-toolbar-search {
|
}
|
||||||
position: relative;
|
|
||||||
display: flex;
|
.ui-toolbar-search {
|
||||||
width: min(280px, 100%);
|
position: relative;
|
||||||
margin-left: auto;
|
display: flex;
|
||||||
align-items: center;
|
width: min(280px, 100%);
|
||||||
}
|
margin-left: auto;
|
||||||
|
align-items: center;
|
||||||
.ui-toolbar-search-icon {
|
}
|
||||||
position: absolute;
|
|
||||||
left: 10px;
|
.ui-toolbar-search-icon {
|
||||||
color: var(--ui-text-muted);
|
position: absolute;
|
||||||
}
|
left: 10px;
|
||||||
|
color: var(--ui-text-muted);
|
||||||
.ui-toolbar-search-input {
|
}
|
||||||
width: 100%;
|
|
||||||
padding: 7px 12px 7px 34px;
|
.ui-toolbar-search-input {
|
||||||
}
|
width: 100%;
|
||||||
|
padding: 7px 12px 7px 34px;
|
||||||
.ui-data-table {
|
}
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
.ui-data-table {
|
||||||
min-height: 0;
|
display: flex;
|
||||||
flex: 1 1 auto;
|
min-width: 0;
|
||||||
flex-direction: column;
|
min-height: 0;
|
||||||
overflow: hidden;
|
flex: 1 1 auto;
|
||||||
background: var(--ui-surface);
|
flex-direction: column;
|
||||||
color: var(--ui-text);
|
overflow: hidden;
|
||||||
}
|
background: var(--ui-surface);
|
||||||
|
color: var(--ui-text);
|
||||||
.ui-data-table-header {
|
}
|
||||||
min-height: 41px;
|
|
||||||
flex: 0 0 41px;
|
.ui-data-table-header {
|
||||||
border-bottom: 1px solid var(--ui-border);
|
min-height: 41px;
|
||||||
background: var(--ui-table-header);
|
flex: 0 0 41px;
|
||||||
color: var(--ui-text-secondary);
|
border-bottom: 1px solid var(--ui-border);
|
||||||
}
|
background: var(--ui-table-header);
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
.ui-data-table-body {
|
}
|
||||||
position: relative;
|
|
||||||
display: flex;
|
.ui-data-table-body {
|
||||||
min-width: 0;
|
position: relative;
|
||||||
min-height: 0;
|
display: flex;
|
||||||
flex: 1 1 auto;
|
min-width: 0;
|
||||||
flex-direction: column;
|
min-height: 0;
|
||||||
overflow: auto;
|
flex: 1 1 auto;
|
||||||
}
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
.ui-data-table-empty {
|
}
|
||||||
display: flex;
|
|
||||||
min-height: 100%;
|
.ui-data-table-empty {
|
||||||
flex: 1 1 auto;
|
display: flex;
|
||||||
}
|
min-height: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
.ui-data-table-empty-cell {
|
}
|
||||||
display: flex;
|
|
||||||
max-width: 440px;
|
.ui-data-table-empty-cell {
|
||||||
margin: auto;
|
display: flex;
|
||||||
align-items: center;
|
max-width: 440px;
|
||||||
flex-direction: column;
|
margin: auto;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
padding: 32px 24px;
|
flex-direction: column;
|
||||||
color: var(--ui-text-muted);
|
gap: 8px;
|
||||||
text-align: center;
|
padding: 32px 24px;
|
||||||
}
|
color: var(--ui-text-muted);
|
||||||
|
text-align: center;
|
||||||
.ui-data-table-empty-illustration {
|
}
|
||||||
display: grid;
|
|
||||||
width: 48px;
|
.ui-data-table-empty-illustration {
|
||||||
height: 48px;
|
display: grid;
|
||||||
margin-bottom: 4px;
|
width: 48px;
|
||||||
place-items: center;
|
height: 48px;
|
||||||
color: var(--ui-text-secondary);
|
margin-bottom: 4px;
|
||||||
}
|
place-items: center;
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
.ui-data-table-empty-title {
|
}
|
||||||
color: var(--ui-text);
|
|
||||||
font-size: 18px;
|
.ui-data-table-empty-title {
|
||||||
font-weight: 600;
|
color: var(--ui-text);
|
||||||
line-height: 24px;
|
font-size: 18px;
|
||||||
}
|
font-weight: 600;
|
||||||
|
line-height: 24px;
|
||||||
.ui-data-table-empty-description {
|
}
|
||||||
color: var(--ui-text-muted);
|
|
||||||
}
|
.ui-data-table-empty-description {
|
||||||
|
color: var(--ui-text-muted);
|
||||||
.ui-data-table-footer {
|
}
|
||||||
display: flex;
|
|
||||||
min-height: 61px;
|
.ui-data-table-footer {
|
||||||
flex: 0 0 61px;
|
display: flex;
|
||||||
align-items: center;
|
min-height: 61px;
|
||||||
justify-content: flex-end;
|
flex: 0 0 61px;
|
||||||
gap: 24px;
|
align-items: center;
|
||||||
padding: 0 16px;
|
justify-content: flex-end;
|
||||||
border-top: 1px solid var(--ui-border);
|
gap: 24px;
|
||||||
background: var(--ui-surface);
|
padding: 0 16px;
|
||||||
color: var(--ui-text-secondary);
|
border-top: 1px solid var(--ui-border);
|
||||||
font-size: 12px;
|
background: var(--ui-surface);
|
||||||
line-height: 16px;
|
color: var(--ui-text-secondary);
|
||||||
}
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
.ui-context-info {
|
}
|
||||||
position: absolute;
|
|
||||||
bottom: 16px;
|
.ui-context-info {
|
||||||
left: 16px;
|
position: absolute;
|
||||||
z-index: 20;
|
bottom: 16px;
|
||||||
display: flex;
|
left: 16px;
|
||||||
align-items: flex-end;
|
z-index: 20;
|
||||||
gap: 8px;
|
display: flex;
|
||||||
}
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
.ui-context-info-trigger {
|
}
|
||||||
display: inline-grid;
|
|
||||||
width: 32px;
|
.ui-context-info-trigger {
|
||||||
height: 32px;
|
display: inline-grid;
|
||||||
padding: 0;
|
width: 32px;
|
||||||
place-items: center;
|
height: 32px;
|
||||||
border: 1px solid var(--ui-border);
|
padding: 0;
|
||||||
border-radius: 6px;
|
place-items: center;
|
||||||
background: var(--ui-surface);
|
border: 1px solid var(--ui-border);
|
||||||
color: var(--ui-text-secondary);
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
background: var(--ui-surface);
|
||||||
}
|
color: var(--ui-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
.ui-context-info-trigger:hover {
|
}
|
||||||
background: var(--ui-hover);
|
|
||||||
color: var(--ui-text);
|
.ui-context-info-trigger:hover {
|
||||||
}
|
background: var(--ui-hover);
|
||||||
|
color: var(--ui-text);
|
||||||
.ui-context-info-region {
|
}
|
||||||
width: min(360px, calc(100vw - 80px));
|
|
||||||
max-height: min(320px, calc(100vh - 96px));
|
.ui-context-info-region {
|
||||||
overflow: auto;
|
width: min(360px, calc(100vw - 80px));
|
||||||
padding: 12px 16px;
|
max-height: min(320px, calc(100vh - 96px));
|
||||||
border: 1px solid var(--ui-border);
|
overflow: auto;
|
||||||
border-radius: 8px;
|
padding: 12px 16px;
|
||||||
background: var(--ui-tooltip);
|
border: 1px solid var(--ui-border);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
border-radius: 8px;
|
||||||
color: #FFFFFF;
|
background: var(--ui-tooltip);
|
||||||
}
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||||
|
color: #FFFFFF;
|
||||||
.ui-error-boundary {
|
}
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
.ui-error-boundary {
|
||||||
z-index: 10000;
|
position: fixed;
|
||||||
overflow: auto;
|
inset: 0;
|
||||||
padding: clamp(24px, 5vw, 48px);
|
z-index: 10000;
|
||||||
background: var(--ui-canvas);
|
overflow: auto;
|
||||||
color: var(--ui-text);
|
padding: clamp(24px, 5vw, 48px);
|
||||||
text-align: center;
|
background: var(--ui-canvas);
|
||||||
}
|
color: var(--ui-text);
|
||||||
|
text-align: center;
|
||||||
.ui-error-boundary-content {
|
}
|
||||||
display: flex;
|
|
||||||
width: min(640px, 100%);
|
.ui-error-boundary-content {
|
||||||
min-height: 100%;
|
display: flex;
|
||||||
margin: auto;
|
width: min(640px, 100%);
|
||||||
flex-direction: column;
|
min-height: 100%;
|
||||||
align-items: center;
|
margin: auto;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
gap: 16px;
|
align-items: center;
|
||||||
}
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
.ui-error-boundary-title {
|
}
|
||||||
margin: 0;
|
|
||||||
font-size: 20px;
|
.ui-error-boundary-title {
|
||||||
font-weight: 600;
|
margin: 0;
|
||||||
line-height: 28px;
|
font-size: 20px;
|
||||||
}
|
font-weight: 600;
|
||||||
|
line-height: 28px;
|
||||||
.ui-error-boundary-description {
|
}
|
||||||
max-width: 560px;
|
|
||||||
margin: 0;
|
.ui-error-boundary-description {
|
||||||
color: var(--ui-text-secondary);
|
max-width: 560px;
|
||||||
}
|
margin: 0;
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
.ui-error-boundary-details {
|
}
|
||||||
width: 100%;
|
|
||||||
max-height: min(200px, 40vh);
|
.ui-error-boundary-details {
|
||||||
margin: 0;
|
width: 100%;
|
||||||
overflow: auto;
|
max-height: min(200px, 40vh);
|
||||||
padding: 12px;
|
margin: 0;
|
||||||
border: 1px solid var(--ui-border);
|
overflow: auto;
|
||||||
border-radius: 6px;
|
padding: 12px;
|
||||||
background: var(--ui-input);
|
border: 1px solid var(--ui-border);
|
||||||
color: var(--ui-danger);
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
background: var(--ui-input);
|
||||||
line-height: 18px;
|
color: var(--ui-danger);
|
||||||
overflow-wrap: anywhere;
|
font-size: 12px;
|
||||||
text-align: left;
|
line-height: 18px;
|
||||||
user-select: text;
|
overflow-wrap: anywhere;
|
||||||
white-space: pre-wrap;
|
text-align: left;
|
||||||
}
|
user-select: text;
|
||||||
|
white-space: pre-wrap;
|
||||||
.ui-error-boundary-reload {
|
}
|
||||||
min-height: 40px;
|
|
||||||
padding: 8px 20px;
|
.ui-error-boundary-reload {
|
||||||
border: 0;
|
min-height: 40px;
|
||||||
border-radius: 6px;
|
padding: 8px 20px;
|
||||||
background: var(--ui-primary);
|
border: 0;
|
||||||
color: var(--ui-error-action-text);
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
background: var(--ui-primary);
|
||||||
font-weight: 600;
|
color: var(--ui-error-action-text);
|
||||||
}
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
.ui-error-boundary-reload:hover {
|
}
|
||||||
background: var(--ui-primary-hover);
|
|
||||||
}
|
.ui-error-boundary-reload:hover {
|
||||||
|
background: var(--ui-primary-hover);
|
||||||
.ui-error-boundary-details:focus-visible,
|
}
|
||||||
.ui-error-boundary-reload:focus-visible {
|
|
||||||
outline: 2px solid var(--ui-accent);
|
.ui-error-boundary-details:focus-visible,
|
||||||
outline-offset: 2px;
|
.ui-error-boundary-reload:focus-visible {
|
||||||
}
|
outline: 2px solid var(--ui-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
@media (prefers-reduced-motion: reduce) {
|
}
|
||||||
*,
|
|
||||||
*::before,
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*::after {
|
*,
|
||||||
animation-duration: 0.01ms !important;
|
*::before,
|
||||||
animation-iteration-count: 1 !important;
|
*::after {
|
||||||
scroll-behavior: auto !important;
|
animation-duration: 0.01ms !important;
|
||||||
transition-duration: 0.01ms !important;
|
animation-iteration-count: 1 !important;
|
||||||
}
|
scroll-behavior: auto !important;
|
||||||
}
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef, useState, type ReactElement } from "react";
|
||||||
|
|
||||||
|
export type RollingMetricDirection = "up" | "down" | "none";
|
||||||
|
|
||||||
|
interface RollingMetricValueProps {
|
||||||
|
numericValue: number;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RollingMetricTransition {
|
||||||
|
direction: Exclude<RollingMetricDirection, "none">;
|
||||||
|
from: string;
|
||||||
|
id: number;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useMetricLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||||
|
|
||||||
|
export function getRollingMetricDirection(previous: number, next: number): RollingMetricDirection {
|
||||||
|
if (next > previous) return "up";
|
||||||
|
if (next < previous) return "down";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RollingMetricValue({ numericValue, value }: RollingMetricValueProps): ReactElement {
|
||||||
|
const previousRef = useRef({ numericValue, value });
|
||||||
|
const sequenceRef = useRef(0);
|
||||||
|
const outgoingRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const incomingRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const [transition, setTransition] = useState<RollingMetricTransition | null>(null);
|
||||||
|
|
||||||
|
useMetricLayoutEffect(() => {
|
||||||
|
const previous = previousRef.current;
|
||||||
|
if (previous.value === value && previous.numericValue === numericValue) return;
|
||||||
|
previousRef.current = { numericValue, value };
|
||||||
|
const direction = getRollingMetricDirection(previous.numericValue, numericValue);
|
||||||
|
if (direction === "none") {
|
||||||
|
setTransition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sequenceRef.current += 1;
|
||||||
|
setTransition({ direction, from: previous.value, id: sequenceRef.current, to: value });
|
||||||
|
}, [numericValue, value]);
|
||||||
|
|
||||||
|
useMetricLayoutEffect(() => {
|
||||||
|
if (!transition || !outgoingRef.current || !incomingRef.current) return;
|
||||||
|
const distance = transition.direction === "up" ? -1 : 1;
|
||||||
|
const options: KeyframeAnimationOptions = { duration: 320, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" };
|
||||||
|
const outgoing = outgoingRef.current.animate([
|
||||||
|
{ opacity: 1, transform: "translateY(0)" },
|
||||||
|
{ opacity: 0, transform: `translateY(${distance * 115}%)` }
|
||||||
|
], options);
|
||||||
|
const incoming = incomingRef.current.animate([
|
||||||
|
{ opacity: 0, transform: `translateY(${-distance * 115}%)` },
|
||||||
|
{ opacity: 1, transform: "translateY(0)" }
|
||||||
|
], options);
|
||||||
|
let active = true;
|
||||||
|
Promise.all([outgoing.finished, incoming.finished]).then(() => {
|
||||||
|
if (active) setTransition((current) => current?.id === transition.id ? null : current);
|
||||||
|
}).catch(() => {});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
outgoing.cancel();
|
||||||
|
incoming.cancel();
|
||||||
|
};
|
||||||
|
}, [transition]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<strong aria-label={value} className="downloads-rolling-value" data-direction={transition?.direction ?? "none"}>
|
||||||
|
{transition
|
||||||
|
? <><span aria-hidden="true" className="downloads-rolling-value-layer is-outgoing" ref={outgoingRef}>{transition.from}</span><span aria-hidden="true" className="downloads-rolling-value-layer is-incoming" ref={incomingRef}>{transition.to}</span></>
|
||||||
|
: <span aria-hidden="true" className="downloads-rolling-value-layer">{value}</span>}
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { createElement, useEffect, useLayoutEffect, useRef, type HTMLAttributes, type ReactElement, type ReactNode } from "react";
|
||||||
|
|
||||||
|
const useSelectionLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||||
|
|
||||||
|
export interface SlidingSelectionProps extends HTMLAttributes<HTMLElement> {
|
||||||
|
activeKey: string;
|
||||||
|
as?: "div" | "nav";
|
||||||
|
axis: "horizontal" | "vertical";
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleSelectionLayout(
|
||||||
|
hasPosition: boolean,
|
||||||
|
frame: number,
|
||||||
|
apply: () => void,
|
||||||
|
requestFrame: (callback: FrameRequestCallback) => number = requestAnimationFrame,
|
||||||
|
cancelFrame: (frame: number) => void = cancelAnimationFrame,
|
||||||
|
enableTransitions: () => void = () => {}
|
||||||
|
): number {
|
||||||
|
if (!hasPosition) {
|
||||||
|
apply();
|
||||||
|
return requestFrame(enableTransitions);
|
||||||
|
}
|
||||||
|
cancelFrame(frame);
|
||||||
|
return requestFrame(apply);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SlidingSelection({ activeKey, as = "div", axis, children, className = "", ...attributes }: SlidingSelectionProps): ReactElement {
|
||||||
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
const hasPositionRef = useRef(false);
|
||||||
|
|
||||||
|
useSelectionLayoutEffect(() => {
|
||||||
|
const element = ref.current;
|
||||||
|
if (!element) return;
|
||||||
|
let frame = 0;
|
||||||
|
let transitionFrame = 0;
|
||||||
|
const applyLayout = (): void => {
|
||||||
|
const active = element.querySelector<HTMLElement>('[data-sliding-selection-active="true"]');
|
||||||
|
if (!active) {
|
||||||
|
element.style.setProperty("--ui-sliding-selection-width", "0px");
|
||||||
|
element.style.setProperty("--ui-sliding-selection-height", "0px");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const containerRect = element.getBoundingClientRect();
|
||||||
|
const activeRect = active.getBoundingClientRect();
|
||||||
|
element.style.setProperty("--ui-sliding-selection-x", `${activeRect.left - containerRect.left + element.scrollLeft}px`);
|
||||||
|
element.style.setProperty("--ui-sliding-selection-y", `${activeRect.top - containerRect.top + element.scrollTop}px`);
|
||||||
|
element.style.setProperty("--ui-sliding-selection-width", `${activeRect.width}px`);
|
||||||
|
element.style.setProperty("--ui-sliding-selection-height", `${activeRect.height}px`);
|
||||||
|
hasPositionRef.current = true;
|
||||||
|
};
|
||||||
|
const sync = (): void => {
|
||||||
|
if (!hasPositionRef.current) {
|
||||||
|
transitionFrame = scheduleSelectionLayout(false, transitionFrame, applyLayout, requestAnimationFrame, cancelAnimationFrame, () => {
|
||||||
|
if (hasPositionRef.current) {
|
||||||
|
element.style.setProperty("--ui-sliding-selection-duration", "420ms");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frame = scheduleSelectionLayout(hasPositionRef.current, frame, applyLayout);
|
||||||
|
};
|
||||||
|
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(sync);
|
||||||
|
observer?.observe(element);
|
||||||
|
element.querySelectorAll<HTMLElement>('[data-sliding-selection-item="true"]').forEach((item) => observer?.observe(item));
|
||||||
|
element.addEventListener("scroll", sync, { passive: true });
|
||||||
|
sync();
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
cancelAnimationFrame(transitionFrame);
|
||||||
|
observer?.disconnect();
|
||||||
|
element.removeEventListener("scroll", sync);
|
||||||
|
};
|
||||||
|
}, [activeKey]);
|
||||||
|
|
||||||
|
return createElement(as, {
|
||||||
|
...attributes,
|
||||||
|
className: `ui-sliding-selection ui-sliding-selection-${axis}${className ? ` ${className}` : ""}`,
|
||||||
|
ref
|
||||||
|
}, children);
|
||||||
|
}
|
||||||
@@ -1,208 +1,209 @@
|
|||||||
import type { ChangeEvent, ReactElement } from "react";
|
import type { ChangeEvent, ReactElement } from "react";
|
||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
DataTableBody,
|
DataTableBody,
|
||||||
DataTableEmpty,
|
DataTableEmpty,
|
||||||
DataTableHeader
|
DataTableHeader
|
||||||
} from "../../ui/DataTable";
|
} from "../../ui/DataTable";
|
||||||
import { Dialog } from "../../ui/Dialog";
|
import { Dialog } from "../../ui/Dialog";
|
||||||
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||||
import type { CollectorViewModel } from "./collector-model";
|
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||||
import "./collector.css";
|
import type { CollectorViewModel } from "./collector-model";
|
||||||
|
import "./collector.css";
|
||||||
export interface CollectorViewActions {
|
|
||||||
onTabSelect: (tabId: string) => void;
|
export interface CollectorViewActions {
|
||||||
onTabAdd: () => void;
|
onTabSelect: (tabId: string) => void;
|
||||||
onTabRemove: (tabId: string) => void;
|
onTabAdd: () => void;
|
||||||
onOpenInput: () => void;
|
onTabRemove: (tabId: string) => void;
|
||||||
onImportDlc: () => void;
|
onOpenInput: () => void;
|
||||||
onImportFile: () => void;
|
onImportDlc: () => void;
|
||||||
onExportQueue: () => void;
|
onImportFile: () => void;
|
||||||
onSubmit: () => void;
|
onExportQueue: () => void;
|
||||||
onQueryChange: (value: string) => void;
|
onSubmit: () => void;
|
||||||
onSelectionChange: (rowId: string) => void;
|
onQueryChange: (value: string) => void;
|
||||||
onRemoveSelected: () => void;
|
onSelectionChange: (rowId: string) => void;
|
||||||
}
|
onRemoveSelected: () => void;
|
||||||
|
}
|
||||||
export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content";
|
|
||||||
|
export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content";
|
||||||
export interface CollectorViewProps {
|
|
||||||
model: CollectorViewModel;
|
export interface CollectorViewProps {
|
||||||
actions: CollectorViewActions;
|
model: CollectorViewModel;
|
||||||
region?: CollectorViewRegion;
|
actions: CollectorViewActions;
|
||||||
}
|
region?: CollectorViewRegion;
|
||||||
|
}
|
||||||
export interface CollectorInputDialogProps {
|
|
||||||
open: boolean;
|
export interface CollectorInputDialogProps {
|
||||||
tabName: string;
|
open: boolean;
|
||||||
value: string;
|
tabName: string;
|
||||||
onChange: (value: string) => void;
|
value: string;
|
||||||
onClose: () => void;
|
onChange: (value: string) => void;
|
||||||
onCommit: () => void;
|
onClose: () => void;
|
||||||
}
|
onCommit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactElement {
|
export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div aria-label="Sammlungen" className="collector-sidebar" data-visual-region="collector-sidebar">
|
<div aria-label="Sammlungen" className="collector-sidebar" data-visual-region="collector-sidebar">
|
||||||
<div className="collector-sidebar-heading">
|
<div className="collector-sidebar-heading">
|
||||||
<strong>Sammlungen</strong>
|
<strong>Sammlungen</strong>
|
||||||
<span>{model.tabs.length}</span>
|
<span>{model.tabs.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="collector-sidebar-list">
|
<SlidingSelection activeKey={model.activeTabId} axis="vertical" className="collector-sidebar-list">
|
||||||
{model.tabs.map((tab) => (
|
{model.tabs.map((tab) => (
|
||||||
<div className={`collector-sidebar-item${tab.id === model.activeTabId ? " is-active" : ""}`} key={tab.id}>
|
<div className={`collector-sidebar-item${tab.id === model.activeTabId ? " is-active" : ""}`} data-sliding-selection-active={tab.id === model.activeTabId} data-sliding-selection-item="true" key={tab.id}>
|
||||||
<button
|
<button
|
||||||
aria-current={tab.id === model.activeTabId ? "page" : undefined}
|
aria-current={tab.id === model.activeTabId ? "page" : undefined}
|
||||||
className="collector-sidebar-select"
|
className="collector-sidebar-select"
|
||||||
onClick={() => actions.onTabSelect(tab.id)}
|
onClick={() => actions.onTabSelect(tab.id)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>{tab.name}</span>
|
<span>{tab.name}</span>
|
||||||
<span className="collector-sidebar-count">{tab.linkCount}</span>
|
<span className="collector-sidebar-count">{tab.linkCount}</span>
|
||||||
</button>
|
</button>
|
||||||
{model.tabs.length > 1 ? (
|
{model.tabs.length > 1 ? (
|
||||||
<button
|
<button
|
||||||
aria-label={`${tab.name} entfernen`}
|
aria-label={`${tab.name} entfernen`}
|
||||||
className="collector-sidebar-remove"
|
className="collector-sidebar-remove"
|
||||||
onClick={() => actions.onTabRemove(tab.id)}
|
onClick={() => actions.onTabRemove(tab.id)}
|
||||||
type="button"
|
type="button"
|
||||||
>×</button>
|
>×</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</SlidingSelection>
|
||||||
<button className="collector-sidebar-add" onClick={actions.onTabAdd} type="button">Neue Sammlung</button>
|
<button className="collector-sidebar-add" onClick={actions.onTabAdd} type="button">Neue Sammlung</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
|
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
|
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
|
||||||
<ToolbarGroup label="Links erfassen">
|
<ToolbarGroup label="Links erfassen">
|
||||||
<button className="collector-action collector-action-primary" disabled={model.busy} onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
|
<button className="collector-action collector-action-primary" disabled={model.busy} onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
|
||||||
<button className="collector-action" disabled={model.busy} onClick={actions.onImportDlc} type="button">DLC importieren</button>
|
<button className="collector-action" disabled={model.busy} onClick={actions.onImportDlc} type="button">DLC importieren</button>
|
||||||
<button className="collector-action" disabled={model.busy} onClick={actions.onImportFile} type="button">Datei importieren</button>
|
<button className="collector-action" disabled={model.busy} onClick={actions.onImportFile} type="button">Datei importieren</button>
|
||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
<ToolbarGroup label="Sammlung verarbeiten">
|
<ToolbarGroup label="Sammlung verarbeiten">
|
||||||
<button className="collector-action" disabled={model.busy} onClick={actions.onExportQueue} type="button">Queue exportieren</button>
|
<button className="collector-action" disabled={model.busy} onClick={actions.onExportQueue} type="button">Queue exportieren</button>
|
||||||
<button className="collector-action" disabled={model.busy || model.tabs.length === 0} onClick={actions.onSubmit} type="button">An Downloads übergeben</button>
|
<button className="collector-action" disabled={model.busy || model.tabs.length === 0} onClick={actions.onSubmit} type="button">An Downloads übergeben</button>
|
||||||
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
|
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
|
||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
<ToolbarSearch
|
<ToolbarSearch
|
||||||
label="Links durchsuchen"
|
label="Links durchsuchen"
|
||||||
onChange={(event) => actions.onQueryChange(event.target.value)}
|
onChange={(event) => actions.onQueryChange(event.target.value)}
|
||||||
placeholder="Links durchsuchen"
|
placeholder="Links durchsuchen"
|
||||||
value={model.query}
|
value={model.query}
|
||||||
/>
|
/>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
|
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
|
||||||
const selected = new Set(model.selectedIds);
|
const selected = new Set(model.selectedIds);
|
||||||
return (
|
return (
|
||||||
<section className="collector-content" aria-label="Gesammelte Links">
|
<section className="collector-content" aria-label="Gesammelte Links">
|
||||||
<DataTable className="collector-table" label="Gesammelte Links">
|
<DataTable className="collector-table" label="Gesammelte Links">
|
||||||
<DataTableHeader className="collector-table-header">
|
<DataTableHeader className="collector-table-header">
|
||||||
<div className="collector-table-header-row" role="row">
|
<div className="collector-table-header-row" role="row">
|
||||||
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
|
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
|
||||||
<span role="columnheader">Sammlung</span>
|
<span role="columnheader">Sammlung</span>
|
||||||
<span role="columnheader">URL oder Rohzeile</span>
|
<span role="columnheader">URL oder Rohzeile</span>
|
||||||
<span role="columnheader">Zeile</span>
|
<span role="columnheader">Zeile</span>
|
||||||
<span role="columnheader">Status</span>
|
<span role="columnheader">Status</span>
|
||||||
</div>
|
</div>
|
||||||
</DataTableHeader>
|
</DataTableHeader>
|
||||||
<DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
|
<DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
|
||||||
{model.busy ? (
|
{model.busy ? (
|
||||||
<DataTableEmpty title="Links werden verarbeitet" description="Die laufende Aktion wird abgeschlossen." />
|
<DataTableEmpty title="Links werden verarbeitet" description="Die laufende Aktion wird abgeschlossen." />
|
||||||
) : model.error ? (
|
) : model.error ? (
|
||||||
<DataTableEmpty className="collector-table-error" title={model.error} description="Die lokale Sammlung bleibt unverändert." />
|
<DataTableEmpty className="collector-table-error" title={model.error} description="Die lokale Sammlung bleibt unverändert." />
|
||||||
) : model.empty ? (
|
) : model.empty ? (
|
||||||
<DataTableEmpty
|
<DataTableEmpty
|
||||||
data-visual-region="collector-empty-state"
|
data-visual-region="collector-empty-state"
|
||||||
description={model.query ? "Passe die Suche an oder lösche den Filter." : "Füge Links hinzu oder importiere eine vorhandene Liste."}
|
description={model.query ? "Passe die Suche an oder lösche den Filter." : "Füge Links hinzu oder importiere eine vorhandene Liste."}
|
||||||
title={model.query ? "Keine passenden Links" : "Noch keine Links"}
|
title={model.query ? "Keine passenden Links" : "Noch keine Links"}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
model.rows.map((row) => (
|
model.rows.map((row) => (
|
||||||
<div className={`collector-row${selected.has(row.id) ? " is-selected" : ""}`} key={row.id} role="row">
|
<div className={`collector-row${selected.has(row.id) ? " is-selected" : ""}`} key={row.id} role="row">
|
||||||
<span className="collector-column-select" role="cell">
|
<span className="collector-column-select" role="cell">
|
||||||
<input
|
<input
|
||||||
aria-label="Link auswählen"
|
aria-label="Link auswählen"
|
||||||
checked={selected.has(row.id)}
|
checked={selected.has(row.id)}
|
||||||
onChange={() => actions.onSelectionChange(row.id)}
|
onChange={() => actions.onSelectionChange(row.id)}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="collector-row-source" role="cell">{row.tabName}</span>
|
<span className="collector-row-source" role="cell">{row.tabName}</span>
|
||||||
<span className="collector-row-value" role="cell" title={row.value}>{row.value}</span>
|
<span className="collector-row-value" role="cell" title={row.value}>{row.value}</span>
|
||||||
<span className="collector-row-line" role="cell">{row.lineNumber}</span>
|
<span className="collector-row-line" role="cell">{row.lineNumber}</span>
|
||||||
<span className="collector-row-status" role="cell">Lokal</span>
|
<span className="collector-row-status" role="cell">Lokal</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</DataTableBody>
|
</DataTableBody>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement {
|
export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement {
|
||||||
if (region === "sidebar") {
|
if (region === "sidebar") {
|
||||||
return <CollectorSidebar actions={actions} model={model} />;
|
return <CollectorSidebar actions={actions} model={model} />;
|
||||||
}
|
}
|
||||||
if (region === "toolbar") {
|
if (region === "toolbar") {
|
||||||
return <CollectorToolbar actions={actions} model={model} />;
|
return <CollectorToolbar actions={actions} model={model} />;
|
||||||
}
|
}
|
||||||
if (region === "content") {
|
if (region === "content") {
|
||||||
return <CollectorContent actions={actions} model={model} />;
|
return <CollectorContent actions={actions} model={model} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="collector-view">
|
<div className="collector-view">
|
||||||
<CollectorSidebar actions={actions} model={model} />
|
<CollectorSidebar actions={actions} model={model} />
|
||||||
<div className="collector-view-main">
|
<div className="collector-view-main">
|
||||||
<CollectorToolbar actions={actions} model={model} />
|
<CollectorToolbar actions={actions} model={model} />
|
||||||
<CollectorContent actions={actions} model={model} />
|
<CollectorContent actions={actions} model={model} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CollectorInputDialog({
|
export function CollectorInputDialog({
|
||||||
open,
|
open,
|
||||||
tabName,
|
tabName,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
onCommit
|
onCommit
|
||||||
}: CollectorInputDialogProps): ReactElement | null {
|
}: CollectorInputDialogProps): ReactElement | null {
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
actions={(
|
actions={(
|
||||||
<>
|
<>
|
||||||
<button className="collector-dialog-secondary" onClick={onClose} type="button">Abbrechen</button>
|
<button className="collector-dialog-secondary" onClick={onClose} type="button">Abbrechen</button>
|
||||||
<button className="collector-dialog-primary" onClick={onCommit} type="button">Übernehmen</button>
|
<button className="collector-dialog-primary" onClick={onCommit} type="button">Übernehmen</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
description={`Links für ${tabName} lokal erfassen.`}
|
description={`Links für ${tabName} lokal erfassen.`}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
open={open}
|
open={open}
|
||||||
size="wide"
|
size="wide"
|
||||||
title="Links hinzufügen"
|
title="Links hinzufügen"
|
||||||
>
|
>
|
||||||
<label className="collector-input-label">
|
<label className="collector-input-label">
|
||||||
<span>Links</span>
|
<span>Links</span>
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="Links"
|
aria-label="Links"
|
||||||
autoFocus
|
autoFocus
|
||||||
className="collector-input"
|
className="collector-input"
|
||||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)}
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)}
|
||||||
placeholder="Eine URL oder Rohzeile pro Zeile"
|
placeholder="Eine URL oder Rohzeile pro Zeile"
|
||||||
rows={12}
|
rows={12}
|
||||||
value={value}
|
value={value}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,309 +1,309 @@
|
|||||||
.collector-view {
|
.collector-view {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 270px minmax(0, 1fr);
|
grid-template-columns: 270px minmax(0, 1fr);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-view-main {
|
.collector-view-main {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-sidebar {
|
.collector-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-sidebar-heading {
|
.collector-sidebar-heading {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--ui-text-secondary);
|
color: var(--ui-text-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-sidebar-heading span,
|
.collector-sidebar-heading span,
|
||||||
.collector-sidebar-count {
|
.collector-sidebar-count {
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-muted);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-sidebar-list {
|
.collector-sidebar-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-sidebar-item {
|
.collector-sidebar-item {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-sidebar-item:hover {
|
.collector-sidebar-item:hover {
|
||||||
background: var(--ui-hover);
|
background: var(--ui-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-sidebar-item.is-active {
|
.collector-sidebar-item.is-active {
|
||||||
background: var(--ui-active);
|
|
||||||
border-color: var(--ui-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-sidebar-select,
|
|
||||||
.collector-sidebar-remove,
|
|
||||||
.collector-sidebar-add {
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border-color: transparent;
|
||||||
color: var(--ui-text-secondary);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-sidebar-select {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
gap: 8px;
|
|
||||||
justify-content: space-between;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0 8px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-sidebar-select span:first-child {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-sidebar-remove {
|
|
||||||
border-left: 1px solid var(--ui-border);
|
|
||||||
min-width: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-sidebar-add {
|
|
||||||
border: 1px solid var(--ui-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
min-height: 36px;
|
|
||||||
padding: 0 10px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-sidebar-add:hover,
|
|
||||||
.collector-sidebar-remove:hover {
|
|
||||||
background: var(--ui-hover);
|
|
||||||
color: var(--ui-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-toolbar {
|
|
||||||
min-width: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-action {
|
|
||||||
background: var(--ui-input);
|
|
||||||
border: 1px solid var(--ui-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--ui-text-secondary);
|
|
||||||
font: inherit;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-action:hover:not(:disabled) {
|
|
||||||
background: var(--ui-hover);
|
|
||||||
color: var(--ui-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-action-primary {
|
|
||||||
background: var(--ui-primary);
|
|
||||||
border-color: var(--ui-primary);
|
|
||||||
color: #181A1F;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-action-primary:hover:not(:disabled) {
|
|
||||||
background: var(--ui-primary-hover);
|
|
||||||
color: #181A1F;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-action-danger:not(:disabled) {
|
|
||||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
|
||||||
color: var(--ui-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-action:disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-content {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-table {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-table-header-row,
|
|
||||||
.collector-row {
|
|
||||||
align-items: center;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 48px minmax(140px, 0.8fr) minmax(320px, 3fr) 90px 100px;
|
|
||||||
min-width: 760px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-table-header {
|
|
||||||
height: 41px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-table-header-row {
|
|
||||||
color: var(--ui-text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
height: 41px;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-table-header-row > span,
|
|
||||||
.collector-row > span {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-table-body {
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-row {
|
|
||||||
border-bottom: 1px solid var(--ui-border);
|
|
||||||
color: var(--ui-text-secondary);
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-row:hover {
|
|
||||||
background: var(--ui-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-row.is-selected {
|
|
||||||
background: var(--ui-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-column-select {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-row-source {
|
|
||||||
font-weight: 600;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-row-value {
|
|
||||||
color: var(--ui-text);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
user-select: text;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-row-line,
|
|
||||||
.collector-row-status {
|
|
||||||
color: var(--ui-text-muted);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-table-error .ui-data-table-empty-title {
|
|
||||||
color: var(--ui-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-input-label {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-input-label > span {
|
|
||||||
color: var(--ui-text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-input {
|
|
||||||
background: var(--ui-input);
|
|
||||||
border: 1px solid var(--ui-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--ui-text);
|
|
||||||
font: inherit;
|
|
||||||
line-height: 1.5;
|
|
||||||
min-height: 240px;
|
|
||||||
padding: 12px;
|
|
||||||
resize: vertical;
|
|
||||||
user-select: text;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-dialog-primary,
|
|
||||||
.collector-dialog-secondary {
|
|
||||||
border: 1px solid var(--ui-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
font: inherit;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-dialog-primary {
|
|
||||||
background: var(--ui-primary);
|
|
||||||
border-color: var(--ui-primary);
|
|
||||||
color: #181A1F;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-dialog-secondary {
|
|
||||||
background: var(--ui-modal-secondary);
|
|
||||||
color: var(--ui-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1366px) {
|
|
||||||
.collector-view {
|
|
||||||
grid-template-columns: 56px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-sidebar {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-action {
|
|
||||||
padding: 0 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collector-table-header-row,
|
|
||||||
.collector-row {
|
|
||||||
grid-template-columns: 44px minmax(120px, 0.7fr) minmax(280px, 2.4fr) 70px 86px;
|
|
||||||
min-width: 660px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1120px) {
|
|
||||||
.collector-table-header-row,
|
|
||||||
.collector-row {
|
|
||||||
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
|
|
||||||
min-width: 610px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collector-sidebar-select,
|
||||||
|
.collector-sidebar-remove,
|
||||||
|
.collector-sidebar-add {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-sidebar-select {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-sidebar-select span:first-child {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-sidebar-remove {
|
||||||
|
border-left: 1px solid var(--ui-border);
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-sidebar-add {
|
||||||
|
border: 1px solid var(--ui-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-sidebar-add:hover,
|
||||||
|
.collector-sidebar-remove:hover {
|
||||||
|
background: var(--ui-hover);
|
||||||
|
color: var(--ui-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-toolbar {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-action {
|
||||||
|
background: var(--ui-input);
|
||||||
|
border: 1px solid var(--ui-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-action:hover:not(:disabled) {
|
||||||
|
background: var(--ui-hover);
|
||||||
|
color: var(--ui-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-action-primary {
|
||||||
|
background: var(--ui-primary);
|
||||||
|
border-color: var(--ui-primary);
|
||||||
|
color: #181A1F;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-action-primary:hover:not(:disabled) {
|
||||||
|
background: var(--ui-primary-hover);
|
||||||
|
color: #181A1F;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-action-danger:not(:disabled) {
|
||||||
|
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||||
|
color: var(--ui-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-action:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-content {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-table {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-table-header-row,
|
||||||
|
.collector-row {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 48px minmax(140px, 0.8fr) minmax(320px, 3fr) 90px 100px;
|
||||||
|
min-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-table-header {
|
||||||
|
height: 41px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-table-header-row {
|
||||||
|
color: var(--ui-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
height: 41px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-table-header-row > span,
|
||||||
|
.collector-row > span {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-table-body {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-row {
|
||||||
|
border-bottom: 1px solid var(--ui-border);
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-row:hover {
|
||||||
|
background: var(--ui-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-row.is-selected {
|
||||||
|
background: var(--ui-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-column-select {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-row-source {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-row-value {
|
||||||
|
color: var(--ui-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
user-select: text;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-row-line,
|
||||||
|
.collector-row-status {
|
||||||
|
color: var(--ui-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-table-error .ui-data-table-empty-title {
|
||||||
|
color: var(--ui-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-input-label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-input-label > span {
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-input {
|
||||||
|
background: var(--ui-input);
|
||||||
|
border: 1px solid var(--ui-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--ui-text);
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 240px;
|
||||||
|
padding: 12px;
|
||||||
|
resize: vertical;
|
||||||
|
user-select: text;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-dialog-primary,
|
||||||
|
.collector-dialog-secondary {
|
||||||
|
border: 1px solid var(--ui-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font: inherit;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-dialog-primary {
|
||||||
|
background: var(--ui-primary);
|
||||||
|
border-color: var(--ui-primary);
|
||||||
|
color: #181A1F;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-dialog-secondary {
|
||||||
|
background: var(--ui-modal-secondary);
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1366px) {
|
||||||
|
.collector-view {
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-sidebar {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-action {
|
||||||
|
padding: 0 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-table-header-row,
|
||||||
|
.collector-row {
|
||||||
|
grid-template-columns: 44px minmax(120px, 0.7fr) minmax(280px, 2.4fr) 70px 86px;
|
||||||
|
min-width: 660px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.collector-table-header-row,
|
||||||
|
.collector-row {
|
||||||
|
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
|
||||||
|
min-width: 610px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { memo, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from "react";
|
import { memo, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactElement } from "react";
|
||||||
import type { DownloadItem } from "../../../shared/types";
|
import type { DownloadItem } from "../../../shared/types";
|
||||||
import {
|
import {
|
||||||
compactProviderLabels,
|
compactProviderLabels,
|
||||||
extractHoster,
|
extractHoster,
|
||||||
formatAudioStripSummary,
|
formatAudioStripSummary,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
|
formatHosterLabel,
|
||||||
formatSpeedMbps,
|
formatSpeedMbps,
|
||||||
humanSize,
|
humanSize,
|
||||||
providerLabels
|
providerLabels
|
||||||
@@ -13,24 +14,77 @@ import type { DownloadPackageRow } from "./downloads-model";
|
|||||||
|
|
||||||
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress";
|
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress";
|
||||||
|
|
||||||
|
const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px";
|
||||||
|
const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px";
|
||||||
|
const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||||
|
|
||||||
|
type HosterLabel = ReturnType<typeof formatHosterLabel>;
|
||||||
|
|
||||||
|
function HosterLabelContent({ label }: { label: HosterLabel }): ReactElement {
|
||||||
|
const [iconFailed, setIconFailed] = useState(false);
|
||||||
|
if (label.iconSrc && !iconFailed) return <img alt="" className="downloads-hoster-icon" data-hoster={label.title.toLowerCase()} onError={() => setIconFailed(true)} src={label.iconSrc} />;
|
||||||
|
return <span>{label.compact}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HosterLabels({ labels }: { labels: HosterLabel[] }): ReactElement {
|
||||||
|
return (
|
||||||
|
<span className="downloads-cell downloads-hoster-cell" title={labels.map((label) => label.title).join(", ")}>
|
||||||
|
{labels.map((label) => <HosterLabelContent key={label.title} label={label} />)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadGridTemplate(gridTemplate: string): string {
|
||||||
|
return `${DOWNLOAD_SELECTION_COLUMN_WIDTH} ${gridTemplate} ${DOWNLOAD_ACTION_COLUMN_WIDTH}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const downloadColumnDefinitions: Record<string, { label: string; width: string; sortable?: DownloadSortColumn }> = {
|
export const downloadColumnDefinitions: Record<string, { label: string; width: string; sortable?: DownloadSortColumn }> = {
|
||||||
name: { label: "Name", width: "minmax(0, 0.92fr)", sortable: "name" },
|
name: { label: "Name", width: "minmax(290px, 2.3fr)", sortable: "name" },
|
||||||
size: { label: "Geladen / Größe", width: "160px", sortable: "size" },
|
size: { label: "Geladen / Größe", width: "minmax(140px, 1.1fr)", sortable: "size" },
|
||||||
progress: { label: "Fortschritt", width: "80px", sortable: "progress" },
|
progress: { label: "Fortschritt", width: "minmax(105px, 0.85fr)", sortable: "progress" },
|
||||||
hoster: { label: "Hoster", width: "110px", sortable: "hoster" },
|
hoster: { label: "Hoster", width: "minmax(90px, 0.85fr)", sortable: "hoster" },
|
||||||
account: { label: "Service", width: "132px" },
|
account: { label: "Service", width: "minmax(90px, 0.85fr)" },
|
||||||
prio: { label: "Priorität", width: "70px" },
|
prio: { label: "Priorität", width: "minmax(85px, 0.8fr)" },
|
||||||
status: { label: "Status", width: "160px" },
|
status: { label: "Status", width: "minmax(90px, 0.85fr)" },
|
||||||
speed: { label: "Geschwindigkeit", width: "90px" },
|
speed: { label: "Geschwindigkeit", width: "minmax(120px, 1fr)" },
|
||||||
added: { label: "Hinzugefügt am", width: "155px" }
|
availability: { label: "Verfügbarkeit", width: "minmax(110px, 1fr)" },
|
||||||
|
added: { label: "Hinzugefügt am", width: "minmax(135px, 1fr)" }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AvailabilityState = "online" | "partial" | "offline" | "checking";
|
||||||
|
|
||||||
|
export function getAvailabilitySummary(items: DownloadItem[]): { online: number; total: number; state: AvailabilityState } {
|
||||||
|
const total = items.length;
|
||||||
|
const online = items.filter((item) => item.onlineStatus === "online").length;
|
||||||
|
const offline = items.filter((item) => item.onlineStatus === "offline").length;
|
||||||
|
if (total > 0 && online === total) return { online, total, state: "online" };
|
||||||
|
if (total > 0 && offline === total) return { online, total, state: "offline" };
|
||||||
|
if (total > 0 && online + offline === total) return { online, total, state: "partial" };
|
||||||
|
return { online, total, state: "checking" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function Availability({ online, total, state, text }: { online: number; total: number; state: AvailabilityState; text?: string }): ReactElement {
|
||||||
|
const symbol = state === "online" ? "✓" : state === "offline" ? "×" : "●";
|
||||||
|
const label = text ?? `${online}/${total} online`;
|
||||||
|
if (text) {
|
||||||
|
return <span aria-label={label} className={`downloads-cell downloads-availability is-${state}`} title={label}><span aria-hidden="true" className="downloads-availability-symbol">{symbol}</span><span className="downloads-availability-label">{text}</span></span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span aria-label={label} className={`downloads-cell downloads-availability has-counts is-${state}`} title={label}>
|
||||||
|
<span aria-hidden="true" className="downloads-availability-symbol">{symbol}</span>
|
||||||
|
<span className="downloads-availability-count is-online-count">{online}</span>
|
||||||
|
<span aria-hidden="true" className="downloads-availability-separator">/</span>
|
||||||
|
<span className="downloads-availability-count is-total-count">{total}</span>
|
||||||
|
<span className="downloads-availability-label">online</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadsTableActions {
|
export interface DownloadsTableActions {
|
||||||
onSetVisibleSelection: (ids: string[], selected: boolean) => void;
|
onSetVisibleSelection: (ids: string[], selected: boolean) => void;
|
||||||
onToggleSelection: (id: string, ctrlKey: boolean, shiftKey: boolean) => void;
|
onToggleSelection: (id: string, ctrlKey: boolean, shiftKey: boolean) => void;
|
||||||
onSelectionMouseDown: (id: string, event: ReactMouseEvent) => void;
|
onSelectionMouseDown: (id: string, event: ReactMouseEvent) => void;
|
||||||
onSelectionMouseEnter: (id: string) => void;
|
onSelectionMouseEnter: (id: string) => void;
|
||||||
onTogglePackage: (packageId: string) => void;
|
|
||||||
onTogglePackageCollapse: (packageId: string) => void;
|
onTogglePackageCollapse: (packageId: string) => void;
|
||||||
onStartPackageRename: (packageId: string, packageName: string) => void;
|
onStartPackageRename: (packageId: string, packageName: string) => void;
|
||||||
onPackageRenameChange: (name: string) => void;
|
onPackageRenameChange: (name: string) => void;
|
||||||
@@ -41,11 +95,10 @@ export interface DownloadsTableActions {
|
|||||||
onMovePackageDown: (packageId: string) => void;
|
onMovePackageDown: (packageId: string) => void;
|
||||||
onRemoveItem: (itemId: string) => void;
|
onRemoveItem: (itemId: string) => void;
|
||||||
onOpenContextMenu: (id: string, x: number, y: number, packageId?: string) => void;
|
onOpenContextMenu: (id: string, x: number, y: number, packageId?: string) => void;
|
||||||
onColumnDragStart: (column: string, event: DragEvent<HTMLDivElement>) => void;
|
onColumnPointerDown: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
onColumnDragOver: (column: string, event: DragEvent<HTMLDivElement>) => void;
|
onColumnPointerMove: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
onColumnDragLeave: () => void;
|
onColumnPointerUp: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
onColumnDrop: (column: string, event: DragEvent<HTMLDivElement>) => void;
|
onColumnPointerCancel: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
onColumnDragEnd: () => void;
|
|
||||||
onColumnContextMenu: (column: string, x: number, y: number) => void;
|
onColumnContextMenu: (column: string, x: number, y: number) => void;
|
||||||
onSortColumn: (column: DownloadSortColumn) => void;
|
onSortColumn: (column: DownloadSortColumn) => void;
|
||||||
}
|
}
|
||||||
@@ -85,12 +138,18 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean):
|
|||||||
}
|
}
|
||||||
if (column === "hoster") {
|
if (column === "hoster") {
|
||||||
const hoster = extractHoster(item.url);
|
const hoster = extractHoster(item.url);
|
||||||
return <span className="downloads-cell" title={hoster}>{hoster}</span>;
|
const label = formatHosterLabel(hoster);
|
||||||
|
return <HosterLabels labels={[label]} />;
|
||||||
}
|
}
|
||||||
if (column === "account") return <span className="downloads-cell">{item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}</span>;
|
if (column === "account") return <span className="downloads-cell">{item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}</span>;
|
||||||
if (column === "prio") return <span className="downloads-cell" />;
|
if (column === "prio") return <span className="downloads-cell" />;
|
||||||
if (column === "status") return <span className="downloads-cell" title={statusTitle}>{displayStatus}</span>;
|
if (column === "status") return <span className="downloads-cell" title={statusTitle}>{displayStatus}</span>;
|
||||||
if (column === "speed") return <span className="downloads-cell">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
|
if (column === "speed") return <span className="downloads-cell">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
|
||||||
|
if (column === "availability") {
|
||||||
|
const state = item.onlineStatus === "online" ? "online" : item.onlineStatus === "offline" ? "offline" : "checking";
|
||||||
|
const text = state === "online" ? "Online" : state === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Prüfung" : "Ungeprüft";
|
||||||
|
return <Availability online={state === "online" ? 1 : 0} total={1} state={state} text={text} />;
|
||||||
|
}
|
||||||
if (column === "added") return <span className="downloads-cell">{formatDateTime(item.createdAt)}</span>;
|
if (column === "added") return <span className="downloads-cell">{formatDateTime(item.createdAt)}</span>;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -110,7 +169,7 @@ export function ItemRowContent({ item, selected, sessionRunning = true, columnOr
|
|||||||
className={`downloads-item-row${selected ? " is-selected" : ""}`}
|
className={`downloads-item-row${selected ? " is-selected" : ""}`}
|
||||||
data-download-row-id={item.id}
|
data-download-row-id={item.id}
|
||||||
role="row"
|
role="row"
|
||||||
style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}
|
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
actions.onToggleSelection(item.id, event.ctrlKey || event.metaKey, event.shiftKey);
|
actions.onToggleSelection(item.id, event.ctrlKey || event.metaKey, event.shiftKey);
|
||||||
@@ -126,8 +185,8 @@ export function ItemRowContent({ item, selected, sessionRunning = true, columnOr
|
|||||||
actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId);
|
actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="downloads-selection-cell" role="cell"><input aria-label={`${item.fileName} auswählen`} checked={selected} onChange={() => actions.onToggleSelection(item.id, true, false)} onClick={(event) => event.stopPropagation()} type="checkbox" /></span>
|
<span className="downloads-selection-cell" role="cell"><input aria-label={`${item.fileName} auswählen`} checked={selected} onChange={() => {}} onClick={(event) => { event.stopPropagation(); actions.onToggleSelection(item.id, true, event.shiftKey); }} type="checkbox" /></span>
|
||||||
{columnOrder.map((column) => <span className="downloads-cell-slot" key={column} role="cell">{itemCell(item, column, sessionRunning)}</span>)}
|
{columnOrder.map((column) => <span className="downloads-cell-slot" data-download-column={column} key={column} role="cell">{itemCell(item, column, sessionRunning)}</span>)}
|
||||||
<span className="downloads-action-cell" role="cell"><button aria-label={`${item.fileName} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId); }} type="button">⋮</button></span>
|
<span className="downloads-action-cell" role="cell"><button aria-label={`${item.fileName} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId); }} type="button">⋮</button></span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -163,6 +222,71 @@ export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps)
|
|||||||
|
|
||||||
export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual);
|
export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual);
|
||||||
|
|
||||||
|
interface PackageItemsTransitionProps {
|
||||||
|
actions: DownloadsTableActions;
|
||||||
|
collapsed: boolean;
|
||||||
|
columnOrder: readonly string[];
|
||||||
|
gridTemplate: string;
|
||||||
|
items: DownloadItem[];
|
||||||
|
selectedIds: ReadonlySet<string>;
|
||||||
|
sessionRunning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate, items, selectedIds, sessionRunning }: PackageItemsTransitionProps): ReactElement | null {
|
||||||
|
const [renderItems, setRenderItems] = useState(!collapsed);
|
||||||
|
const animationRef = useRef<Animation | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const innerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const initialRenderRef = useRef(true);
|
||||||
|
|
||||||
|
useRendererLayoutEffect(() => {
|
||||||
|
if (initialRenderRef.current) {
|
||||||
|
initialRenderRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!renderItems) {
|
||||||
|
if (!collapsed) setRenderItems(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const container = containerRef.current;
|
||||||
|
const inner = innerRef.current;
|
||||||
|
if (!container || !inner) return;
|
||||||
|
animationRef.current?.cancel();
|
||||||
|
const targetHeight = inner.scrollHeight;
|
||||||
|
const animation = collapsed
|
||||||
|
? container.animate([{ height: `${targetHeight}px`, opacity: 1 }, { height: "0px", opacity: 0 }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" })
|
||||||
|
: container.animate([{ height: "0px", opacity: 0 }, { height: `${targetHeight}px`, opacity: 1 }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" });
|
||||||
|
animationRef.current = animation;
|
||||||
|
animation.onfinish = () => {
|
||||||
|
if (animationRef.current !== animation) return;
|
||||||
|
animationRef.current = null;
|
||||||
|
if (collapsed) {
|
||||||
|
setRenderItems(false);
|
||||||
|
} else {
|
||||||
|
animation.cancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
animation.onfinish = null;
|
||||||
|
animation.cancel();
|
||||||
|
if (animationRef.current === animation) animationRef.current = null;
|
||||||
|
};
|
||||||
|
}, [collapsed, renderItems]);
|
||||||
|
|
||||||
|
if (!renderItems) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden={collapsed}
|
||||||
|
className={`downloads-package-items ${collapsed ? "is-collapsed" : "is-expanded"}`}
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
<div className="downloads-package-items-inner" ref={innerRef}>
|
||||||
|
{items.map((item) => <ItemRow actions={actions} columnOrder={columnOrder} gridTemplate={gridTemplate} item={item} key={item.id} selected={selectedIds.has(item.id)} sessionRunning={sessionRunning} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function packageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
|
function packageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
|
||||||
let done = 0;
|
let done = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@@ -204,7 +328,6 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
|||||||
return (
|
return (
|
||||||
<span className="downloads-cell downloads-name-cell">
|
<span className="downloads-cell downloads-name-cell">
|
||||||
<button aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : "−"}</button>
|
<button aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : "−"}</button>
|
||||||
<input aria-label={`${entry.name} aktivieren`} checked={entry.enabled} onChange={() => actions.onTogglePackage(entry.id)} onClick={(event) => event.stopPropagation()} type="checkbox" />
|
|
||||||
{editing
|
{editing
|
||||||
? <input autoFocus className="downloads-rename-input" value={editingName} onBlur={() => finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
? <input autoFocus className="downloads-rename-input" value={editingName} onBlur={() => finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
@@ -228,8 +351,8 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
|||||||
}
|
}
|
||||||
if (column === "progress") return <span className="downloads-cell downloads-progress-cell"><span className="downloads-meter"><span style={{ width: `${stats.value}%` }} /><b>{stats.value}%</b></span></span>;
|
if (column === "progress") return <span className="downloads-cell downloads-progress-cell"><span className="downloads-meter"><span style={{ width: `${stats.value}%` }} /><b>{stats.value}%</b></span></span>;
|
||||||
if (column === "hoster") {
|
if (column === "hoster") {
|
||||||
const value = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].join(", ");
|
const labels = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].map(formatHosterLabel);
|
||||||
return <span className="downloads-cell" title={value}>{value}</span>;
|
return <HosterLabels labels={labels} />;
|
||||||
}
|
}
|
||||||
if (column === "account") {
|
if (column === "account") {
|
||||||
const value = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
|
const value = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
|
||||||
@@ -241,6 +364,10 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
|||||||
return <span className="downloads-cell" title={audio?.tooltip}>{stats.done}/{stats.total}{stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}{stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}{entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}{audio ? ` · ${audio.text}` : ""}</span>;
|
return <span className="downloads-cell" title={audio?.tooltip}>{stats.done}/{stats.total}{stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}{stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}{entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}{audio ? ` · ${audio.text}` : ""}</span>;
|
||||||
}
|
}
|
||||||
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
|
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
|
||||||
|
if (column === "availability") {
|
||||||
|
const availability = getAvailabilitySummary(row.items);
|
||||||
|
return <Availability {...availability} />;
|
||||||
|
}
|
||||||
if (column === "added") return <span className="downloads-cell">{formatDateTime(entry.createdAt)}</span>;
|
if (column === "added") return <span className="downloads-cell">{formatDateTime(entry.createdAt)}</span>;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -272,7 +399,7 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`package-card downloads-package-card${entry.enabled ? "" : " is-disabled"}${selectedIds.has(entry.id) ? " is-selected" : ""}`}
|
className={`downloads-package-card${entry.enabled ? "" : " is-disabled"}${selectedIds.has(entry.id) ? " is-selected" : ""}`}
|
||||||
data-download-package-id={entry.id}
|
data-download-package-id={entry.id}
|
||||||
draggable={draggable}
|
draggable={draggable}
|
||||||
onContextMenu={(event) => {
|
onContextMenu={(event) => {
|
||||||
@@ -289,7 +416,7 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
|
|||||||
className="downloads-package-row"
|
className="downloads-package-row"
|
||||||
data-download-row-id={entry.id}
|
data-download-row-id={entry.id}
|
||||||
role="row"
|
role="row"
|
||||||
style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}
|
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||||
@@ -302,11 +429,11 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
|
|||||||
onMouseDown={(event) => actions.onSelectionMouseDown(entry.id, event)}
|
onMouseDown={(event) => actions.onSelectionMouseDown(entry.id, event)}
|
||||||
onMouseEnter={() => actions.onSelectionMouseEnter(entry.id)}
|
onMouseEnter={() => actions.onSelectionMouseEnter(entry.id)}
|
||||||
>
|
>
|
||||||
<span className="downloads-selection-cell" role="cell"><input aria-label={`${entry.name} auswählen`} checked={selectedIds.has(entry.id)} onChange={() => actions.onToggleSelection(entry.id, true, false)} onClick={(event) => event.stopPropagation()} type="checkbox" /></span>
|
<span className="downloads-selection-cell" role="cell"><input aria-label={`${entry.name} auswählen`} checked={selectedIds.has(entry.id)} onChange={() => {}} onClick={(event) => { event.stopPropagation(); actions.onToggleSelection(entry.id, true, event.shiftKey); }} type="checkbox" /></span>
|
||||||
{columnOrder.map((column) => <span className="downloads-cell-slot" key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
|
{columnOrder.map((column) => <span className="downloads-cell-slot" data-download-column={column} key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
|
||||||
<span className="downloads-action-cell" role="cell"><button aria-label={`${entry.name} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} type="button">⋮</button></span>
|
<span className="downloads-action-cell" role="cell"><button aria-label={`${entry.name} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} type="button">⋮</button></span>
|
||||||
</div>
|
</div>
|
||||||
{!row.collapsed && row.items.map((item) => <ItemRow actions={actions} columnOrder={columnOrder} gridTemplate={gridTemplate} item={item} key={item.id} selected={selectedIds.has(item.id)} sessionRunning={sessionRunning} />)}
|
<PackageItemsTransition actions={actions} collapsed={row.collapsed} columnOrder={columnOrder} gridTemplate={gridTemplate} items={row.items} selectedIds={selectedIds} sessionRunning={sessionRunning} />
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -345,7 +472,7 @@ export interface DownloadsTableHeaderProps {
|
|||||||
|
|
||||||
export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement {
|
export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}>
|
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}>
|
||||||
<span className="downloads-selection-cell" role="columnheader"><input aria-label="Alle sichtbaren Downloads auswählen" checked={visibleIds.length > 0 && selectedCount === visibleIds.length} onChange={(event) => actions.onSetVisibleSelection(visibleIds, event.target.checked)} type="checkbox" /></span>
|
<span className="downloads-selection-cell" role="columnheader"><input aria-label="Alle sichtbaren Downloads auswählen" checked={visibleIds.length > 0 && selectedCount === visibleIds.length} onChange={(event) => actions.onSetVisibleSelection(visibleIds, event.target.checked)} type="checkbox" /></span>
|
||||||
{columnOrder.map((column) => {
|
{columnOrder.map((column) => {
|
||||||
const definition = downloadColumnDefinitions[column];
|
const definition = downloadColumnDefinitions[column];
|
||||||
@@ -353,14 +480,20 @@ export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortC
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="downloads-column-header"
|
className="downloads-column-header"
|
||||||
draggable
|
data-download-column={column}
|
||||||
key={column}
|
key={column}
|
||||||
onContextMenu={(event) => { event.preventDefault(); actions.onColumnContextMenu(column, event.clientX, event.clientY); }}
|
onContextMenu={(event) => { event.preventDefault(); actions.onColumnContextMenu(column, event.clientX, event.clientY); }}
|
||||||
onDragEnd={actions.onColumnDragEnd}
|
onPointerCancel={(event) => actions.onColumnPointerCancel(column, event)}
|
||||||
onDragLeave={actions.onColumnDragLeave}
|
onPointerDown={(event) => {
|
||||||
onDragOver={(event) => actions.onColumnDragOver(column, event)}
|
if (event.button !== 0 || !event.isPrimary) return;
|
||||||
onDragStart={(event) => actions.onColumnDragStart(column, event)}
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
onDrop={(event) => actions.onColumnDrop(column, event)}
|
actions.onColumnPointerDown(column, event);
|
||||||
|
}}
|
||||||
|
onPointerMove={(event) => actions.onColumnPointerMove(column, event)}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
actions.onColumnPointerUp(column, event);
|
||||||
|
}}
|
||||||
role="columnheader"
|
role="columnheader"
|
||||||
>
|
>
|
||||||
{definition.sortable
|
{definition.sortable
|
||||||
|
|||||||
@@ -1,197 +1,198 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import type { DownloadPackageRow, DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
|
import { RollingMetricValue } from "../../ui/RollingMetricValue";
|
||||||
import {
|
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||||
DownloadsTableHeader,
|
import type { DownloadPackageRow, DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
|
||||||
ItemRow,
|
import {
|
||||||
PackageCard,
|
DownloadsTableHeader,
|
||||||
type DownloadSortColumn,
|
ItemRow,
|
||||||
type DownloadsTableActions
|
PackageCard,
|
||||||
} from "./DownloadsTable";
|
type DownloadSortColumn,
|
||||||
import "./downloads.css";
|
type DownloadsTableActions
|
||||||
|
} from "./DownloadsTable";
|
||||||
export interface DownloadsStatusModel {
|
import "./downloads.css";
|
||||||
packages: number;
|
|
||||||
links: number;
|
export interface DownloadsStatusModel {
|
||||||
|
packages: number;
|
||||||
|
links: number;
|
||||||
session: string;
|
session: string;
|
||||||
|
sessionBytes: number;
|
||||||
total: string;
|
total: string;
|
||||||
hosters: number;
|
totalBytes: number;
|
||||||
speed: string;
|
hosters: number;
|
||||||
eta: string;
|
speed: string;
|
||||||
}
|
eta: string;
|
||||||
|
}
|
||||||
export interface DownloadsViewModel extends DownloadsViewModelCore {
|
|
||||||
running: boolean;
|
export interface DownloadsViewModel extends DownloadsViewModelCore {
|
||||||
paused: boolean;
|
running: boolean;
|
||||||
canStart: boolean;
|
paused: boolean;
|
||||||
canPause: boolean;
|
canStart: boolean;
|
||||||
canStop: boolean;
|
canPause: boolean;
|
||||||
actionBusy: boolean;
|
canStop: boolean;
|
||||||
reconnectSeconds: number;
|
actionBusy: boolean;
|
||||||
reconnectReason: string;
|
reconnectSeconds: number;
|
||||||
clipboardWatcher: boolean;
|
reconnectReason: string;
|
||||||
scheduleActive: boolean;
|
clipboardWatcher: boolean;
|
||||||
scheduleOpen: boolean;
|
scheduleActive: boolean;
|
||||||
scheduleTime: string;
|
scheduleOpen: boolean;
|
||||||
scheduleLabel: string;
|
scheduleTime: string;
|
||||||
packageSpeedBps: Record<string, number>;
|
scheduleLabel: string;
|
||||||
editingPackageId: string | null;
|
packageSpeedBps: Record<string, number>;
|
||||||
editingName: string;
|
editingPackageId: string | null;
|
||||||
columnOrder: readonly string[];
|
editingName: string;
|
||||||
gridTemplate: string;
|
columnOrder: readonly string[];
|
||||||
sortColumn?: DownloadSortColumn;
|
gridTemplate: string;
|
||||||
sortDirection?: "asc" | "desc";
|
sortColumn?: DownloadSortColumn;
|
||||||
status: DownloadsStatusModel;
|
sortDirection?: "asc" | "desc";
|
||||||
}
|
status: DownloadsStatusModel;
|
||||||
|
}
|
||||||
export interface DownloadsViewActions extends DownloadsTableActions {
|
|
||||||
onDisplayModeChange: (mode: DownloadDisplayMode) => void;
|
export interface DownloadsViewActions extends DownloadsTableActions {
|
||||||
onFilterChange: (filter: DownloadSidebarFilter) => void;
|
onDisplayModeChange: (mode: DownloadDisplayMode) => void;
|
||||||
onProviderFilterChange: (provider: string) => void;
|
onFilterChange: (filter: DownloadSidebarFilter) => void;
|
||||||
onQueryChange: (query: string) => void;
|
onProviderFilterChange: (provider: string) => void;
|
||||||
onAddLinks: () => void;
|
onQueryChange: (query: string) => void;
|
||||||
onStartDownloads: () => void;
|
onAddLinks: () => void;
|
||||||
onPauseDownloads: () => void;
|
onStartDownloads: () => void;
|
||||||
onStopDownloads: () => void;
|
onPauseDownloads: () => void;
|
||||||
onToggleSchedule: () => void;
|
onStopDownloads: () => void;
|
||||||
onScheduleTimeChange: (value: string) => void;
|
onToggleSchedule: () => void;
|
||||||
onActivateSchedule: () => void;
|
onScheduleTimeChange: (value: string) => void;
|
||||||
onCancelSchedule: () => void;
|
onActivateSchedule: () => void;
|
||||||
onMoveSelectionUp: () => void;
|
onCancelSchedule: () => void;
|
||||||
onMoveSelectionDown: () => void;
|
onMoveSelectionUp: () => void;
|
||||||
onRenameSelection: () => void;
|
onMoveSelectionDown: () => void;
|
||||||
onRemoveSelection: () => void;
|
onRenameSelection: () => void;
|
||||||
onToggleClipboardWatcher: () => void;
|
onRemoveSelection: () => void;
|
||||||
onClearAll: () => void;
|
onToggleClipboardWatcher: () => void;
|
||||||
onToggleAllPackages: () => void;
|
onClearAll: () => void;
|
||||||
onShowAllPackages: () => void;
|
onToggleAllPackages: () => void;
|
||||||
onPackageDragStart: (packageId: string) => void;
|
onShowAllPackages: () => void;
|
||||||
onPackageDrop: (packageId: string) => void;
|
onPackageDragStart: (packageId: string) => void;
|
||||||
onPackageDragEnd: () => void;
|
onPackageDrop: (packageId: string) => void;
|
||||||
}
|
onPackageDragEnd: () => void;
|
||||||
|
}
|
||||||
const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
|
|
||||||
{ id: "all", label: "Alle" },
|
const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
|
||||||
{ id: "active", label: "Aktiv" },
|
{ id: "all", label: "Alle" },
|
||||||
{ id: "queued", label: "Wartend" },
|
{ id: "active", label: "Aktiv" },
|
||||||
{ id: "paused", label: "Pausiert" },
|
{ id: "queued", label: "Wartend" },
|
||||||
{ id: "completed", label: "Fertig" },
|
{ id: "paused", label: "Pausiert" },
|
||||||
{ id: "failed", label: "Fehler" }
|
{ id: "completed", label: "Fertig" },
|
||||||
];
|
{ id: "failed", label: "Fehler" }
|
||||||
|
];
|
||||||
|
|
||||||
export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||||
return (
|
return (
|
||||||
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
|
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
|
||||||
<div className="downloads-mode-switch" role="group" aria-label="Downloadansicht">
|
<div className="downloads-mode-title">Pakete</div>
|
||||||
<button className={model.displayMode === "packages" ? "is-active" : ""} onClick={() => actions.onDisplayModeChange("packages")} type="button">Pakete</button>
|
<SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical">
|
||||||
<button className={model.displayMode === "files" ? "is-active" : ""} onClick={() => actions.onDisplayModeChange("files")} type="button">Dateien</button>
|
{filters.map((filter) => <button className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
|
||||||
</div>
|
</SlidingSelection>
|
||||||
<nav aria-label="Downloadfilter">
|
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
|
||||||
{filters.map((filter) => <button className={model.filter === filter.id ? "is-active" : ""} key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
|
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
|
||||||
</nav>
|
|
||||||
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
|
|
||||||
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
|
|
||||||
<div className="downloads-sidebar-actions">
|
<div className="downloads-sidebar-actions">
|
||||||
<button onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
<button onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||||
<button disabled={model.empty} onClick={actions.onClearAll} type="button">Liste leeren</button>
|
<button disabled={model.empty} onClick={actions.onClearAll} type="button">Liste leeren</button>
|
||||||
<label><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
<label className="downloads-clipboard-toggle"><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label>
|
||||||
);
|
</aside>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel }): ReactElement {
|
export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel }): ReactElement {
|
||||||
|
const speed = model.status.speed.replace(/^Geschwindigkeit:\s*/i, "");
|
||||||
|
const eta = model.status.eta.replace(/^ETA:\s*/i, "");
|
||||||
const entries = [
|
const entries = [
|
||||||
["Pakete", String(model.status.packages)],
|
{ label: "Pakete", metric: "packages", numericValue: model.status.packages, value: String(model.status.packages) },
|
||||||
["Links", String(model.status.links)],
|
{ label: "Links", metric: "links", numericValue: model.status.links, value: String(model.status.links) },
|
||||||
["Sitzung", model.status.session],
|
{ label: "Sitzung", metric: "session", numericValue: model.status.sessionBytes, value: model.status.session },
|
||||||
["Gesamt", model.status.total],
|
{ label: "Gesamt", metric: "total", numericValue: model.status.totalBytes, value: model.status.total },
|
||||||
["Hoster", String(model.status.hosters)],
|
{ label: "Hoster", metric: "hosters", numericValue: model.status.hosters, value: String(model.status.hosters) }
|
||||||
["Geschwindigkeit", model.status.speed],
|
|
||||||
["ETA", model.status.eta]
|
|
||||||
];
|
];
|
||||||
return <section className="downloads-sidebar-status" data-visual-region="downloads-sidebar-status" aria-label="Downloadstatus">{entries.map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</section>;
|
return <section className="downloads-sidebar-status" data-visual-region="downloads-sidebar-status" aria-label="Downloadstatus">{entries.map((entry) => <div key={entry.metric}><span>{entry.label}</span><RollingMetricValue numericValue={entry.numericValue} value={entry.value} /></div>)}<div><span>Geschwindigkeit</span><strong data-status-metric="speed">{speed}</strong></div><div><span>ETA</span><strong data-status-metric="eta">{eta}</strong></div></section>;
|
||||||
}
|
|
||||||
|
|
||||||
export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
|
||||||
const hasSelection = model.actionableSelectedIds.length > 0;
|
|
||||||
const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0;
|
|
||||||
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
|
|
||||||
return (
|
|
||||||
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
|
|
||||||
<button className="ui-primary-button" onClick={actions.onAddLinks} type="button">Links hinzufügen</button>
|
|
||||||
<span className="downloads-toolbar-divider" />
|
|
||||||
<button disabled={model.actionBusy || (!model.canStart && !model.paused)} onClick={actions.onStartDownloads} type="button">Start</button>
|
|
||||||
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
|
|
||||||
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
|
|
||||||
{model.scheduleActive
|
|
||||||
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
|
|
||||||
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}
|
|
||||||
<span className="downloads-toolbar-divider" />
|
|
||||||
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionUp} type="button">Nach oben</button>
|
|
||||||
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
|
|
||||||
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
|
|
||||||
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tableState(model: DownloadsViewModel): ReactElement | null {
|
|
||||||
if (model.empty) return <div className="downloads-empty-state" data-visual-region="downloads-empty-state" role="row"><div role="cell"><strong>Noch keine Downloads</strong><span>Füge Links hinzu, um den ersten Download zu starten.</span></div></div>;
|
|
||||||
if (model.filteredEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Keine passenden Downloads</strong><span>Passe Filter oder Suche an.</span></div></div>;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function packageRows(model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement[] {
|
|
||||||
return model.packageRows.map((row: DownloadPackageRow) => (
|
|
||||||
<PackageCard
|
|
||||||
actions={actions}
|
|
||||||
columnOrder={model.columnOrder}
|
|
||||||
editing={model.editingPackageId === row.package.id}
|
|
||||||
editingName={model.editingName}
|
|
||||||
gridTemplate={model.gridTemplate}
|
|
||||||
key={row.package.id}
|
|
||||||
packageSpeedBps={model.packageSpeedBps[row.package.id] ?? 0}
|
|
||||||
onDragEnd={actions.onPackageDragEnd}
|
|
||||||
onDragStart={actions.onPackageDragStart}
|
|
||||||
onDrop={actions.onPackageDrop}
|
|
||||||
row={row}
|
|
||||||
selectedIds={model.selectedIds}
|
|
||||||
selectedVersion={model.actionableSelectedIds.length}
|
|
||||||
sessionRunning={model.running}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DownloadsContent({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
|
||||||
return (
|
|
||||||
<main className="downloads-content">
|
|
||||||
<div className="downloads-table" role="table" aria-label="Downloads">
|
|
||||||
<DownloadsTableHeader actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} selectedCount={model.actionableSelectedIds.length} sortColumn={model.sortColumn ?? "name"} sortDirection={model.sortDirection ?? "asc"} visibleIds={model.visibleRowIds} />
|
|
||||||
<div className="downloads-table-body" data-visual-region="downloads-table-body" role="rowgroup">
|
|
||||||
{tableState(model)}
|
|
||||||
{!model.empty && !model.filteredEmpty && model.displayMode === "packages" ? packageRows(model, actions) : null}
|
|
||||||
{!model.empty && !model.filteredEmpty && model.displayMode === "files" ? model.fileRows.map((item) => <ItemRow actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} item={item} key={item.id} selected={model.selectedIds.has(item.id)} sessionRunning={model.running} />) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
|
||||||
return (
|
|
||||||
<footer className="downloads-footer" data-visual-region="downloads-pagination">
|
|
||||||
<span>{model.paginationLabel}</span>
|
|
||||||
{model.limited ? <button onClick={actions.onShowAllPackages} type="button">Alle anzeigen</button> : null}
|
|
||||||
<span>{model.running ? model.paused ? "Pausiert" : "Download läuft" : "Bereit"}</span>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DownloadsView({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="downloads-view">
|
|
||||||
<div className="downloads-side-column"><DownloadsSidebar actions={actions} model={model} /><DownloadsSidebarStatus model={model} /></div>
|
|
||||||
<div className="downloads-main-column"><DownloadsToolbar actions={actions} model={model} /><DownloadsContent actions={actions} model={model} /><DownloadsFooter actions={actions} model={model} /></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||||
|
const hasSelection = model.actionableSelectedIds.length > 0;
|
||||||
|
const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0;
|
||||||
|
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
|
||||||
|
return (
|
||||||
|
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
|
||||||
|
<button className="ui-primary-button" onClick={actions.onAddLinks} type="button">Links hinzufügen</button>
|
||||||
|
<span className="downloads-toolbar-divider" />
|
||||||
|
<button disabled={model.actionBusy || (!model.canStart && !model.paused)} onClick={actions.onStartDownloads} type="button">Start</button>
|
||||||
|
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
|
||||||
|
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
|
||||||
|
{model.scheduleActive
|
||||||
|
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
|
||||||
|
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}
|
||||||
|
<span className="downloads-toolbar-divider" />
|
||||||
|
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionUp} type="button">Nach oben</button>
|
||||||
|
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
|
||||||
|
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
|
||||||
|
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableState(model: DownloadsViewModel): ReactElement | null {
|
||||||
|
if (model.empty) return <div className="downloads-empty-state" data-visual-region="downloads-empty-state" role="row"><div role="cell"><strong>Noch keine Downloads</strong><span>Füge Links hinzu, um den ersten Download zu starten.</span></div></div>;
|
||||||
|
if (model.filteredEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Keine passenden Downloads</strong><span>Passe Filter oder Suche an.</span></div></div>;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageRows(model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement[] {
|
||||||
|
return model.packageRows.map((row: DownloadPackageRow) => (
|
||||||
|
<PackageCard
|
||||||
|
actions={actions}
|
||||||
|
columnOrder={model.columnOrder}
|
||||||
|
editing={model.editingPackageId === row.package.id}
|
||||||
|
editingName={model.editingName}
|
||||||
|
gridTemplate={model.gridTemplate}
|
||||||
|
key={row.package.id}
|
||||||
|
packageSpeedBps={model.packageSpeedBps[row.package.id] ?? 0}
|
||||||
|
onDragEnd={actions.onPackageDragEnd}
|
||||||
|
onDragStart={actions.onPackageDragStart}
|
||||||
|
onDrop={actions.onPackageDrop}
|
||||||
|
row={row}
|
||||||
|
selectedIds={model.selectedIds}
|
||||||
|
selectedVersion={model.actionableSelectedIds.length}
|
||||||
|
sessionRunning={model.running}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadsContent({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||||
|
return (
|
||||||
|
<main className="downloads-content">
|
||||||
|
<div className="downloads-table" role="table" aria-label="Downloads">
|
||||||
|
<DownloadsTableHeader actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} selectedCount={model.actionableSelectedIds.length} sortColumn={model.sortColumn ?? "name"} sortDirection={model.sortDirection ?? "asc"} visibleIds={model.visibleRowIds} />
|
||||||
|
<div className="downloads-table-body" data-visual-region="downloads-table-body" role="rowgroup">
|
||||||
|
{tableState(model)}
|
||||||
|
{!model.empty && !model.filteredEmpty && model.displayMode === "packages" ? packageRows(model, actions) : null}
|
||||||
|
{!model.empty && !model.filteredEmpty && model.displayMode === "files" ? model.fileRows.map((item) => <ItemRow actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} item={item} key={item.id} selected={model.selectedIds.has(item.id)} sessionRunning={model.running} />) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||||
|
return (
|
||||||
|
<footer className="downloads-footer" data-visual-region="downloads-pagination">
|
||||||
|
<span>{model.paginationLabel}</span>
|
||||||
|
{model.limited ? <button onClick={actions.onShowAllPackages} type="button">Alle anzeigen</button> : null}
|
||||||
|
<span>{model.running ? model.paused ? "Pausiert" : "Download läuft" : "Bereit"}</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadsView({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="downloads-view">
|
||||||
|
<div className="downloads-side-column"><DownloadsSidebar actions={actions} model={model} /><DownloadsSidebarStatus model={model} /></div>
|
||||||
|
<div className="downloads-main-column"><DownloadsToolbar actions={actions} model={model} /><DownloadsContent actions={actions} model={model} /><DownloadsFooter actions={actions} model={model} /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
export interface ColumnDragMeasurement {
|
||||||
|
id: string;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnDragPreview {
|
||||||
|
order: string[];
|
||||||
|
offsets: Record<string, number>;
|
||||||
|
settleOffsets: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadColumnDragSession {
|
||||||
|
active: boolean;
|
||||||
|
draggedId: string;
|
||||||
|
measurements: ColumnDragMeasurement[];
|
||||||
|
pointerId: number;
|
||||||
|
preview: ColumnDragPreview;
|
||||||
|
root: HTMLElement;
|
||||||
|
startX: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateColumnDragPreview(columns: readonly ColumnDragMeasurement[], draggedId: string, deltaX: number): ColumnDragPreview {
|
||||||
|
const dragged = columns.find((column) => column.id === draggedId);
|
||||||
|
if (!dragged) {
|
||||||
|
const order = columns.map((column) => column.id);
|
||||||
|
const offsets = Object.fromEntries(order.map((id) => [id, 0]));
|
||||||
|
return { order, offsets, settleOffsets: { ...offsets } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const draggedCenter = dragged.left + dragged.width / 2 + deltaX;
|
||||||
|
const remaining = columns.filter((column) => column.id !== draggedId);
|
||||||
|
let targetIndex = remaining.findIndex((column) => draggedCenter < column.left + column.width / 2);
|
||||||
|
if (targetIndex < 0) targetIndex = remaining.length;
|
||||||
|
|
||||||
|
const order = remaining.map((column) => column.id);
|
||||||
|
order.splice(targetIndex, 0, draggedId);
|
||||||
|
|
||||||
|
const originalById = new Map(columns.map((column) => [column.id, column]));
|
||||||
|
const targetLeftById = new Map<string, number>();
|
||||||
|
let nextLeft = columns[0]?.left ?? 0;
|
||||||
|
for (const id of order) {
|
||||||
|
const column = originalById.get(id);
|
||||||
|
if (!column) continue;
|
||||||
|
targetLeftById.set(id, nextLeft);
|
||||||
|
nextLeft += column.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settleOffsets = Object.fromEntries(columns.map((column) => [column.id, (targetLeftById.get(column.id) ?? column.left) - column.left]));
|
||||||
|
const offsets = { ...settleOffsets, [draggedId]: deltaX };
|
||||||
|
return { order, offsets, settleOffsets };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setColumnOffsets(root: HTMLElement, offsets: Record<string, number>, excludedId?: string): void {
|
||||||
|
for (const [id, offset] of Object.entries(offsets)) {
|
||||||
|
if (id === excludedId) continue;
|
||||||
|
root.style.setProperty(`--downloads-column-drag-${id}`, `${offset}px`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDraggedColumn(root: HTMLElement, draggedId: string, dragging: boolean): void {
|
||||||
|
if (dragging && root.dataset.columnDragging !== draggedId) {
|
||||||
|
root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging);
|
||||||
|
root.querySelectorAll<HTMLElement>(`[data-download-column="${draggedId}"]`).forEach((element) => { element.dataset.columnDragging = "true"; });
|
||||||
|
root.dataset.columnDragging = draggedId;
|
||||||
|
} else if (!dragging && root.dataset.columnDragging) {
|
||||||
|
root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging);
|
||||||
|
delete root.dataset.columnDragging;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beginDownloadColumnDrag(element: HTMLElement, draggedId: string, pointerId: number, clientX: number): DownloadColumnDragSession | null {
|
||||||
|
const root = element.closest<HTMLElement>(".downloads-table");
|
||||||
|
const header = element.closest<HTMLElement>(".downloads-table-header");
|
||||||
|
if (!root || !header) return null;
|
||||||
|
const measurements = Array.from(header.querySelectorAll<HTMLElement>(".downloads-column-header[data-download-column]")).map((column) => {
|
||||||
|
const rect = column.getBoundingClientRect();
|
||||||
|
return { id: column.dataset.downloadColumn ?? "", left: rect.left, width: rect.width };
|
||||||
|
}).filter((column) => column.id && column.width > 0);
|
||||||
|
if (!measurements.some((column) => column.id === draggedId)) return null;
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
draggedId,
|
||||||
|
measurements,
|
||||||
|
pointerId,
|
||||||
|
preview: calculateColumnDragPreview(measurements, draggedId, 0),
|
||||||
|
root,
|
||||||
|
startX: clientX
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDownloadColumnDrag(session: DownloadColumnDragSession, clientX: number): boolean {
|
||||||
|
const deltaX = clientX - session.startX;
|
||||||
|
if (!session.active && Math.abs(deltaX) < 5) return false;
|
||||||
|
if (!session.active) {
|
||||||
|
session.active = true;
|
||||||
|
session.root.classList.add("is-column-drag-active");
|
||||||
|
setDraggedColumn(session.root, session.draggedId, true);
|
||||||
|
}
|
||||||
|
const nextPreview = calculateColumnDragPreview(session.measurements, session.draggedId, deltaX);
|
||||||
|
session.root.style.setProperty("--downloads-active-drag-x", `${deltaX}px`);
|
||||||
|
if (nextPreview.order.join("|") !== session.preview.order.join("|")) {
|
||||||
|
setColumnOffsets(session.root, nextPreview.offsets, session.draggedId);
|
||||||
|
}
|
||||||
|
session.preview = nextPreview;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function settleDownloadColumnDrag(session: DownloadColumnDragSession, cancelled: boolean): string[] | null {
|
||||||
|
if (!session.active) return null;
|
||||||
|
const originalOrder = session.measurements.map((column) => column.id);
|
||||||
|
const order = cancelled ? originalOrder : session.preview.order;
|
||||||
|
const offsets = cancelled
|
||||||
|
? Object.fromEntries(originalOrder.map((id) => [id, 0]))
|
||||||
|
: session.preview.settleOffsets;
|
||||||
|
setColumnOffsets(session.root, offsets);
|
||||||
|
session.root.style.removeProperty("--downloads-active-drag-x");
|
||||||
|
setDraggedColumn(session.root, session.draggedId, false);
|
||||||
|
session.root.classList.add("is-column-drag-settling");
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDownloadColumnDrag(session: DownloadColumnDragSession): void {
|
||||||
|
session.root.classList.remove("is-column-drag-active", "is-column-drag-settling");
|
||||||
|
session.root.style.removeProperty("--downloads-active-drag-x");
|
||||||
|
session.measurements.forEach((measurement) => session.root.style.removeProperty(`--downloads-column-drag-${measurement.id}`));
|
||||||
|
session.root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging);
|
||||||
|
delete session.root.dataset.columnDragging;
|
||||||
|
}
|
||||||
@@ -1,186 +1,194 @@
|
|||||||
import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
|
import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
|
||||||
|
|
||||||
export type DownloadDisplayMode = "packages" | "files";
|
export type DownloadDisplayMode = "packages" | "files";
|
||||||
export type DownloadSidebarFilter = "all" | "active" | "queued" | "paused" | "completed" | "failed";
|
export type DownloadSidebarFilter = "all" | "active" | "queued" | "paused" | "completed" | "failed";
|
||||||
|
|
||||||
export interface DownloadsModelInput {
|
export interface DownloadsModelInput {
|
||||||
packageOrder: string[];
|
packageOrder: string[];
|
||||||
packages: Record<string, PackageEntry>;
|
packages: Record<string, PackageEntry>;
|
||||||
items: Record<string, DownloadItem>;
|
items: Record<string, DownloadItem>;
|
||||||
displayMode: DownloadDisplayMode;
|
displayMode: DownloadDisplayMode;
|
||||||
filter: DownloadSidebarFilter;
|
filter: DownloadSidebarFilter;
|
||||||
providerFilter: string;
|
providerFilter: string;
|
||||||
query: string;
|
query: string;
|
||||||
collapsedPackageIds: Iterable<string>;
|
collapsedPackageIds: Iterable<string>;
|
||||||
selectedIds: Iterable<string>;
|
selectedIds: Iterable<string>;
|
||||||
hideExtractedItems: boolean;
|
hideExtractedItems: boolean;
|
||||||
showAllPackages: boolean;
|
showAllPackages: boolean;
|
||||||
renderLimit: number;
|
renderLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadFilterCounts {
|
export interface DownloadFilterCounts {
|
||||||
all: number;
|
all: number;
|
||||||
active: number;
|
active: number;
|
||||||
queued: number;
|
queued: number;
|
||||||
paused: number;
|
paused: number;
|
||||||
completed: number;
|
completed: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadPackageRow {
|
export interface DownloadPackageRow {
|
||||||
package: PackageEntry;
|
package: PackageEntry;
|
||||||
items: DownloadItem[];
|
items: DownloadItem[];
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadsViewModelCore {
|
export interface DownloadsViewModelCore {
|
||||||
displayMode: DownloadDisplayMode;
|
displayMode: DownloadDisplayMode;
|
||||||
filter: DownloadSidebarFilter;
|
filter: DownloadSidebarFilter;
|
||||||
providerFilter: string;
|
providerFilter: string;
|
||||||
providerOptions: Array<{ id: string; label: string }>;
|
providerOptions: Array<{ id: string; label: string }>;
|
||||||
query: string;
|
query: string;
|
||||||
counts: DownloadFilterCounts;
|
counts: DownloadFilterCounts;
|
||||||
packageRows: DownloadPackageRow[];
|
packageRows: DownloadPackageRow[];
|
||||||
fileRows: DownloadItem[];
|
fileRows: DownloadItem[];
|
||||||
visibleItemIds: string[];
|
visibleItemIds: string[];
|
||||||
visibleRowIds: string[];
|
visibleRowIds: string[];
|
||||||
actionableSelectedIds: string[];
|
actionableSelectedIds: string[];
|
||||||
actionableSelectedPackageIds: string[];
|
actionableSelectedPackageIds: string[];
|
||||||
selectedIds: Set<string>;
|
selectedIds: Set<string>;
|
||||||
mainRowCount: number;
|
mainRowCount: number;
|
||||||
totalMainRowCount: number;
|
totalMainRowCount: number;
|
||||||
paginationLabel: string;
|
paginationLabel: string;
|
||||||
limited: boolean;
|
limited: boolean;
|
||||||
empty: boolean;
|
empty: boolean;
|
||||||
filteredEmpty: boolean;
|
filteredEmpty: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeStatuses = new Set<DownloadStatus>(["downloading", "validating", "extracting", "integrity_check"]);
|
const activeStatuses = new Set<DownloadStatus>(["downloading", "validating", "extracting", "integrity_check"]);
|
||||||
const queuedStatuses = new Set<DownloadStatus>(["queued", "reconnect_wait"]);
|
const queuedStatuses = new Set<DownloadStatus>(["queued", "reconnect_wait"]);
|
||||||
|
|
||||||
export function classifyDownloadStatus(status: DownloadStatus): DownloadSidebarFilter {
|
export function classifyDownloadStatus(status: DownloadStatus): DownloadSidebarFilter {
|
||||||
if (activeStatuses.has(status)) return "active";
|
if (activeStatuses.has(status)) return "active";
|
||||||
if (queuedStatuses.has(status)) return "queued";
|
if (queuedStatuses.has(status)) return "queued";
|
||||||
if (status === "paused" || status === "completed" || status === "failed") return status;
|
if (status === "paused" || status === "completed" || status === "failed") return status;
|
||||||
return "all";
|
return "all";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDownloadSidebarCounts(items: Iterable<DownloadItem>): DownloadFilterCounts {
|
export function buildDownloadSidebarCounts(items: Iterable<DownloadItem>): DownloadFilterCounts {
|
||||||
const counts: DownloadFilterCounts = { all: 0, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 };
|
const counts: DownloadFilterCounts = { all: 0, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 };
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
counts.all += 1;
|
counts.all += 1;
|
||||||
const category = classifyDownloadStatus(item.status);
|
const category = classifyDownloadStatus(item.status);
|
||||||
if (category !== "all") counts[category] += 1;
|
if (category !== "all") counts[category] += 1;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExtracted(item: DownloadItem): boolean {
|
export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): number {
|
||||||
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
|
let total = 0;
|
||||||
}
|
for (const item of items) {
|
||||||
|
total += item.totalBytes || item.downloadedBytes || 0;
|
||||||
function matchesQuery(value: string | undefined, query: string): boolean {
|
|
||||||
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesFilter(item: DownloadItem, filter: DownloadSidebarFilter): boolean {
|
|
||||||
return filter === "all" || classifyDownloadStatus(item.status) === filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesProvider(item: DownloadItem, providerFilter: string): boolean {
|
|
||||||
return providerFilter === "all" || item.provider === providerFilter;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isActivePackage(row: DownloadPackageRow): boolean {
|
|
||||||
return row.items.some((entry) => classifyDownloadStatus(entry.status) === "active");
|
|
||||||
}
|
|
||||||
|
|
||||||
function paginationLabel(visible: number, total: number): string {
|
|
||||||
if (visible === 0 || total === 0) return "0 von 0";
|
|
||||||
return `1–${visible} von ${total}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsViewModelCore {
|
|
||||||
const allPackages = input.packageOrder
|
|
||||||
.map((id) => input.packages[id])
|
|
||||||
.filter((entry): entry is PackageEntry => Boolean(entry));
|
|
||||||
const allItems = allPackages.flatMap((entry) => entry.itemIds.map((id) => input.items[id]).filter((item): item is DownloadItem => Boolean(item)));
|
|
||||||
const counts = buildDownloadSidebarCounts(allItems);
|
|
||||||
const providerMap = new Map<string, string>();
|
|
||||||
for (const entry of allItems) {
|
|
||||||
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
|
|
||||||
}
|
}
|
||||||
|
return total;
|
||||||
const query = input.query.trim().toLocaleLowerCase("de-DE");
|
|
||||||
const collapsed = new Set(input.collapsedPackageIds);
|
|
||||||
const selectedIds = new Set(input.selectedIds);
|
|
||||||
let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => {
|
|
||||||
const items = entry.itemIds
|
|
||||||
.map((id) => input.items[id])
|
|
||||||
.filter((item): item is DownloadItem => Boolean(item))
|
|
||||||
.filter((item) => !input.hideExtractedItems || !isExtracted(item));
|
|
||||||
const packageMatchesQuery = query === "" || matchesQuery(entry.name, query) || matchesQuery(entry.status, query);
|
|
||||||
const matchingItems = items.filter((item) => {
|
|
||||||
const itemMatchesQuery = query === ""
|
|
||||||
|| matchesQuery(item.fileName, query)
|
|
||||||
|| matchesQuery(item.targetPath, query)
|
|
||||||
|| matchesQuery(item.providerLabel, query)
|
|
||||||
|| matchesQuery(item.providerAccountLabel, query)
|
|
||||||
|| matchesQuery(item.fullStatus, query)
|
|
||||||
|| matchesQuery(item.lastError, query);
|
|
||||||
return matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter) && (packageMatchesQuery || itemMatchesQuery);
|
|
||||||
});
|
|
||||||
if (matchingItems.length === 0) return [];
|
|
||||||
const visibleItems = packageMatchesQuery && query !== ""
|
|
||||||
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
|
|
||||||
: matchingItems;
|
|
||||||
return [{ package: entry, items: visibleItems, collapsed: collapsed.has(entry.id) }];
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalPackageRows = packageRows.length;
|
|
||||||
const allMatchingFileRows = packageRows.flatMap((row) => row.items);
|
|
||||||
if (!input.showAllPackages && input.renderLimit > 0 && packageRows.length > input.renderLimit) {
|
|
||||||
const activeRows = packageRows.filter(isActivePackage);
|
|
||||||
const inactiveRows = packageRows.filter((row) => !isActivePackage(row));
|
|
||||||
packageRows = [...activeRows, ...inactiveRows].slice(0, input.renderLimit);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileRows = input.displayMode === "files" ? allMatchingFileRows : [];
|
|
||||||
const displayedPackages = input.displayMode === "packages" ? packageRows : [];
|
|
||||||
const visibleItemIds = (input.displayMode === "files"
|
|
||||||
? fileRows
|
|
||||||
: displayedPackages.flatMap((row) => row.collapsed ? [] : row.items)).map((entry) => entry.id);
|
|
||||||
const visibleRowIds = input.displayMode === "files"
|
|
||||||
? visibleItemIds
|
|
||||||
: displayedPackages.flatMap((row) => row.collapsed ? [row.package.id] : [row.package.id, ...row.items.map((entry) => entry.id)]);
|
|
||||||
const visibleRowSet = new Set(visibleRowIds);
|
|
||||||
const actionableSelectedIds = [...selectedIds].filter((id) => visibleRowSet.has(id));
|
|
||||||
const visiblePackageSet = new Set(displayedPackages.map((row) => row.package.id));
|
|
||||||
const actionableSelectedPackageIds = actionableSelectedIds.filter((id) => visiblePackageSet.has(id));
|
|
||||||
const mainRowCount = input.displayMode === "files" ? fileRows.length : displayedPackages.length;
|
|
||||||
const totalMainRowCount = input.displayMode === "files"
|
|
||||||
? fileRows.length
|
|
||||||
: totalPackageRows;
|
|
||||||
|
|
||||||
return {
|
|
||||||
displayMode: input.displayMode,
|
|
||||||
filter: input.filter,
|
|
||||||
providerFilter: input.providerFilter,
|
|
||||||
providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
|
|
||||||
query: input.query,
|
|
||||||
counts,
|
|
||||||
packageRows: displayedPackages,
|
|
||||||
fileRows,
|
|
||||||
visibleItemIds,
|
|
||||||
visibleRowIds,
|
|
||||||
actionableSelectedIds,
|
|
||||||
actionableSelectedPackageIds,
|
|
||||||
selectedIds,
|
|
||||||
mainRowCount,
|
|
||||||
totalMainRowCount,
|
|
||||||
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
|
|
||||||
limited: mainRowCount < totalMainRowCount,
|
|
||||||
empty: allItems.length === 0,
|
|
||||||
filteredEmpty: allItems.length > 0 && mainRowCount === 0
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExtracted(item: DownloadItem): boolean {
|
||||||
|
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesQuery(value: string | undefined, query: string): boolean {
|
||||||
|
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(item: DownloadItem, filter: DownloadSidebarFilter): boolean {
|
||||||
|
return filter === "all" || classifyDownloadStatus(item.status) === filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesProvider(item: DownloadItem, providerFilter: string): boolean {
|
||||||
|
return providerFilter === "all" || item.provider === providerFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActivePackage(row: DownloadPackageRow): boolean {
|
||||||
|
return row.items.some((entry) => classifyDownloadStatus(entry.status) === "active");
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginationLabel(visible: number, total: number): string {
|
||||||
|
if (visible === 0 || total === 0) return "0 von 0";
|
||||||
|
return `1–${visible} von ${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsViewModelCore {
|
||||||
|
const allPackages = input.packageOrder
|
||||||
|
.map((id) => input.packages[id])
|
||||||
|
.filter((entry): entry is PackageEntry => Boolean(entry));
|
||||||
|
const allItems = allPackages.flatMap((entry) => entry.itemIds.map((id) => input.items[id]).filter((item): item is DownloadItem => Boolean(item)));
|
||||||
|
const counts = buildDownloadSidebarCounts(allItems);
|
||||||
|
const providerMap = new Map<string, string>();
|
||||||
|
for (const entry of allItems) {
|
||||||
|
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = input.query.trim().toLocaleLowerCase("de-DE");
|
||||||
|
const collapsed = new Set(input.collapsedPackageIds);
|
||||||
|
const selectedIds = new Set(input.selectedIds);
|
||||||
|
let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => {
|
||||||
|
const items = entry.itemIds
|
||||||
|
.map((id) => input.items[id])
|
||||||
|
.filter((item): item is DownloadItem => Boolean(item))
|
||||||
|
.filter((item) => !input.hideExtractedItems || !isExtracted(item));
|
||||||
|
const packageMatchesQuery = query === "" || matchesQuery(entry.name, query) || matchesQuery(entry.status, query);
|
||||||
|
const matchingItems = items.filter((item) => {
|
||||||
|
const itemMatchesQuery = query === ""
|
||||||
|
|| matchesQuery(item.fileName, query)
|
||||||
|
|| matchesQuery(item.targetPath, query)
|
||||||
|
|| matchesQuery(item.providerLabel, query)
|
||||||
|
|| matchesQuery(item.providerAccountLabel, query)
|
||||||
|
|| matchesQuery(item.fullStatus, query)
|
||||||
|
|| matchesQuery(item.lastError, query);
|
||||||
|
return matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter) && (packageMatchesQuery || itemMatchesQuery);
|
||||||
|
});
|
||||||
|
if (matchingItems.length === 0) return [];
|
||||||
|
const visibleItems = packageMatchesQuery && query !== ""
|
||||||
|
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
|
||||||
|
: matchingItems;
|
||||||
|
return [{ package: entry, items: visibleItems, collapsed: collapsed.has(entry.id) }];
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPackageRows = packageRows.length;
|
||||||
|
const allMatchingFileRows = packageRows.flatMap((row) => row.items);
|
||||||
|
if (!input.showAllPackages && input.renderLimit > 0 && packageRows.length > input.renderLimit) {
|
||||||
|
const activeRows = packageRows.filter(isActivePackage);
|
||||||
|
const inactiveRows = packageRows.filter((row) => !isActivePackage(row));
|
||||||
|
packageRows = [...activeRows, ...inactiveRows].slice(0, input.renderLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileRows = input.displayMode === "files" ? allMatchingFileRows : [];
|
||||||
|
const displayedPackages = input.displayMode === "packages" ? packageRows : [];
|
||||||
|
const visibleItemIds = (input.displayMode === "files"
|
||||||
|
? fileRows
|
||||||
|
: displayedPackages.flatMap((row) => row.collapsed ? [] : row.items)).map((entry) => entry.id);
|
||||||
|
const visibleRowIds = input.displayMode === "files"
|
||||||
|
? visibleItemIds
|
||||||
|
: displayedPackages.flatMap((row) => row.collapsed ? [row.package.id] : [row.package.id, ...row.items.map((entry) => entry.id)]);
|
||||||
|
const visibleRowSet = new Set(visibleRowIds);
|
||||||
|
const actionableSelectedIds = [...selectedIds].filter((id) => visibleRowSet.has(id));
|
||||||
|
const visiblePackageSet = new Set(displayedPackages.map((row) => row.package.id));
|
||||||
|
const actionableSelectedPackageIds = actionableSelectedIds.filter((id) => visiblePackageSet.has(id));
|
||||||
|
const mainRowCount = input.displayMode === "files" ? fileRows.length : displayedPackages.length;
|
||||||
|
const totalMainRowCount = input.displayMode === "files"
|
||||||
|
? fileRows.length
|
||||||
|
: totalPackageRows;
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayMode: input.displayMode,
|
||||||
|
filter: input.filter,
|
||||||
|
providerFilter: input.providerFilter,
|
||||||
|
providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
|
||||||
|
query: input.query,
|
||||||
|
counts,
|
||||||
|
packageRows: displayedPackages,
|
||||||
|
fileRows,
|
||||||
|
visibleItemIds,
|
||||||
|
visibleRowIds,
|
||||||
|
actionableSelectedIds,
|
||||||
|
actionableSelectedPackageIds,
|
||||||
|
selectedIds,
|
||||||
|
mainRowCount,
|
||||||
|
totalMainRowCount,
|
||||||
|
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
|
||||||
|
limited: mainRowCount < totalMainRowCount,
|
||||||
|
empty: allItems.length === 0,
|
||||||
|
filteredEmpty: allItems.length > 0 && mainRowCount === 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,43 +34,51 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 14px 12px;
|
padding: 14px 12px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-mode-switch {
|
.downloads-mode-title {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 1fr;
|
min-height: 36px;
|
||||||
gap: 2px;
|
align-items: center;
|
||||||
padding: 2px;
|
padding: 0 10px;
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
color: var(--ui-text);
|
||||||
|
background: var(--ui-active);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-mode-switch button,
|
|
||||||
.downloads-sidebar nav button,
|
.downloads-sidebar nav button,
|
||||||
.downloads-sidebar-actions button {
|
.downloads-sidebar-actions button {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
border: 0;
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-mode-switch button {
|
.downloads-sidebar nav button:not(.is-active):hover,
|
||||||
text-align: center;
|
.downloads-sidebar-actions button:hover:not(:disabled),
|
||||||
|
.downloads-clipboard-toggle:hover {
|
||||||
|
border-color: var(--ui-border);
|
||||||
|
background: var(--ui-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-mode-switch button.is-active,
|
|
||||||
.downloads-sidebar nav button.is-active {
|
.downloads-sidebar nav button.is-active {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: var(--ui-accent);
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-sidebar nav {
|
.downloads-sidebar nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
|
--ui-sliding-selection-background: var(--ui-accent);
|
||||||
|
--ui-sliding-selection-border: transparent;
|
||||||
|
--ui-sliding-selection-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-sidebar nav button {
|
.downloads-sidebar nav button {
|
||||||
@@ -103,15 +111,31 @@
|
|||||||
.downloads-sidebar-actions {
|
.downloads-sidebar-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
margin-top: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-sidebar-actions label {
|
.downloads-sidebar-actions button {
|
||||||
|
border: 1px solid var(--ui-border);
|
||||||
|
background: var(--ui-input);
|
||||||
|
padding: 0 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-sidebar-actions button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-clipboard-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
margin-top: auto;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--ui-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--ui-input);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-sidebar-status {
|
.downloads-sidebar-status {
|
||||||
@@ -135,6 +159,23 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-rolling-value {
|
||||||
|
display: inline-grid;
|
||||||
|
height: 1.35em;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: end;
|
||||||
|
overflow: hidden;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-rolling-value-layer {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-main-column {
|
.downloads-main-column {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr) 60px;
|
grid-template-rows: auto minmax(0, 1fr) 60px;
|
||||||
@@ -211,6 +252,14 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table-header,
|
||||||
|
.downloads-table-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1191px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-table-header {
|
.downloads-table-header {
|
||||||
@@ -220,9 +269,8 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
min-width: max-content;
|
|
||||||
border-bottom: 1px solid var(--ui-border);
|
border-bottom: 1px solid var(--ui-border);
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text);
|
||||||
background: var(--ui-table-header);
|
background: var(--ui-table-header);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -230,54 +278,113 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.downloads-table-body {
|
.downloads-table-body {
|
||||||
min-width: max-content;
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
contain: layout style;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-package-card {
|
.downloads-package-card {
|
||||||
min-width: max-content;
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid var(--ui-border);
|
border-bottom: 1px solid color-mix(in srgb, var(--ui-border) 72%, transparent);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--ui-canvas);
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-package-card.is-disabled {
|
.downloads-package-card.is-disabled {
|
||||||
opacity: 0.58;
|
opacity: 0.58;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-package-items {
|
||||||
|
height: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-package-items.is-collapsed {
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-package-items-inner {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-view :where(button, input, select):focus-visible {
|
||||||
|
outline: 1px solid #7B8492;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-item-row,
|
.downloads-item-row,
|
||||||
.downloads-package-row {
|
.downloads-package-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 48px;
|
height: 40px;
|
||||||
min-width: max-content;
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
background: var(--ui-canvas);
|
background: transparent;
|
||||||
|
border-color: var(--ui-border);
|
||||||
|
contain: layout paint style;
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-item-row {
|
.downloads-item-row {
|
||||||
|
height: 38px;
|
||||||
|
contain-intrinsic-size: 38px;
|
||||||
border-top: 1px solid var(--ui-border);
|
border-top: 1px solid var(--ui-border);
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-item-row.is-selected,
|
.downloads-item-row.is-selected,
|
||||||
.downloads-package-card.is-selected > .downloads-package-row {
|
.downloads-package-card.is-selected > .downloads-package-row {
|
||||||
background: var(--ui-active);
|
background: color-mix(in srgb, var(--ui-success) 14%, var(--ui-canvas));
|
||||||
|
box-shadow: inset 3px 0 0 var(--ui-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-package-row:hover,
|
||||||
|
.downloads-item-row:hover {
|
||||||
|
background: var(--ui-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-item-row.is-selected:hover,
|
||||||
|
.downloads-package-card.is-selected > .downloads-package-row:hover {
|
||||||
|
background: color-mix(in srgb, var(--ui-success) 18%, var(--ui-canvas));
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-cell-slot {
|
.downloads-cell-slot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-cell-slot > .downloads-cell {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-cell-slot[data-download-column="name"] > .downloads-name-cell {
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-cell,
|
.downloads-cell,
|
||||||
.downloads-name-cell {
|
.downloads-name-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0 9px;
|
padding: 0 7px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -298,20 +405,117 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-action-cell button,
|
.downloads-hoster-cell {
|
||||||
.downloads-collapse-button {
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-hoster-icon {
|
||||||
|
display: block;
|
||||||
|
flex: 0 0 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-hoster-icon[data-hoster="rapidgator"] {
|
||||||
|
transform: translateY(-4px) scale(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-selection-cell input[type="checkbox"] {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--ui-success);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-action-cell button {
|
||||||
|
box-sizing: border-box;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-collapse-button {
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 0 0 30px;
|
||||||
|
width: 30px;
|
||||||
|
min-width: 30px;
|
||||||
|
max-width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-name-cell > input[type="checkbox"] {
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 0 0 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-column-header {
|
.downloads-column-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0 9px;
|
padding: 0 9px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table.is-column-drag-active [data-download-column] {
|
||||||
|
position: relative;
|
||||||
|
transform: translate3d(var(--downloads-column-drag-x, 0px), 0, 0);
|
||||||
|
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table [data-download-column="name"] { --downloads-column-drag-x: var(--downloads-column-drag-name, 0px); }
|
||||||
|
.downloads-table [data-download-column="size"] { --downloads-column-drag-x: var(--downloads-column-drag-size, 0px); }
|
||||||
|
.downloads-table [data-download-column="progress"] { --downloads-column-drag-x: var(--downloads-column-drag-progress, 0px); }
|
||||||
|
.downloads-table [data-download-column="hoster"] { --downloads-column-drag-x: var(--downloads-column-drag-hoster, 0px); }
|
||||||
|
.downloads-table [data-download-column="account"] { --downloads-column-drag-x: var(--downloads-column-drag-account, 0px); }
|
||||||
|
.downloads-table [data-download-column="prio"] { --downloads-column-drag-x: var(--downloads-column-drag-prio, 0px); }
|
||||||
|
.downloads-table [data-download-column="status"] { --downloads-column-drag-x: var(--downloads-column-drag-status, 0px); }
|
||||||
|
.downloads-table [data-download-column="speed"] { --downloads-column-drag-x: var(--downloads-column-drag-speed, 0px); }
|
||||||
|
.downloads-table [data-download-column="availability"] { --downloads-column-drag-x: var(--downloads-column-drag-availability, 0px); }
|
||||||
|
.downloads-table [data-download-column="added"] { --downloads-column-drag-x: var(--downloads-column-drag-added, 0px); }
|
||||||
|
|
||||||
|
.downloads-table.is-column-drag-active [data-column-dragging="true"] {
|
||||||
|
--downloads-column-drag-x: var(--downloads-active-drag-x, 0px);
|
||||||
|
z-index: 4;
|
||||||
|
transition: none;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table.is-column-drag-active .downloads-column-header[data-column-dragging="true"] {
|
||||||
|
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--ui-text-muted) 58%, transparent), inset -2px 0 0 color-mix(in srgb, var(--ui-text-muted) 58%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table.is-column-drag-settling [data-download-column] {
|
||||||
|
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.downloads-table.is-column-drag-active [data-download-column],
|
||||||
|
.downloads-table.is-column-drag-settling [data-download-column] {
|
||||||
|
transition-duration: 220ms !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table.is-column-drag-active [data-column-dragging="true"] {
|
||||||
|
transition-duration: 0s !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-column-header button {
|
.downloads-column-header button {
|
||||||
@@ -319,10 +523,19 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
text-transform: inherit;
|
text-transform: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-column-header[data-download-column="name"] {
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-column-header[data-download-column="name"] button {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-link-state {
|
.downloads-link-state {
|
||||||
flex: 0 0 8px;
|
flex: 0 0 8px;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@@ -343,11 +556,65 @@
|
|||||||
background: var(--ui-warning);
|
background: var(--ui-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-availability {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16px 7ch;
|
||||||
|
gap: 0;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-availability.has-counts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16px 4ch 1ch 4ch auto;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-availability-symbol {
|
||||||
|
width: 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-availability-count.is-online-count {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-availability-count.is-total-count {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-availability-separator {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-availability-label {
|
||||||
|
padding-left: 4px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-availability.is-online {
|
||||||
|
color: var(--ui-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-availability.is-partial {
|
||||||
|
color: var(--ui-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-availability.is-offline {
|
||||||
|
color: var(--ui-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-availability.is-checking {
|
||||||
|
color: var(--ui-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-meter {
|
.downloads-meter {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 24px;
|
height: 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -357,7 +624,7 @@
|
|||||||
.downloads-meter > span {
|
.downloads-meter > span {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0 auto 0 0;
|
inset: 0 auto 0 0;
|
||||||
background: color-mix(in srgb, var(--ui-accent) 58%, transparent);
|
background: var(--ui-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-meter > b {
|
.downloads-meter > b {
|
||||||
@@ -371,6 +638,10 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-item-row .downloads-meter > b {
|
||||||
|
color: var(--ui-text);
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-empty-state,
|
.downloads-empty-state,
|
||||||
.downloads-table-message {
|
.downloads-table-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,239 +1,243 @@
|
|||||||
import type { ChangeEvent, MouseEvent, ReactElement } from "react";
|
import type { ChangeEvent, MouseEvent, ReactElement } from "react";
|
||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
DataTableBody,
|
DataTableBody,
|
||||||
DataTableEmpty,
|
DataTableEmpty,
|
||||||
DataTableFooter,
|
DataTableFooter,
|
||||||
DataTableHeader
|
DataTableHeader
|
||||||
} from "../../ui/DataTable";
|
} from "../../ui/DataTable";
|
||||||
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||||
import type { HistoryFilter, HistoryRow, HistoryViewModel } from "./history-model";
|
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||||
import "./history.css";
|
import type { HistoryFilter, HistoryRow, HistoryViewModel } from "./history-model";
|
||||||
|
import "./history.css";
|
||||||
export interface HistoryViewActions {
|
|
||||||
onFilterChange: (filter: HistoryFilter) => void;
|
export interface HistoryViewActions {
|
||||||
onQueryChange: (value: string) => void;
|
onFilterChange: (filter: HistoryFilter) => void;
|
||||||
onToggleSelection: (entryId: string) => void;
|
onQueryChange: (value: string) => void;
|
||||||
onToggleSelectAll: (visibleIds: string[]) => void;
|
onToggleSelection: (entryId: string) => void;
|
||||||
onToggleExpansion: (entryId: string) => void;
|
onToggleSelectAll: (visibleIds: string[]) => void;
|
||||||
onRestore: (entryIds: string[]) => void;
|
onToggleExpansion: (entryId: string) => void;
|
||||||
onReveal: (entryId: string) => void;
|
onRestore: (entryIds: string[]) => void;
|
||||||
onRemove: (entryIds: string[]) => void;
|
onReveal: (entryId: string) => void;
|
||||||
onClearSelection: () => void;
|
onRemove: (entryIds: string[]) => void;
|
||||||
onClearHistory: () => void;
|
onClearSelection: () => void;
|
||||||
onContextMenu: (entryId: string, x: number, y: number) => void;
|
onClearHistory: () => void;
|
||||||
}
|
onContextMenu: (entryId: string, x: number, y: number) => void;
|
||||||
|
}
|
||||||
export interface HistoryViewProps {
|
|
||||||
model: HistoryViewModel;
|
export interface HistoryViewProps {
|
||||||
actions: HistoryViewActions;
|
model: HistoryViewModel;
|
||||||
}
|
actions: HistoryViewActions;
|
||||||
|
}
|
||||||
const filterItems: Array<{ id: HistoryFilter; label: string }> = [
|
|
||||||
{ id: "all", label: "Alle Einträge" },
|
const filterItems: Array<{ id: HistoryFilter; label: string }> = [
|
||||||
{ id: "today", label: "Heute" },
|
{ id: "all", label: "Alle Einträge" },
|
||||||
{ id: "week", label: "Letzte 7 Tage" },
|
{ id: "today", label: "Heute" },
|
||||||
{ id: "older", label: "Älter" },
|
{ id: "week", label: "Letzte 7 Tage" },
|
||||||
{ id: "completed", label: "Fertig" },
|
{ id: "older", label: "Älter" },
|
||||||
{ id: "deleted", label: "Gelöscht" },
|
{ id: "completed", label: "Fertig" },
|
||||||
{ id: "failed", label: "Fehlgeschlagen" }
|
{ id: "deleted", label: "Gelöscht" },
|
||||||
];
|
{ id: "failed", label: "Fehlgeschlagen" }
|
||||||
|
];
|
||||||
function HistoryRowDetails({ row }: { row: HistoryRow }): ReactElement {
|
|
||||||
return (
|
function HistoryRowDetails({ row }: { row: HistoryRow }): ReactElement {
|
||||||
<div className="history-detail-row" role="row">
|
return (
|
||||||
<div className="history-detail-cell" role="cell">
|
<div className="history-detail-row" role="row">
|
||||||
<dl className="history-details-grid">
|
<div className="history-detail-cell" role="cell">
|
||||||
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
|
<dl className="history-details-grid">
|
||||||
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
|
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
|
||||||
<div><dt>Dauer</dt><dd>{row.durationLabel}</dd></div>
|
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
|
||||||
<div><dt>Durchschnitt</dt><dd>{row.averageSpeedLabel}</dd></div>
|
<div><dt>Dauer</dt><dd>{row.durationLabel}</dd></div>
|
||||||
<div className="history-detail-wide"><dt>Zielordner</dt><dd className="history-copyable">{row.outputDir || "—"}</dd></div>
|
<div><dt>Durchschnitt</dt><dd>{row.averageSpeedLabel}</dd></div>
|
||||||
<div className="history-detail-wide"><dt>URLs</dt><dd className="history-copyable">{row.urls?.length ? row.urls.join("\n") : "—"}</dd></div>
|
<div className="history-detail-wide"><dt>Zielordner</dt><dd className="history-copyable">{row.outputDir || "—"}</dd></div>
|
||||||
</dl>
|
<div className="history-detail-wide"><dt>URLs</dt><dd className="history-copyable">{row.urls?.length ? row.urls.join("\n") : "—"}</dd></div>
|
||||||
</div>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function HistorySidebar({ model, actions }: HistoryViewProps): ReactElement {
|
export function HistorySidebar({ model, actions }: HistoryViewProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div aria-label="Verlaufsfilter" className="history-sidebar" data-visual-region="history-sidebar">
|
<div aria-label="Verlaufsfilter" className="history-sidebar" data-visual-region="history-sidebar">
|
||||||
<strong className="history-sidebar-heading">Verlauf</strong>
|
<strong className="history-sidebar-heading">Verlauf</strong>
|
||||||
<div className="history-filter-list">
|
<SlidingSelection activeKey={model.filter} axis="vertical" className="history-filter-list">
|
||||||
{filterItems.map((item) => (
|
{filterItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
aria-current={model.filter === item.id ? "page" : undefined}
|
aria-current={model.filter === item.id ? "page" : undefined}
|
||||||
className={`history-filter${model.filter === item.id ? " is-active" : ""}`}
|
className={`history-filter${model.filter === item.id ? " is-active" : ""}`}
|
||||||
key={item.id}
|
data-sliding-selection-active={model.filter === item.id}
|
||||||
onClick={() => actions.onFilterChange(item.id)}
|
data-sliding-selection-item="true"
|
||||||
type="button"
|
key={item.id}
|
||||||
>
|
onClick={() => actions.onFilterChange(item.id)}
|
||||||
<span>{item.label}</span>
|
type="button"
|
||||||
<span>{model.counts[item.id]}</span>
|
>
|
||||||
</button>
|
<span>{item.label}</span>
|
||||||
))}
|
<span>{model.counts[item.id]}</span>
|
||||||
</div>
|
</button>
|
||||||
<button
|
))}
|
||||||
className="history-sidebar-clear"
|
</SlidingSelection>
|
||||||
disabled={model.totalCount === 0 || model.loading}
|
<button
|
||||||
onClick={actions.onClearHistory}
|
className="history-sidebar-clear"
|
||||||
type="button"
|
disabled={model.totalCount === 0 || model.loading}
|
||||||
>Verlauf leeren</button>
|
onClick={actions.onClearHistory}
|
||||||
</div>
|
type="button"
|
||||||
);
|
>Verlauf leeren</button>
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactElement {
|
}
|
||||||
const selectedIds = model.selectedIds;
|
|
||||||
const selectedSet = new Set(selectedIds);
|
export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactElement {
|
||||||
const restorable = model.rows.some((row) => selectedSet.has(row.id) && (row.urls?.length ?? 0) > 0);
|
const selectedIds = model.selectedIds;
|
||||||
return (
|
const selectedSet = new Set(selectedIds);
|
||||||
<Toolbar className="history-workspace-toolbar" data-visual-region="history-toolbar" label="Verlaufsaktionen">
|
const restorable = model.rows.some((row) => selectedSet.has(row.id) && (row.urls?.length ?? 0) > 0);
|
||||||
<ToolbarGroup label="Einträge">
|
return (
|
||||||
<button className="history-action" disabled={selectedIds.length === 0 || !restorable} onClick={() => actions.onRestore(selectedIds)} type="button">Erneut hinzufügen</button>
|
<Toolbar className="history-workspace-toolbar" data-visual-region="history-toolbar" label="Verlaufsaktionen">
|
||||||
<button className="history-action" disabled={selectedIds.length !== 1} onClick={() => actions.onReveal(selectedIds[0])} type="button">Im Ordner zeigen</button>
|
<ToolbarGroup label="Einträge">
|
||||||
<button className="history-action history-action-danger" disabled={selectedIds.length === 0} onClick={() => actions.onRemove(selectedIds)} type="button">Entfernen</button>
|
<button className="history-action" disabled={selectedIds.length === 0 || !restorable} onClick={() => actions.onRestore(selectedIds)} type="button">Erneut hinzufügen</button>
|
||||||
<button className="history-action" disabled={selectedIds.length === 0} onClick={actions.onClearSelection} type="button">Auswahl löschen</button>
|
<button className="history-action" disabled={selectedIds.length !== 1} onClick={() => actions.onReveal(selectedIds[0])} type="button">Im Ordner zeigen</button>
|
||||||
</ToolbarGroup>
|
<button className="history-action history-action-danger" disabled={selectedIds.length === 0} onClick={() => actions.onRemove(selectedIds)} type="button">Entfernen</button>
|
||||||
<ToolbarSearch
|
<button className="history-action" disabled={selectedIds.length === 0} onClick={actions.onClearSelection} type="button">Auswahl löschen</button>
|
||||||
label="Verlauf durchsuchen"
|
</ToolbarGroup>
|
||||||
onChange={(event: ChangeEvent<HTMLInputElement>) => actions.onQueryChange(event.target.value)}
|
<ToolbarSearch
|
||||||
placeholder="Name, Pfad, Hoster oder Provider"
|
label="Verlauf durchsuchen"
|
||||||
value={model.query}
|
onChange={(event: ChangeEvent<HTMLInputElement>) => actions.onQueryChange(event.target.value)}
|
||||||
/>
|
placeholder="Name, Pfad, Hoster oder Provider"
|
||||||
</Toolbar>
|
value={model.query}
|
||||||
);
|
/>
|
||||||
}
|
</Toolbar>
|
||||||
|
);
|
||||||
export function HistoryContent({ model, actions }: HistoryViewProps): ReactElement {
|
}
|
||||||
const selected = new Set(model.selectedIds);
|
|
||||||
const expanded = new Set(model.expandedIds);
|
export function HistoryContent({ model, actions }: HistoryViewProps): ReactElement {
|
||||||
const visibleIds = model.rows.map((row) => row.id);
|
const selected = new Set(model.selectedIds);
|
||||||
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
|
const expanded = new Set(model.expandedIds);
|
||||||
const showEmpty = !model.loading && !model.error && model.rows.length === 0;
|
const visibleIds = model.rows.map((row) => row.id);
|
||||||
const emptyTitle = model.totalCount === 0 && !model.query && model.filter === "all"
|
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
|
||||||
? "Noch kein Verlauf"
|
const showEmpty = !model.loading && !model.error && model.rows.length === 0;
|
||||||
: "Keine passenden Einträge";
|
const emptyTitle = model.totalCount === 0 && !model.query && model.filter === "all"
|
||||||
|
? "Noch kein Verlauf"
|
||||||
return (
|
: "Keine passenden Einträge";
|
||||||
<section aria-label="Verlaufstabelle" className="history-content">
|
|
||||||
<DataTable className="history-table" label="Verlauf">
|
return (
|
||||||
<DataTableHeader className="history-table-header">
|
<section aria-label="Verlaufstabelle" className="history-content">
|
||||||
<div className="history-table-header-row" role="row">
|
<DataTable className="history-table" label="Verlauf">
|
||||||
<span className="history-column-select" role="columnheader">
|
<DataTableHeader className="history-table-header">
|
||||||
<input
|
<div className="history-table-header-row" role="row">
|
||||||
aria-label="Alle sichtbaren Einträge auswählen"
|
<span className="history-column-select" role="columnheader">
|
||||||
checked={allVisibleSelected}
|
<input
|
||||||
disabled={visibleIds.length === 0}
|
aria-label="Alle sichtbaren Einträge auswählen"
|
||||||
onChange={() => actions.onToggleSelectAll(visibleIds)}
|
checked={allVisibleSelected}
|
||||||
type="checkbox"
|
disabled={visibleIds.length === 0}
|
||||||
/>
|
onChange={() => actions.onToggleSelectAll(visibleIds)}
|
||||||
</span>
|
type="checkbox"
|
||||||
<span role="columnheader">Paket / Datei</span>
|
/>
|
||||||
<span role="columnheader">Status</span>
|
</span>
|
||||||
<span role="columnheader">Größe</span>
|
<span role="columnheader">Paket / Datei</span>
|
||||||
<span role="columnheader">Hoster</span>
|
<span role="columnheader">Status</span>
|
||||||
<span role="columnheader">Gestartet</span>
|
<span role="columnheader">Größe</span>
|
||||||
<span role="columnheader">Beendet</span>
|
<span role="columnheader">Hoster</span>
|
||||||
<span role="columnheader">Aktion</span>
|
<span role="columnheader">Gestartet</span>
|
||||||
</div>
|
<span role="columnheader">Beendet</span>
|
||||||
</DataTableHeader>
|
<span role="columnheader">Aktion</span>
|
||||||
<DataTableBody className="history-table-body" data-visual-region="history-table-body">
|
</div>
|
||||||
{model.loading ? (
|
</DataTableHeader>
|
||||||
<DataTableEmpty description="Die gespeicherten Einträge werden geladen." title="Verlauf wird geladen" />
|
<DataTableBody className="history-table-body" data-visual-region="history-table-body">
|
||||||
) : model.error ? (
|
{model.loading ? (
|
||||||
<DataTableEmpty className="history-table-error" description="Öffne die Ansicht erneut, um es noch einmal zu versuchen." title={model.error} />
|
<DataTableEmpty description="Die gespeicherten Einträge werden geladen." title="Verlauf wird geladen" />
|
||||||
) : showEmpty ? (
|
) : model.error ? (
|
||||||
<DataTableEmpty description={emptyTitle === "Noch kein Verlauf" ? "Abgeschlossene und gelöschte Pakete erscheinen hier." : "Passe Filter oder Suche an."} title={emptyTitle} />
|
<DataTableEmpty className="history-table-error" description="Öffne die Ansicht erneut, um es noch einmal zu versuchen." title={model.error} />
|
||||||
) : (
|
) : showEmpty ? (
|
||||||
model.rows.map((row) => {
|
<DataTableEmpty description={emptyTitle === "Noch kein Verlauf" ? "Abgeschlossene und gelöschte Pakete erscheinen hier." : "Passe Filter oder Suche an."} title={emptyTitle} />
|
||||||
const isSelected = selected.has(row.id);
|
) : (
|
||||||
const isExpanded = expanded.has(row.id);
|
model.rows.map((row) => {
|
||||||
const onContextMenu = (event: MouseEvent<HTMLElement>): void => {
|
const isSelected = selected.has(row.id);
|
||||||
event.preventDefault();
|
const isExpanded = expanded.has(row.id);
|
||||||
event.stopPropagation();
|
const onContextMenu = (event: MouseEvent<HTMLElement>): void => {
|
||||||
event.currentTarget.querySelector<HTMLButtonElement>(".history-row-action button")?.focus({ preventScroll: true });
|
event.preventDefault();
|
||||||
actions.onContextMenu(row.id, event.clientX, event.clientY);
|
event.stopPropagation();
|
||||||
};
|
event.currentTarget.querySelector<HTMLButtonElement>(".history-row-action button")?.focus({ preventScroll: true });
|
||||||
return (
|
actions.onContextMenu(row.id, event.clientX, event.clientY);
|
||||||
<div className="history-row-group" key={row.id}>
|
};
|
||||||
<div
|
return (
|
||||||
className={`history-row${isSelected ? " is-selected" : ""}`}
|
<div className="history-row-group" key={row.id}>
|
||||||
data-history-row-id={row.id}
|
<div
|
||||||
onContextMenu={onContextMenu}
|
className={`history-row${isSelected ? " is-selected" : ""}`}
|
||||||
role="row"
|
data-history-row-id={row.id}
|
||||||
>
|
onContextMenu={onContextMenu}
|
||||||
<span className="history-column-select" role="cell">
|
role="row"
|
||||||
<input
|
>
|
||||||
aria-label={`${row.name} auswählen`}
|
<span className="history-column-select" role="cell">
|
||||||
checked={isSelected}
|
<input
|
||||||
onChange={() => actions.onToggleSelection(row.id)}
|
aria-label={`${row.name} auswählen`}
|
||||||
type="checkbox"
|
checked={isSelected}
|
||||||
/>
|
onChange={() => actions.onToggleSelection(row.id)}
|
||||||
</span>
|
type="checkbox"
|
||||||
<span className="history-row-name" role="cell">
|
/>
|
||||||
<button
|
</span>
|
||||||
aria-expanded={isExpanded}
|
<span className="history-row-name" role="cell">
|
||||||
aria-label={isExpanded ? "Details ausblenden" : "Details anzeigen"}
|
<button
|
||||||
className="history-expand"
|
aria-expanded={isExpanded}
|
||||||
onClick={(event) => {
|
aria-label={isExpanded ? "Details ausblenden" : "Details anzeigen"}
|
||||||
event.stopPropagation();
|
className="history-expand"
|
||||||
actions.onToggleExpansion(row.id);
|
onClick={(event) => {
|
||||||
}}
|
event.stopPropagation();
|
||||||
type="button"
|
actions.onToggleExpansion(row.id);
|
||||||
>{isExpanded ? "−" : "+"}</button>
|
}}
|
||||||
<span title={row.name}>{row.name}</span>
|
type="button"
|
||||||
</span>
|
>{isExpanded ? "−" : "+"}</button>
|
||||||
<span role="cell"><span className={`history-status history-status-${row.status}`}>{row.statusLabel}</span></span>
|
<span title={row.name}>{row.name}</span>
|
||||||
<span className="history-row-size" role="cell" title={row.sizeLabel}>{row.sizeLabel}</span>
|
</span>
|
||||||
<span className="history-row-hoster" role="cell" title={row.hoster}>{row.hoster}</span>
|
<span role="cell"><span className={`history-status history-status-${row.status}`}>{row.statusLabel}</span></span>
|
||||||
<span className="history-row-time" role="cell">{row.startedLabel}</span>
|
<span className="history-row-size" role="cell" title={row.sizeLabel}>{row.sizeLabel}</span>
|
||||||
<span className="history-row-time" role="cell">{row.completedLabel}</span>
|
<span className="history-row-hoster" role="cell" title={row.hoster}>{row.hoster}</span>
|
||||||
<span className="history-row-action" role="cell">
|
<span className="history-row-time" role="cell">{row.startedLabel}</span>
|
||||||
<button
|
<span className="history-row-time" role="cell">{row.completedLabel}</span>
|
||||||
|
<span className="history-row-action" role="cell">
|
||||||
|
<button
|
||||||
aria-label={`Aktionen für ${row.name}`}
|
aria-label={`Aktionen für ${row.name}`}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
actions.onContextMenu(row.id, rect.right, rect.bottom);
|
actions.onContextMenu(row.id, rect.right, rect.bottom);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>•••</button>
|
>⋮</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded ? <HistoryRowDetails row={row} /> : null}
|
{isExpanded ? <HistoryRowDetails row={row} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</DataTableBody>
|
</DataTableBody>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryFooter({ model }: Pick<HistoryViewProps, "model">): ReactElement {
|
export function HistoryFooter({ model }: Pick<HistoryViewProps, "model">): ReactElement {
|
||||||
const count = model.rows.length;
|
const count = model.rows.length;
|
||||||
return (
|
return (
|
||||||
<DataTableFooter
|
<DataTableFooter
|
||||||
className="history-pagination"
|
className="history-pagination"
|
||||||
data-visual-region="history-pagination"
|
data-visual-region="history-pagination"
|
||||||
pageSize={count}
|
pageSize={count}
|
||||||
paginationVisible
|
paginationVisible
|
||||||
rangeLabel={count === 0 ? "0 von 0" : `1–${count} von ${count}`}
|
rangeLabel={count === 0 ? "0 von 0" : `1–${count} von ${count}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryView({ model, actions }: HistoryViewProps): ReactElement {
|
export function HistoryView({ model, actions }: HistoryViewProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="history-workspace-view">
|
<div className="history-workspace-view">
|
||||||
<HistorySidebar actions={actions} model={model} />
|
<HistorySidebar actions={actions} model={model} />
|
||||||
<div className="history-view-main">
|
<div className="history-view-main">
|
||||||
<HistoryToolbar actions={actions} model={model} />
|
<HistoryToolbar actions={actions} model={model} />
|
||||||
<HistoryContent actions={actions} model={model} />
|
<HistoryContent actions={actions} model={model} />
|
||||||
<HistoryFooter model={model} />
|
<HistoryFooter model={model} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,366 +1,381 @@
|
|||||||
.history-workspace-view {
|
.history-workspace-view {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 270px minmax(0, 1fr);
|
grid-template-columns: 270px minmax(0, 1fr);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-view-main {
|
.history-view-main {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr) 60px;
|
grid-template-rows: auto minmax(0, 1fr) 60px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-sidebar {
|
.history-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-sidebar-heading {
|
.history-sidebar-heading {
|
||||||
color: var(--ui-text-secondary);
|
color: var(--ui-text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-filter-list {
|
.history-filter-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-filter,
|
.history-filter,
|
||||||
.history-sidebar-clear,
|
.history-sidebar-clear,
|
||||||
.history-action {
|
.history-action {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--ui-text-secondary);
|
color: var(--ui-text-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-filter {
|
.history-filter {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-filter span:last-child {
|
.history-filter span:last-child {
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-muted);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-filter:hover,
|
.history-filter:hover,
|
||||||
.history-sidebar-clear:hover:not(:disabled),
|
.history-sidebar-clear:hover:not(:disabled),
|
||||||
.history-action:hover:not(:disabled) {
|
.history-action:hover:not(:disabled) {
|
||||||
background: var(--ui-hover);
|
background: var(--ui-hover);
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-filter.is-active {
|
.history-filter.is-active {
|
||||||
background: var(--ui-active);
|
background: transparent;
|
||||||
border-color: var(--ui-border);
|
border-color: transparent;
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-sidebar-clear {
|
.history-sidebar-clear {
|
||||||
border-color: var(--ui-border);
|
border-color: var(--ui-border);
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-sidebar-clear:disabled,
|
.history-sidebar-clear:disabled,
|
||||||
.history-action:disabled {
|
.history-action:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-sidebar,
|
.history-sidebar,
|
||||||
.history-workspace-toolbar,
|
.history-workspace-toolbar,
|
||||||
.history-content,
|
.history-content,
|
||||||
.history-pagination {
|
.history-pagination {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-workspace-toolbar {
|
.history-workspace-toolbar {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-workspace-toolbar .ui-toolbar-search-input {
|
.history-workspace-toolbar .ui-toolbar-search-input {
|
||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-action {
|
.history-action {
|
||||||
background: var(--ui-input);
|
background: var(--ui-input);
|
||||||
border-color: var(--ui-border);
|
border-color: var(--ui-border);
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-action-danger:not(:disabled) {
|
.history-action-danger:not(:disabled) {
|
||||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-content {
|
.history-content {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-content .history-table {
|
.history-content .history-table {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table-header-row,
|
.history-table-header-row,
|
||||||
.history-row {
|
.history-row {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 48px minmax(190px, 1.45fr) minmax(112px, 0.75fr) minmax(150px, 1fr) minmax(130px, 0.9fr) minmax(135px, 0.9fr) minmax(135px, 0.9fr) 72px;
|
grid-template-columns: 48px minmax(190px, 1.45fr) minmax(112px, 0.75fr) minmax(150px, 1fr) minmax(130px, 0.9fr) minmax(135px, 0.9fr) minmax(135px, 0.9fr) 72px;
|
||||||
min-width: 1080px;
|
min-width: 1080px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table > .history-table-header {
|
.history-table > .history-table-header {
|
||||||
height: 41px;
|
height: 41px;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table-header-row {
|
.history-table-header-row {
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-muted);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
height: 41px;
|
height: 41px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table-header-row > span,
|
.history-table-header-row > span {
|
||||||
.history-row > span {
|
text-align: center;
|
||||||
min-width: 0;
|
|
||||||
padding: 0 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table > .history-table-body {
|
.history-table-header-row > span:nth-child(2) {
|
||||||
overflow: visible;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-row-group {
|
.history-table-header-row > span,
|
||||||
min-width: 1080px;
|
.history-row > span {
|
||||||
}
|
min-width: 0;
|
||||||
|
padding: 0 10px;
|
||||||
.history-row {
|
}
|
||||||
border-bottom: 1px solid var(--ui-border);
|
|
||||||
color: var(--ui-text-secondary);
|
.history-table > .history-table-body {
|
||||||
height: 48px;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-row:hover {
|
.history-row-group {
|
||||||
background: var(--ui-hover);
|
min-width: 1080px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-row.is-selected {
|
.history-row {
|
||||||
background: var(--ui-active);
|
border-bottom: 1px solid var(--ui-border);
|
||||||
}
|
color: var(--ui-text-secondary);
|
||||||
|
height: 48px;
|
||||||
.history-column-select {
|
}
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
.history-row:hover {
|
||||||
}
|
background: var(--ui-hover);
|
||||||
|
}
|
||||||
.history-row-name {
|
|
||||||
align-items: center;
|
.history-row.is-selected {
|
||||||
color: var(--ui-text);
|
background: var(--ui-active);
|
||||||
display: flex;
|
}
|
||||||
font-weight: 600;
|
|
||||||
gap: 8px;
|
.history-column-select {
|
||||||
overflow: hidden;
|
display: grid;
|
||||||
}
|
place-items: center;
|
||||||
|
}
|
||||||
.history-row-name > span {
|
|
||||||
overflow: hidden;
|
.history-row-name {
|
||||||
text-overflow: ellipsis;
|
align-items: center;
|
||||||
white-space: nowrap;
|
color: var(--ui-text);
|
||||||
}
|
display: flex;
|
||||||
|
font-weight: 600;
|
||||||
.history-expand,
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-row-name > span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-expand,
|
||||||
|
.history-row-action button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font: inherit;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-expand {
|
||||||
|
width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
.history-row-action button {
|
.history-row-action button {
|
||||||
background: transparent;
|
background: var(--ui-input);
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--ui-text-secondary);
|
|
||||||
flex: 0 0 auto;
|
|
||||||
font: inherit;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-expand {
|
|
||||||
width: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-row-action button {
|
|
||||||
width: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-expand:hover,
|
|
||||||
.history-row-action button:hover {
|
|
||||||
background: var(--ui-hover);
|
|
||||||
border-color: var(--ui-border);
|
|
||||||
color: var(--ui-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-status {
|
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-border);
|
||||||
border-radius: 999px;
|
border-radius: 5px;
|
||||||
display: inline-flex;
|
color: var(--ui-text);
|
||||||
font-size: 12px;
|
height: 30px;
|
||||||
font-weight: 600;
|
min-height: 30px;
|
||||||
line-height: 22px;
|
padding: 0;
|
||||||
max-width: 100%;
|
width: 30px;
|
||||||
overflow: hidden;
|
|
||||||
padding: 0 8px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-status-completed {
|
.history-expand:hover,
|
||||||
background: color-mix(in srgb, var(--ui-success) 16%, transparent);
|
.history-row-action button:hover {
|
||||||
border-color: color-mix(in srgb, var(--ui-success) 60%, var(--ui-border));
|
background: var(--ui-hover);
|
||||||
color: var(--ui-success);
|
border-color: var(--ui-border);
|
||||||
}
|
color: var(--ui-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-status {
|
||||||
|
border: 1px solid var(--ui-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 8px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-status-completed {
|
||||||
|
background: color-mix(in srgb, var(--ui-success) 16%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--ui-success) 60%, var(--ui-border));
|
||||||
|
color: var(--ui-success);
|
||||||
|
}
|
||||||
|
|
||||||
.history-status-deleted {
|
.history-status-deleted {
|
||||||
background: color-mix(in srgb, var(--ui-warning) 15%, transparent);
|
|
||||||
border-color: color-mix(in srgb, var(--ui-warning) 60%, var(--ui-border));
|
|
||||||
color: var(--ui-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-status-failed {
|
|
||||||
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
|
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
|
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-row-size,
|
.history-status-failed {
|
||||||
.history-row-hoster,
|
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
|
||||||
.history-row-time {
|
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
|
||||||
overflow: hidden;
|
color: var(--ui-danger);
|
||||||
text-overflow: ellipsis;
|
}
|
||||||
white-space: nowrap;
|
|
||||||
}
|
.history-row-size,
|
||||||
|
.history-row-hoster,
|
||||||
.history-row-size,
|
.history-row-time {
|
||||||
.history-row-time {
|
overflow: hidden;
|
||||||
font-variant-numeric: tabular-nums;
|
text-overflow: ellipsis;
|
||||||
}
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.history-row-action {
|
|
||||||
display: grid;
|
.history-row-size,
|
||||||
place-items: center;
|
.history-row-time {
|
||||||
}
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
.history-detail-row {
|
|
||||||
border-bottom: 1px solid var(--ui-border);
|
.history-row-action {
|
||||||
background: var(--ui-input);
|
display: grid;
|
||||||
min-width: 1080px;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-detail-cell {
|
.history-detail-row {
|
||||||
padding: 14px 48px;
|
border-bottom: 1px solid var(--ui-border);
|
||||||
}
|
background: var(--ui-input);
|
||||||
|
min-width: 1080px;
|
||||||
.history-details-grid {
|
}
|
||||||
display: grid;
|
|
||||||
gap: 12px 24px;
|
.history-detail-cell {
|
||||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
padding: 14px 48px;
|
||||||
margin: 0;
|
}
|
||||||
}
|
|
||||||
|
.history-details-grid {
|
||||||
.history-details-grid > div {
|
display: grid;
|
||||||
display: grid;
|
gap: 12px 24px;
|
||||||
gap: 4px;
|
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||||
min-width: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-details-grid dt {
|
.history-details-grid > div {
|
||||||
color: var(--ui-text-muted);
|
display: grid;
|
||||||
font-size: 11px;
|
gap: 4px;
|
||||||
font-weight: 700;
|
min-width: 0;
|
||||||
text-transform: uppercase;
|
}
|
||||||
}
|
|
||||||
|
.history-details-grid dt {
|
||||||
.history-details-grid dd {
|
color: var(--ui-text-muted);
|
||||||
color: var(--ui-text-secondary);
|
font-size: 11px;
|
||||||
margin: 0;
|
font-weight: 700;
|
||||||
min-width: 0;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-details-grid .history-detail-wide {
|
.history-details-grid dd {
|
||||||
grid-column: span 2;
|
color: var(--ui-text-secondary);
|
||||||
}
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
.history-copyable {
|
}
|
||||||
overflow-wrap: anywhere;
|
|
||||||
user-select: text;
|
.history-details-grid .history-detail-wide {
|
||||||
white-space: pre-wrap;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table-error .ui-data-table-empty-title {
|
.history-copyable {
|
||||||
color: var(--ui-danger);
|
overflow-wrap: anywhere;
|
||||||
}
|
user-select: text;
|
||||||
|
white-space: pre-wrap;
|
||||||
.history-pagination {
|
}
|
||||||
height: 60px;
|
|
||||||
}
|
.history-table-error .ui-data-table-empty-title {
|
||||||
|
color: var(--ui-danger);
|
||||||
@media (max-width: 1366px) {
|
}
|
||||||
.history-workspace-view {
|
|
||||||
grid-template-columns: 56px minmax(0, 1fr);
|
.history-pagination {
|
||||||
}
|
height: 60px;
|
||||||
|
}
|
||||||
.history-sidebar {
|
|
||||||
overflow: hidden;
|
@media (max-width: 1366px) {
|
||||||
}
|
.history-workspace-view {
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr);
|
||||||
.history-action {
|
}
|
||||||
padding: 0 9px;
|
|
||||||
}
|
.history-sidebar {
|
||||||
|
overflow: hidden;
|
||||||
.history-table-header-row,
|
}
|
||||||
.history-row {
|
|
||||||
grid-template-columns: 44px minmax(170px, 1.25fr) minmax(108px, 0.7fr) minmax(140px, 0.9fr) minmax(120px, 0.8fr) minmax(125px, 0.8fr) minmax(125px, 0.8fr) 64px;
|
.history-action {
|
||||||
min-width: 996px;
|
padding: 0 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-row-group,
|
.history-table-header-row,
|
||||||
.history-detail-row {
|
.history-row {
|
||||||
min-width: 996px;
|
grid-template-columns: 44px minmax(170px, 1.25fr) minmax(108px, 0.7fr) minmax(140px, 0.9fr) minmax(120px, 0.8fr) minmax(125px, 0.8fr) minmax(125px, 0.8fr) 64px;
|
||||||
}
|
min-width: 996px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1120px) {
|
.history-row-group,
|
||||||
.history-table-header-row,
|
.history-detail-row {
|
||||||
.history-row {
|
min-width: 996px;
|
||||||
grid-template-columns: 44px minmax(154px, 1.2fr) minmax(102px, 0.7fr) minmax(128px, 0.9fr) minmax(112px, 0.8fr) minmax(116px, 0.8fr) minmax(116px, 0.8fr) 60px;
|
}
|
||||||
min-width: 932px;
|
}
|
||||||
}
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
.history-row-group,
|
.history-table-header-row,
|
||||||
.history-detail-row {
|
.history-row {
|
||||||
min-width: 932px;
|
grid-template-columns: 44px minmax(154px, 1.2fr) minmax(102px, 0.7fr) minmax(128px, 0.9fr) minmax(112px, 0.8fr) minmax(116px, 0.8fr) minmax(116px, 0.8fr) 60px;
|
||||||
}
|
min-width: 932px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-row-group,
|
||||||
|
.history-detail-row {
|
||||||
|
min-width: 932px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,99 +1,102 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import {
|
import {
|
||||||
SETTINGS_SECTIONS,
|
SETTINGS_SECTIONS,
|
||||||
getSettingsSaveLabel,
|
getSettingsSaveLabel,
|
||||||
type SettingsFormViewModel,
|
type SettingsFormViewModel,
|
||||||
type SettingsSaveState,
|
type SettingsSaveState,
|
||||||
type SettingsSection
|
type SettingsSection
|
||||||
} from "./settings-model";
|
} from "./settings-model";
|
||||||
import {
|
import {
|
||||||
AccountWorkspace,
|
AccountWorkspace,
|
||||||
type AccountWorkspaceActions,
|
type AccountWorkspaceActions,
|
||||||
type AccountWorkspaceViewModel
|
type AccountWorkspaceViewModel
|
||||||
} from "./AccountWorkspace";
|
} from "./AccountWorkspace";
|
||||||
import { SettingsForm, type SettingsFormActions } from "./SettingsForm";
|
import { SettingsForm, type SettingsFormActions } from "./SettingsForm";
|
||||||
import "./settings.css";
|
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||||
|
import "./settings.css";
|
||||||
export type SettingsViewRegion = "all" | "sidebar" | "content";
|
|
||||||
|
export type SettingsViewRegion = "all" | "sidebar" | "content";
|
||||||
export interface SettingsViewModel {
|
|
||||||
section: SettingsSection;
|
export interface SettingsViewModel {
|
||||||
saveState: SettingsSaveState;
|
section: SettingsSection;
|
||||||
form: SettingsFormViewModel;
|
saveState: SettingsSaveState;
|
||||||
accounts: AccountWorkspaceViewModel;
|
form: SettingsFormViewModel;
|
||||||
}
|
accounts: AccountWorkspaceViewModel;
|
||||||
|
}
|
||||||
export interface SettingsViewActions {
|
|
||||||
onSectionChange: (section: SettingsSection) => void;
|
export interface SettingsViewActions {
|
||||||
onSave: () => void;
|
onSectionChange: (section: SettingsSection) => void;
|
||||||
form: SettingsFormActions;
|
onSave: () => void;
|
||||||
accounts: AccountWorkspaceActions;
|
form: SettingsFormActions;
|
||||||
}
|
accounts: AccountWorkspaceActions;
|
||||||
|
}
|
||||||
export interface SettingsViewProps {
|
|
||||||
model: SettingsViewModel;
|
export interface SettingsViewProps {
|
||||||
actions: SettingsViewActions;
|
model: SettingsViewModel;
|
||||||
region?: SettingsViewRegion;
|
actions: SettingsViewActions;
|
||||||
}
|
region?: SettingsViewRegion;
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsSidebar({ model, actions }: SettingsViewProps): ReactElement {
|
export function SettingsSidebar({ model, actions }: SettingsViewProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Einstellungen" className="settings-sidebar" data-visual-region="settings-sidebar">
|
<nav aria-label="Einstellungen" className="settings-sidebar" data-visual-region="settings-sidebar">
|
||||||
<div className="settings-sidebar-heading">
|
<div className="settings-sidebar-heading">
|
||||||
<strong>Einstellungen</strong>
|
<strong>Einstellungen</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-sidebar-list">
|
<SlidingSelection activeKey={model.section} axis="vertical" className="settings-sidebar-list">
|
||||||
{SETTINGS_SECTIONS.map((section) => (
|
{SETTINGS_SECTIONS.map((section) => (
|
||||||
<button
|
<button
|
||||||
aria-current={model.section === section.id ? "page" : undefined}
|
aria-current={model.section === section.id ? "page" : undefined}
|
||||||
className={`settings-sidebar-item${model.section === section.id ? " is-active" : ""}`}
|
className={`settings-sidebar-item${model.section === section.id ? " is-active" : ""}`}
|
||||||
key={section.id}
|
data-sliding-selection-active={model.section === section.id}
|
||||||
onClick={() => actions.onSectionChange(section.id)}
|
data-sliding-selection-item="true"
|
||||||
type="button"
|
key={section.id}
|
||||||
>{section.label}</button>
|
onClick={() => actions.onSectionChange(section.id)}
|
||||||
))}
|
type="button"
|
||||||
</div>
|
>{section.label}</button>
|
||||||
</nav>
|
))}
|
||||||
);
|
</SlidingSelection>
|
||||||
}
|
</nav>
|
||||||
|
);
|
||||||
export function SettingsContent({ model, actions }: SettingsViewProps): ReactElement {
|
}
|
||||||
const saveLabel = getSettingsSaveLabel(model.saveState);
|
|
||||||
const saveDisabled = model.saveState === "clean" || model.saveState === "saved" || model.saveState === "saving";
|
export function SettingsContent({ model, actions }: SettingsViewProps): ReactElement {
|
||||||
return (
|
const saveLabel = getSettingsSaveLabel(model.saveState);
|
||||||
<section aria-label="Einstellungsbereich" className="settings-content settings-static">
|
const saveDisabled = model.saveState === "clean" || model.saveState === "saved" || model.saveState === "saving";
|
||||||
<header className="settings-content-header">
|
return (
|
||||||
<div>
|
<section aria-label="Einstellungsbereich" className="settings-content settings-static">
|
||||||
<h1>Einstellungen</h1>
|
<header className="settings-content-header">
|
||||||
<span aria-live="polite" className={`settings-save-state is-${model.saveState}`} role="status">{saveLabel}</span>
|
<div>
|
||||||
</div>
|
<h1>Einstellungen</h1>
|
||||||
<button
|
<span aria-live="polite" className={`settings-save-state is-${model.saveState}`} role="status">{saveLabel}</span>
|
||||||
className="settings-button settings-button-primary"
|
</div>
|
||||||
disabled={saveDisabled}
|
<button
|
||||||
onClick={actions.onSave}
|
className="settings-button settings-button-primary"
|
||||||
type="button"
|
disabled={saveDisabled}
|
||||||
>Einstellungen speichern</button>
|
onClick={actions.onSave}
|
||||||
</header>
|
type="button"
|
||||||
<div className={`settings-content-body${model.section === "accounts" ? " is-accounts" : ""}`}>
|
>Einstellungen speichern</button>
|
||||||
{model.section === "accounts"
|
</header>
|
||||||
? <AccountWorkspace actions={actions.accounts} model={model.accounts} />
|
<div className={`settings-content-body${model.section === "accounts" ? " is-accounts" : ""}`}>
|
||||||
: <SettingsForm actions={actions.form} model={model.form} />}
|
{model.section === "accounts"
|
||||||
</div>
|
? <AccountWorkspace actions={actions.accounts} model={model.accounts} />
|
||||||
</section>
|
: <SettingsForm actions={actions.form} model={model.form} />}
|
||||||
);
|
</div>
|
||||||
}
|
</section>
|
||||||
|
);
|
||||||
export function SettingsView({ model, actions, region = "all" }: SettingsViewProps): ReactElement {
|
}
|
||||||
if (region === "sidebar") {
|
|
||||||
return <SettingsSidebar actions={actions} model={model} />;
|
export function SettingsView({ model, actions, region = "all" }: SettingsViewProps): ReactElement {
|
||||||
}
|
if (region === "sidebar") {
|
||||||
if (region === "content") {
|
return <SettingsSidebar actions={actions} model={model} />;
|
||||||
return <SettingsContent actions={actions} model={model} />;
|
}
|
||||||
}
|
if (region === "content") {
|
||||||
return (
|
return <SettingsContent actions={actions} model={model} />;
|
||||||
<div className="settings-view">
|
}
|
||||||
<SettingsSidebar actions={actions} model={model} />
|
return (
|
||||||
<SettingsContent actions={actions} model={model} />
|
<div className="settings-view">
|
||||||
</div>
|
<SettingsSidebar actions={actions} model={model} />
|
||||||
);
|
<SettingsContent actions={actions} model={model} />
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -408,6 +408,22 @@ export function buildSettingsFormViewModel({
|
|||||||
title: "Allgemein",
|
title: "Allgemein",
|
||||||
description: "Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.",
|
description: "Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.",
|
||||||
groups: [
|
groups: [
|
||||||
|
{
|
||||||
|
id: "general-language",
|
||||||
|
title: "Sprache",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "language",
|
||||||
|
kind: "select",
|
||||||
|
label: "Sprache",
|
||||||
|
value: settings.language,
|
||||||
|
options: [
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "de", label: "Deutsch" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "general-storage",
|
id: "general-storage",
|
||||||
title: "Speicherort",
|
title: "Speicherort",
|
||||||
|
|||||||
@@ -53,8 +53,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-sidebar-item.is-active {
|
.settings-sidebar-item.is-active {
|
||||||
border-color: var(--ui-border);
|
border-color: transparent;
|
||||||
background: var(--ui-active);
|
background: transparent;
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,7 +439,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-tabs button[aria-selected="true"] {
|
.settings-account-tabs button[aria-selected="true"] {
|
||||||
border-bottom-color: var(--ui-accent);
|
border-bottom-color: transparent;
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,13 +474,17 @@
|
|||||||
|
|
||||||
.settings-account-table-grid {
|
.settings-account-table-grid {
|
||||||
height: 41px;
|
height: 41px;
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-form-group:has(#update\:check) .settings-action-field {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-account-table-grid > span,
|
.settings-account-table-grid > span,
|
||||||
.settings-account-row > span {
|
.settings-account-row > span {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -604,7 +608,7 @@
|
|||||||
.settings-account-traffic,
|
.settings-account-traffic,
|
||||||
.settings-account-expires,
|
.settings-account-expires,
|
||||||
.settings-account-credential {
|
.settings-account-credential {
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
@@ -1,226 +1,229 @@
|
|||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import type {
|
import type {
|
||||||
StatisticsMetric,
|
StatisticsMetric,
|
||||||
StatisticsProviderScope,
|
StatisticsProviderScope,
|
||||||
StatisticsRange,
|
StatisticsRange,
|
||||||
StatisticsViewModel
|
StatisticsViewModel
|
||||||
} from "./statistics-model";
|
} from "./statistics-model";
|
||||||
import "./statistics.css";
|
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||||
|
import "./statistics.css";
|
||||||
export interface StatisticsViewActions {
|
|
||||||
onRangeChange: (range: StatisticsRange) => void;
|
export interface StatisticsViewActions {
|
||||||
onResetSession: () => void;
|
onRangeChange: (range: StatisticsRange) => void;
|
||||||
onResetAll: () => void;
|
onResetSession: () => void;
|
||||||
onResetErrors: () => void;
|
onResetAll: () => void;
|
||||||
}
|
onResetErrors: () => void;
|
||||||
|
}
|
||||||
export interface StatisticsViewProps {
|
|
||||||
model: StatisticsViewModel;
|
export interface StatisticsViewProps {
|
||||||
actions: StatisticsViewActions;
|
model: StatisticsViewModel;
|
||||||
chart: ReactNode;
|
actions: StatisticsViewActions;
|
||||||
}
|
chart: ReactNode;
|
||||||
|
}
|
||||||
const rangeItems: Array<{ id: StatisticsRange; label: string }> = [
|
|
||||||
{ id: "session", label: "Sitzung" },
|
const rangeItems: Array<{ id: StatisticsRange; label: string }> = [
|
||||||
{ id: "today", label: "Heute" },
|
{ id: "session", label: "Sitzung" },
|
||||||
{ id: "week", label: "Sieben Tage" },
|
{ id: "today", label: "Heute" },
|
||||||
{ id: "month", label: "30 Tage" },
|
{ id: "week", label: "Sieben Tage" },
|
||||||
{ id: "all", label: "Gesamt" }
|
{ id: "month", label: "30 Tage" },
|
||||||
];
|
{ id: "all", label: "Gesamt" }
|
||||||
|
];
|
||||||
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
|
|
||||||
|
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
const safe = Math.max(0, Number.isFinite(bytes) ? bytes : 0);
|
function formatBytes(bytes: number): string {
|
||||||
if (safe < 1024) {
|
const safe = Math.max(0, Number.isFinite(bytes) ? bytes : 0);
|
||||||
return `${Math.round(safe)} B`;
|
if (safe < 1024) {
|
||||||
}
|
return `${Math.round(safe)} B`;
|
||||||
const units = ["KB", "MB", "GB", "TB", "PB"];
|
}
|
||||||
let value = safe / 1024;
|
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||||
let unitIndex = 0;
|
let value = safe / 1024;
|
||||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
let unitIndex = 0;
|
||||||
value /= 1024;
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
unitIndex += 1;
|
value /= 1024;
|
||||||
}
|
unitIndex += 1;
|
||||||
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
|
}
|
||||||
}
|
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
function formatMetric(metric: StatisticsMetric, kind: "bytes" | "count" | "percent" | "speed"): string {
|
|
||||||
if (!metric.available || metric.value === null) {
|
function formatMetric(metric: StatisticsMetric, kind: "bytes" | "count" | "percent" | "speed"): string {
|
||||||
return "–";
|
if (!metric.available || metric.value === null) {
|
||||||
}
|
return "–";
|
||||||
if (kind === "bytes") {
|
}
|
||||||
return formatBytes(metric.value);
|
if (kind === "bytes") {
|
||||||
}
|
return formatBytes(metric.value);
|
||||||
if (kind === "speed") {
|
}
|
||||||
return `${formatBytes(metric.value)}/s`;
|
if (kind === "speed") {
|
||||||
}
|
return `${formatBytes(metric.value)}/s`;
|
||||||
if (kind === "percent") {
|
}
|
||||||
return `${numberFormatter.format(metric.value)} %`;
|
if (kind === "percent") {
|
||||||
}
|
return `${numberFormatter.format(metric.value)} %`;
|
||||||
return numberFormatter.format(metric.value);
|
}
|
||||||
}
|
return numberFormatter.format(metric.value);
|
||||||
|
}
|
||||||
function providerScopeLabel(scope: StatisticsProviderScope | null): string {
|
|
||||||
if (scope === "current-queue") {
|
function providerScopeLabel(scope: StatisticsProviderScope | null): string {
|
||||||
return "Aktuelle Queue";
|
if (scope === "current-queue") {
|
||||||
}
|
return "Aktuelle Queue";
|
||||||
if (scope === "today") {
|
}
|
||||||
return "Heute";
|
if (scope === "today") {
|
||||||
}
|
return "Heute";
|
||||||
if (scope === "all") {
|
}
|
||||||
return "Gesamt";
|
if (scope === "all") {
|
||||||
}
|
return "Gesamt";
|
||||||
return "Nicht verfügbar";
|
}
|
||||||
}
|
return "Nicht verfügbar";
|
||||||
|
}
|
||||||
function emptyProviderMessage(model: StatisticsViewModel): string {
|
|
||||||
if (model.coverage === "unavailable") {
|
function emptyProviderMessage(model: StatisticsViewModel): string {
|
||||||
return model.message;
|
if (model.coverage === "unavailable") {
|
||||||
}
|
return model.message;
|
||||||
if (model.providerScope === "today") {
|
}
|
||||||
return "Heute wurden noch keine Providerbytes erfasst.";
|
if (model.providerScope === "today") {
|
||||||
}
|
return "Heute wurden noch keine Providerbytes erfasst.";
|
||||||
if (model.providerScope === "all") {
|
}
|
||||||
return "Noch keine gespeicherten Providerbytes vorhanden.";
|
if (model.providerScope === "all") {
|
||||||
}
|
return "Noch keine gespeicherten Providerbytes vorhanden.";
|
||||||
return "In der aktuellen Queue sind noch keine Providerwerte vorhanden.";
|
}
|
||||||
}
|
return "In der aktuellen Queue sind noch keine Providerwerte vorhanden.";
|
||||||
|
}
|
||||||
function StatisticsMetricCard({
|
|
||||||
label,
|
function StatisticsMetricCard({
|
||||||
metric,
|
label,
|
||||||
kind
|
metric,
|
||||||
}: {
|
kind
|
||||||
label: string;
|
}: {
|
||||||
metric: StatisticsMetric;
|
label: string;
|
||||||
kind: "bytes" | "count" | "percent" | "speed";
|
metric: StatisticsMetric;
|
||||||
}): ReactElement {
|
kind: "bytes" | "count" | "percent" | "speed";
|
||||||
return (
|
}): ReactElement {
|
||||||
<article className={`statistics-kpi${metric.tone === "danger" ? " statistics-kpi-danger" : ""}`}>
|
return (
|
||||||
<span className="statistics-kpi-label">{label}</span>
|
<article className={`statistics-kpi${metric.tone === "danger" ? " statistics-kpi-danger" : ""}`}>
|
||||||
<strong className="statistics-kpi-value">{formatMetric(metric, kind)}</strong>
|
<span className="statistics-kpi-label">{label}</span>
|
||||||
<span className="statistics-kpi-source">{metric.sourceLabel}</span>
|
<strong className="statistics-kpi-value">{formatMetric(metric, kind)}</strong>
|
||||||
</article>
|
<span className="statistics-kpi-source">{metric.sourceLabel}</span>
|
||||||
);
|
</article>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StatisticsSidebar({ model, actions }: Pick<StatisticsViewProps, "model" | "actions">): ReactElement {
|
export function StatisticsSidebar({ model, actions }: Pick<StatisticsViewProps, "model" | "actions">): ReactElement {
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Statistik-Zeitraum" className="statistics-sidebar" data-visual-region="statistics-sidebar">
|
<nav aria-label="Statistik-Zeitraum" className="statistics-sidebar" data-visual-region="statistics-sidebar">
|
||||||
<strong className="statistics-sidebar-heading">Zeitraum</strong>
|
<strong className="statistics-sidebar-heading">Zeitraum</strong>
|
||||||
<div className="statistics-range-list">
|
<SlidingSelection activeKey={model.range} axis="vertical" className="statistics-range-list">
|
||||||
{rangeItems.map((item) => (
|
{rangeItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
aria-current={model.range === item.id ? "page" : undefined}
|
aria-current={model.range === item.id ? "page" : undefined}
|
||||||
className={`statistics-range${model.range === item.id ? " statistics-range-active" : ""}`}
|
className={`statistics-range${model.range === item.id ? " statistics-range-active" : ""}`}
|
||||||
key={item.id}
|
data-sliding-selection-active={model.range === item.id}
|
||||||
onClick={() => actions.onRangeChange(item.id)}
|
data-sliding-selection-item="true"
|
||||||
type="button"
|
key={item.id}
|
||||||
>{item.label}</button>
|
onClick={() => actions.onRangeChange(item.id)}
|
||||||
))}
|
type="button"
|
||||||
</div>
|
>{item.label}</button>
|
||||||
<p className="statistics-sidebar-message">{model.message}</p>
|
))}
|
||||||
</nav>
|
</SlidingSelection>
|
||||||
);
|
<p className="statistics-sidebar-message">{model.message}</p>
|
||||||
}
|
</nav>
|
||||||
|
);
|
||||||
export function StatisticsSidebarStatus({ model }: Pick<StatisticsViewProps, "model">): ReactElement | null {
|
}
|
||||||
const metrics = model.metrics;
|
|
||||||
const rows = [
|
export function StatisticsSidebarStatus({ model }: Pick<StatisticsViewProps, "model">): ReactElement | null {
|
||||||
metrics.downloadedBytes.available ? `Daten: ${formatMetric(metrics.downloadedBytes, "bytes")}` : null,
|
const metrics = model.metrics;
|
||||||
metrics.files.available ? `Dateien: ${formatMetric(metrics.files, "count")}` : null,
|
const rows = [
|
||||||
metrics.successRate.available ? `Erfolg: ${formatMetric(metrics.successRate, "percent")}` : null,
|
metrics.downloadedBytes.available ? `Daten: ${formatMetric(metrics.downloadedBytes, "bytes")}` : null,
|
||||||
metrics.errors.available ? `Fehler: ${formatMetric(metrics.errors, "count")}` : null,
|
metrics.files.available ? `Dateien: ${formatMetric(metrics.files, "count")}` : null,
|
||||||
model.providerScope ? `Provider: ${model.providers.length}` : null
|
metrics.successRate.available ? `Erfolg: ${formatMetric(metrics.successRate, "percent")}` : null,
|
||||||
].filter((value): value is string => value !== null);
|
metrics.errors.available ? `Fehler: ${formatMetric(metrics.errors, "count")}` : null,
|
||||||
if (rows.length === 0) {
|
model.providerScope ? `Provider: ${model.providers.length}` : null
|
||||||
return null;
|
].filter((value): value is string => value !== null);
|
||||||
}
|
if (rows.length === 0) {
|
||||||
return (
|
return null;
|
||||||
<div className="statistics-sidebar-status">
|
}
|
||||||
{rows.map((row) => <span key={row}>{row}</span>)}
|
return (
|
||||||
</div>
|
<div className="statistics-sidebar-status">
|
||||||
);
|
{rows.map((row) => <span key={row}>{row}</span>)}
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
export function StatisticsContent({ model, actions, chart }: StatisticsViewProps): ReactElement {
|
}
|
||||||
return (
|
|
||||||
<section aria-label="Statistik-Dashboard" className="statistics-content">
|
export function StatisticsContent({ model, actions, chart }: StatisticsViewProps): ReactElement {
|
||||||
<header className="statistics-heading">
|
return (
|
||||||
<div>
|
<section aria-label="Statistik-Dashboard" className="statistics-content">
|
||||||
<h2>Statistiken</h2>
|
<header className="statistics-heading">
|
||||||
<p>{model.message}</p>
|
<div>
|
||||||
</div>
|
<h2>Statistiken</h2>
|
||||||
<div aria-label="Statistiken zurücksetzen" className="statistics-reset-actions">
|
<p>{model.message}</p>
|
||||||
<button className="statistics-reset" onClick={actions.onResetSession} type="button">Sitzung zurücksetzen</button>
|
</div>
|
||||||
<button className="statistics-reset" onClick={actions.onResetAll} type="button">Gesamt zurücksetzen</button>
|
<div aria-label="Statistiken zurücksetzen" className="statistics-reset-actions">
|
||||||
<button
|
<button className="statistics-reset" onClick={actions.onResetSession} type="button">Sitzung zurücksetzen</button>
|
||||||
className="statistics-reset statistics-reset-danger"
|
<button className="statistics-reset" onClick={actions.onResetAll} type="button">Gesamt zurücksetzen</button>
|
||||||
disabled={!model.errorResetAvailable}
|
<button
|
||||||
onClick={actions.onResetErrors}
|
className="statistics-reset statistics-reset-danger"
|
||||||
type="button"
|
disabled={!model.errorResetAvailable}
|
||||||
>Fehler zurücksetzen</button>
|
onClick={actions.onResetErrors}
|
||||||
</div>
|
type="button"
|
||||||
</header>
|
>Fehler zurücksetzen</button>
|
||||||
|
</div>
|
||||||
<div className="statistics-kpis" data-visual-region="statistics-kpis">
|
</header>
|
||||||
<StatisticsMetricCard kind="bytes" label="Datenmenge" metric={model.metrics.downloadedBytes} />
|
|
||||||
<StatisticsMetricCard kind="count" label="Dateien" metric={model.metrics.files} />
|
<div className="statistics-kpis" data-visual-region="statistics-kpis">
|
||||||
<StatisticsMetricCard kind="percent" label="Erfolgsquote" metric={model.metrics.successRate} />
|
<StatisticsMetricCard kind="bytes" label="Datenmenge" metric={model.metrics.downloadedBytes} />
|
||||||
<StatisticsMetricCard kind="speed" label="Durchschnitt" metric={model.metrics.averageSpeedBps} />
|
<StatisticsMetricCard kind="count" label="Dateien" metric={model.metrics.files} />
|
||||||
<StatisticsMetricCard kind="count" label="Fehler" metric={model.metrics.errors} />
|
<StatisticsMetricCard kind="percent" label="Erfolgsquote" metric={model.metrics.successRate} />
|
||||||
</div>
|
<StatisticsMetricCard kind="speed" label="Durchschnitt" metric={model.metrics.averageSpeedBps} />
|
||||||
|
<StatisticsMetricCard kind="count" label="Fehler" metric={model.metrics.errors} />
|
||||||
<div className="statistics-detail-grid">
|
</div>
|
||||||
<section className="statistics-chart" data-visual-region="statistics-chart">
|
|
||||||
<div className="statistics-section-heading">
|
<div className="statistics-detail-grid">
|
||||||
<h3>Bandbreitenverlauf</h3>
|
<section className="statistics-chart" data-visual-region="statistics-chart">
|
||||||
<span>Live aus der aktuellen Renderer-Sitzung</span>
|
<div className="statistics-section-heading">
|
||||||
</div>
|
<h3>Bandbreitenverlauf</h3>
|
||||||
<div className="statistics-chart-canvas">{chart}</div>
|
<span>Live aus der aktuellen Renderer-Sitzung</span>
|
||||||
</section>
|
</div>
|
||||||
|
<div className="statistics-chart-canvas">{chart}</div>
|
||||||
<section className="statistics-providers">
|
</section>
|
||||||
<div className="statistics-section-heading">
|
|
||||||
<h3>Provider</h3>
|
<section className="statistics-providers">
|
||||||
<span>{providerScopeLabel(model.providerScope)}</span>
|
<div className="statistics-section-heading">
|
||||||
</div>
|
<h3>Provider</h3>
|
||||||
<div aria-label="Provider-Nutzung" className="statistics-provider-table" role="table">
|
<span>{providerScopeLabel(model.providerScope)}</span>
|
||||||
<div className="statistics-provider-header" role="row">
|
</div>
|
||||||
<span role="columnheader">Provider</span>
|
<div aria-label="Provider-Nutzung" className="statistics-provider-table" role="table">
|
||||||
<span role="columnheader">Daten</span>
|
<div className="statistics-provider-header" role="row">
|
||||||
<span role="columnheader">Ergebnisse</span>
|
<span role="columnheader">Provider</span>
|
||||||
</div>
|
<span role="columnheader">Daten</span>
|
||||||
<div className="statistics-provider-body" role="rowgroup">
|
<span role="columnheader">Ergebnisse</span>
|
||||||
{model.providers.length > 0 ? model.providers.map((provider) => (
|
</div>
|
||||||
<div className="statistics-provider-row" key={provider.id} role="row">
|
<div className="statistics-provider-body" role="rowgroup">
|
||||||
<span className="statistics-provider-name" role="cell">{provider.label}</span>
|
{model.providers.length > 0 ? model.providers.map((provider) => (
|
||||||
<span role="cell">{formatBytes(provider.bytes)}</span>
|
<div className="statistics-provider-row" key={provider.id} role="row">
|
||||||
<span className={provider.failed && provider.failed > 0 ? "statistics-provider-errors" : undefined} role="cell">
|
<span className="statistics-provider-name" role="cell">{provider.label}</span>
|
||||||
{provider.completed === null || provider.failed === null
|
<span role="cell">{formatBytes(provider.bytes)}</span>
|
||||||
? "–"
|
<span className={provider.failed && provider.failed > 0 ? "statistics-provider-errors" : undefined} role="cell">
|
||||||
: `${provider.completed} fertig · ${provider.failed} Fehler`}
|
{provider.completed === null || provider.failed === null
|
||||||
</span>
|
? "–"
|
||||||
</div>
|
: `${provider.completed} fertig · ${provider.failed} Fehler`}
|
||||||
)) : (
|
</span>
|
||||||
<div className="statistics-provider-empty" role="row">
|
</div>
|
||||||
<span aria-colspan={3} role="cell">{emptyProviderMessage(model)}</span>
|
)) : (
|
||||||
</div>
|
<div className="statistics-provider-empty" role="row">
|
||||||
)}
|
<span aria-colspan={3} role="cell">{emptyProviderMessage(model)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</section>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
</div>
|
||||||
}
|
</section>
|
||||||
|
);
|
||||||
export function StatisticsView({ model, actions, chart }: StatisticsViewProps): ReactElement {
|
}
|
||||||
return (
|
|
||||||
<div className="statistics-composed-view">
|
export function StatisticsView({ model, actions, chart }: StatisticsViewProps): ReactElement {
|
||||||
<StatisticsSidebar actions={actions} model={model} />
|
return (
|
||||||
<StatisticsContent actions={actions} chart={chart} model={model} />
|
<div className="statistics-composed-view">
|
||||||
</div>
|
<StatisticsSidebar actions={actions} model={model} />
|
||||||
);
|
<StatisticsContent actions={actions} chart={chart} model={model} />
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,370 +1,370 @@
|
|||||||
.statistics-sidebar,
|
.statistics-sidebar,
|
||||||
.statistics-content,
|
.statistics-content,
|
||||||
.statistics-sidebar-status {
|
.statistics-sidebar-status {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-sidebar {
|
.statistics-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-sidebar-heading {
|
.statistics-sidebar-heading {
|
||||||
color: var(--ui-text-secondary);
|
color: var(--ui-text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-range-list {
|
.statistics-range-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-range {
|
.statistics-range {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--ui-text-secondary);
|
color: var(--ui-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-range:hover {
|
.statistics-range:hover {
|
||||||
background: var(--ui-hover);
|
background: var(--ui-hover);
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-range-active {
|
.statistics-range-active {
|
||||||
border-color: var(--ui-border);
|
border-color: transparent;
|
||||||
background: var(--ui-active);
|
background: transparent;
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-range:focus-visible,
|
.statistics-range:focus-visible,
|
||||||
.statistics-reset:focus-visible {
|
.statistics-reset:focus-visible {
|
||||||
outline: 2px solid var(--ui-accent);
|
outline: 2px solid var(--ui-accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-sidebar-message {
|
.statistics-sidebar-message {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-sidebar-status {
|
.statistics-sidebar-status {
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-content {
|
.statistics-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 20px 24px 24px;
|
padding: 20px 24px 24px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-heading {
|
.statistics-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-heading h2,
|
.statistics-heading h2,
|
||||||
.statistics-section-heading h3 {
|
.statistics-section-heading h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-heading h2 {
|
.statistics-heading h2 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-heading p {
|
.statistics-heading p {
|
||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-reset-actions {
|
.statistics-reset-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-reset {
|
.statistics-reset {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--ui-input);
|
background: var(--ui-input);
|
||||||
color: var(--ui-text-secondary);
|
color: var(--ui-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-reset:hover:not(:disabled) {
|
.statistics-reset:hover:not(:disabled) {
|
||||||
background: var(--ui-hover);
|
background: var(--ui-hover);
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-reset-danger {
|
.statistics-reset-danger {
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-reset:disabled {
|
.statistics-reset:disabled {
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-muted);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-kpis {
|
.statistics-kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-kpi {
|
.statistics-kpi {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 116px;
|
min-height: 116px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--ui-surface);
|
background: var(--ui-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-kpi-label {
|
.statistics-kpi-label {
|
||||||
color: var(--ui-text-secondary);
|
color: var(--ui-text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-kpi-value {
|
.statistics-kpi-value {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
font-size: clamp(20px, 1.6vw, 28px);
|
font-size: clamp(20px, 1.6vw, 28px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-kpi-danger .statistics-kpi-value {
|
.statistics-kpi-danger .statistics-kpi-value {
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-kpi-source {
|
.statistics-kpi-source {
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-detail-grid {
|
.statistics-detail-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
|
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-chart,
|
.statistics-chart,
|
||||||
.statistics-providers {
|
.statistics-providers {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 340px;
|
min-height: 340px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--ui-surface);
|
background: var(--ui-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-section-heading {
|
.statistics-section-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-section-heading h3 {
|
.statistics-section-heading h3 {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-section-heading span {
|
.statistics-section-heading span {
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-chart-canvas {
|
.statistics-chart-canvas {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-chart-canvas .bandwidth-chart-container {
|
.statistics-chart-canvas .bandwidth-chart-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--ui-input);
|
background: var(--ui-input);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-provider-table {
|
.statistics-provider-table {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-provider-header,
|
.statistics-provider-header,
|
||||||
.statistics-provider-row {
|
.statistics-provider-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
grid-template-columns: minmax(120px, 1fr) minmax(88px, 0.65fr) minmax(130px, 1fr);
|
grid-template-columns: minmax(120px, 1fr) minmax(88px, 0.65fr) minmax(130px, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-provider-header {
|
.statistics-provider-header {
|
||||||
min-height: 41px;
|
min-height: 41px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border-bottom: 1px solid var(--ui-border);
|
border-bottom: 1px solid var(--ui-border);
|
||||||
background: var(--ui-table-header);
|
background: var(--ui-table-header);
|
||||||
color: var(--ui-text-secondary);
|
color: var(--ui-text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-provider-body {
|
.statistics-provider-body {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-provider-row {
|
.statistics-provider-row {
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-bottom: 1px solid var(--ui-border);
|
border-bottom: 1px solid var(--ui-border);
|
||||||
color: var(--ui-text-secondary);
|
color: var(--ui-text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-provider-row:last-child {
|
.statistics-provider-row:last-child {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-provider-row:hover {
|
.statistics-provider-row:hover {
|
||||||
background: var(--ui-hover);
|
background: var(--ui-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-provider-row > span {
|
.statistics-provider-row > span {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-provider-name {
|
.statistics-provider-name {
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-provider-errors {
|
.statistics-provider-errors {
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-provider-empty {
|
.statistics-provider-empty {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-composed-view {
|
.statistics-composed-view {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
grid-template-columns: 270px minmax(0, 1fr);
|
grid-template-columns: 270px minmax(0, 1fr);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1500px) {
|
@media (max-width: 1500px) {
|
||||||
.statistics-composed-view {
|
.statistics-composed-view {
|
||||||
grid-template-columns: 56px minmax(0, 1fr);
|
grid-template-columns: 56px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-sidebar {
|
.statistics-sidebar {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-kpis {
|
.statistics-kpis {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-detail-grid {
|
.statistics-detail-grid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1120px) {
|
@media (max-width: 1120px) {
|
||||||
.statistics-content {
|
.statistics-content {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-heading {
|
.statistics-heading {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-reset-actions {
|
.statistics-reset-actions {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-kpis {
|
.statistics-kpis {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-6
@@ -26,7 +26,8 @@ export type DebridProvider =
|
|||||||
| "debridlink"
|
| "debridlink"
|
||||||
| "linksnappy";
|
| "linksnappy";
|
||||||
export type DebridFallbackProvider = DebridProvider | "none";
|
export type DebridFallbackProvider = DebridProvider | "none";
|
||||||
export type AppTheme = "dark" | "light";
|
export type AppTheme = "dark" | "light";
|
||||||
|
export type AppLanguage = "en" | "de";
|
||||||
export type PackagePriority = "high" | "normal" | "low";
|
export type PackagePriority = "high" | "normal" | "low";
|
||||||
export type ExtractCpuPriority = "high" | "middle" | "low";
|
export type ExtractCpuPriority = "high" | "middle" | "low";
|
||||||
export type HistoryRetentionMode = "never" | "session" | "permanent";
|
export type HistoryRetentionMode = "never" | "session" | "permanent";
|
||||||
@@ -66,8 +67,9 @@ export interface DebridAccountStatus {
|
|||||||
checkedAt: number;
|
checkedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
token: string;
|
language: AppLanguage;
|
||||||
|
token: string;
|
||||||
realDebridUseWebLogin: boolean;
|
realDebridUseWebLogin: boolean;
|
||||||
megaLogin: string;
|
megaLogin: string;
|
||||||
megaPassword: string;
|
megaPassword: string;
|
||||||
@@ -143,9 +145,10 @@ export interface AppSettings {
|
|||||||
totalDownloadedAllTime: number;
|
totalDownloadedAllTime: number;
|
||||||
totalCompletedFilesAllTime: number;
|
totalCompletedFilesAllTime: number;
|
||||||
totalRuntimeAllTimeMs: number;
|
totalRuntimeAllTimeMs: number;
|
||||||
bandwidthSchedules: BandwidthScheduleEntry[];
|
bandwidthSchedules: BandwidthScheduleEntry[];
|
||||||
columnOrder: string[];
|
columnOrder: string[];
|
||||||
extractCpuPriority: ExtractCpuPriority;
|
columnOrderVersion?: number;
|
||||||
|
extractCpuPriority: ExtractCpuPriority;
|
||||||
autoExtractWhenStopped: boolean;
|
autoExtractWhenStopped: boolean;
|
||||||
disabledProviders: DebridProvider[];
|
disabledProviders: DebridProvider[];
|
||||||
hosterRouting: Record<string, DebridProvider>;
|
hosterRouting: Record<string, DebridProvider>;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveAppIconPath } from "../src/main/app-icon";
|
||||||
|
|
||||||
|
describe("application icon path", () => {
|
||||||
|
it("uses the unpacked resource copy in packaged builds", () => {
|
||||||
|
expect(resolveAppIconPath(true, "C:\\app\\resources\\app.asar", "C:\\app\\resources")).toBe(
|
||||||
|
path.join("C:\\app\\resources", "assets", "app_icon.ico")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the source asset in development", () => {
|
||||||
|
expect(resolveAppIconPath(false, "C:\\repo", "C:\\app\\resources")).toBe(
|
||||||
|
path.join("C:\\repo", "assets", "app_icon.ico")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+113
-74
@@ -1,87 +1,126 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/AvatarMenu";
|
import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/AvatarMenu";
|
||||||
import { AppShell } from "../src/renderer/shell/AppShell";
|
import { AppHeader } from "../src/renderer/shell/AppHeader";
|
||||||
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
import { AppShell } from "../src/renderer/shell/AppShell";
|
||||||
|
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
||||||
|
|
||||||
describe("desktop shell", () => {
|
describe("desktop shell", () => {
|
||||||
|
it("keeps application menus mounted for animated opening and closing", () => {
|
||||||
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||||
|
const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
|
||||||
|
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(source.match(/menu-dropdown\$\{openMenu ===/g)).toHaveLength(3);
|
||||||
|
expect(css).toMatch(/\.menu-dropdown\s*\{[^}]*opacity:\s*0;[^}]*transform:\s*translateY\(-8px\);[^}]*visibility:\s*hidden;/s);
|
||||||
|
expect(css).toMatch(/\.menu-dropdown\.is-open\s*\{[^}]*opacity:\s*1;[^}]*transform:\s*translateY\(0\);[^}]*visibility:\s*visible;/s);
|
||||||
|
expect(shellCss).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.md-application-menu-tree \.menu-dropdown\s*\{[^}]*transition-duration:\s*150ms, 180ms, 0s !important;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the product asset in the header brand", () => {
|
||||||
|
const html = renderToStaticMarkup(<AppHeader activeView="downloads" actions={null} onViewChange={() => {}} />);
|
||||||
|
expect(html).toContain("Multi-Debrid Downloader");
|
||||||
|
expect(html).toContain("app_icon.png");
|
||||||
|
});
|
||||||
|
|
||||||
it("exposes all five views with exactly one active item", () => {
|
it("exposes all five views with exactly one active item", () => {
|
||||||
const items = buildMainNavigation("downloads");
|
const items = buildMainNavigation("downloads");
|
||||||
expect(items.map((item) => item.id)).toEqual(["downloads", "collector", "settings", "history", "statistics"]);
|
expect(items.map((item) => item.id)).toEqual(["downloads", "collector", "settings", "history", "statistics"]);
|
||||||
expect(items.filter((item) => item.active)).toHaveLength(1);
|
expect(items.filter((item) => item.active)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders context regions without global placeholders", () => {
|
it("renders one measured sliding indicator behind the active navigation item", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(<AppHeader activeView="downloads" actions={null} onViewChange={() => {}} />);
|
||||||
<AppShell
|
const source = readFileSync(new URL("../src/renderer/shell/AppHeader.tsx", import.meta.url), "utf8");
|
||||||
activeView="downloads"
|
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||||
onViewChange={() => {}}
|
|
||||||
sidebar={<div>Filter</div>}
|
|
||||||
sidebarStatus={<div>2 Downloads</div>}
|
|
||||||
headerActions={null}
|
|
||||||
toolbar={<div>Aktionen</div>}
|
|
||||||
footer={<div>1–1 von 1</div>}
|
|
||||||
contextInfo={null}
|
|
||||||
sidebarCollapsed={false}
|
|
||||||
onSidebarCollapsedChange={() => {}}
|
|
||||||
>
|
|
||||||
<div>Inhalt</div>
|
|
||||||
</AppShell>
|
|
||||||
);
|
|
||||||
expect(html).toContain("data-ui-region=\"header\"");
|
|
||||||
expect(html).toContain("data-ui-region=\"sidebar\"");
|
|
||||||
expect(html).toContain("data-ui-region=\"sidebar-status\"");
|
|
||||||
expect(html).toContain("data-ui-region=\"main\"");
|
|
||||||
expect(html).toContain("1–1 von 1");
|
|
||||||
});
|
|
||||||
|
|
||||||
|
expect(html).toContain("data-main-view=\"downloads\"");
|
||||||
|
expect(source).toContain("ResizeObserver");
|
||||||
|
expect(css).toMatch(/\.md-shell-navigation::before\s*\{[^}]*transform:\s*translate3d\(var\(--md-navigation-active-x[^}]*transition:\s*transform 420ms cubic-bezier\(0\.22, 0\.76, 0\.22, 1\), width 420ms cubic-bezier\(0\.22, 0\.76, 0\.22, 1\)/s);
|
||||||
|
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.md-shell-navigation::before\s*\{[^}]*transition-duration:\s*420ms, 420ms, 420ms, 120ms !important;/);
|
||||||
|
expect(css).toMatch(/\.md-shell-navigation-item\.is-active\s*\{[^}]*background:\s*transparent;/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders context regions without global placeholders", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AppShell
|
||||||
|
activeView="downloads"
|
||||||
|
onViewChange={() => {}}
|
||||||
|
sidebar={<div>Filter</div>}
|
||||||
|
sidebarStatus={<div>2 Downloads</div>}
|
||||||
|
headerActions={null}
|
||||||
|
toolbar={<div>Aktionen</div>}
|
||||||
|
footer={<div>1–1 von 1</div>}
|
||||||
|
contextInfo={null}
|
||||||
|
sidebarCollapsed={false}
|
||||||
|
onSidebarCollapsedChange={() => {}}
|
||||||
|
>
|
||||||
|
<div>Inhalt</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
expect(html).toContain("data-ui-region=\"header\"");
|
||||||
|
expect(html).toContain("data-ui-region=\"sidebar\"");
|
||||||
|
expect(html).toContain("data-ui-region=\"sidebar-status\"");
|
||||||
|
expect(html).toContain("data-ui-region=\"main\"");
|
||||||
|
expect(html).toContain("1–1 von 1");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not reserve a collapsed sidebar column when sidebar slots are empty", () => {
|
it("does not reserve a collapsed sidebar column when sidebar slots are empty", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell
|
<AppShell
|
||||||
activeView="settings"
|
activeView="settings"
|
||||||
onViewChange={() => {}}
|
onViewChange={() => {}}
|
||||||
sidebar={null}
|
sidebar={null}
|
||||||
sidebarStatus={null}
|
sidebarStatus={null}
|
||||||
headerActions={null}
|
headerActions={null}
|
||||||
toolbar={null}
|
toolbar={null}
|
||||||
footer={null}
|
footer={null}
|
||||||
contextInfo={null}
|
contextInfo={null}
|
||||||
sidebarCollapsed
|
sidebarCollapsed
|
||||||
onSidebarCollapsedChange={() => {}}
|
onSidebarCollapsedChange={() => {}}
|
||||||
>
|
>
|
||||||
<div>Einstellungen</div>
|
<div>Einstellungen</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
expect(html).not.toContain("has-collapsed-sidebar");
|
expect(html).not.toContain("has-collapsed-sidebar");
|
||||||
expect(html).not.toContain("data-ui-region=\"sidebar\"");
|
expect(html).not.toContain("data-ui-region=\"sidebar\"");
|
||||||
expect(html).not.toContain("data-ui-region=\"toolbar\"");
|
expect(html).not.toContain("data-ui-region=\"toolbar\"");
|
||||||
expect(html).not.toContain("data-ui-region=\"footer\"");
|
expect(html).not.toContain("data-ui-region=\"footer\"");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the account popover only while open", () => {
|
it("keeps every view sidebar on the same content axis", () => {
|
||||||
expect(renderToStaticMarkup(<AvatarMenu open={false} accountLabel="konto@example.test" actions={[]} onClose={() => {}} />)).toBe("");
|
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<AvatarMenu
|
|
||||||
open
|
|
||||||
accountLabel="konto@example.test"
|
|
||||||
actions={[{ id: "logout", label: "Abmelden", danger: true, onSelect: () => {} }]}
|
|
||||||
onClose={() => {}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(html).toContain("role=\"menu\"");
|
|
||||||
expect(html).toContain("aria-label=\"Kontomenü\"");
|
|
||||||
expect(html).toContain("konto@example.test");
|
|
||||||
expect(html).toContain("Abmelden");
|
|
||||||
expect(html).toContain("autofocus=\"\"");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps menu keys to wrapped focus movement and closing", () => {
|
expect(shellCss).toMatch(/\.md-shell-sidebar-scroll > :is\(\.collector-sidebar, \.settings-sidebar, \.history-sidebar, \.statistics-sidebar\)\s*\{[^}]*gap:\s*8px;[^}]*padding:\s*0;[^}]*width:\s*100%;/s);
|
||||||
expect(getAvatarMenuKeyboardAction("ArrowDown", 0, 3)).toEqual({ type: "focus", index: 1 });
|
expect(shellCss).toMatch(/\.md-shell-sidebar-scroll > :is\(\.collector-sidebar, \.settings-sidebar, \.history-sidebar, \.statistics-sidebar\) > :is\(\.collector-sidebar-heading, \.settings-sidebar-heading, \.history-sidebar-heading, \.statistics-sidebar-heading\)\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*28px;[^}]*padding:\s*0;/s);
|
||||||
expect(getAvatarMenuKeyboardAction("ArrowDown", 2, 3)).toEqual({ type: "focus", index: 0 });
|
expect(shellCss).toMatch(/\.md-shell-sidebar-scroll \.collector-sidebar-select\s*\{[^}]*padding:\s*0 10px;/s);
|
||||||
expect(getAvatarMenuKeyboardAction("ArrowUp", 0, 3)).toEqual({ type: "focus", index: 2 });
|
|
||||||
expect(getAvatarMenuKeyboardAction("Home", 2, 3)).toEqual({ type: "focus", index: 0 });
|
|
||||||
expect(getAvatarMenuKeyboardAction("End", 0, 3)).toEqual({ type: "focus", index: 2 });
|
|
||||||
expect(getAvatarMenuKeyboardAction("Escape", 1, 3)).toEqual({ type: "close" });
|
|
||||||
expect(getAvatarMenuKeyboardAction("Tab", 1, 3)).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
it("renders the account popover only while open", () => {
|
||||||
|
expect(renderToStaticMarkup(<AvatarMenu open={false} accountLabel="konto@example.test" actions={[]} onClose={() => {}} />)).toBe("");
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AvatarMenu
|
||||||
|
open
|
||||||
|
accountLabel="konto@example.test"
|
||||||
|
actions={[{ id: "logout", label: "Abmelden", danger: true, onSelect: () => {} }]}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(html).toContain("role=\"menu\"");
|
||||||
|
expect(html).toContain("aria-label=\"Kontomenü\"");
|
||||||
|
expect(html).toContain("konto@example.test");
|
||||||
|
expect(html).toContain("Abmelden");
|
||||||
|
expect(html).toContain("autofocus=\"\"");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps menu keys to wrapped focus movement and closing", () => {
|
||||||
|
expect(getAvatarMenuKeyboardAction("ArrowDown", 0, 3)).toEqual({ type: "focus", index: 1 });
|
||||||
|
expect(getAvatarMenuKeyboardAction("ArrowDown", 2, 3)).toEqual({ type: "focus", index: 0 });
|
||||||
|
expect(getAvatarMenuKeyboardAction("ArrowUp", 0, 3)).toEqual({ type: "focus", index: 2 });
|
||||||
|
expect(getAvatarMenuKeyboardAction("Home", 2, 3)).toEqual({ type: "focus", index: 0 });
|
||||||
|
expect(getAvatarMenuKeyboardAction("End", 0, 3)).toEqual({ type: "focus", index: 2 });
|
||||||
|
expect(getAvatarMenuKeyboardAction("Escape", 1, 3)).toEqual({ type: "close" });
|
||||||
|
expect(getAvatarMenuKeyboardAction("Tab", 1, 3)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+265
-252
@@ -1,257 +1,270 @@
|
|||||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
mergeCollectorDraftText,
|
mergeCollectorDraftText,
|
||||||
planCollectorTabRemoval,
|
planCollectorTabRemoval,
|
||||||
planCollectorTextReplacement
|
planCollectorTextReplacement
|
||||||
} from "../src/renderer/App";
|
} from "../src/renderer/App";
|
||||||
import {
|
import {
|
||||||
buildCollectorRows,
|
buildCollectorRows,
|
||||||
buildCollectorViewModel,
|
buildCollectorViewModel,
|
||||||
type CollectorSourceTab
|
type CollectorSourceTab
|
||||||
} from "../src/renderer/views/collector/collector-model";
|
} from "../src/renderer/views/collector/collector-model";
|
||||||
import {
|
import {
|
||||||
CollectorInputDialog,
|
CollectorInputDialog,
|
||||||
CollectorContent,
|
CollectorContent,
|
||||||
|
CollectorSidebar,
|
||||||
CollectorToolbar,
|
CollectorToolbar,
|
||||||
CollectorView,
|
CollectorView,
|
||||||
type CollectorViewActions
|
type CollectorViewActions
|
||||||
} from "../src/renderer/views/collector/CollectorView";
|
} from "../src/renderer/views/collector/CollectorView";
|
||||||
|
|
||||||
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
|
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
|
||||||
if (Array.isArray(node)) {
|
if (Array.isArray(node)) {
|
||||||
node.forEach((child) => visitElements(child, visit));
|
node.forEach((child) => visitElements(child, visit));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isValidElement(node)) {
|
if (!isValidElement(node)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
visit(node);
|
visit(node);
|
||||||
visitElements(node.props.children, visit);
|
visitElements(node.props.children, visit);
|
||||||
visitElements(node.props.actions, visit);
|
visitElements(node.props.actions, visit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
|
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
|
||||||
let result: ReactElement | null = null;
|
let result: ReactElement | null = null;
|
||||||
visitElements(node, (element) => {
|
visitElements(node, (element) => {
|
||||||
if (!result && predicate(element)) {
|
if (!result && predicate(element)) {
|
||||||
result = element;
|
result = element;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error("Element not found");
|
throw new Error("Element not found");
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findButton(node: ReactNode, label: string): ReactElement {
|
function findButton(node: ReactNode, label: string): ReactElement {
|
||||||
return findElement(node, (element) => element.type === "button" && element.props.children === label);
|
return findElement(node, (element) => element.type === "button" && element.props.children === label);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createActions(overrides: Partial<CollectorViewActions> = {}): CollectorViewActions {
|
function createActions(overrides: Partial<CollectorViewActions> = {}): CollectorViewActions {
|
||||||
return {
|
return {
|
||||||
onTabSelect: () => {},
|
onTabSelect: () => {},
|
||||||
onTabAdd: () => {},
|
onTabAdd: () => {},
|
||||||
onTabRemove: () => {},
|
onTabRemove: () => {},
|
||||||
onOpenInput: () => {},
|
onOpenInput: () => {},
|
||||||
onImportDlc: () => {},
|
onImportDlc: () => {},
|
||||||
onImportFile: () => {},
|
onImportFile: () => {},
|
||||||
onExportQueue: () => {},
|
onExportQueue: () => {},
|
||||||
onSubmit: () => {},
|
onSubmit: () => {},
|
||||||
onQueryChange: () => {},
|
onQueryChange: () => {},
|
||||||
onSelectionChange: () => {},
|
onSelectionChange: () => {},
|
||||||
onRemoveSelected: () => {},
|
onRemoveSelected: () => {},
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const populatedTabs: CollectorSourceTab[] = [
|
const populatedTabs: CollectorSourceTab[] = [
|
||||||
{
|
{
|
||||||
id: "tab-a",
|
id: "tab-a",
|
||||||
name: "Sammlung A",
|
name: "Sammlung A",
|
||||||
text: "https://example.test/a\n\n https://example.test/b "
|
text: "https://example.test/a\n\n https://example.test/b "
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("collector model", () => {
|
describe("collector model", () => {
|
||||||
it("derives stable rows from non-empty raw lines without validating or regrouping them", () => {
|
it("derives stable rows from non-empty raw lines without validating or regrouping them", () => {
|
||||||
const rows = buildCollectorRows(populatedTabs, "tab-a", "");
|
const rows = buildCollectorRows(populatedTabs, "tab-a", "");
|
||||||
|
|
||||||
expect(rows).toHaveLength(2);
|
expect(rows).toHaveLength(2);
|
||||||
expect(rows.map((row) => row.id)).toEqual(["tab-a:0", "tab-a:2"]);
|
expect(rows.map((row) => row.id)).toEqual(["tab-a:0", "tab-a:2"]);
|
||||||
expect(rows.map((row) => row.originalLineIndex)).toEqual([0, 2]);
|
expect(rows.map((row) => row.originalLineIndex)).toEqual([0, 2]);
|
||||||
expect(rows.map((row) => row.value)).toEqual([
|
expect(rows.map((row) => row.value)).toEqual([
|
||||||
"https://example.test/a",
|
"https://example.test/a",
|
||||||
"https://example.test/b"
|
"https://example.test/b"
|
||||||
]);
|
]);
|
||||||
expect(rows[0].linkCount).toBe(2);
|
expect(rows[0].linkCount).toBe(2);
|
||||||
expect(rows[1].linkCount).toBe(2);
|
expect(rows[1].linkCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters presentation rows while keeping source counts and original line identities", () => {
|
it("filters presentation rows while keeping source counts and original line identities", () => {
|
||||||
const model = buildCollectorViewModel(populatedTabs, "tab-a", "EXAMPLE.TEST/B", false, ["tab-a:0"]);
|
const model = buildCollectorViewModel(populatedTabs, "tab-a", "EXAMPLE.TEST/B", false, ["tab-a:0"]);
|
||||||
|
|
||||||
expect(model.rows.map((row) => row.id)).toEqual(["tab-a:2"]);
|
expect(model.rows.map((row) => row.id)).toEqual(["tab-a:2"]);
|
||||||
expect(model.tabs).toEqual([{ id: "tab-a", name: "Sammlung A", linkCount: 2 }]);
|
expect(model.tabs).toEqual([{ id: "tab-a", name: "Sammlung A", linkCount: 2 }]);
|
||||||
expect(model.selectedIds).toEqual(["tab-a:0"]);
|
expect(model.selectedIds).toEqual(["tab-a:0"]);
|
||||||
expect(model.empty).toBe(false);
|
expect(model.empty).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves clipboard and drop appends that arrive while an input draft is open", () => {
|
it("preserves clipboard and drop appends that arrive while an input draft is open", () => {
|
||||||
expect(mergeCollectorDraftText(
|
expect(mergeCollectorDraftText(
|
||||||
"https://example.test/old",
|
"https://example.test/old",
|
||||||
"https://example.test/old\nhttps://example.test/clipboard",
|
"https://example.test/old\nhttps://example.test/clipboard",
|
||||||
"https://example.test/edited"
|
"https://example.test/edited"
|
||||||
)).toBe("https://example.test/edited\nhttps://example.test/clipboard");
|
)).toBe("https://example.test/edited\nhttps://example.test/clipboard");
|
||||||
expect(mergeCollectorDraftText("old", "old", "edited")).toBe("edited");
|
expect(mergeCollectorDraftText("old", "old", "edited")).toBe("edited");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves the active identity to an existing neighbor before later appends arrive", () => {
|
it("moves the active identity to an existing neighbor before later appends arrive", () => {
|
||||||
const tabs: CollectorSourceTab[] = [
|
const tabs: CollectorSourceTab[] = [
|
||||||
{ id: "tab-a", name: "Sammlung A", text: "a" },
|
{ id: "tab-a", name: "Sammlung A", text: "a" },
|
||||||
{ id: "tab-b", name: "Sammlung B", text: "b" },
|
{ id: "tab-b", name: "Sammlung B", text: "b" },
|
||||||
{ id: "tab-c", name: "Sammlung C", text: "c" }
|
{ id: "tab-c", name: "Sammlung C", text: "c" }
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(planCollectorTabRemoval(tabs, "tab-b", "tab-b")).toEqual({
|
expect(planCollectorTabRemoval(tabs, "tab-b", "tab-b")).toEqual({
|
||||||
tabs: [tabs[0], tabs[2]],
|
tabs: [tabs[0], tabs[2]],
|
||||||
activeTabId: "tab-a"
|
activeTabId: "tab-a"
|
||||||
});
|
});
|
||||||
expect(planCollectorTabRemoval(tabs, "tab-c", "tab-a")).toEqual({
|
expect(planCollectorTabRemoval(tabs, "tab-c", "tab-a")).toEqual({
|
||||||
tabs: [tabs[1], tabs[2]],
|
tabs: [tabs[1], tabs[2]],
|
||||||
activeTabId: "tab-c"
|
activeTabId: "tab-c"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invalidates positional row selection whenever raw text is replaced", () => {
|
it("invalidates positional row selection whenever raw text is replaced", () => {
|
||||||
const tabs: CollectorSourceTab[] = [
|
const tabs: CollectorSourceTab[] = [
|
||||||
{ id: "tab-a", name: "Sammlung A", text: "old-a\nold-b" },
|
{ id: "tab-a", name: "Sammlung A", text: "old-a\nold-b" },
|
||||||
{ id: "tab-b", name: "Sammlung B", text: "untouched" }
|
{ id: "tab-b", name: "Sammlung B", text: "untouched" }
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(planCollectorTextReplacement(tabs, "tab-a", "new-a")).toEqual({
|
expect(planCollectorTextReplacement(tabs, "tab-a", "new-a")).toEqual({
|
||||||
tabs: [
|
tabs: [
|
||||||
{ id: "tab-a", name: "Sammlung A", text: "new-a" },
|
{ id: "tab-a", name: "Sammlung A", text: "new-a" },
|
||||||
tabs[1]
|
tabs[1]
|
||||||
],
|
],
|
||||||
selectedIds: []
|
selectedIds: []
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CollectorView", () => {
|
describe("CollectorView", () => {
|
||||||
|
it("marks collections for one measured vertical selection indicator", () => {
|
||||||
|
const model = buildCollectorViewModel([
|
||||||
|
{ id: "tab-a", name: "Sammlung A", text: "https://example.test/a" },
|
||||||
|
{ id: "tab-b", name: "Sammlung B", text: "https://example.test/b" }
|
||||||
|
], "tab-b", "", false, []);
|
||||||
|
const html = renderToStaticMarkup(<CollectorSidebar actions={createActions()} model={model} />);
|
||||||
|
|
||||||
|
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||||||
|
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(2);
|
||||||
|
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps empty, busy and error states inside the same table body", () => {
|
it("keeps empty, busy and error states inside the same table body", () => {
|
||||||
const empty = renderToStaticMarkup(
|
const empty = renderToStaticMarkup(
|
||||||
<CollectorView
|
<CollectorView
|
||||||
actions={createActions()}
|
actions={createActions()}
|
||||||
model={buildCollectorViewModel([{ id: "tab-a", name: "Sammlung A", text: "" }], "tab-a", "", false, [])}
|
model={buildCollectorViewModel([{ id: "tab-a", name: "Sammlung A", text: "" }], "tab-a", "", false, [])}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const busy = renderToStaticMarkup(
|
const busy = renderToStaticMarkup(
|
||||||
<CollectorView
|
<CollectorView
|
||||||
actions={createActions()}
|
actions={createActions()}
|
||||||
model={{ ...buildCollectorViewModel([], "", "", true, []), error: "" }}
|
model={{ ...buildCollectorViewModel([], "", "", true, []), error: "" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const failed = renderToStaticMarkup(
|
const failed = renderToStaticMarkup(
|
||||||
<CollectorView
|
<CollectorView
|
||||||
actions={createActions()}
|
actions={createActions()}
|
||||||
model={{ ...buildCollectorViewModel([], "", "", false, []), error: "Import fehlgeschlagen" }}
|
model={{ ...buildCollectorViewModel([], "", "", false, []), error: "Import fehlgeschlagen" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [html, state] of [
|
for (const [html, state] of [
|
||||||
[empty, "Noch keine Links"],
|
[empty, "Noch keine Links"],
|
||||||
[busy, "Links werden verarbeitet"],
|
[busy, "Links werden verarbeitet"],
|
||||||
[failed, "Import fehlgeschlagen"]
|
[failed, "Import fehlgeschlagen"]
|
||||||
]) {
|
]) {
|
||||||
expect(html.indexOf(state)).toBeGreaterThan(html.indexOf("data-visual-region=\"collector-table-body\""));
|
expect(html.indexOf(state)).toBeGreaterThan(html.indexOf("data-visual-region=\"collector-table-body\""));
|
||||||
}
|
}
|
||||||
expect(empty).toContain("data-visual-region=\"collector-empty-state\"");
|
expect(empty).toContain("data-visual-region=\"collector-empty-state\"");
|
||||||
expect(empty).not.toContain("aria-label=\"Seitennavigation\"");
|
expect(empty).not.toContain("aria-label=\"Seitennavigation\"");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders compact occupied rows and removes the empty marker", () => {
|
it("renders compact occupied rows and removes the empty marker", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<CollectorView
|
<CollectorView
|
||||||
actions={createActions()}
|
actions={createActions()}
|
||||||
model={buildCollectorViewModel(populatedTabs, "tab-a", "", false, [])}
|
model={buildCollectorViewModel(populatedTabs, "tab-a", "", false, [])}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html.match(/class=\"collector-row(?: is-selected)?\"/g)).toHaveLength(2);
|
expect(html.match(/class=\"collector-row(?: is-selected)?\"/g)).toHaveLength(2);
|
||||||
expect(html).not.toContain("data-visual-region=\"collector-empty-state\"");
|
expect(html).not.toContain("data-visual-region=\"collector-empty-state\"");
|
||||||
expect(html).toContain("data-visual-region=\"collector-sidebar\"");
|
expect(html).toContain("data-visual-region=\"collector-sidebar\"");
|
||||||
expect(html).toContain("data-visual-region=\"collector-toolbar\"");
|
expect(html).toContain("data-visual-region=\"collector-toolbar\"");
|
||||||
expect(html).toContain("data-visual-region=\"collector-table-body\"");
|
expect(html).toContain("data-visual-region=\"collector-table-body\"");
|
||||||
expect(html).not.toContain("data-visual-region=\"downloads-toolbar\"");
|
expect(html).not.toContain("data-visual-region=\"downloads-toolbar\"");
|
||||||
expect(html).not.toContain("aria-label=\"Seitennavigation\"");
|
expect(html).not.toContain("aria-label=\"Seitennavigation\"");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("separates local input, queue submission, search, selection and local removal callbacks", () => {
|
it("separates local input, queue submission, search, selection and local removal callbacks", () => {
|
||||||
let inputOpens = 0;
|
let inputOpens = 0;
|
||||||
let queueSubmits = 0;
|
let queueSubmits = 0;
|
||||||
let query = "";
|
let query = "";
|
||||||
let selected = "";
|
let selected = "";
|
||||||
let removals = 0;
|
let removals = 0;
|
||||||
const actions = createActions({
|
const actions = createActions({
|
||||||
onOpenInput: () => { inputOpens += 1; },
|
onOpenInput: () => { inputOpens += 1; },
|
||||||
onSubmit: () => { queueSubmits += 1; },
|
onSubmit: () => { queueSubmits += 1; },
|
||||||
onQueryChange: (value) => { query = value; },
|
onQueryChange: (value) => { query = value; },
|
||||||
onSelectionChange: (rowId) => { selected = rowId; },
|
onSelectionChange: (rowId) => { selected = rowId; },
|
||||||
onRemoveSelected: () => { removals += 1; }
|
onRemoveSelected: () => { removals += 1; }
|
||||||
});
|
});
|
||||||
const toolbar = CollectorToolbar({
|
const toolbar = CollectorToolbar({
|
||||||
actions,
|
actions,
|
||||||
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, ["tab-a:0"])
|
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, ["tab-a:0"])
|
||||||
});
|
});
|
||||||
const content = CollectorContent({
|
const content = CollectorContent({
|
||||||
actions,
|
actions,
|
||||||
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, ["tab-a:0"])
|
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, ["tab-a:0"])
|
||||||
});
|
});
|
||||||
|
|
||||||
findButton(toolbar, "Links hinzufügen").props.onClick();
|
findButton(toolbar, "Links hinzufügen").props.onClick();
|
||||||
expect(inputOpens).toBe(1);
|
expect(inputOpens).toBe(1);
|
||||||
expect(queueSubmits).toBe(0);
|
expect(queueSubmits).toBe(0);
|
||||||
|
|
||||||
findButton(toolbar, "An Downloads übergeben").props.onClick();
|
findButton(toolbar, "An Downloads übergeben").props.onClick();
|
||||||
expect(queueSubmits).toBe(1);
|
expect(queueSubmits).toBe(1);
|
||||||
|
|
||||||
const search = findElement(toolbar, (element) => element.props.label === "Links durchsuchen");
|
const search = findElement(toolbar, (element) => element.props.label === "Links durchsuchen");
|
||||||
search.props.onChange({ target: { value: "release" } });
|
search.props.onChange({ target: { value: "release" } });
|
||||||
expect(query).toBe("release");
|
expect(query).toBe("release");
|
||||||
|
|
||||||
const checkbox = findElement(content, (element) => element.type === "input" && element.props["aria-label"] === "Link auswählen");
|
const checkbox = findElement(content, (element) => element.type === "input" && element.props["aria-label"] === "Link auswählen");
|
||||||
checkbox.props.onChange();
|
checkbox.props.onChange();
|
||||||
findButton(toolbar, "Auswahl entfernen").props.onClick();
|
findButton(toolbar, "Auswahl entfernen").props.onClick();
|
||||||
expect(selected).toBe("tab-a:0");
|
expect(selected).toBe("tab-a:0");
|
||||||
expect(removals).toBe(1);
|
expect(removals).toBe(1);
|
||||||
expect(queueSubmits).toBe(1);
|
expect(queueSubmits).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names the input dialog and commits only through the local draft callback", () => {
|
it("names the input dialog and commits only through the local draft callback", () => {
|
||||||
let value = "";
|
let value = "";
|
||||||
let commits = 0;
|
let commits = 0;
|
||||||
const dialog = CollectorInputDialog({
|
const dialog = CollectorInputDialog({
|
||||||
open: true,
|
open: true,
|
||||||
tabName: "Sammlung A",
|
tabName: "Sammlung A",
|
||||||
value,
|
value,
|
||||||
onChange: (next) => { value = next; },
|
onChange: (next) => { value = next; },
|
||||||
onClose: () => {},
|
onClose: () => {},
|
||||||
onCommit: () => { commits += 1; }
|
onCommit: () => { commits += 1; }
|
||||||
});
|
});
|
||||||
const html = renderToStaticMarkup(dialog);
|
const html = renderToStaticMarkup(dialog);
|
||||||
|
|
||||||
expect(html).toContain("role=\"dialog\"");
|
expect(html).toContain("role=\"dialog\"");
|
||||||
expect(html).toContain("aria-label=\"Links\"");
|
expect(html).toContain("aria-label=\"Links\"");
|
||||||
expect(html).toContain("Links hinzufügen");
|
expect(html).toContain("Links hinzufügen");
|
||||||
expect(html).toContain("Übernehmen");
|
expect(html).toContain("Übernehmen");
|
||||||
|
|
||||||
const textbox = findElement(dialog, (element) => element.type === "textarea" && element.props["aria-label"] === "Links");
|
const textbox = findElement(dialog, (element) => element.type === "textarea" && element.props["aria-label"] === "Links");
|
||||||
textbox.props.onChange({ target: { value: "https://example.test/new" } });
|
textbox.props.onChange({ target: { value: "https://example.test/new" } });
|
||||||
findButton(dialog, "Übernehmen").props.onClick();
|
findButton(dialog, "Übernehmen").props.onClick();
|
||||||
expect(value).toBe("https://example.test/new");
|
expect(value).toBe("https://example.test/new");
|
||||||
expect(commits).toBe(1);
|
expect(commits).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { calculateColumnDragPreview, updateDownloadColumnDrag, type DownloadColumnDragSession } from "../src/renderer/views/downloads/column-drag";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: "name", left: 0, width: 300 },
|
||||||
|
{ id: "size", left: 300, width: 150 },
|
||||||
|
{ id: "status", left: 450, width: 100 }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("animated download column drag", () => {
|
||||||
|
it("moves a narrow right column left while adjacent columns make room", () => {
|
||||||
|
expect(calculateColumnDragPreview(columns, "status", -420)).toEqual({
|
||||||
|
order: ["status", "name", "size"],
|
||||||
|
offsets: { name: 100, size: 100, status: -420 },
|
||||||
|
settleOffsets: { name: 100, size: 100, status: -450 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a wide left column right using the measured column widths", () => {
|
||||||
|
expect(calculateColumnDragPreview(columns, "name", 500)).toEqual({
|
||||||
|
order: ["size", "status", "name"],
|
||||||
|
offsets: { name: 500, size: -300, status: -300 },
|
||||||
|
settleOffsets: { name: 250, size: -300, status: -300 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the original order while the dragged center has not crossed a neighbor", () => {
|
||||||
|
expect(calculateColumnDragPreview(columns, "size", 20).order).toEqual(["name", "size", "status"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes only the continuous active offset while the target slot stays unchanged", () => {
|
||||||
|
const setProperty = vi.fn();
|
||||||
|
const root = {
|
||||||
|
classList: { add: vi.fn() },
|
||||||
|
dataset: { columnDragging: "size" },
|
||||||
|
querySelectorAll: vi.fn(() => []),
|
||||||
|
style: { setProperty }
|
||||||
|
} as unknown as HTMLElement;
|
||||||
|
const session = {
|
||||||
|
active: true,
|
||||||
|
draggedId: "size",
|
||||||
|
measurements: columns,
|
||||||
|
pointerId: 1,
|
||||||
|
preview: calculateColumnDragPreview(columns, "size", 20),
|
||||||
|
root,
|
||||||
|
startX: 0
|
||||||
|
} as DownloadColumnDragSession;
|
||||||
|
|
||||||
|
updateDownloadColumnDrag(session, 25);
|
||||||
|
|
||||||
|
expect(setProperty).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setProperty).toHaveBeenCalledWith("--downloads-active-drag-x", "25px");
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
-1
@@ -4,7 +4,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
|||||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||||
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
||||||
import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
@@ -2749,6 +2749,38 @@ describe("normalizeResolvedFilename", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseRapidgatorFileSize", () => {
|
||||||
|
it("converts hoster size labels to bytes", () => {
|
||||||
|
expect(parseRapidgatorFileSize("1.50 GB")).toBe(1_610_612_736);
|
||||||
|
expect(parseRapidgatorFileSize("658,25 MB")).toBe(690_225_152);
|
||||||
|
expect(parseRapidgatorFileSize("1024 B")).toBe(1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing and malformed values", () => {
|
||||||
|
expect(parseRapidgatorFileSize(null)).toBeNull();
|
||||||
|
expect(parseRapidgatorFileSize("unknown")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkRapidgatorOnline", () => {
|
||||||
|
it("loads metadata directly without waiting for a separate HEAD request", async () => {
|
||||||
|
const methods: string[] = [];
|
||||||
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
methods.push(String(init?.method || "GET"));
|
||||||
|
return new Response('<html><title>episode.part01.rar</title><div>File size: <strong>1.50 GB</strong></div></html>', { status: 200 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const result = await checkRapidgatorOnline("https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||||
|
|
||||||
|
expect(methods).toEqual(["GET"]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
online: true,
|
||||||
|
fileName: "episode.part01.rar",
|
||||||
|
fileSizeBytes: 1_610_612_736
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("filenameFromRapidgatorUrlPath", () => {
|
describe("filenameFromRapidgatorUrlPath", () => {
|
||||||
it("extracts filename from standard rapidgator URL", () => {
|
it("extracts filename from standard rapidgator URL", () => {
|
||||||
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Show.S01E01.part01.rar.html"))
|
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Show.S01E01.part01.rar.html"))
|
||||||
|
|||||||
@@ -393,7 +393,8 @@ describe("debug-server", () => {
|
|||||||
const payload = await response.json() as Record<string, any>;
|
const payload = await response.json() as Record<string, any>;
|
||||||
|
|
||||||
expect(payload.enabled).toBe(true);
|
expect(payload.enabled).toBe(true);
|
||||||
expect(payload.status).toBe("ok");
|
expect(Array.isArray(payload.warnings)).toBe(true);
|
||||||
|
expect(payload.status).toBe(payload.warnings.length > 0 ? "warn" : "ok");
|
||||||
expect(payload.runtimeBaseDir).toBe(fixture.baseDir);
|
expect(payload.runtimeBaseDir).toBe(fixture.baseDir);
|
||||||
expect(payload.host).toBe("0.0.0.0");
|
expect(payload.host).toBe("0.0.0.0");
|
||||||
expect(payload.localOnly).toBe(false);
|
expect(payload.localOnly).toBe(false);
|
||||||
@@ -420,7 +421,8 @@ describe("debug-server", () => {
|
|||||||
const response = await fetch(`${fixture.baseUrl}/self-check?token=${fixture.token}`);
|
const response = await fetch(`${fixture.baseUrl}/self-check?token=${fixture.token}`);
|
||||||
expect(response.ok).toBe(true);
|
expect(response.ok).toBe(true);
|
||||||
const payload = await response.json() as Record<string, any>;
|
const payload = await response.json() as Record<string, any>;
|
||||||
expect(payload.status).toBe("ok");
|
expect(Array.isArray(payload.warnings)).toBe(true);
|
||||||
|
expect(payload.status).toBe(payload.warnings.length > 0 ? "warn" : "ok");
|
||||||
expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0);
|
expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DEV_SERVER_PORT, DEV_SERVER_URL } from "../src/main/dev-server-url";
|
||||||
|
|
||||||
|
describe("development server URL", () => {
|
||||||
|
it("uses the isolated downloader development port by default", () => {
|
||||||
|
expect(DEV_SERVER_PORT).toBe("5180");
|
||||||
|
expect(DEV_SERVER_URL).toBe("http://localhost:5180");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
|
|||||||
import { EventEmitter, once } from "node:events";
|
import { EventEmitter, once } from "node:events";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, resolveArchiveItemsFromList } from "../src/main/download-manager";
|
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager";
|
||||||
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
import { defaultSettings } from "../src/main/constants";
|
||||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||||
@@ -20,7 +20,26 @@ import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/
|
|||||||
import { UnrestrictedLink } from "../src/main/realdebrid";
|
import { UnrestrictedLink } from "../src/main/realdebrid";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
describe("runWithLimitedConcurrency", () => {
|
||||||
|
it("processes the full batch without exceeding the configured worker count", async () => {
|
||||||
|
let active = 0;
|
||||||
|
let peak = 0;
|
||||||
|
const completed: number[] = [];
|
||||||
|
|
||||||
|
await runWithLimitedConcurrency([1, 2, 3, 4, 5, 6], 3, async (value) => {
|
||||||
|
active += 1;
|
||||||
|
peak = Math.max(peak, active);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
completed.push(value);
|
||||||
|
active -= 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(peak).toBe(3);
|
||||||
|
expect(completed.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("extractArchiveNameFromExtractorLogMessage", () => {
|
describe("extractArchiveNameFromExtractorLogMessage", () => {
|
||||||
it("detects archive names from extractor log variants", () => {
|
it("detects archive names from extractor log variants", () => {
|
||||||
@@ -166,6 +185,28 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("download manager", () => {
|
describe("download manager", () => {
|
||||||
|
it("stores RapidGator metadata before a download starts", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
|
||||||
|
manager.addPackages([{ name: "metadata", links: ["https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] }]);
|
||||||
|
const snapshot = manager.getSnapshot();
|
||||||
|
const itemId = snapshot.session.packages[snapshot.session.packageOrder[0]].itemIds[0];
|
||||||
|
const internal = manager as unknown as {
|
||||||
|
session: typeof snapshot.session;
|
||||||
|
applyRapidgatorCheckResult: (item: typeof snapshot.session.items[string], result: { online: boolean; fileName: string; fileSizeBytes: number | null }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
internal.applyRapidgatorCheckResult(internal.session.items[itemId], {
|
||||||
|
online: true,
|
||||||
|
fileName: "episode.part01.rar",
|
||||||
|
fileSizeBytes: 1_610_612_736
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(internal.session.items[itemId].totalBytes).toBe(1_610_612_736);
|
||||||
|
expect(internal.session.items[itemId].status).toBe("queued");
|
||||||
|
});
|
||||||
|
|
||||||
it("applies an imported settings snapshot without touching queued items or filesystem workflows", () => {
|
it("applies an imported settings snapshot without touching queued items or filesystem workflows", () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-import-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-import-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
+213
-20
@@ -1,4 +1,6 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
@@ -7,6 +9,7 @@ import {
|
|||||||
buildDownloadSidebarCounts,
|
buildDownloadSidebarCounts,
|
||||||
buildDownloadsViewModel,
|
buildDownloadsViewModel,
|
||||||
classifyDownloadStatus,
|
classifyDownloadStatus,
|
||||||
|
getDownloadQueueTotalBytes,
|
||||||
type DownloadSidebarFilter,
|
type DownloadSidebarFilter,
|
||||||
type DownloadsModelInput
|
type DownloadsModelInput
|
||||||
} from "../src/renderer/views/downloads/downloads-model";
|
} from "../src/renderer/views/downloads/downloads-model";
|
||||||
@@ -21,13 +24,64 @@ import {
|
|||||||
} from "../src/renderer/views/downloads/DownloadsView";
|
} from "../src/renderer/views/downloads/DownloadsView";
|
||||||
import {
|
import {
|
||||||
DownloadsTableHeader,
|
DownloadsTableHeader,
|
||||||
|
ItemRowContent,
|
||||||
PackageCardContent,
|
PackageCardContent,
|
||||||
areItemRowPropsEqual,
|
areItemRowPropsEqual,
|
||||||
arePackageCardPropsEqual
|
arePackageCardPropsEqual,
|
||||||
|
downloadColumnDefinitions,
|
||||||
|
getAvailabilitySummary
|
||||||
} from "../src/renderer/views/downloads/DownloadsTable";
|
} from "../src/renderer/views/downloads/DownloadsTable";
|
||||||
|
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
|
||||||
|
|
||||||
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
||||||
|
|
||||||
|
describe("Downloadtabellen-Spalten", () => {
|
||||||
|
it("verteilt die Breite mit ausreichend Platz für vollständige Überschriften", () => {
|
||||||
|
expect(downloadColumnDefinitions.name.width).toBe("minmax(290px, 2.3fr)");
|
||||||
|
expect(downloadColumnDefinitions.progress.width).toBe("minmax(105px, 0.85fr)");
|
||||||
|
expect(downloadColumnDefinitions.prio.width).toBe("minmax(85px, 0.8fr)");
|
||||||
|
expect(downloadColumnDefinitions.speed).toEqual(expect.objectContaining({ label: "Geschwindigkeit", width: "minmax(120px, 1fr)" }));
|
||||||
|
expect(downloadColumnDefinitions.availability).toEqual(expect.objectContaining({ label: "Verfügbarkeit", width: "minmax(110px, 1fr)" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the normal text color for sortable and static column headers", () => {
|
||||||
|
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||||||
|
|
||||||
|
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*color:\s*var\(--ui-text\);/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves complete columns through pointer capture and animated transforms", () => {
|
||||||
|
const source = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/DownloadsTable.tsx"), "utf8");
|
||||||
|
const dragSource = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/column-drag.ts"), "utf8");
|
||||||
|
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||||||
|
|
||||||
|
expect(source).toContain("onColumnPointerDown");
|
||||||
|
expect(source).toContain("setPointerCapture");
|
||||||
|
expect(source).toContain("onPointerMove");
|
||||||
|
expect(source).not.toMatch(/className="downloads-column-header"[\s\S]{0,180}\sdraggable/);
|
||||||
|
expect(dragSource).toContain('root.style.setProperty(`--downloads-column-drag-${id}`');
|
||||||
|
expect(css).toMatch(/\.downloads-table\.is-column-drag-active \[data-download-column\]\s*\{[^}]*transform:\s*translate3d\(var\(--downloads-column-drag-x, 0px\), 0, 0\);[^}]*transition:\s*transform 220ms/s);
|
||||||
|
expect(css).toMatch(/\.downloads-table\.is-column-drag-active \[data-column-dragging="true"\]\s*\{[^}]*transition:\s*none;/s);
|
||||||
|
expect(css).not.toMatch(/\.downloads-table\.is-column-drag-active \[data-column-dragging="true"\]\s*\{[^}]*background:/s);
|
||||||
|
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.downloads-table\.is-column-drag-active \[data-download-column\][^{]*\{[^}]*transition-duration:\s*220ms !important;/s);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rollende Downloadkennzahlen", () => {
|
||||||
|
it("moves increasing values up and decreasing values down", () => {
|
||||||
|
expect(getRollingMetricDirection(300, 600)).toBe("up");
|
||||||
|
expect(getRollingMetricDirection(600, 300)).toBe("down");
|
||||||
|
expect(getRollingMetricDirection(300, 300)).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("animates exactly the five stable sidebar metrics", () => {
|
||||||
|
const html = renderToStaticMarkup(<DownloadsSidebarStatus model={withRuntime(createInput())} />);
|
||||||
|
expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(5);
|
||||||
|
expect(html).toContain('data-status-metric="speed"');
|
||||||
|
expect(html).toContain('data-status-metric="eta"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function item(id: string, packageId: string, status: DownloadStatus, overrides: Partial<DownloadItem> = {}): DownloadItem {
|
function item(id: string, packageId: string, status: DownloadStatus, overrides: Partial<DownloadItem> = {}): DownloadItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -57,10 +111,21 @@ function pkg(id: string, name: string, itemIds: string[]): PackageEntry {
|
|||||||
return { id, name, itemIds, createdAt: now } as PackageEntry;
|
return { id, name, itemIds, createdAt: now } as PackageEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("Download-Gesamtgröße", () => {
|
||||||
|
it("summiert bekannte Dateigrößen und verwendet geladene Bytes nur als Fallback", () => {
|
||||||
|
const items = [
|
||||||
|
item("known", "package-a", "queued", { totalBytes: 4_000, downloadedBytes: 500 }),
|
||||||
|
item("fallback", "package-a", "queued", { totalBytes: null, downloadedBytes: 750 })
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getDownloadQueueTotalBytes(items)).toBe(4_750);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function createInput(overrides: Partial<DownloadsModelInput> = {}): DownloadsModelInput {
|
function createInput(overrides: Partial<DownloadsModelInput> = {}): DownloadsModelInput {
|
||||||
const items = [
|
const items = [
|
||||||
item("active", "package-a", "downloading"),
|
item("active", "package-a", "downloading"),
|
||||||
item("queued", "package-a", "queued", { provider: "debridlink", providerLabel: "Debrid-Link" }),
|
item("queued", "package-a", "queued", { provider: "debridlink", providerLabel: "Debrid-Link", url: "https://ddownload.com/file/queued" }),
|
||||||
item("failed", "package-b", "failed", { provider: "alldebrid", providerLabel: "AllDebrid" }),
|
item("failed", "package-b", "failed", { provider: "alldebrid", providerLabel: "AllDebrid" }),
|
||||||
item("done", "package-b", "completed", { provider: "realdebrid", providerLabel: "Real-Debrid" })
|
item("done", "package-b", "completed", { provider: "realdebrid", providerLabel: "Real-Debrid" })
|
||||||
];
|
];
|
||||||
@@ -114,7 +179,6 @@ function createActions(overrides: Partial<DownloadsViewActions> = {}): Downloads
|
|||||||
onToggleSelection: () => {},
|
onToggleSelection: () => {},
|
||||||
onSelectionMouseDown: () => {},
|
onSelectionMouseDown: () => {},
|
||||||
onSelectionMouseEnter: () => {},
|
onSelectionMouseEnter: () => {},
|
||||||
onTogglePackage: () => {},
|
|
||||||
onTogglePackageCollapse: () => {},
|
onTogglePackageCollapse: () => {},
|
||||||
onStartPackageRename: () => {},
|
onStartPackageRename: () => {},
|
||||||
onPackageRenameChange: () => {},
|
onPackageRenameChange: () => {},
|
||||||
@@ -126,11 +190,10 @@ function createActions(overrides: Partial<DownloadsViewActions> = {}): Downloads
|
|||||||
onRemoveItem: () => {},
|
onRemoveItem: () => {},
|
||||||
onOpenContextMenu: () => {},
|
onOpenContextMenu: () => {},
|
||||||
onSortColumn: () => {},
|
onSortColumn: () => {},
|
||||||
onColumnDragStart: () => {},
|
onColumnPointerDown: () => {},
|
||||||
onColumnDragOver: () => {},
|
onColumnPointerMove: () => {},
|
||||||
onColumnDragLeave: () => {},
|
onColumnPointerUp: () => {},
|
||||||
onColumnDrop: () => {},
|
onColumnPointerCancel: () => {},
|
||||||
onColumnDragEnd: () => {},
|
|
||||||
onColumnContextMenu: () => {},
|
onColumnContextMenu: () => {},
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
@@ -190,7 +253,9 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
|
|||||||
packages: 2,
|
packages: 2,
|
||||||
links: 4,
|
links: 4,
|
||||||
session: "3,00 GB",
|
session: "3,00 GB",
|
||||||
|
sessionBytes: 3_000_000_000,
|
||||||
total: "10,00 GB",
|
total: "10,00 GB",
|
||||||
|
totalBytes: 10_000_000_000,
|
||||||
hosters: 3,
|
hosters: 3,
|
||||||
speed: "96,00 Mbit/s",
|
speed: "96,00 Mbit/s",
|
||||||
eta: "00:05:00"
|
eta: "00:05:00"
|
||||||
@@ -325,6 +390,39 @@ describe("downloads model", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("downloads view", () => {
|
describe("downloads view", () => {
|
||||||
|
it("pins the clipboard toggle separately at the bottom above the status metrics", () => {
|
||||||
|
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
|
||||||
|
const actionsMarkup = html.match(/<div class="downloads-sidebar-actions">([\s\S]*?)<\/div>/)?.[1] ?? "";
|
||||||
|
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||||||
|
|
||||||
|
expect(html).toContain('class="downloads-clipboard-toggle"');
|
||||||
|
expect(actionsMarkup).not.toContain("Zwischenablage überwachen");
|
||||||
|
expect(css).toMatch(/\.downloads-sidebar\s*\{[^}]*height:\s*100%;[^}]*padding:\s*14px 12px 6px;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-clipboard-toggle\s*\{[^}]*margin-top:\s*auto;[^}]*border:\s*1px solid var\(--ui-border\);[^}]*background:\s*var\(--ui-input\);/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the sidebar actions as permanently recognizable buttons", () => {
|
||||||
|
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||||||
|
|
||||||
|
expect(css).toMatch(/\.downloads-sidebar-actions button\s*\{[^}]*border:\s*1px solid var\(--ui-border\);[^}]*background:\s*var\(--ui-input\);[^}]*padding:\s*0 10px;/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the download filters for one measured vertical selection indicator", () => {
|
||||||
|
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
|
||||||
|
|
||||||
|
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||||||
|
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(6);
|
||||||
|
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only the package mode while the file mode remains hidden", () => {
|
||||||
|
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
|
||||||
|
|
||||||
|
expect(html).toContain("Pakete");
|
||||||
|
expect(html).not.toContain(">Dateien<");
|
||||||
|
expect(html).not.toContain("downloads-mode-switch");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => {
|
it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => {
|
||||||
const occupied = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
|
const occupied = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
|
||||||
const empty = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput({ packageOrder: [], packages: {}, items: {} }), { running: false })} />);
|
const empty = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput({ packageOrder: [], packages: {}, items: {} }), { running: false })} />);
|
||||||
@@ -474,15 +572,36 @@ describe("downloads view", () => {
|
|||||||
|
|
||||||
expect(html.indexOf("downloads-table-header")).toBeGreaterThan(html.indexOf("downloads-table"));
|
expect(html.indexOf("downloads-table-header")).toBeGreaterThan(html.indexOf("downloads-table"));
|
||||||
expect(html.indexOf("data-visual-region=\"downloads-table-body\"")).toBeGreaterThan(html.indexOf("downloads-table-header"));
|
expect(html.indexOf("data-visual-region=\"downloads-table-body\"")).toBeGreaterThan(html.indexOf("downloads-table-header"));
|
||||||
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;/s);
|
expect(html).toContain('class="downloads-package-card ');
|
||||||
|
expect(html).not.toContain('class="package-card');
|
||||||
|
expect(html).toContain('class="downloads-hoster-icon" data-hoster="rapidgator" src="data:image/x-icon;base64,');
|
||||||
|
expect(html).toContain('class="downloads-hoster-icon" data-hoster="ddownload" src="./provider-icons/ddownload.ico"');
|
||||||
|
expect(html).not.toContain('title="RapidGator">RG<');
|
||||||
|
expect(html).toContain('data-download-column="name"');
|
||||||
|
expect(html).toContain('data-download-column="hoster"');
|
||||||
|
expect(html).toMatch(/grid-template-columns:[^"]+ 60px/);
|
||||||
|
expect(html).toContain('class="downloads-package-items is-expanded"');
|
||||||
|
expect(html).toContain('class="downloads-package-items-inner"');
|
||||||
|
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*scrollbar-gutter:\s*stable;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-table-header,\s*\.downloads-table-body\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*1191px;/s);
|
||||||
|
expect(css).not.toMatch(/min-width:\s*max-content;/);
|
||||||
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*height:\s*41px;[^}]*position:\s*sticky;/s);
|
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*height:\s*41px;[^}]*position:\s*sticky;/s);
|
||||||
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*48px;/s);
|
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*40px;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-item-row\s*\{[^}]*height:\s*38px;/s);
|
||||||
expect(css).toMatch(/\.downloads-toolbar button,\s*\.downloads-footer button,[^{]+\{[^}]*height:\s*36px;/s);
|
expect(css).toMatch(/\.downloads-toolbar button,\s*\.downloads-footer button,[^{]+\{[^}]*height:\s*36px;/s);
|
||||||
expect(css).toMatch(/\.downloads-content\s*\{[^}]*height:\s*100%;/s);
|
expect(css).toMatch(/\.downloads-content\s*\{[^}]*height:\s*100%;/s);
|
||||||
expect(css).toMatch(/\.downloads-action-cell button,\s*\.downloads-collapse-button\s*\{[^}]*width:\s*30px;[^}]*height:\s*30px;/s);
|
expect(css).toMatch(/\.downloads-collapse-button\s*\{[^}]*box-sizing:\s*border-box;[^}]*flex:\s*0 0 30px;[^}]*width:\s*30px;[^}]*min-width:\s*30px;[^}]*max-width:\s*30px;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-cell-slot\s*\{[^}]*display:\s*flex;[^}]*justify-content:\s*center;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-cell-slot\s*>\s*\.downloads-cell\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
|
||||||
|
expect(css).toMatch(/\[data-download-column="name"\][^{]*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-package-items\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*hidden;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-package-items\.is-collapsed\s*\{[^}]*height:\s*0;[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-item-row\s+\.downloads-meter\s*>\s*b\s*\{[^}]*color:\s*var\(--ui-text\);/s);
|
||||||
|
expect(readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8")).toMatch(/\.animate\(\[\{ height: "0px", opacity: 0 \}, \{ height: `\$\{targetHeight\}px`, opacity: 1 \}\]/);
|
||||||
expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s);
|
expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s);
|
||||||
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid var\(--ui-border\);[^}]*padding:\s*0;/s);
|
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid color-mix\(in srgb, var\(--ui-border\) 72%, transparent\);[^}]*padding:\s*0;/s);
|
||||||
expect(css).not.toMatch(/gradient|box-shadow|nth-child/i);
|
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*box-shadow:\s*none;/s);
|
||||||
|
expect(css).not.toMatch(/gradient|nth-child/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps visible interaction text non-selectable and only inputs plus copy values selectable", () => {
|
it("keeps visible interaction text non-selectable and only inputs plus copy values selectable", () => {
|
||||||
@@ -492,6 +611,17 @@ describe("downloads view", () => {
|
|||||||
expect(css).toMatch(/\.downloads-copyable,\s*\.downloads-search-input,\s*\.downloads-rename-input\s*\{[^}]*user-select:\s*text;/s);
|
expect(css).toMatch(/\.downloads-copyable,\s*\.downloads-search-input,\s*\.downloads-rename-input\s*\{[^}]*user-select:\s*text;/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks selected rows clearly, enlarges selection checkboxes and slows package disclosure", () => {
|
||||||
|
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||||||
|
const source = readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(css).toMatch(/\.downloads-item-row\.is-selected,\s*\.downloads-package-card\.is-selected\s*>\s*\.downloads-package-row\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--ui-success\) 14%, var\(--ui-canvas\)\);[^}]*box-shadow:\s*inset 3px 0 0 var\(--ui-success\);/s);
|
||||||
|
expect(css).toMatch(/\.downloads-selection-cell\s+input\[type="checkbox"\]\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-hoster-icon\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;[^}]*object-fit:\s*contain;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-hoster-icon\[data-hoster="rapidgator"\]\s*\{[^}]*transform:\s*translateY\(-4px\) scale\(2\);/s);
|
||||||
|
expect(source.match(/duration:\s*300/g)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the 1120px layout inside the single downloads table scroll owner", () => {
|
it("keeps the 1120px layout inside the single downloads table scroll owner", () => {
|
||||||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
@@ -511,6 +641,14 @@ describe("downloads view", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("downloads App integration", () => {
|
describe("downloads App integration", () => {
|
||||||
|
it("updates the clipboard checkbox optimistically before IPC reconciliation", () => {
|
||||||
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||||
|
|
||||||
|
expect(source).toMatch(/const toggleClipboardWatcher = useCallback\(\(\): void => \{[\s\S]*setClipboardWatcherActive\(next\);[\s\S]*window\.rd\.toggleClipboard\(\)/);
|
||||||
|
expect(source).toContain("onToggleClipboardWatcher: toggleClipboardWatcher");
|
||||||
|
expect(source).not.toContain("onToggleClipboardWatcher: () => { void performQuickAction(() => window.rd.toggleClipboard()); }");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the extracted table only, cleans temporary drag listeners and routes the global context start through the shared callback", () => {
|
it("uses the extracted table only, cleans temporary drag listeners and routes the global context start through the shared callback", () => {
|
||||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||||
|
|
||||||
@@ -523,6 +661,63 @@ describe("downloads App integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("download table row contracts", () => {
|
describe("download table row contracts", () => {
|
||||||
|
it("summarizes full, partial, offline and unchecked package availability", () => {
|
||||||
|
expect(getAvailabilitySummary([
|
||||||
|
item("online-a", "package-a", "queued", { onlineStatus: "online" }),
|
||||||
|
item("online-b", "package-a", "queued", { onlineStatus: "online" })
|
||||||
|
])).toEqual({ online: 2, total: 2, state: "online" });
|
||||||
|
expect(getAvailabilitySummary([
|
||||||
|
item("partial-a", "package-a", "queued", { onlineStatus: "online" }),
|
||||||
|
item("partial-b", "package-a", "queued", { onlineStatus: "offline" })
|
||||||
|
])).toEqual({ online: 1, total: 2, state: "partial" });
|
||||||
|
expect(getAvailabilitySummary([
|
||||||
|
item("offline-a", "package-a", "queued", { onlineStatus: "offline" }),
|
||||||
|
item("offline-b", "package-a", "queued", { onlineStatus: "offline" })
|
||||||
|
])).toEqual({ online: 0, total: 2, state: "offline" });
|
||||||
|
expect(getAvailabilitySummary([
|
||||||
|
item("unknown-a", "package-a", "queued", { onlineStatus: undefined })
|
||||||
|
])).toEqual({ online: 0, total: 1, state: "checking" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders availability for package and file rows", () => {
|
||||||
|
const onlineItem = item("online-file", "package-a", "queued", { onlineStatus: "online" });
|
||||||
|
const packageHtml = renderToStaticMarkup(PackageCardContent({
|
||||||
|
actions: createActions(),
|
||||||
|
columnOrder: ["availability"],
|
||||||
|
editing: false,
|
||||||
|
editingName: "",
|
||||||
|
gridTemplate: "110px",
|
||||||
|
packageSpeedBps: 0,
|
||||||
|
row: { package: pkg("package-a", "Paket", [onlineItem.id]), items: [onlineItem], collapsed: true },
|
||||||
|
selectedIds: new Set<string>(),
|
||||||
|
selectedVersion: 0
|
||||||
|
}));
|
||||||
|
const itemHtml = renderToStaticMarkup(ItemRowContent({
|
||||||
|
actions: createActions(),
|
||||||
|
columnOrder: ["availability"],
|
||||||
|
gridTemplate: "110px",
|
||||||
|
item: onlineItem,
|
||||||
|
selected: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(packageHtml).toContain("1/1 online");
|
||||||
|
expect(packageHtml).toContain("downloads-availability has-counts is-online");
|
||||||
|
expect(packageHtml).toContain("downloads-availability-count is-online-count");
|
||||||
|
expect(packageHtml).toContain("downloads-availability-separator");
|
||||||
|
expect(packageHtml).toContain("downloads-availability-count is-total-count");
|
||||||
|
expect(packageHtml).toContain("downloads-availability-label");
|
||||||
|
expect(itemHtml).toContain("Online");
|
||||||
|
expect(itemHtml).toContain("downloads-availability is-online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aligns availability symbols, split counts and labels on fixed axes", () => {
|
||||||
|
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||||||
|
|
||||||
|
expect(css).toMatch(/\.downloads-availability\.has-counts\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*16px 4ch 1ch 4ch auto;[^}]*font-variant-numeric:\s*tabular-nums;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-availability-count\.is-online-count\s*\{[^}]*text-align:\s*right;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-availability-count\.is-total-count\s*\{[^}]*text-align:\s*left;/s);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves the package download and extraction phase split", () => {
|
it("preserves the package download and extraction phase split", () => {
|
||||||
const extractionPackage = {
|
const extractionPackage = {
|
||||||
...pkg("extracting-package", "Entpackendes Paket", ["extracting-item"]),
|
...pkg("extracting-package", "Entpackendes Paket", ["extracting-item"]),
|
||||||
@@ -646,14 +841,13 @@ describe("download table row contracts", () => {
|
|||||||
expect(commits).toEqual(["package-a:Neuer Name"]);
|
expect(commits).toEqual(["package-a:Neuer Name"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps package selection and activation as separate controls and sends context coordinates", () => {
|
it("removes the redundant package activation checkbox and preserves context actions", () => {
|
||||||
const calls: Array<unknown> = [];
|
const calls: Array<unknown> = [];
|
||||||
const model = withRuntime(createInput());
|
const model = withRuntime(createInput());
|
||||||
const row = model.packageRows[0];
|
const row = model.packageRows[0];
|
||||||
const component = PackageCardContent({
|
const component = PackageCardContent({
|
||||||
actions: createActions({
|
actions: createActions({
|
||||||
onToggleSelection: (id) => calls.push(["select", id]),
|
onToggleSelection: (id, ctrl, shift) => calls.push(["select", id, ctrl, shift]),
|
||||||
onTogglePackage: (id) => calls.push(["toggle", id]),
|
|
||||||
onOpenContextMenu: (id, x, y) => calls.push(["context", id, x, y])
|
onOpenContextMenu: (id, x, y) => calls.push(["context", id, x, y])
|
||||||
}),
|
}),
|
||||||
columnOrder: model.columnOrder,
|
columnOrder: model.columnOrder,
|
||||||
@@ -666,13 +860,12 @@ describe("download table row contracts", () => {
|
|||||||
selectedVersion: 1
|
selectedVersion: 1
|
||||||
});
|
});
|
||||||
const selection = findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie auswählen");
|
const selection = findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie auswählen");
|
||||||
const activation = findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie aktivieren");
|
|
||||||
const packageElement = findElement(component, (element) => element.props["data-download-package-id"] === "package-a");
|
const packageElement = findElement(component, (element) => element.props["data-download-package-id"] === "package-a");
|
||||||
|
|
||||||
selection.props.onChange();
|
selection.props.onClick({ stopPropagation: () => {}, ctrlKey: false, metaKey: false, shiftKey: true });
|
||||||
activation.props.onChange();
|
|
||||||
packageElement.props.onContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: 30, clientY: 50 });
|
packageElement.props.onContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: 30, clientY: 50 });
|
||||||
|
|
||||||
expect(calls).toEqual([["select", "package-a"], ["toggle", "package-a"], ["context", "package-a", 30, 50]]);
|
expect(calls).toEqual([["select", "package-a", true, true], ["context", "package-a", 30, 50]]);
|
||||||
|
expect(() => findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie aktivieren")).toThrow("Element not found");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+47
-11
@@ -14,8 +14,9 @@ import {
|
|||||||
type HistoryViewEntry
|
type HistoryViewEntry
|
||||||
} from "../src/renderer/views/history/history-model";
|
} from "../src/renderer/views/history/history-model";
|
||||||
import {
|
import {
|
||||||
HistoryContent,
|
HistoryContent,
|
||||||
HistoryToolbar,
|
HistorySidebar,
|
||||||
|
HistoryToolbar,
|
||||||
HistoryView,
|
HistoryView,
|
||||||
type HistoryViewActions
|
type HistoryViewActions
|
||||||
} from "../src/renderer/views/history/HistoryView";
|
} from "../src/renderer/views/history/HistoryView";
|
||||||
@@ -203,8 +204,17 @@ describe("history model", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("HistoryView", () => {
|
describe("HistoryView", () => {
|
||||||
it("keeps the header and rows inside the same internal horizontal scroll context", () => {
|
it("marks history filters for one measured vertical selection indicator", () => {
|
||||||
|
const model = buildHistoryViewModel(entries, "week", "", [], [], false, "", now);
|
||||||
|
const html = renderToStaticMarkup(<HistorySidebar actions={createActions()} model={model} />);
|
||||||
|
|
||||||
|
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||||||
|
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(7);
|
||||||
|
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the header and rows inside the same internal horizontal scroll context", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<HistoryView
|
<HistoryView
|
||||||
actions={createActions()}
|
actions={createActions()}
|
||||||
@@ -273,11 +283,25 @@ describe("HistoryView", () => {
|
|||||||
expect(html).toContain("history-status-failed");
|
expect(html).toContain("history-status-failed");
|
||||||
expect(html).toContain("Abgeschlossen");
|
expect(html).toContain("Abgeschlossen");
|
||||||
expect(html).toContain("Gelöscht");
|
expect(html).toContain("Gelöscht");
|
||||||
expect(html).toContain("Fehlgeschlagen");
|
expect(html).toContain("Fehlgeschlagen");
|
||||||
expect(html).not.toMatch(/>Start<|>Pause<|>Stop<|Priorität/);
|
expect(html).not.toMatch(/>Start<|>Pause<|>Stop<|Priorität/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders each visual marker once, occupied main rows separately from closed detail rows and an honest footer", () => {
|
it("matches the download action control and centers every header except package and file", () => {
|
||||||
|
const content = HistoryContent({
|
||||||
|
actions: createActions(),
|
||||||
|
model: buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now)
|
||||||
|
});
|
||||||
|
const actionCell = findElement(content, (element) => element.props.className === "history-row-action");
|
||||||
|
const styles = readFileSync(new URL("../src/renderer/views/history/history.css", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||||
|
|
||||||
|
expect(actionCell.props.children.props.children).toBe("⋮");
|
||||||
|
expect(styles).toMatch(/\.history-row-action button\s*\{[^}]*background:\s*var\(--ui-input\);[^}]*border:\s*1px solid var\(--ui-border\);[^}]*height:\s*30px;[^}]*width:\s*30px;/s);
|
||||||
|
expect(styles).toMatch(/\.history-table-header-row > span\s*\{[^}]*text-align:\s*center;/s);
|
||||||
|
expect(styles).toMatch(/\.history-table-header-row > span:nth-child\(2\)\s*\{[^}]*text-align:\s*left;/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders each visual marker once, occupied main rows separately from closed detail rows and an honest footer", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<HistoryView
|
<HistoryView
|
||||||
actions={createActions()}
|
actions={createActions()}
|
||||||
@@ -392,8 +416,20 @@ describe("HistoryView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("visual history states", () => {
|
describe("visual history states", () => {
|
||||||
it("re-arms the real App mounted gate before every StrictMode lifecycle setup can start async work", () => {
|
it("keeps the selected category visible immediately while only its geometry glides", () => {
|
||||||
|
const styles = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||||
|
const selection = readFileSync(new URL("../src/renderer/ui/SlidingSelection.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||||
|
const selectionStyles = styles.slice(styles.indexOf(".ui-sliding-selection"), styles.indexOf("html,"));
|
||||||
|
|
||||||
|
expect(selectionStyles).toMatch(/\.ui-sliding-selection::before\s*\{[^}]*opacity:\s*1;/s);
|
||||||
|
expect(selectionStyles).not.toContain(".ui-sliding-selection.has-sliding-selection::before");
|
||||||
|
expect(selectionStyles).not.toMatch(/transition(?:-property)?:[^;]*opacity/);
|
||||||
|
expect(selection).not.toContain("classList.add(\"has-sliding-selection\")");
|
||||||
|
expect(selection).not.toContain("classList.remove(\"has-sliding-selection\")");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-arms the real App mounted gate before every StrictMode lifecycle setup can start async work", () => {
|
||||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||||
const firstRequest = source.indexOf("window.rd.getVersion()");
|
const firstRequest = source.indexOf("window.rd.getVersion()");
|
||||||
const effectStart = source.lastIndexOf("useEffect(() => {", firstRequest);
|
const effectStart = source.lastIndexOf("useEffect(() => {", firstRequest);
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeLanguage, translateUiText } from "../src/renderer/i18n";
|
||||||
|
|
||||||
|
describe("renderer localization", () => {
|
||||||
|
it("falls back to English", () => {
|
||||||
|
expect(normalizeLanguage(undefined)).toBe("en");
|
||||||
|
expect(normalizeLanguage("fr")).toBe("en");
|
||||||
|
expect(normalizeLanguage("de")).toBe("de");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("translates exact interface labels in both directions", () => {
|
||||||
|
expect(translateUiText("Einstellungen speichern", "en")).toBe("Save settings");
|
||||||
|
expect(translateUiText("Save settings", "de")).toBe("Einstellungen speichern");
|
||||||
|
expect(translateUiText("Passwort/Zugang", "en")).toBe("Password/access");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("translates dynamic update and pagination text", () => {
|
||||||
|
expect(translateUiText("v2.0.14 ist verfügbar. Installierte Version: 2.0.13.", "en"))
|
||||||
|
.toBe("v2.0.14 is available. Installed version: 2.0.13.");
|
||||||
|
expect(translateUiText("1–46 von 46", "en")).toBe("1–46 of 46");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("translates the complete history surface including status values", () => {
|
||||||
|
const translations = new Map([
|
||||||
|
["Alle Einträge", "All entries"],
|
||||||
|
["Heute", "Today"],
|
||||||
|
["Letzte 7 Tage", "Last 7 days"],
|
||||||
|
["Älter", "Older"],
|
||||||
|
["Gelöscht", "Deleted"],
|
||||||
|
["Fehlgeschlagen", "Failed"],
|
||||||
|
["Verlauf leeren", "Clear history"],
|
||||||
|
["Erneut hinzufügen", "Add again"],
|
||||||
|
["Im Ordner zeigen", "Show in folder"],
|
||||||
|
["Auswahl löschen", "Clear selection"],
|
||||||
|
["Noch kein Verlauf", "No history yet"],
|
||||||
|
["Keine passenden Einträge", "No matching entries"]
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const [german, english] of translations) {
|
||||||
|
expect(translateUiText(german, "en")).toBe(english);
|
||||||
|
expect(translateUiText(english, "de")).toBe(german);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("translates dynamic queue and availability values", () => {
|
||||||
|
expect(translateUiText("26/26 online", "en")).toBe("26/26 online");
|
||||||
|
expect(translateUiText("3 abgebrochen", "en")).toBe("3 cancelled");
|
||||||
|
expect(translateUiText("Geplant: 18:30", "en")).toBe("Scheduled: 18:30");
|
||||||
|
expect(translateUiText("Einträge: 2", "en")).toBe("Entries: 2");
|
||||||
|
expect(translateUiText("Sichtbar: 2", "en")).toBe("Visible: 2");
|
||||||
|
expect(translateUiText("Ausgewählt: 0", "en")).toBe("Selected: 0");
|
||||||
|
expect(translateUiText("2 pro Seite", "en")).toBe("2 per page");
|
||||||
|
expect(translateUiText("Sichtbar: ", "en")).toBe("Visible: ");
|
||||||
|
expect(translateUiText(" pro Seite", "en")).toBe(" per page");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Verfügbarkeit", "Availability"],
|
||||||
|
["Hinzugefügt am", "Added on"],
|
||||||
|
["Ungeprüft", "Unchecked"],
|
||||||
|
["Paket gestoppt", "Package stopped"],
|
||||||
|
["Noch keine Downloads", "No downloads yet"],
|
||||||
|
["Füge Links hinzu, um den ersten Download zu starten.", "Add links to start the first download."],
|
||||||
|
["Neue Sammlung", "New collection"],
|
||||||
|
["Links erfassen", "Capture links"],
|
||||||
|
["Datei importieren", "Import file"],
|
||||||
|
["Sammlung verarbeiten", "Process collection"],
|
||||||
|
["An Downloads übergeben", "Send to downloads"],
|
||||||
|
["Keine passenden Links", "No matching links"],
|
||||||
|
["Noch keine Links", "No links yet"],
|
||||||
|
["Eine URL oder Rohzeile pro Zeile", "One URL or raw line per line"],
|
||||||
|
["Prüfen und speichern", "Check and save"],
|
||||||
|
["Accounts durchsuchen", "Search accounts"],
|
||||||
|
["Account-Typ filtern", "Filter account type"],
|
||||||
|
["Keine passenden Account-Typen.", "No matching account types."],
|
||||||
|
["Geschützter Zugang", "Protected access"],
|
||||||
|
["Immer erste Tonspur", "Always first audio track"],
|
||||||
|
["Keine Archive löschen", "Do not delete archives"],
|
||||||
|
["Sieben Tage", "Seven days"],
|
||||||
|
["Zeitraum", "Period"],
|
||||||
|
["Nicht verfügbar", "Unavailable"],
|
||||||
|
["Datenmenge", "Data volume"],
|
||||||
|
["Erfolgsquote", "Success rate"],
|
||||||
|
["Durchschnitt", "Average"],
|
||||||
|
["Live aus der aktuellen Renderer-Sitzung", "Live from the current renderer session"],
|
||||||
|
["Kontextmenü", "Context menu"],
|
||||||
|
["Die Oberfläche hat einen Fehler ausgelöst", "The interface encountered an error"],
|
||||||
|
["Oberfläche neu laden", "Reload interface"],
|
||||||
|
["Unbekannter Fehler", "Unknown error"],
|
||||||
|
["Details anzeigen", "Show details"],
|
||||||
|
["Details ausblenden", "Hide details"]
|
||||||
|
])("translates renderer text %s in both directions", (german, english) => {
|
||||||
|
expect(translateUiText(german, "en")).toBe(english);
|
||||||
|
expect(translateUiText(english, "de")).toBe(german);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Film.mkv auswählen", "Select Film.mkv"],
|
||||||
|
["Aktionen für Film.mkv", "Actions for Film.mkv"],
|
||||||
|
["RapidGator Zuordnung entfernen", "Remove RapidGator assignment"],
|
||||||
|
["RapidGator nach oben", "Move RapidGator up"],
|
||||||
|
["Geplant: Heute 22:15", "Scheduled: Today 22:15"],
|
||||||
|
["Tonspur: 2 OK · 1 ohne DE-Tag · ffmpeg fehlt · 3 Fehler", "Audio track: 2 OK · 1 without DE tag · ffmpeg missing · 3 errors"],
|
||||||
|
["4/8 fertig · 2 Fehler", "4/8 completed · 2 errors"],
|
||||||
|
["Entpacken 52%", "Extracting 52%"],
|
||||||
|
["Fehlgeschlagen nach 3 Versuchen: HTTP 503 von https://host.test/a", "Failed after 3 attempts: HTTP 503 von https://host.test/a"],
|
||||||
|
["Update-Check fehlgeschlagen: ECONNRESET https://api.test/v1", "Update check failed: ECONNRESET https://api.test/v1"],
|
||||||
|
["Account geprüft — Premium bis 2027-01-01", "Account checked — Premium until 2027-01-01"],
|
||||||
|
["7 Link(s) zur Queue hinzugefügt", "7 link(s) added to the queue"],
|
||||||
|
["2 Paket(e), 5 Link(s) importiert", "2 package(s), 5 link(s) imported"],
|
||||||
|
["3 ausgewählt", "3 selected"],
|
||||||
|
["vor 4 Std", "4 hr ago"],
|
||||||
|
["Zeitregel 4", "Schedule rule 4"],
|
||||||
|
["1.5 GB von 10 GB übrig", "1.5 GB of 10 GB remaining"]
|
||||||
|
])("translates composed renderer text %s without changing its payload", (german, english) => {
|
||||||
|
expect(translateUiText(german, "en")).toBe(english);
|
||||||
|
expect(translateUiText(english, "de")).toBe(german);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"https://rapidgator.net/file/abc?token=secret",
|
||||||
|
"C:\\Downloads\\Film [1080p]\\video.mkv",
|
||||||
|
"video.GERMAN.DL.1080p.mkv",
|
||||||
|
"ECONNRESET at api.example.test:443",
|
||||||
|
"RapidGator API: quota=0"
|
||||||
|
])("keeps free technical content unchanged: %s", (value) => {
|
||||||
|
expect(translateUiText(value, "en")).toBe(value);
|
||||||
|
expect(translateUiText(value, "de")).toBe(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["nicht gesetzt", "not set"],
|
||||||
|
["Schätzwert", "Estimate"],
|
||||||
|
["ist verfügbar. Installierte Version:", "is available. Installed version:"],
|
||||||
|
["Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster.", "Daily limit reached. New links will switch to the next hoster."],
|
||||||
|
["Nur lokal", "Local only"],
|
||||||
|
["Name kopiert", "Name copied"],
|
||||||
|
["Link kopiert", "Link copied"],
|
||||||
|
["Bei \"überspringen\" wird nur das erneute Entpacken übersprungen - offene Downloads bleiben in der Queue.", "With \"skip\", only repeated extraction is skipped - open downloads remain in the queue."]
|
||||||
|
])("translates remaining renderer inventory text %s", (german, english) => {
|
||||||
|
expect(translateUiText(german, "en")).toBe(english);
|
||||||
|
expect(translateUiText(english, "de")).toBe(german);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Debug-Server aktiv: 127.0.0.1:9345", "Debug server active: 127.0.0.1:9345"],
|
||||||
|
["Unbekannter Account-Typ: custom-provider", "Unknown account type: custom-provider"],
|
||||||
|
["RapidGator: Bitte Passwort eintragen.", "RapidGator: Enter a password."],
|
||||||
|
["Update-Download: 42% (42 MB / 100 MB)", "Update download: 42% (42 MB / 100 MB)"],
|
||||||
|
["Zwischenablage: 3 Link(s) erkannt", "Clipboard: 3 link(s) detected"],
|
||||||
|
["2/4 API-Keys deaktiviert.", "2/4 API keys disabled."],
|
||||||
|
["Account-Check: 3/4 Login gültig, 2 mit Premium.", "Account check: 3/4 logins valid, 2 with premium."],
|
||||||
|
["Soll RapidGator wirklich aus der Accountliste entfernt werden?", "Remove RapidGator from the account list?"],
|
||||||
|
["Konflikte gelöst: 2 überschrieben, 3 übersprungen", "Conflicts resolved: 2 overwritten, 3 skipped"],
|
||||||
|
["DLC importiert: 2 Paket(e), 5 Link(s)", "DLC imported: 2 package(s), 5 link(s)"],
|
||||||
|
["3 Fehler, 2 Warnungen (letzte 5)", "3 errors, 2 warnings (latest 5)"],
|
||||||
|
["Links für Sammlung 1 lokal erfassen.", "Capture links locally for Sammlung 1."],
|
||||||
|
["Film.mkv Klicken zum Kopieren", "Click to copy Film.mkv"]
|
||||||
|
])("translates additional composed renderer text %s", (german, english) => {
|
||||||
|
expect(translateUiText(german, "en")).toBe(english);
|
||||||
|
expect(translateUiText(english, "de")).toBe(german);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,401 +1,417 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
type ReleaseVerification = {
|
type ReleaseVerification = {
|
||||||
publish: {
|
publish: {
|
||||||
provider: string;
|
provider: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
repo: string;
|
repo: string;
|
||||||
};
|
};
|
||||||
latestArtifact: string;
|
latestArtifact: string;
|
||||||
missingArtifacts: string[];
|
missingArtifacts: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type CommandResult = {
|
type CommandResult = {
|
||||||
status: number | null;
|
status: number | null;
|
||||||
stdout?: string;
|
stdout?: string;
|
||||||
stderr?: string;
|
stderr?: string;
|
||||||
error?: Error;
|
error?: Error;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ArchiveVerification = {
|
type ArchiveVerification = {
|
||||||
verifiedArchives: string[];
|
verifiedArchives: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const verifierPath = path.resolve("scripts", "verify_public_release.mjs");
|
const verifierPath = path.resolve("scripts", "verify_public_release.mjs");
|
||||||
const verifierUrl = "../scripts/verify_public_release.mjs";
|
const verifierUrl = "../scripts/verify_public_release.mjs";
|
||||||
const { verifyPublicRelease, verifyReleaseArchives } = await import(verifierUrl) as {
|
const { verifyPublicRelease, verifyReleaseArchives } = await import(verifierUrl) as {
|
||||||
verifyPublicRelease: (rootDir: string) => ReleaseVerification;
|
verifyPublicRelease: (rootDir: string) => ReleaseVerification;
|
||||||
verifyReleaseArchives: (
|
verifyReleaseArchives: (
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
options: {
|
options: {
|
||||||
sevenZipPath: string;
|
sevenZipPath: string;
|
||||||
runCommand: (command: string, args: string[]) => CommandResult;
|
runCommand: (command: string, args: string[]) => CommandResult;
|
||||||
}
|
}
|
||||||
) => ArchiveVerification;
|
) => ArchiveVerification;
|
||||||
};
|
};
|
||||||
const fixtureRoots: string[] = [];
|
const fixtureRoots: string[] = [];
|
||||||
const redistributionFiles = [
|
const redistributionFiles = [
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
"THIRD_PARTY_NOTICES.md",
|
"THIRD_PARTY_NOTICES.md",
|
||||||
"resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
"resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
||||||
"resources/extractor-jvm/licenses/7-Zip-license.txt",
|
"resources/extractor-jvm/licenses/7-Zip-license.txt",
|
||||||
"resources/extractor-jvm/licenses/Apache-2.0.txt",
|
"resources/extractor-jvm/licenses/Apache-2.0.txt",
|
||||||
"resources/extractor-jvm/THIRD_PARTY_NOTICES.txt"
|
"resources/extractor-jvm/THIRD_PARTY_NOTICES.txt"
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function writeFile(rootDir: string, relativePath: string, content: string | Buffer): void {
|
function writeFile(rootDir: string, relativePath: string, content: string | Buffer): void {
|
||||||
const filePath = path.join(rootDir, ...relativePath.split("/"));
|
const filePath = path.join(rootDir, ...relativePath.split("/"));
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
fs.writeFileSync(filePath, content);
|
fs.writeFileSync(filePath, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeRedistributionFiles(rootDir: string, packaged = false): void {
|
function writeRedistributionFiles(rootDir: string, packaged = false): void {
|
||||||
for (const relativePath of redistributionFiles) {
|
for (const relativePath of redistributionFiles) {
|
||||||
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
|
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
|
||||||
let targetPath: string = relativePath;
|
let targetPath: string = relativePath;
|
||||||
if (packaged && relativePath === "LICENSE") {
|
if (packaged && relativePath === "LICENSE") {
|
||||||
targetPath = "win-unpacked/resources/LICENSE";
|
targetPath = "win-unpacked/resources/LICENSE";
|
||||||
} else if (packaged && relativePath === "THIRD_PARTY_NOTICES.md") {
|
} else if (packaged && relativePath === "THIRD_PARTY_NOTICES.md") {
|
||||||
targetPath = "win-unpacked/resources/THIRD_PARTY_NOTICES.md";
|
targetPath = "win-unpacked/resources/THIRD_PARTY_NOTICES.md";
|
||||||
} else if (packaged) {
|
} else if (packaged) {
|
||||||
targetPath = `win-unpacked/resources/app.asar.unpacked/${relativePath}`;
|
targetPath = `win-unpacked/resources/app.asar.unpacked/${relativePath}`;
|
||||||
}
|
}
|
||||||
writeFile(rootDir, targetPath, content);
|
writeFile(rootDir, targetPath, content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeArchivePayload(outputDir: string, omittedName = ""): void {
|
function writeArchivePayload(outputDir: string, omittedName = ""): void {
|
||||||
for (const relativePath of redistributionFiles) {
|
for (const relativePath of redistributionFiles) {
|
||||||
if (path.basename(relativePath) === omittedName) {
|
if (path.basename(relativePath) === omittedName) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
|
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
|
||||||
const targetPath = relativePath === "LICENSE"
|
const targetPath = relativePath === "LICENSE"
|
||||||
? "resources/LICENSE"
|
? "resources/LICENSE"
|
||||||
: relativePath === "THIRD_PARTY_NOTICES.md"
|
: relativePath === "THIRD_PARTY_NOTICES.md"
|
||||||
? "resources/THIRD_PARTY_NOTICES.md"
|
? "resources/THIRD_PARTY_NOTICES.md"
|
||||||
: `resources/app.asar.unpacked/${relativePath}`;
|
: `resources/app.asar.unpacked/${relativePath}`;
|
||||||
writeFile(outputDir, targetPath, content);
|
writeFile(outputDir, targetPath, content);
|
||||||
|
}
|
||||||
|
if (omittedName !== "app_icon.ico") {
|
||||||
|
writeFile(outputDir, "resources/assets/app_icon.ico", "application-icon");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createArchiveCommandRunner(omittedName = "") {
|
function createArchiveCommandRunner(omittedName = "") {
|
||||||
return (command: string, args: string[]): CommandResult => {
|
return (command: string, args: string[]): CommandResult => {
|
||||||
const archivePath = args[1] || "";
|
const archivePath = args[1] || "";
|
||||||
const outputArg = args.find((arg) => arg.startsWith("-o"));
|
const outputArg = args.find((arg) => arg.startsWith("-o"));
|
||||||
if (!outputArg) {
|
if (!outputArg) {
|
||||||
return { status: 2, stderr: "missing output directory" };
|
return { status: 2, stderr: "missing output directory" };
|
||||||
}
|
}
|
||||||
const outputDir = outputArg.slice(2);
|
const outputDir = outputArg.slice(2);
|
||||||
if (archivePath.toLowerCase().endsWith(".exe")) {
|
if (archivePath.toLowerCase().endsWith(".exe")) {
|
||||||
writeFile(outputDir, "payload/app-64.7z", "nested archive");
|
writeFile(outputDir, "payload/app-64.7z", "nested archive");
|
||||||
} else if (archivePath.toLowerCase().endsWith(".7z")) {
|
} else if (archivePath.toLowerCase().endsWith(".7z")) {
|
||||||
writeArchivePayload(outputDir, omittedName);
|
writeArchivePayload(outputDir, omittedName);
|
||||||
}
|
}
|
||||||
return { status: command ? 0 : 2, stdout: "ok", stderr: "" };
|
return { status: command ? 0 : 2, stdout: "ok", stderr: "" };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createReleaseFixture(): string {
|
function createReleaseFixture(): string {
|
||||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-metadata-"));
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-metadata-"));
|
||||||
fixtureRoots.push(rootDir);
|
fixtureRoots.push(rootDir);
|
||||||
const setupPayload = Buffer.from("setup");
|
const setupPayload = Buffer.from("setup");
|
||||||
const setupSha512 = crypto.createHash("sha512").update(setupPayload).digest("base64");
|
const setupSha512 = crypto.createHash("sha512").update(setupPayload).digest("base64");
|
||||||
|
|
||||||
writeFile(rootDir, "package.json", `${JSON.stringify({
|
writeFile(rootDir, "package.json", `${JSON.stringify({
|
||||||
name: "real-debrid-downloader",
|
name: "real-debrid-downloader",
|
||||||
version: "1.7.233",
|
version: "1.7.233",
|
||||||
build: {
|
build: {
|
||||||
productName: "Real-Debrid-Downloader",
|
productName: "Real-Debrid-Downloader",
|
||||||
publish: {
|
publish: {
|
||||||
provider: "github",
|
provider: "github",
|
||||||
owner: "Sucukdeluxe",
|
owner: "Sucukdeluxe",
|
||||||
repo: "multi-debrid-downloader"
|
repo: "multi-debrid-downloader"
|
||||||
},
|
},
|
||||||
files: [
|
files: [
|
||||||
"build/main/**/*",
|
"build/main/**/*",
|
||||||
"build/renderer/**/*",
|
"build/renderer/**/*",
|
||||||
"resources/extractor-jvm/**/*",
|
"resources/extractor-jvm/**/*",
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
"THIRD_PARTY_NOTICES.md",
|
"THIRD_PARTY_NOTICES.md",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
extraResources: [
|
extraResources: [
|
||||||
{
|
{
|
||||||
from: "LICENSE",
|
from: "LICENSE",
|
||||||
to: "LICENSE"
|
to: "LICENSE"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: "THIRD_PARTY_NOTICES.md",
|
from: "THIRD_PARTY_NOTICES.md",
|
||||||
to: "THIRD_PARTY_NOTICES.md"
|
to: "THIRD_PARTY_NOTICES.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: "assets/app_icon.ico",
|
||||||
|
to: "assets/app_icon.ico"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
nsis: {
|
nsis: {
|
||||||
artifactName: "${productName}-Setup-${version}.${ext}",
|
artifactName: "${productName}-Setup-${version}.${ext}",
|
||||||
oneClick: false,
|
oneClick: false,
|
||||||
perMachine: false,
|
perMachine: false,
|
||||||
allowToChangeInstallationDirectory: true,
|
allowToChangeInstallationDirectory: true,
|
||||||
createDesktopShortcut: true
|
createDesktopShortcut: true
|
||||||
},
|
},
|
||||||
portable: {
|
portable: {
|
||||||
artifactName: "${productName}-${version}-portable.${ext}"
|
artifactName: "${productName}-${version}-portable.${ext}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, null, 2)}\n`);
|
}, null, 2)}\n`);
|
||||||
writeFile(
|
writeFile(
|
||||||
rootDir,
|
rootDir,
|
||||||
"latest.yml",
|
"latest.yml",
|
||||||
`version: 1.7.233\nfiles:\n - url: Real-Debrid-Downloader-Setup-1.7.233.exe\n sha512: ${setupSha512}\n size: ${setupPayload.length}\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\nsha512: ${setupSha512}\n`
|
`version: 1.7.233\nfiles:\n - url: Real-Debrid-Downloader-Setup-1.7.233.exe\n sha512: ${setupSha512}\n size: ${setupPayload.length}\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\nsha512: ${setupSha512}\n`
|
||||||
);
|
);
|
||||||
writeFile(
|
writeFile(
|
||||||
rootDir,
|
rootDir,
|
||||||
"win-unpacked/resources/app-update.yml",
|
"win-unpacked/resources/app-update.yml",
|
||||||
"provider: github\nowner: Sucukdeluxe\nrepo: multi-debrid-downloader\n"
|
"provider: github\nowner: Sucukdeluxe\nrepo: multi-debrid-downloader\n"
|
||||||
);
|
);
|
||||||
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe", setupPayload);
|
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe", setupPayload);
|
||||||
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe.blockmap", "blockmap");
|
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe.blockmap", "blockmap");
|
||||||
writeFile(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe", "portable");
|
writeFile(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe", "portable");
|
||||||
writeRedistributionFiles(rootDir);
|
writeRedistributionFiles(rootDir);
|
||||||
writeRedistributionFiles(rootDir, true);
|
writeRedistributionFiles(rootDir, true);
|
||||||
|
writeFile(rootDir, "assets/app_icon.ico", "application-icon");
|
||||||
return rootDir;
|
writeFile(rootDir, "win-unpacked/resources/assets/app_icon.ico", "application-icon");
|
||||||
}
|
|
||||||
|
return rootDir;
|
||||||
afterEach(() => {
|
}
|
||||||
for (const rootDir of fixtureRoots.splice(0)) {
|
|
||||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
afterEach(() => {
|
||||||
}
|
for (const rootDir of fixtureRoots.splice(0)) {
|
||||||
});
|
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
describe("public release metadata", () => {
|
});
|
||||||
it("accepts the canonical GitHub release metadata and artifacts", () => {
|
|
||||||
const rootDir = createReleaseFixture();
|
describe("public release metadata", () => {
|
||||||
|
it("accepts the canonical GitHub release metadata and artifacts", () => {
|
||||||
const result = verifyPublicRelease(rootDir);
|
const rootDir = createReleaseFixture();
|
||||||
|
|
||||||
expect(result.publish).toEqual({
|
const result = verifyPublicRelease(rootDir);
|
||||||
provider: "github",
|
|
||||||
owner: "Sucukdeluxe",
|
expect(result.publish).toEqual({
|
||||||
repo: "multi-debrid-downloader"
|
provider: "github",
|
||||||
});
|
owner: "Sucukdeluxe",
|
||||||
expect(result.latestArtifact).toBe("Real-Debrid-Downloader-Setup-1.7.233.exe");
|
repo: "multi-debrid-downloader"
|
||||||
expect(result.missingArtifacts).toEqual([]);
|
});
|
||||||
});
|
expect(result.latestArtifact).toBe("Real-Debrid-Downloader-Setup-1.7.233.exe");
|
||||||
|
expect(result.missingArtifacts).toEqual([]);
|
||||||
it("rejects a package configured for a different GitHub owner", () => {
|
});
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
const packagePath = path.join(rootDir, "package.json");
|
it("rejects a package configured for a different GitHub owner", () => {
|
||||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
const rootDir = createReleaseFixture();
|
||||||
packageJson.build.publish.owner = "DifferentOwner";
|
const packagePath = path.join(rootDir, "package.json");
|
||||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||||
|
packageJson.build.publish.owner = "DifferentOwner";
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/owner/i);
|
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
});
|
|
||||||
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/owner/i);
|
||||||
it("rejects a latest.yml path whose artifact does not exist", () => {
|
});
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
fs.rmSync(path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe"));
|
it("rejects a latest.yml path whose artifact does not exist", () => {
|
||||||
|
const rootDir = createReleaseFixture();
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/Real-Debrid-Downloader-Setup-1\.7\.233\.exe/);
|
fs.rmSync(path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe"));
|
||||||
});
|
|
||||||
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/Real-Debrid-Downloader-Setup-1\.7\.233\.exe/);
|
||||||
it("rejects syntactically invalid latest.yml", () => {
|
});
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
fs.writeFileSync(
|
it("rejects syntactically invalid latest.yml", () => {
|
||||||
path.join(rootDir, "latest.yml"),
|
const rootDir = createReleaseFixture();
|
||||||
"version: 1.7.233\nfiles: [\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\n"
|
fs.writeFileSync(
|
||||||
);
|
path.join(rootDir, "latest.yml"),
|
||||||
|
"version: 1.7.233\nfiles: [\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\n"
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/latest\.yml|yaml/i);
|
);
|
||||||
});
|
|
||||||
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/latest\.yml|yaml/i);
|
||||||
it("rejects a noncanonical files entry in latest.yml", () => {
|
});
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
const latestPath = path.join(rootDir, "latest.yml");
|
it("rejects a noncanonical files entry in latest.yml", () => {
|
||||||
const latest = fs.readFileSync(latestPath, "utf8").replace(
|
const rootDir = createReleaseFixture();
|
||||||
"url: Real-Debrid-Downloader-Setup-1.7.233.exe",
|
const latestPath = path.join(rootDir, "latest.yml");
|
||||||
"url: Different-Setup-1.7.233.exe"
|
const latest = fs.readFileSync(latestPath, "utf8").replace(
|
||||||
);
|
"url: Real-Debrid-Downloader-Setup-1.7.233.exe",
|
||||||
fs.writeFileSync(latestPath, latest);
|
"url: Different-Setup-1.7.233.exe"
|
||||||
|
);
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/files|url|canonical/i);
|
fs.writeFileSync(latestPath, latest);
|
||||||
});
|
|
||||||
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/files|url|canonical/i);
|
||||||
it("rejects a latest.yml SHA512 digest that does not match the installer", () => {
|
});
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
const latestPath = path.join(rootDir, "latest.yml");
|
it("rejects a latest.yml SHA512 digest that does not match the installer", () => {
|
||||||
const latest = fs.readFileSync(latestPath, "utf8");
|
const rootDir = createReleaseFixture();
|
||||||
const wrongDigest = Buffer.alloc(64, 0x23).toString("base64");
|
const latestPath = path.join(rootDir, "latest.yml");
|
||||||
fs.writeFileSync(latestPath, latest.replace(/sha512: [^\n]+/g, `sha512: ${wrongDigest}`));
|
const latest = fs.readFileSync(latestPath, "utf8");
|
||||||
|
const wrongDigest = Buffer.alloc(64, 0x23).toString("base64");
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/sha512|digest|integrity/i);
|
fs.writeFileSync(latestPath, latest.replace(/sha512: [^\n]+/g, `sha512: ${wrongDigest}`));
|
||||||
});
|
|
||||||
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/sha512|digest|integrity/i);
|
||||||
it("rejects a directory in place of an artifact file", () => {
|
});
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
|
it("rejects a directory in place of an artifact file", () => {
|
||||||
fs.rmSync(setupPath);
|
const rootDir = createReleaseFixture();
|
||||||
fs.mkdirSync(setupPath);
|
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
|
||||||
|
fs.rmSync(setupPath);
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|file/i);
|
fs.mkdirSync(setupPath);
|
||||||
});
|
|
||||||
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|file/i);
|
||||||
it("rejects an empty artifact file", () => {
|
});
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
fs.writeFileSync(path.join(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe"), "");
|
it("rejects an empty artifact file", () => {
|
||||||
|
const rootDir = createReleaseFixture();
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|empty|file/i);
|
fs.writeFileSync(path.join(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe"), "");
|
||||||
});
|
|
||||||
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|empty|file/i);
|
||||||
it("rejects a release missing a declared redistribution license", () => {
|
});
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
fs.rmSync(path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt"));
|
it("rejects a release missing a declared redistribution license", () => {
|
||||||
|
const rootDir = createReleaseFixture();
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/Apache-2\.0\.txt/);
|
fs.rmSync(path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt"));
|
||||||
});
|
|
||||||
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/Apache-2\.0\.txt/);
|
||||||
it("rejects a modified official license text", () => {
|
});
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
fs.appendFileSync(
|
it("rejects a modified official license text", () => {
|
||||||
path.join(rootDir, "resources", "extractor-jvm", "licenses", "LGPL-2.1.txt"),
|
const rootDir = createReleaseFixture();
|
||||||
"modified"
|
fs.appendFileSync(
|
||||||
);
|
path.join(rootDir, "resources", "extractor-jvm", "licenses", "LGPL-2.1.txt"),
|
||||||
|
"modified"
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/LGPL-2\.1\.txt|digest|content/i);
|
);
|
||||||
});
|
|
||||||
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/LGPL-2\.1\.txt|digest|content/i);
|
||||||
it("rejects swapped third-party license assignments", () => {
|
});
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
const noticePath = path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt");
|
it("rejects swapped third-party license assignments", () => {
|
||||||
const notice = fs.readFileSync(noticePath, "utf8")
|
const rootDir = createReleaseFixture();
|
||||||
.replace("GNU Lesser General Public License 2.1 or later", "Apache License 2.0")
|
const noticePath = path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt");
|
||||||
.replace("licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt", "licenses/Apache-2.0.txt");
|
const notice = fs.readFileSync(noticePath, "utf8")
|
||||||
fs.writeFileSync(noticePath, notice);
|
.replace("GNU Lesser General Public License 2.1 or later", "Apache License 2.0")
|
||||||
|
.replace("licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt", "licenses/Apache-2.0.txt");
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|digest|mapping/i);
|
fs.writeFileSync(noticePath, notice);
|
||||||
});
|
|
||||||
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|digest|mapping/i);
|
||||||
it("rejects a release whose unpacked application omits a license", () => {
|
});
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
fs.rmSync(path.join(
|
it("rejects a release whose unpacked application omits a license", () => {
|
||||||
rootDir,
|
const rootDir = createReleaseFixture();
|
||||||
"win-unpacked",
|
fs.rmSync(path.join(
|
||||||
"resources",
|
rootDir,
|
||||||
"app.asar.unpacked",
|
"win-unpacked",
|
||||||
"resources",
|
"resources",
|
||||||
"extractor-jvm",
|
"app.asar.unpacked",
|
||||||
"licenses",
|
"resources",
|
||||||
"Apache-2.0.txt"
|
"extractor-jvm",
|
||||||
));
|
"licenses",
|
||||||
|
"Apache-2.0.txt"
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/win-unpacked|Apache-2\.0\.txt|packaged/i);
|
));
|
||||||
});
|
|
||||||
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/win-unpacked|Apache-2\.0\.txt|packaged/i);
|
||||||
it("rejects a symlink in place of a release artifact", () => {
|
});
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
|
it("rejects a symlink in place of a release artifact", () => {
|
||||||
const targetPath = path.join(rootDir, "setup-target.exe");
|
const rootDir = createReleaseFixture();
|
||||||
fs.renameSync(setupPath, targetPath);
|
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
|
||||||
fs.symlinkSync(targetPath, setupPath, "file");
|
const targetPath = path.join(rootDir, "setup-target.exe");
|
||||||
|
fs.renameSync(setupPath, targetPath);
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
|
fs.symlinkSync(targetPath, setupPath, "file");
|
||||||
});
|
|
||||||
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
|
||||||
it("rejects a symlink in place of an official license", () => {
|
});
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
const licensePath = path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt");
|
it("rejects a symlink in place of an official license", () => {
|
||||||
const targetPath = path.join(rootDir, "Apache-target.txt");
|
const rootDir = createReleaseFixture();
|
||||||
fs.renameSync(licensePath, targetPath);
|
const licensePath = path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt");
|
||||||
fs.symlinkSync(targetPath, licensePath, "file");
|
const targetPath = path.join(rootDir, "Apache-target.txt");
|
||||||
|
fs.renameSync(licensePath, targetPath);
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
|
fs.symlinkSync(targetPath, licensePath, "file");
|
||||||
});
|
|
||||||
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
|
||||||
it("rejects build metadata that omits the project license", () => {
|
});
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
const packagePath = path.join(rootDir, "package.json");
|
it("rejects build metadata that omits the project license", () => {
|
||||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
const rootDir = createReleaseFixture();
|
||||||
packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE");
|
const packagePath = path.join(rootDir, "package.json");
|
||||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||||
|
packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE");
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/);
|
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
});
|
|
||||||
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects build metadata that does not copy the project license into resources", () => {
|
it("rejects build metadata that does not copy the project license into resources", () => {
|
||||||
const rootDir = createReleaseFixture();
|
const rootDir = createReleaseFixture();
|
||||||
const packagePath = path.join(rootDir, "package.json");
|
const packagePath = path.join(rootDir, "package.json");
|
||||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||||
delete packageJson.build.extraResources;
|
delete packageJson.build.extraResources;
|
||||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/extraResources|LICENSE/);
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/extraResources|LICENSE/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects incomplete third-party redistribution notices", () => {
|
it("rejects a packaged application without its window and tray icon", () => {
|
||||||
const rootDir = createReleaseFixture();
|
const rootDir = createReleaseFixture();
|
||||||
fs.writeFileSync(
|
fs.rmSync(path.join(rootDir, "win-unpacked", "resources", "assets", "app_icon.ico"));
|
||||||
path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt"),
|
|
||||||
"net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01 LGPL-2.1.txt\n"
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|content/i);
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/app_icon|icon/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a nonzero CLI status for invalid release metadata", () => {
|
it("rejects incomplete third-party redistribution notices", () => {
|
||||||
const rootDir = createReleaseFixture();
|
const rootDir = createReleaseFixture();
|
||||||
const packagePath = path.join(rootDir, "package.json");
|
fs.writeFileSync(
|
||||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt"),
|
||||||
packageJson.build.publish.owner = "DifferentOwner";
|
"net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01 LGPL-2.1.txt\n"
|
||||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
);
|
||||||
|
|
||||||
const result = spawnSync(process.execPath, [verifierPath, rootDir], {
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|content/i);
|
||||||
encoding: "utf8"
|
});
|
||||||
});
|
|
||||||
|
it("returns a nonzero CLI status for invalid release metadata", () => {
|
||||||
expect(result.status).not.toBe(0);
|
const rootDir = createReleaseFixture();
|
||||||
expect(result.stderr).toMatch(/owner/i);
|
const packagePath = path.join(rootDir, "package.json");
|
||||||
});
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||||
|
packageJson.build.publish.owner = "DifferentOwner";
|
||||||
it("recursively verifies redistribution files inside setup and portable archives", () => {
|
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
const rootDir = createReleaseFixture();
|
|
||||||
const result = verifyReleaseArchives(rootDir, {
|
const result = spawnSync(process.execPath, [verifierPath, rootDir], {
|
||||||
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
encoding: "utf8"
|
||||||
runCommand: createArchiveCommandRunner()
|
});
|
||||||
});
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
expect(result.verifiedArchives).toEqual([
|
expect(result.stderr).toMatch(/owner/i);
|
||||||
"Real-Debrid-Downloader-Setup-1.7.233.exe",
|
});
|
||||||
"Real-Debrid-Downloader-1.7.233-portable.exe"
|
|
||||||
]);
|
it("recursively verifies redistribution files inside setup and portable archives", () => {
|
||||||
});
|
const rootDir = createReleaseFixture();
|
||||||
|
const result = verifyReleaseArchives(rootDir, {
|
||||||
it("rejects an archive whose nested application payload omits a license", () => {
|
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
||||||
const rootDir = createReleaseFixture();
|
runCommand: createArchiveCommandRunner()
|
||||||
|
});
|
||||||
expect(() => verifyReleaseArchives(rootDir, {
|
|
||||||
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
expect(result.verifiedArchives).toEqual([
|
||||||
runCommand: createArchiveCommandRunner("Apache-2.0.txt")
|
"Real-Debrid-Downloader-Setup-1.7.233.exe",
|
||||||
})).toThrow(/Apache-2\.0\.txt|missing redistribution file/i);
|
"Real-Debrid-Downloader-1.7.233-portable.exe"
|
||||||
});
|
]);
|
||||||
|
});
|
||||||
it("exposes archive verification as a nonzero CLI gate", () => {
|
|
||||||
const rootDir = createReleaseFixture();
|
it("rejects an archive whose nested application payload omits a license", () => {
|
||||||
const result = spawnSync(process.execPath, [
|
const rootDir = createReleaseFixture();
|
||||||
verifierPath,
|
|
||||||
rootDir,
|
expect(() => verifyReleaseArchives(rootDir, {
|
||||||
"--verify-archives",
|
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
||||||
"--seven-zip",
|
runCommand: createArchiveCommandRunner("Apache-2.0.txt")
|
||||||
path.join(rootDir, "missing-7z.exe")
|
})).toThrow(/Apache-2\.0\.txt|missing redistribution file/i);
|
||||||
], {
|
});
|
||||||
encoding: "utf8"
|
|
||||||
});
|
it("exposes archive verification as a nonzero CLI gate", () => {
|
||||||
|
const rootDir = createReleaseFixture();
|
||||||
expect(result.status).not.toBe(0);
|
const result = spawnSync(process.execPath, [
|
||||||
expect(result.stderr).toMatch(/7-Zip|command|spawn/i);
|
verifierPath,
|
||||||
});
|
rootDir,
|
||||||
});
|
"--verify-archives",
|
||||||
|
"--seven-zip",
|
||||||
|
path.join(rootDir, "missing-7z.exe")
|
||||||
|
], {
|
||||||
|
encoding: "utf8"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toMatch(/7-Zip|command|spawn/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+23
-3
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { pruneSelection } from "../src/renderer/selection";
|
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "../src/renderer/selection";
|
||||||
import type { SessionState } from "../src/shared/types";
|
import type { SessionState } from "../src/shared/types";
|
||||||
|
|
||||||
function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "packages" | "items"> {
|
function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "packages" | "items"> {
|
||||||
@@ -10,7 +10,7 @@ function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "p
|
|||||||
return { packages, items };
|
return { packages, items };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("pruneSelection", () => {
|
describe("pruneSelection", () => {
|
||||||
it("drops ids whose package/item no longer exists", () => {
|
it("drops ids whose package/item no longer exists", () => {
|
||||||
const sel = new Set(["p1", "i1", "ghost-p", "ghost-i"]);
|
const sel = new Set(["p1", "i1", "ghost-p", "ghost-i"]);
|
||||||
const next = pruneSelection(sel, session(["p1"], ["i1"]));
|
const next = pruneSelection(sel, session(["p1"], ["i1"]));
|
||||||
@@ -41,4 +41,24 @@ describe("pruneSelection", () => {
|
|||||||
expect([...next].sort()).toEqual(["i1", "p1", "p2"]);
|
expect([...next].sort()).toEqual(["i1", "p1", "p2"]);
|
||||||
expect(next).toBe(sel); // unchanged → same instance
|
expect(next).toBe(sel); // unchanged → same instance
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("download selection clearing", () => {
|
||||||
|
const target = (...classes: string[]): Pick<Element, "closest"> => ({
|
||||||
|
closest: (selector: string) => selector.split(",").some((entry: string) => classes.includes(entry.trim().slice(1))) ? {} as Element : null
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves selection while header, package and file selection controls handle their own click", () => {
|
||||||
|
expect(shouldClearDownloadSelection(target("downloads-selection-cell"))).toBe(false);
|
||||||
|
expect(shouldClearDownloadSelection(target("downloads-package-card"))).toBe(false);
|
||||||
|
expect(shouldClearDownloadSelection(target("downloads-item-row"))).toBe(false);
|
||||||
|
expect(shouldClearDownloadSelection(target("unrelated-surface"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears checkbox-focused selection on Escape but preserves text editing", () => {
|
||||||
|
expect(shouldClearDownloadSelectionOnEscape("INPUT", "checkbox")).toBe(true);
|
||||||
|
expect(shouldClearDownloadSelectionOnEscape("DIV")).toBe(true);
|
||||||
|
expect(shouldClearDownloadSelectionOnEscape("INPUT", "text")).toBe(false);
|
||||||
|
expect(shouldClearDownloadSelectionOnEscape("TEXTAREA")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
|||||||
import {
|
import {
|
||||||
ACCOUNT_COLUMNS,
|
ACCOUNT_COLUMNS,
|
||||||
SETTINGS_SECTIONS,
|
SETTINGS_SECTIONS,
|
||||||
|
buildSettingsFormViewModel,
|
||||||
buildAccountRowId,
|
buildAccountRowId,
|
||||||
buildTargetedAccountCheck,
|
buildTargetedAccountCheck,
|
||||||
filterAccountAddOptions,
|
filterAccountAddOptions,
|
||||||
@@ -411,6 +412,35 @@ describe("settings model", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("settings views", () => {
|
describe("settings views", () => {
|
||||||
|
it("marks settings sections for one measured vertical selection indicator", () => {
|
||||||
|
const html = renderToStaticMarkup(<SettingsSidebar actions={viewActions()} model={viewModel()} />);
|
||||||
|
|
||||||
|
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||||||
|
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(SETTINGS_SECTIONS.length);
|
||||||
|
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers English and German as a live language setting", () => {
|
||||||
|
const form = buildSettingsFormViewModel({
|
||||||
|
settings: defaultSettings(),
|
||||||
|
section: "allgemein",
|
||||||
|
speedLimitInput: "0",
|
||||||
|
scheduleSpeedInputs: {}
|
||||||
|
});
|
||||||
|
const language = form.groups.flatMap((group) => group.fields).find((field) => field.id === "language");
|
||||||
|
|
||||||
|
expect(language).toEqual({
|
||||||
|
id: "language",
|
||||||
|
kind: "select",
|
||||||
|
label: "Sprache",
|
||||||
|
value: "en",
|
||||||
|
options: [
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "de", label: "Deutsch" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("renders one real sidebar marker and all sections", () => {
|
it("renders one real sidebar marker and all sections", () => {
|
||||||
const html = renderToStaticMarkup(<SettingsSidebar actions={viewActions()} model={viewModel()} />);
|
const html = renderToStaticMarkup(<SettingsSidebar actions={viewActions()} model={viewModel()} />);
|
||||||
expect(count(html, "data-visual-region=\"settings-sidebar\"")).toBe(1);
|
expect(count(html, "data-visual-region=\"settings-sidebar\"")).toBe(1);
|
||||||
@@ -459,6 +489,14 @@ describe("settings views", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("account workspace", () => {
|
describe("account workspace", () => {
|
||||||
|
it("marks account panels for one measured horizontal selection indicator", () => {
|
||||||
|
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
|
||||||
|
|
||||||
|
expect(html).toContain("ui-sliding-selection ui-sliding-selection-horizontal");
|
||||||
|
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(2);
|
||||||
|
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the exact columns, one table marker, full usernames and no raw credentials", () => {
|
it("renders the exact columns, one table marker, full usernames and no raw credentials", () => {
|
||||||
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
|
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
|
||||||
const positions = ACCOUNT_COLUMNS.map((column) => html.indexOf(column));
|
const positions = ACCOUNT_COLUMNS.map((column) => html.indexOf(column));
|
||||||
@@ -621,6 +659,7 @@ describe("settings geometry", () => {
|
|||||||
expect(css).toMatch(/\.settings-switch\s*{[^}]*width:\s*40px;[^}]*height:\s*20px;/s);
|
expect(css).toMatch(/\.settings-switch\s*{[^}]*width:\s*40px;[^}]*height:\s*20px;/s);
|
||||||
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*height:\s*41px;/s);
|
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*height:\s*41px;/s);
|
||||||
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*overflow:\s*hidden;/s);
|
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*overflow:\s*hidden;/s);
|
||||||
|
expect(css).toMatch(/\.settings-account-table-grid\s*{[^}]*color:\s*var\(--ui-text\);/s);
|
||||||
expect(css).toMatch(/\.settings-account-row\s*{[^}]*height:\s*48px;/s);
|
expect(css).toMatch(/\.settings-account-row\s*{[^}]*height:\s*48px;/s);
|
||||||
expect(css).toMatch(/\.settings-account-table-body\s*{[^}]*overflow:\s*auto;/s);
|
expect(css).toMatch(/\.settings-account-table-body\s*{[^}]*overflow:\s*auto;/s);
|
||||||
expect(accountWorkspaceSource).toContain("onScroll={syncAccountTableScroll}");
|
expect(accountWorkspaceSource).toContain("onScroll={syncAccountTableScroll}");
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("sliding selection scheduling", () => {
|
||||||
|
it("applies the first mounted selection before scheduling later movements", async () => {
|
||||||
|
const selectionModule = await import("../src/renderer/ui/SlidingSelection");
|
||||||
|
const schedule = Reflect.get(selectionModule, "scheduleSelectionLayout");
|
||||||
|
|
||||||
|
expect(schedule).toBeTypeOf("function");
|
||||||
|
|
||||||
|
const events: string[] = [];
|
||||||
|
const pending: { callback?: FrameRequestCallback } = {};
|
||||||
|
const requestFrame = (callback: FrameRequestCallback): number => {
|
||||||
|
events.push("request");
|
||||||
|
pending.callback = callback;
|
||||||
|
return 9;
|
||||||
|
};
|
||||||
|
const cancelFrame = (frame: number): void => {
|
||||||
|
events.push(`cancel:${frame}`);
|
||||||
|
};
|
||||||
|
const apply = (): void => {
|
||||||
|
events.push("apply");
|
||||||
|
};
|
||||||
|
const enableTransitions = (): void => {
|
||||||
|
events.push("enable");
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(schedule(false, 0, apply, requestFrame, cancelFrame, enableTransitions)).toBe(9);
|
||||||
|
expect(events).toEqual(["apply", "request"]);
|
||||||
|
|
||||||
|
pending.callback?.(0);
|
||||||
|
expect(events).toEqual(["apply", "request", "enable"]);
|
||||||
|
|
||||||
|
events.length = 0;
|
||||||
|
expect(schedule(true, 4, apply, requestFrame, cancelFrame)).toBe(9);
|
||||||
|
expect(events).toEqual(["cancel:4", "request"]);
|
||||||
|
|
||||||
|
pending.callback?.(0);
|
||||||
|
expect(events).toEqual(["cancel:4", "request", "apply"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+406
-397
@@ -1,399 +1,408 @@
|
|||||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
|
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
|
||||||
import { readBandwidthChartPalette } from "../src/renderer/App";
|
import { readBandwidthChartPalette } from "../src/renderer/App";
|
||||||
import {
|
import {
|
||||||
buildStatisticsViewModel,
|
buildStatisticsViewModel,
|
||||||
type StatisticsMetric,
|
type StatisticsMetric,
|
||||||
type StatisticsRange
|
type StatisticsRange
|
||||||
} from "../src/renderer/views/statistics/statistics-model";
|
} from "../src/renderer/views/statistics/statistics-model";
|
||||||
import {
|
import {
|
||||||
StatisticsContent,
|
StatisticsContent,
|
||||||
StatisticsSidebar,
|
StatisticsSidebar,
|
||||||
StatisticsView,
|
StatisticsView,
|
||||||
type StatisticsViewActions
|
type StatisticsViewActions
|
||||||
} from "../src/renderer/views/statistics/StatisticsView";
|
} from "../src/renderer/views/statistics/StatisticsView";
|
||||||
import { createVisualFixture } from "./visual/fixtures";
|
import { createVisualFixture } from "./visual/fixtures";
|
||||||
|
|
||||||
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
||||||
|
|
||||||
function createSnapshot(): UiSnapshot {
|
function createSnapshot(): UiSnapshot {
|
||||||
return structuredClone(createVisualFixture("empty").snapshot);
|
return structuredClone(createVisualFixture("empty").snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
function item(
|
function item(
|
||||||
id: string,
|
id: string,
|
||||||
status: DownloadStatus,
|
status: DownloadStatus,
|
||||||
overrides: Partial<DownloadItem> = {}
|
overrides: Partial<DownloadItem> = {}
|
||||||
): DownloadItem {
|
): DownloadItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
packageId: "statistics-package",
|
packageId: "statistics-package",
|
||||||
url: `https://url-host-${id}.example/file`,
|
url: `https://url-host-${id}.example/file`,
|
||||||
provider: "realdebrid",
|
provider: "realdebrid",
|
||||||
providerLabel: "Real-Debrid",
|
providerLabel: "Real-Debrid",
|
||||||
status,
|
status,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
speedBps: 0,
|
speedBps: 0,
|
||||||
downloadedBytes: 100,
|
downloadedBytes: 100,
|
||||||
totalBytes: 100,
|
totalBytes: 100,
|
||||||
progressPercent: status === "completed" ? 100 : 50,
|
progressPercent: status === "completed" ? 100 : 50,
|
||||||
fileName: `${id}.bin`,
|
fileName: `${id}.bin`,
|
||||||
targetPath: `C:\\Downloads\\${id}.bin`,
|
targetPath: `C:\\Downloads\\${id}.bin`,
|
||||||
resumable: true,
|
resumable: true,
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
lastError: status === "failed" ? "Fehlgeschlagen" : "",
|
lastError: status === "failed" ? "Fehlgeschlagen" : "",
|
||||||
fullStatus: status,
|
fullStatus: status,
|
||||||
createdAt: now - 1000,
|
createdAt: now - 1000,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setItems(snapshot: UiSnapshot, items: DownloadItem[]): void {
|
function setItems(snapshot: UiSnapshot, items: DownloadItem[]): void {
|
||||||
snapshot.session.items = Object.fromEntries(items.map((entry) => [entry.id, entry]));
|
snapshot.session.items = Object.fromEntries(items.map((entry) => [entry.id, entry]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
|
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
|
||||||
if (Array.isArray(node)) {
|
if (Array.isArray(node)) {
|
||||||
node.forEach((child) => visitElements(child, visit));
|
node.forEach((child) => visitElements(child, visit));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isValidElement(node)) {
|
if (!isValidElement(node)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
visit(node);
|
visit(node);
|
||||||
visitElements(node.props.children, visit);
|
visitElements(node.props.children, visit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findButton(node: ReactNode, label: string): ReactElement {
|
function findButton(node: ReactNode, label: string): ReactElement {
|
||||||
let result: ReactElement | null = null;
|
let result: ReactElement | null = null;
|
||||||
visitElements(node, (element) => {
|
visitElements(node, (element) => {
|
||||||
if (!result && element.type === "button" && element.props.children === label) {
|
if (!result && element.type === "button" && element.props.children === label) {
|
||||||
result = element;
|
result = element;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error(`Button not found: ${label}`);
|
throw new Error(`Button not found: ${label}`);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createActions(overrides: Partial<StatisticsViewActions> = {}): StatisticsViewActions {
|
function createActions(overrides: Partial<StatisticsViewActions> = {}): StatisticsViewActions {
|
||||||
return {
|
return {
|
||||||
onRangeChange: () => {},
|
onRangeChange: () => {},
|
||||||
onResetSession: () => {},
|
onResetSession: () => {},
|
||||||
onResetAll: () => {},
|
onResetAll: () => {},
|
||||||
onResetErrors: () => {},
|
onResetErrors: () => {},
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectUnavailable(metric: StatisticsMetric): void {
|
function expectUnavailable(metric: StatisticsMetric): void {
|
||||||
expect(metric).toMatchObject({ value: null, available: false });
|
expect(metric).toMatchObject({ value: null, available: false });
|
||||||
expect(metric.sourceLabel.trim()).not.toBe("");
|
expect(metric.sourceLabel.trim()).not.toBe("");
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("statistics model", () => {
|
describe("statistics model", () => {
|
||||||
it("uses real snapshot session fields and excludes active or waiting downloads from the success denominator", () => {
|
it("uses real snapshot session fields and excludes active or waiting downloads from the success denominator", () => {
|
||||||
const snapshot = createSnapshot();
|
const snapshot = createSnapshot();
|
||||||
snapshot.stats.totalDownloaded = 600;
|
snapshot.stats.totalDownloaded = 600;
|
||||||
snapshot.stats.totalFilesSession = 3;
|
snapshot.stats.totalFilesSession = 3;
|
||||||
snapshot.session.running = true;
|
snapshot.session.running = true;
|
||||||
setItems(snapshot, [
|
setItems(snapshot, [
|
||||||
item("complete-a", "completed"),
|
item("complete-a", "completed"),
|
||||||
item("complete-b", "completed"),
|
item("complete-b", "completed"),
|
||||||
item("complete-c", "completed"),
|
item("complete-c", "completed"),
|
||||||
item("failed", "failed"),
|
item("failed", "failed"),
|
||||||
item("active", "downloading"),
|
item("active", "downloading"),
|
||||||
item("waiting", "queued")
|
item("waiting", "queued")
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const model = buildStatisticsViewModel(snapshot, "session", now);
|
const model = buildStatisticsViewModel(snapshot, "session", now);
|
||||||
|
|
||||||
expect(model.metrics.downloadedBytes.value).toBe(600);
|
expect(model.metrics.downloadedBytes.value).toBe(600);
|
||||||
expect(model.metrics.files.value).toBe(3);
|
expect(model.metrics.files.value).toBe(3);
|
||||||
expect(model.metrics.successRate.value).toBe(75);
|
expect(model.metrics.successRate.value).toBe(75);
|
||||||
expect(model.metrics.errors.value).toBe(1);
|
expect(model.metrics.errors.value).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports no success rate when the current queue has no completed or failed result", () => {
|
it("reports no success rate when the current queue has no completed or failed result", () => {
|
||||||
const snapshot = createSnapshot();
|
const snapshot = createSnapshot();
|
||||||
snapshot.session.running = true;
|
snapshot.session.running = true;
|
||||||
setItems(snapshot, [item("active", "downloading"), item("waiting", "queued")]);
|
setItems(snapshot, [item("active", "downloading"), item("waiting", "queued")]);
|
||||||
|
|
||||||
const model = buildStatisticsViewModel(snapshot, "session", now);
|
const model = buildStatisticsViewModel(snapshot, "session", now);
|
||||||
|
|
||||||
expectUnavailable(model.metrics.successRate);
|
expectUnavailable(model.metrics.successRate);
|
||||||
expect(model.metrics.errors).toMatchObject({ value: 0, available: true });
|
expect(model.metrics.errors).toMatchObject({ value: 0, available: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sorts current-queue providers by bytes and then id without deriving them from URL hostnames", () => {
|
it("sorts current-queue providers by bytes and then id without deriving them from URL hostnames", () => {
|
||||||
const snapshot = createSnapshot();
|
const snapshot = createSnapshot();
|
||||||
snapshot.session.running = true;
|
snapshot.session.running = true;
|
||||||
setItems(snapshot, [
|
setItems(snapshot, [
|
||||||
item("real", "completed", {
|
item("real", "completed", {
|
||||||
url: "https://alldebrid.invalid/wrong-source",
|
url: "https://alldebrid.invalid/wrong-source",
|
||||||
provider: "realdebrid",
|
provider: "realdebrid",
|
||||||
providerLabel: "Real-Debrid Konto",
|
providerLabel: "Real-Debrid Konto",
|
||||||
downloadedBytes: 200
|
downloadedBytes: 200
|
||||||
}),
|
}),
|
||||||
item("all", "failed", {
|
item("all", "failed", {
|
||||||
url: "https://realdebrid.invalid/wrong-source",
|
url: "https://realdebrid.invalid/wrong-source",
|
||||||
provider: "alldebrid",
|
provider: "alldebrid",
|
||||||
providerLabel: "AllDebrid",
|
providerLabel: "AllDebrid",
|
||||||
downloadedBytes: 200
|
downloadedBytes: 200
|
||||||
}),
|
}),
|
||||||
item("link", "completed", {
|
item("link", "completed", {
|
||||||
url: "https://realdebrid.invalid/also-wrong",
|
url: "https://realdebrid.invalid/also-wrong",
|
||||||
provider: "debridlink",
|
provider: "debridlink",
|
||||||
providerLabel: "Debrid-Link",
|
providerLabel: "Debrid-Link",
|
||||||
downloadedBytes: 400
|
downloadedBytes: 400
|
||||||
}),
|
}),
|
||||||
item("unknown", "completed", {
|
item("unknown", "completed", {
|
||||||
url: "https://hoster-only.invalid/not-a-provider",
|
url: "https://hoster-only.invalid/not-a-provider",
|
||||||
provider: null,
|
provider: null,
|
||||||
providerLabel: undefined,
|
providerLabel: undefined,
|
||||||
downloadedBytes: 900
|
downloadedBytes: 900
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const model = buildStatisticsViewModel(snapshot, "session", now);
|
const model = buildStatisticsViewModel(snapshot, "session", now);
|
||||||
|
|
||||||
expect(model.providers.map((row) => row.id)).toEqual(["debridlink", "alldebrid", "realdebrid"]);
|
expect(model.providers.map((row) => row.id)).toEqual(["debridlink", "alldebrid", "realdebrid"]);
|
||||||
expect(model.providers.map((row) => row.label)).toEqual(["Debrid-Link", "AllDebrid", "Real-Debrid Konto"]);
|
expect(model.providers.map((row) => row.label)).toEqual(["Debrid-Link", "AllDebrid", "Real-Debrid Konto"]);
|
||||||
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[1, 0], [0, 1], [1, 0]]);
|
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[1, 0], [0, 1], [1, 0]]);
|
||||||
expect(model.providers.some((row) => row.id.includes("host"))).toBe(false);
|
expect(model.providers.some((row) => row.id.includes("host"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses daily provider usage only for the matching local day", () => {
|
it("uses daily provider usage only for the matching local day", () => {
|
||||||
const snapshot = createSnapshot();
|
const snapshot = createSnapshot();
|
||||||
snapshot.stats.totalDownloaded = 999_999;
|
snapshot.stats.totalDownloaded = 999_999;
|
||||||
snapshot.settings.providerDailyUsageDay = "2026-08-10";
|
snapshot.settings.providerDailyUsageDay = "2026-08-10";
|
||||||
snapshot.settings.providerDailyUsageBytes = { realdebrid: 500, alldebrid: 1_500 };
|
snapshot.settings.providerDailyUsageBytes = { realdebrid: 500, alldebrid: 1_500 };
|
||||||
|
|
||||||
const model = buildStatisticsViewModel(snapshot, "today", now);
|
const model = buildStatisticsViewModel(snapshot, "today", now);
|
||||||
|
|
||||||
expect(model.metrics.downloadedBytes).toMatchObject({ value: 2_000, available: true });
|
expect(model.metrics.downloadedBytes).toMatchObject({ value: 2_000, available: true });
|
||||||
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
|
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
|
||||||
["alldebrid", 1_500],
|
["alldebrid", 1_500],
|
||||||
["realdebrid", 500]
|
["realdebrid", 500]
|
||||||
]);
|
]);
|
||||||
expectUnavailable(model.metrics.files);
|
expectUnavailable(model.metrics.files);
|
||||||
expectUnavailable(model.metrics.successRate);
|
expectUnavailable(model.metrics.successRate);
|
||||||
expectUnavailable(model.metrics.errors);
|
expectUnavailable(model.metrics.errors);
|
||||||
expectUnavailable(model.metrics.averageSpeedBps);
|
expectUnavailable(model.metrics.averageSpeedBps);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a stale daily key as a genuine zero today without stale provider rows", () => {
|
it("treats a stale daily key as a genuine zero today without stale provider rows", () => {
|
||||||
const snapshot = createSnapshot();
|
const snapshot = createSnapshot();
|
||||||
snapshot.settings.providerDailyUsageDay = "2026-08-09";
|
snapshot.settings.providerDailyUsageDay = "2026-08-09";
|
||||||
snapshot.settings.providerDailyUsageBytes = { realdebrid: 900 };
|
snapshot.settings.providerDailyUsageBytes = { realdebrid: 900 };
|
||||||
|
|
||||||
const model = buildStatisticsViewModel(snapshot, "today", now);
|
const model = buildStatisticsViewModel(snapshot, "today", now);
|
||||||
|
|
||||||
expect(model.metrics.downloadedBytes).toMatchObject({ value: 0, available: true });
|
expect(model.metrics.downloadedBytes).toMatchObject({ value: 0, available: true });
|
||||||
expect(model.providers).toEqual([]);
|
expect(model.providers).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["week", "month"] satisfies StatisticsRange[])("keeps %s unavailable without inventing historical buckets", (range) => {
|
it.each(["week", "month"] satisfies StatisticsRange[])("keeps %s unavailable without inventing historical buckets", (range) => {
|
||||||
const snapshot = createSnapshot();
|
const snapshot = createSnapshot();
|
||||||
snapshot.stats.totalDownloaded = 5_000;
|
snapshot.stats.totalDownloaded = 5_000;
|
||||||
snapshot.stats.totalDownloadedAllTime = 50_000;
|
snapshot.stats.totalDownloadedAllTime = 50_000;
|
||||||
snapshot.settings.providerDailyUsageDay = "2026-08-10";
|
snapshot.settings.providerDailyUsageDay = "2026-08-10";
|
||||||
snapshot.settings.providerDailyUsageBytes = { realdebrid: 4_000 };
|
snapshot.settings.providerDailyUsageBytes = { realdebrid: 4_000 };
|
||||||
snapshot.settings.providerTotalUsageBytes = { realdebrid: 40_000 };
|
snapshot.settings.providerTotalUsageBytes = { realdebrid: 40_000 };
|
||||||
|
|
||||||
const model = buildStatisticsViewModel(snapshot, range, now);
|
const model = buildStatisticsViewModel(snapshot, range, now);
|
||||||
|
|
||||||
expect(model.coverage).toBe("unavailable");
|
expect(model.coverage).toBe("unavailable");
|
||||||
expect(model.message).toBe("Für diesen Zeitraum werden noch keine historischen Daten gespeichert.");
|
expect(model.message).toBe("Für diesen Zeitraum werden noch keine historischen Daten gespeichert.");
|
||||||
expect(model.providers).toEqual([]);
|
expect(model.providers).toEqual([]);
|
||||||
expect(model.providerScope).toBeNull();
|
expect(model.providerScope).toBeNull();
|
||||||
Object.values(model.metrics).forEach(expectUnavailable);
|
Object.values(model.metrics).forEach(expectUnavailable);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses all-time counters and provider totals without inventing historical outcomes", () => {
|
it("uses all-time counters and provider totals without inventing historical outcomes", () => {
|
||||||
const snapshot = createSnapshot();
|
const snapshot = createSnapshot();
|
||||||
snapshot.stats.totalDownloadedAllTime = 25_000;
|
snapshot.stats.totalDownloadedAllTime = 25_000;
|
||||||
snapshot.stats.totalFilesAllTime = 42;
|
snapshot.stats.totalFilesAllTime = 42;
|
||||||
snapshot.settings.providerTotalUsageBytes = { realdebrid: 5_000, debridlink: 20_000 };
|
snapshot.settings.providerTotalUsageBytes = { realdebrid: 5_000, debridlink: 20_000 };
|
||||||
snapshot.summary = {
|
snapshot.summary = {
|
||||||
total: 10,
|
total: 10,
|
||||||
success: 9,
|
success: 9,
|
||||||
failed: 1,
|
failed: 1,
|
||||||
cancelled: 0,
|
cancelled: 0,
|
||||||
extracted: 9,
|
extracted: 9,
|
||||||
durationSeconds: 10,
|
durationSeconds: 10,
|
||||||
averageSpeedBps: 2_500
|
averageSpeedBps: 2_500
|
||||||
};
|
};
|
||||||
|
|
||||||
const model = buildStatisticsViewModel(snapshot, "all", now);
|
const model = buildStatisticsViewModel(snapshot, "all", now);
|
||||||
|
|
||||||
expect(model.metrics.downloadedBytes.value).toBe(25_000);
|
expect(model.metrics.downloadedBytes.value).toBe(25_000);
|
||||||
expect(model.metrics.files.value).toBe(42);
|
expect(model.metrics.files.value).toBe(42);
|
||||||
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
|
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
|
||||||
["debridlink", 20_000],
|
["debridlink", 20_000],
|
||||||
["realdebrid", 5_000]
|
["realdebrid", 5_000]
|
||||||
]);
|
]);
|
||||||
expect(model.providers.every((row) => row.completed === null && row.failed === null)).toBe(true);
|
expect(model.providers.every((row) => row.completed === null && row.failed === null)).toBe(true);
|
||||||
expectUnavailable(model.metrics.successRate);
|
expectUnavailable(model.metrics.successRate);
|
||||||
expectUnavailable(model.metrics.errors);
|
expectUnavailable(model.metrics.errors);
|
||||||
expectUnavailable(model.metrics.averageSpeedBps);
|
expectUnavailable(model.metrics.averageSpeedBps);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers live queue outcomes over an old summary and uses the summary only after the run ends", () => {
|
it("prefers live queue outcomes over an old summary and uses the summary only after the run ends", () => {
|
||||||
const snapshot = createSnapshot();
|
const snapshot = createSnapshot();
|
||||||
setItems(snapshot, [
|
setItems(snapshot, [
|
||||||
item("complete-a", "completed"),
|
item("complete-a", "completed"),
|
||||||
item("complete-b", "completed"),
|
item("complete-b", "completed"),
|
||||||
item("complete-c", "completed"),
|
item("complete-c", "completed"),
|
||||||
item("failed", "failed")
|
item("failed", "failed")
|
||||||
]);
|
]);
|
||||||
snapshot.summary = {
|
snapshot.summary = {
|
||||||
total: 4,
|
total: 4,
|
||||||
success: 1,
|
success: 1,
|
||||||
failed: 3,
|
failed: 3,
|
||||||
cancelled: 0,
|
cancelled: 0,
|
||||||
extracted: 1,
|
extracted: 1,
|
||||||
durationSeconds: 100,
|
durationSeconds: 100,
|
||||||
averageSpeedBps: 500
|
averageSpeedBps: 500
|
||||||
};
|
};
|
||||||
snapshot.session.running = true;
|
snapshot.session.running = true;
|
||||||
|
|
||||||
const active = buildStatisticsViewModel(snapshot, "session", now);
|
const active = buildStatisticsViewModel(snapshot, "session", now);
|
||||||
snapshot.session.running = false;
|
snapshot.session.running = false;
|
||||||
const ended = buildStatisticsViewModel(snapshot, "session", now);
|
const ended = buildStatisticsViewModel(snapshot, "session", now);
|
||||||
|
|
||||||
expect(active.metrics.successRate.value).toBe(75);
|
expect(active.metrics.successRate.value).toBe(75);
|
||||||
expect(active.metrics.errors.value).toBe(1);
|
expect(active.metrics.errors.value).toBe(1);
|
||||||
expectUnavailable(active.metrics.averageSpeedBps);
|
expectUnavailable(active.metrics.averageSpeedBps);
|
||||||
expect(ended.metrics.successRate.value).toBe(25);
|
expect(ended.metrics.successRate.value).toBe(25);
|
||||||
expect(ended.metrics.errors.value).toBe(3);
|
expect(ended.metrics.errors.value).toBe(3);
|
||||||
expect(ended.metrics.averageSpeedBps).toMatchObject({ value: 500, available: true });
|
expect(ended.metrics.averageSpeedBps).toMatchObject({ value: 500, available: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("models empty, idle, active and paused session states separately", () => {
|
it("models empty, idle, active and paused session states separately", () => {
|
||||||
const empty = createSnapshot();
|
const empty = createSnapshot();
|
||||||
const idle = createSnapshot();
|
const idle = createSnapshot();
|
||||||
setItems(idle, [item("idle", "completed")]);
|
setItems(idle, [item("idle", "completed")]);
|
||||||
const active = createSnapshot();
|
const active = createSnapshot();
|
||||||
active.session.running = true;
|
active.session.running = true;
|
||||||
setItems(active, [item("active", "downloading")]);
|
setItems(active, [item("active", "downloading")]);
|
||||||
const paused = createSnapshot();
|
const paused = createSnapshot();
|
||||||
paused.session.running = true;
|
paused.session.running = true;
|
||||||
paused.session.paused = true;
|
paused.session.paused = true;
|
||||||
setItems(paused, [item("paused", "paused")]);
|
setItems(paused, [item("paused", "paused")]);
|
||||||
|
|
||||||
expect(buildStatisticsViewModel(empty, "session", now).sessionState).toBe("empty");
|
expect(buildStatisticsViewModel(empty, "session", now).sessionState).toBe("empty");
|
||||||
expect(buildStatisticsViewModel(idle, "session", now).sessionState).toBe("idle");
|
expect(buildStatisticsViewModel(idle, "session", now).sessionState).toBe("idle");
|
||||||
expect(buildStatisticsViewModel(active, "session", now).sessionState).toBe("active");
|
expect(buildStatisticsViewModel(active, "session", now).sessionState).toBe("active");
|
||||||
expect(buildStatisticsViewModel(paused, "session", now).sessionState).toBe("paused");
|
expect(buildStatisticsViewModel(paused, "session", now).sessionState).toBe("paused");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("statistics view", () => {
|
describe("statistics view", () => {
|
||||||
|
it("marks statistic ranges for one measured vertical selection indicator", () => {
|
||||||
|
const model = buildStatisticsViewModel(createSnapshot(), "today", now);
|
||||||
|
const html = renderToStaticMarkup(<StatisticsSidebar actions={createActions()} model={model} />);
|
||||||
|
|
||||||
|
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||||||
|
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(5);
|
||||||
|
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders each statistics marker exactly once, all ranges and no download toolbar or pagination", () => {
|
it("renders each statistics marker exactly once, all ranges and no download toolbar or pagination", () => {
|
||||||
const snapshot = createSnapshot();
|
const snapshot = createSnapshot();
|
||||||
snapshot.stats.totalDownloaded = 2_048;
|
snapshot.stats.totalDownloaded = 2_048;
|
||||||
snapshot.stats.totalFilesSession = 1;
|
snapshot.stats.totalFilesSession = 1;
|
||||||
setItems(snapshot, [item("complete", "completed")]);
|
setItems(snapshot, [item("complete", "completed")]);
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<StatisticsView
|
<StatisticsView
|
||||||
actions={createActions()}
|
actions={createActions()}
|
||||||
chart={<div>Bestehender Bandbreitenverlauf</div>}
|
chart={<div>Bestehender Bandbreitenverlauf</div>}
|
||||||
model={buildStatisticsViewModel(snapshot, "session", now)}
|
model={buildStatisticsViewModel(snapshot, "session", now)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const marker of ["statistics-sidebar", "statistics-kpis", "statistics-chart"]) {
|
for (const marker of ["statistics-sidebar", "statistics-kpis", "statistics-chart"]) {
|
||||||
expect(html.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
|
expect(html.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
|
||||||
}
|
}
|
||||||
for (const label of ["Sitzung", "Heute", "Sieben Tage", "30 Tage", "Gesamt"]) {
|
for (const label of ["Sitzung", "Heute", "Sieben Tage", "30 Tage", "Gesamt"]) {
|
||||||
expect(html).toContain(`>${label}<`);
|
expect(html).toContain(`>${label}<`);
|
||||||
}
|
}
|
||||||
expect(html).toContain("Bestehender Bandbreitenverlauf");
|
expect(html).toContain("Bestehender Bandbreitenverlauf");
|
||||||
expect(html).not.toContain("downloads-toolbar");
|
expect(html).not.toContain("downloads-toolbar");
|
||||||
expect(html.toLocaleLowerCase("de-DE")).not.toContain("pagination");
|
expect(html.toLocaleLowerCase("de-DE")).not.toContain("pagination");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dispatches range and reset controls only through the supplied callbacks", () => {
|
it("dispatches range and reset controls only through the supplied callbacks", () => {
|
||||||
const snapshot = createSnapshot();
|
const snapshot = createSnapshot();
|
||||||
setItems(snapshot, [item("failed", "failed")]);
|
setItems(snapshot, [item("failed", "failed")]);
|
||||||
const model = buildStatisticsViewModel(snapshot, "session", now);
|
const model = buildStatisticsViewModel(snapshot, "session", now);
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const actions = createActions({
|
const actions = createActions({
|
||||||
onRangeChange: (range) => calls.push(`range:${range}`),
|
onRangeChange: (range) => calls.push(`range:${range}`),
|
||||||
onResetSession: () => calls.push("reset:session"),
|
onResetSession: () => calls.push("reset:session"),
|
||||||
onResetAll: () => calls.push("reset:all"),
|
onResetAll: () => calls.push("reset:all"),
|
||||||
onResetErrors: () => calls.push("reset:errors")
|
onResetErrors: () => calls.push("reset:errors")
|
||||||
});
|
});
|
||||||
const sidebar = StatisticsSidebar({ actions, model });
|
const sidebar = StatisticsSidebar({ actions, model });
|
||||||
const content = StatisticsContent({ actions, chart: <div />, model });
|
const content = StatisticsContent({ actions, chart: <div />, model });
|
||||||
|
|
||||||
findButton(sidebar, "Heute").props.onClick();
|
findButton(sidebar, "Heute").props.onClick();
|
||||||
findButton(content, "Sitzung zurücksetzen").props.onClick();
|
findButton(content, "Sitzung zurücksetzen").props.onClick();
|
||||||
findButton(content, "Gesamt zurücksetzen").props.onClick();
|
findButton(content, "Gesamt zurücksetzen").props.onClick();
|
||||||
findButton(content, "Fehler zurücksetzen").props.onClick();
|
findButton(content, "Fehler zurücksetzen").props.onClick();
|
||||||
|
|
||||||
expect(calls).toEqual(["range:today", "reset:session", "reset:all", "reset:errors"]);
|
expect(calls).toEqual(["range:today", "reset:session", "reset:all", "reset:errors"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enables error reset only for a positive genuine error metric", () => {
|
it("enables error reset only for a positive genuine error metric", () => {
|
||||||
const clean = createSnapshot();
|
const clean = createSnapshot();
|
||||||
const failed = createSnapshot();
|
const failed = createSnapshot();
|
||||||
setItems(failed, [item("failed", "failed")]);
|
setItems(failed, [item("failed", "failed")]);
|
||||||
|
|
||||||
const cleanContent = StatisticsContent({
|
const cleanContent = StatisticsContent({
|
||||||
actions: createActions(),
|
actions: createActions(),
|
||||||
chart: <div />,
|
chart: <div />,
|
||||||
model: buildStatisticsViewModel(clean, "session", now)
|
model: buildStatisticsViewModel(clean, "session", now)
|
||||||
});
|
});
|
||||||
const failedContent = StatisticsContent({
|
const failedContent = StatisticsContent({
|
||||||
actions: createActions(),
|
actions: createActions(),
|
||||||
chart: <div />,
|
chart: <div />,
|
||||||
model: buildStatisticsViewModel(failed, "session", now)
|
model: buildStatisticsViewModel(failed, "session", now)
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(findButton(cleanContent, "Fehler zurücksetzen").props.disabled).toBe(true);
|
expect(findButton(cleanContent, "Fehler zurücksetzen").props.disabled).toBe(true);
|
||||||
expect(findButton(failedContent, "Fehler zurücksetzen").props.disabled).toBe(false);
|
expect(findButton(failedContent, "Fehler zurücksetzen").props.disabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the empty provider state inside the ARIA table as a row and spanning cell", () => {
|
it("keeps the empty provider state inside the ARIA table as a row and spanning cell", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<StatisticsContent
|
<StatisticsContent
|
||||||
actions={createActions()}
|
actions={createActions()}
|
||||||
chart={<div />}
|
chart={<div />}
|
||||||
model={buildStatisticsViewModel(createSnapshot(), "session", now)}
|
model={buildStatisticsViewModel(createSnapshot(), "session", now)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain('class="statistics-provider-empty" role="row"');
|
expect(html).toContain('class="statistics-provider-empty" role="row"');
|
||||||
expect(html).toContain('aria-colspan="3" role="cell"');
|
expect(html).toContain('aria-colspan="3" role="cell"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("bandwidth chart palette", () => {
|
describe("bandwidth chart palette", () => {
|
||||||
it("requests only the semantic UI color properties and keeps the computed font family", () => {
|
it("requests only the semantic UI color properties and keeps the computed font family", () => {
|
||||||
const requested: string[] = [];
|
const requested: string[] = [];
|
||||||
const values: Record<string, string> = {
|
const values: Record<string, string> = {
|
||||||
"--ui-border": " rgb(61, 61, 61) ",
|
"--ui-border": " rgb(61, 61, 61) ",
|
||||||
"--ui-text-muted": " rgb(145, 145, 145) ",
|
"--ui-text-muted": " rgb(145, 145, 145) ",
|
||||||
"--ui-accent": " rgb(56, 134, 255) "
|
"--ui-accent": " rgb(56, 134, 255) "
|
||||||
};
|
};
|
||||||
|
|
||||||
const palette = readBandwidthChartPalette((property) => {
|
const palette = readBandwidthChartPalette((property) => {
|
||||||
requested.push(property);
|
requested.push(property);
|
||||||
return values[property];
|
return values[property];
|
||||||
}, "Inter, Segoe UI, sans-serif");
|
}, "Inter, Segoe UI, sans-serif");
|
||||||
|
|
||||||
expect(requested).toEqual(["--ui-border", "--ui-text-muted", "--ui-accent"]);
|
expect(requested).toEqual(["--ui-border", "--ui-text-muted", "--ui-accent"]);
|
||||||
expect(palette).toEqual({
|
expect(palette).toEqual({
|
||||||
grid: "rgb(61, 61, 61)",
|
grid: "rgb(61, 61, 61)",
|
||||||
text: "rgb(145, 145, 145)",
|
text: "rgb(145, 145, 145)",
|
||||||
accent: "rgb(56, 134, 255)",
|
accent: "rgb(56, 134, 255)",
|
||||||
fontFamily: "Inter, Segoe UI, sans-serif"
|
fontFamily: "Inter, Segoe UI, sans-serif"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+46
-1
@@ -16,7 +16,52 @@ afterEach(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("settings storage", () => {
|
describe("settings storage", () => {
|
||||||
|
it("repairs a persisted version-2 column order that lost availability during default merging", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const paths = createStoragePaths(dir);
|
||||||
|
fs.writeFileSync(paths.configFile, JSON.stringify({
|
||||||
|
...defaultSettings(),
|
||||||
|
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||||
|
columnOrderVersion: 2
|
||||||
|
}), "utf8");
|
||||||
|
|
||||||
|
const loaded = loadSettings(paths);
|
||||||
|
|
||||||
|
expect(loaded.columnOrder).toEqual(["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"]);
|
||||||
|
expect(loaded.columnOrderVersion).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds availability beside speed once for legacy column settings", () => {
|
||||||
|
const legacy = { ...defaultSettings(), columnOrder: ["name", "status", "speed"] } as Partial<AppSettings>;
|
||||||
|
delete legacy.columnOrderVersion;
|
||||||
|
|
||||||
|
const normalized = normalizeSettings(legacy as AppSettings);
|
||||||
|
|
||||||
|
expect(normalized.columnOrder).toEqual(["name", "status", "speed", "availability"]);
|
||||||
|
expect(normalized.columnOrderVersion).toBe(3);
|
||||||
|
expect(normalizeSettings({ ...normalized, columnOrder: ["name", "speed"] }).columnOrder).toEqual(["name", "speed"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses English for new installations and preserves only supported languages", () => {
|
||||||
|
expect(defaultSettings().language).toBe("en");
|
||||||
|
expect(normalizeSettings({ ...defaultSettings(), language: "de" }).language).toBe("de");
|
||||||
|
expect(normalizeSettings({ ...defaultSettings(), language: "fr" as "en" }).language).toBe("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps German for existing settings files created before language selection existed", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const paths = createStoragePaths(dir);
|
||||||
|
const legacy = { ...defaultSettings() } as Partial<AppSettings>;
|
||||||
|
delete legacy.language;
|
||||||
|
fs.mkdirSync(path.dirname(paths.configFile), { recursive: true });
|
||||||
|
fs.writeFileSync(paths.configFile, JSON.stringify(legacy), "utf8");
|
||||||
|
|
||||||
|
expect(loadSettings(paths).language).toBe("de");
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults download directories to the desktop project folder", () => {
|
it("defaults download directories to the desktop project folder", () => {
|
||||||
const baseDir = path.join(os.homedir(), "Desktop", "Multi-Debrid-Downloader");
|
const baseDir = path.join(os.homedir(), "Desktop", "Multi-Debrid-Downloader");
|
||||||
const defaults = defaultSettings();
|
const defaults = defaultSettings();
|
||||||
|
|||||||
+586
-584
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user