From 67ba5e3eca7aa11e5340b5f9d55c9642fa326859 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 23 Jul 2026 00:33:03 +0200 Subject: [PATCH] feat: redesign Toolbox navigation and personalization --- .env.example | 4 +- Containerfile.release | 4 +- README.md | 49 ++-- THIRD_PARTY_NOTICES.md | 4 + compose.example.yaml | 2 +- compose.yaml | 6 +- package-lock.json | 86 ++++++- package.json | 8 +- public/SOURCE.md | 4 +- public/THIRD_PARTY_NOTICES.md | 9 +- release/toolbox.lock.example.json | 22 +- release/toolbox.lock.json | 22 +- scripts/assembler.test.ts | 2 +- scripts/licenses.mjs | 6 + src/App.test.tsx | 171 +++++++++++++- src/App.tsx | 379 ++++++++++++++++++++---------- src/catalogue.test.ts | 6 +- src/components/AppCard.tsx | 114 +++++---- src/components/HelpPanel.tsx | 109 +++++++++ src/main.tsx | 1 + src/preferences.test.ts | 36 ++- src/preferences.ts | 106 ++++----- src/styles.css | 292 ++++++++++++++--------- src/test/fixtures.ts | 42 +++- src/usePreferences.ts | 18 ++ 25 files changed, 1075 insertions(+), 427 deletions(-) create mode 100644 src/components/HelpPanel.tsx diff --git a/.env.example b/.env.example index c2aa3b7..cbf3440 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,5 @@ TRAEFIK_NETWORK=internal TRAEFIK_CERT_RESOLVER=netcup # Keep these two values paired when deploying a newer published toolbox release. -TOOLBOX_RELEASE_VERSION=0.3.1 -TOOLBOX_RELEASE_SHA256=41d0f99ffa1a88a6b9cfd5f6a6ca2823a96c0a53cec5ee2a42a688de30b12465 +TOOLBOX_RELEASE_VERSION=0.4.0 +TOOLBOX_RELEASE_SHA256=0264f5ee2c545c10c50b96a5b6d55c3576b39bb29c546e1e4f1459fec4e8e112 diff --git a/Containerfile.release b/Containerfile.release index 2ae4e67..cb79da9 100644 --- a/Containerfile.release +++ b/Containerfile.release @@ -2,8 +2,8 @@ ARG NGINX_IMAGE=nginxinc/nginx-unprivileged:1.31.3-alpine@sha256:18d67281256ded3 FROM ${NGINX_IMAGE} AS release -ARG TOOLBOX_RELEASE_VERSION=0.3.1 -ARG TOOLBOX_RELEASE_SHA256=41d0f99ffa1a88a6b9cfd5f6a6ca2823a96c0a53cec5ee2a42a688de30b12465 +ARG TOOLBOX_RELEASE_VERSION=0.4.0 +ARG TOOLBOX_RELEASE_SHA256=0264f5ee2c545c10c50b96a5b6d55c3576b39bb29c546e1e4f1459fec4e8e112 ARG TOOLBOX_RELEASE_BASE_URL=https://git.add-ideas.de/zemion/toolbox-portal/releases/download RUN set -eu; \ diff --git a/README.md b/README.md index 276edc7..0d5ed6f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # add·ideas Toolbox Portal `toolbox-portal` is the static launcher and release assembler for the add·ideas -browser toolbox. Version `0.1.0` reads one same-origin catalogue, shows each app -as a full-page launch card, and keeps personal pins, ordering, visibility, and -appearance in the current browser. +browser toolbox. Version `0.2.0` reads one same-origin catalogue, shows each app +as a full-page launch tile, and keeps personal pins, drag-and-drop ordering, +visibility, and appearance in the current browser. Pinned tools have their own +section; search, category, and clickable tag filters stay close to the tool +list. The v1 boundary is intentionally small: @@ -28,7 +30,7 @@ npm run build npm run dev ``` -The package lock resolves the `^0.1.0` SDK range to the published `0.1.1` +The package lock resolves the `^0.2.0` SDK ranges to the published `0.2.0` packages. A sibling SDK checkout is only needed when intentionally developing the SDK and portal together. @@ -66,7 +68,7 @@ privacy and executable-code warning. Light, dark, and system modes are supported ## Production deployment behind Traefik The committed `compose.yaml` is the shortest production path. Its release -container downloads the immutable Toolbox 0.3.1 ZIP during the image build, +container downloads the immutable Toolbox 0.4.0 ZIP during the image build, verifies SHA-256 before extracting it, and then copies only the verified static files into the pinned unprivileged nginx image. Node.js and a local portal assembly are not required on the deployment host. @@ -117,8 +119,8 @@ The app release should also publish a matching `.sha256` sidecar. The manifest must declare the pinned reverse-DNS id and version. Application and portal versions are independent. -`release/toolbox.lock.json` is the reviewed v0.3.1 lock. Its URLs and checksums -pin the published PDF Tools 0.3.4, XSLT Tools 0.3.2, and OneNote Tools 0.2.1 +`release/toolbox.lock.json` is the reviewed v0.4.0 lock. Its URLs and checksums +pin the published PDF Tools 0.4.0, XSLT Tools 0.4.0, and OneNote Tools 0.3.0 release bytes. Use `release/toolbox.lock.example.json` as the template for a future release and verify every downloaded asset before committing updated values. Artifacts may be: @@ -140,7 +142,7 @@ npm run assemble -- \ --lock release/toolbox.lock.json \ --portal-dist dist \ --output build/toolbox \ - --archive build/add-ideas-toolbox-0.3.1.zip + --archive build/add-ideas-toolbox-0.4.0.zip ``` Existing output is refused. Pass `--force` only when replacing those exact @@ -160,8 +162,8 @@ build/toolbox/ ├── xslt/ └── onenote/ -build/add-ideas-toolbox-0.3.1.zip -build/add-ideas-toolbox-0.3.1.zip.sha256 +build/add-ideas-toolbox-0.4.0.zip +build/add-ideas-toolbox-0.4.0.zip.sha256 ``` The assembler verifies SHA-256 before opening an artifact, validates every app @@ -182,7 +184,7 @@ directory separately: ```sh npm run package:static -- \ --input build/toolbox \ - --output artifacts/add-ideas-toolbox-0.3.1.zip + --output artifacts/add-ideas-toolbox-0.4.0.zip ``` This also emits a `.sha256` sidecar. @@ -199,9 +201,9 @@ normal local XML/XSLT transformations do not require that permission. ```sh podman build -f Containerfile \ - -t git.add-ideas.de/zemion/toolbox:0.3.1 . + -t git.add-ideas.de/zemion/toolbox:0.4.0 . podman run --rm -p 8080:8080 \ - git.add-ideas.de/zemion/toolbox:0.3.1 + git.add-ideas.de/zemion/toolbox:0.4.0 ``` For a direct-port local deployment after assembly, use the separate example: @@ -218,8 +220,8 @@ docker login git.add-ideas.de docker buildx build \ --platform linux/amd64,linux/arm64 \ --file Containerfile.release \ - --tag git.add-ideas.de/zemion/toolbox:0.3.1 \ - --tag git.add-ideas.de/zemion/toolbox:0.3 \ + --tag git.add-ideas.de/zemion/toolbox:0.4.0 \ + --tag git.add-ideas.de/zemion/toolbox:0.4 \ --push . ``` @@ -244,8 +246,8 @@ permission to push code, releases, and container packages. all three apps, switch between them, and confirm the encoded `toolbox` context. 5. Verify the distribution with - `(cd build && sha256sum -c add-ideas-toolbox-0.3.1.zip.sha256)`. -6. Commit the reviewed lock, tag the toolbox release (for example `v0.3.1`), and + `(cd build && sha256sum -c add-ideas-toolbox-0.4.0.zip.sha256)`. +6. Commit the reviewed lock, tag the toolbox release (for example `v0.4.0`), and push the branch and tag to Gitea. 7. In Gitea, open **Releases → New release**, select the tag, and upload the static ZIP plus its `.sha256` file. Do not use a mutable “latest” URL in a @@ -258,12 +260,18 @@ the exact tag, install with `npm ci`, run the same verification/assembly command and upload only the already-created ZIP/checksum and OCI image. The workflow must not re-resolve app versions or substitute a newer release. -## Planned / existing tools +## Existing tools + +| Tool | State and boundary | Principal workflows | Important concrete scope | Critical considerations and integrations | +| ------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`pdf-tools`** | Existing; dedicated repository | Merge, split, reorder, rotate and export PDF pages | Thumbnail workspace; multi-document operations; ZIP and PDF output; saved local workspace | Workers and IndexedDB; large-document memory control; future signature inspection through `crypto-tools`; metadata and safe-sharing through `privacy-tools` | +| **`xslt-tools`** | Existing; dedicated repository | Develop, test and run XSLT transformations | XML/XSLT editors; transformation results; local files; saved projects; validation and diagnostics | Lazy SaxonJS loading; relocatable assets; useful handoffs to `data-tools`, `schema-tools` and `diff-tools` | +| **`onenote-tools`** | Existing rich-reader release; dedicated repository | Open, browse, inspect and export OneNote sections and packages locally | Desktop/unfragmented FSSHTTPB `.one`/`.onetoc2`; none/LZX/MSZIP/Quantum and multi-cabinet `.onepkg`; trees, rich text, images, tables, ink, attachments and export | Native TypeScript worker with no Wasm; bounded parsing, rendering and export; fragmented FSSHTTP fails safely; no editing or pixel-perfect fidelity | + +## Planned tools | Tool | State and boundary | Principal workflows | Important concrete scope | Critical considerations and integrations | | -------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`pdf-tools`** | Existing; dedicated repository | Merge, split, reorder, rotate and export PDF pages | Thumbnail workspace; multi-document operations; ZIP and PDF output; saved local workspace | Workers and IndexedDB; large-document memory control; future signature inspection through `crypto-tools`; metadata and safe-sharing through `privacy-tools` | -| **`xslt-tools`** | Existing; dedicated repository | Develop, test and run XSLT transformations | XML/XSLT editors; transformation results; local files; saved projects; validation and diagnostics | Lazy SaxonJS loading; relocatable assets; useful handoffs to `data-tools`, `schema-tools` and `diff-tools` | | **`svg-tools`** | Planned; dedicated repository | Source and visual SVG editing | Synchronized DOM tree, code and canvas; optimization; path simplification; symbols; accessibility; animation; sanitization | SVG is potentially active content; previews require sanitization or sandboxing; integrate with `colour-tools`, `token-tools`, `image-tools` and `label-tools` | | **`regex-tools`** | Planned; dedicated repository | Develop, test and apply regular expressions | Corpus mode; replacement preview; capture table; flavour comparison; performance visualization; generated test cases; failing-input minimization | Never claim exact equivalence across regex engines without qualification; bound execution; integrate with `text-tools`, `log-tools` and `minimize-tools` | | **`colour-tools`** | Planned; small-to-medium dedicated repository | Colour conversion, calculation and palette work | sRGB, Display P3, Lab, LCH, OKLab and OKLCH; gamut mapping; contrast; colour-vision simulation; gradients; palette extraction; design-token export | Make colour-space assumptions explicit; distinguish mathematical conversion from display simulation; integrate with SVG, image and token tools | @@ -271,7 +279,6 @@ must not re-resolve app versions or substitute a newer release. | **`barcode-tools`** | Planned; dedicated repository | Generate, inspect and decode QR and barcodes | QR, common one- and two-dimensional codes; camera/image decoding; GS1 assistance; CSV batch generation; quiet-zone and print-size checks; structured payload builders | Treat decoded payloads as untrusted; never open links automatically; camera permission must be optional; integrate with `label-tools`, `contact-tools` and `crypto-tools` | | **`av-tools`** | Planned; large dedicated repository | Light audio and video editing and transcoding | Trim, split, concatenate, crop, resize, normalize, fades, waveform, metadata, chapters, subtitles, thumbnail/contact-sheet generation and export presets | Heavy WASM and worker workloads; temporary-file storage; configurable memory limits; possibly special cross-origin-isolation headers; integrate with `subtitle-tools` and `image-tools` | | **`crypto-tools`** | Planned; dedicated repository | Inspect keys, certificates and signatures; validate chains | X.509 PEM/DER, CSR, CRL, PKCS #8, encrypted PKCS #8, PKCS #12/PFX, JWK/JWKS; key/certificate matching; path construction; hostname, purpose and time checks; CMS/JWS later | Inspection-first; explicit trust anchors; no implication that browser/OS trust stores are used; no private-key persistence; offline revocation first; network checks opt-in; restrictive CSP and worker isolation | -| **`onenote-tools`** | Existing rich-reader release; dedicated repository | Open, browse, inspect and export OneNote sections and packages locally | Desktop/unfragmented FSSHTTPB `.one`/`.onetoc2`; none/LZX/MSZIP/Quantum and multi-cabinet `.onepkg`; trees, rich text, images, tables, ink, attachments and export | Native TypeScript worker with no Wasm; bounded parsing, rendering and export; fragmented FSSHTTP fails safely; no editing or pixel-perfect fidelity | | **`data-tools`** | High priority; dedicated repository | Inspect, edit, validate, query and convert structured data | JSON, YAML, TOML, XML, CSV and NDJSON; tree/table/source views; schema inference; flattening; filtering; field mapping; JSONPath-style queries; conversion recipes | Every conversion should disclose information loss, coercion and round-trip instability; XML-specialist workflows hand off to `xslt-tools` | | **`image-tools`** | High priority; dedicated repository | Edit and batch-process raster images | Crop, resize, rotate, compress, convert, contact sheets, sprites, responsive sets, favicon generation, metadata removal and visual quality comparison | Use workers and off-main-thread rendering; preserve or deliberately remove colour profiles; integrate with colour, SVG, PDF and privacy tools | | **`diff-tools`** | High priority; dedicated repository or shared engine plus UI | Compare documents and files semantically | Text; JSON object-aware comparison; XML normalization; CSV keyed comparison; images with overlay/heatmap; archives and directories; PDF pages; optional audio waveform comparison | Normalization and ignored properties must be visible; produce portable reports and standard patch formats where possible | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5c62abd..71a2d85 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,6 +8,10 @@ Runtime components used by the portal include: - React and React DOM — MIT - `@add-ideas/toolbox-contract` — Apache-2.0 +- `@add-ideas/toolbox-shell-react` — Apache-2.0 +- `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`, and + `@dnd-kit/accessibility` — MIT +- `tslib` — 0BSD Their complete installed license and notice texts are generated into `THIRD_PARTY_LICENSES.txt` for every production build. diff --git a/compose.example.yaml b/compose.example.yaml index 3ac6af9..ac6b7ae 100644 --- a/compose.example.yaml +++ b/compose.example.yaml @@ -3,7 +3,7 @@ services: build: context: . dockerfile: Containerfile - image: git.add-ideas.de/zemion/toolbox:0.3.1 + image: git.add-ideas.de/zemion/toolbox:0.4.0 restart: unless-stopped read_only: true ports: diff --git a/compose.yaml b/compose.yaml index 308b234..59fd2ec 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,9 +6,9 @@ services: context: . dockerfile: Containerfile.release args: - TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.3.1}" - TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-41d0f99ffa1a88a6b9cfd5f6a6ca2823a96c0a53cec5ee2a42a688de30b12465}" - image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.3.1}" + TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.4.0}" + TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-0264f5ee2c545c10c50b96a5b6d55c3576b39bb29c546e1e4f1459fec4e8e112}" + image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.4.0}" restart: unless-stopped read_only: true tmpfs: diff --git a/package-lock.json b/package-lock.json index 761746e..1f11167 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,19 @@ { "name": "@add-ideas/toolbox-portal", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@add-ideas/toolbox-portal", - "version": "0.1.0", + "version": "0.2.0", "license": "AGPL-3.0-only", "dependencies": { - "@add-ideas/toolbox-contract": "^0.1.0", + "@add-ideas/toolbox-contract": "^0.2.0", + "@add-ideas/toolbox-shell-react": "^0.2.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "react": "^19.2.6", "react-dom": "^19.2.6" }, @@ -40,11 +44,24 @@ } }, "node_modules/@add-ideas/toolbox-contract": { - "version": "0.1.1", - "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.1.1/toolbox-contract-0.1.1.tgz", - "integrity": "sha512-0q2whA5Y4SqG4PNNleXYR8nGbT3UYsZ1x+6ZWVmxuc/WjMxmujhD7ilIISmFzmnNdbyEyv/Zpr+U0fnHqGODqw==", + "version": "0.2.0", + "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.0/toolbox-contract-0.2.0.tgz", + "integrity": "sha512-up+wY+ft1L7TbXrflcTeTOwEdcFwHBWFlGFj8skQvcLy5z2mDBpd6/7PVQKDvDTkiODu2npviHm6AFglFw+17Q==", "license": "Apache-2.0" }, + "node_modules/@add-ideas/toolbox-shell-react": { + "version": "0.2.0", + "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.0/toolbox-shell-react-0.2.0.tgz", + "integrity": "sha512-qYR+OBXX8rfB+BYCsUbpiCzo2xK12SIPWVNXHre158Da5uT6usr8rRvp5qa5u2oQJ50PNzlXyXMyTeRp0Vz5wg==", + "license": "Apache-2.0", + "dependencies": { + "@add-ideas/toolbox-contract": "0.2.0" + }, + "peerDependencies": { + "react": ">=18 <20", + "react-dom": ">=18 <20" + } + }, "node_modules/@adobe/css-tools": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", @@ -506,6 +523,59 @@ "node": ">=20.19.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -4408,9 +4478,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/package.json b/package.json index 04b2f74..53686d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@add-ideas/toolbox-portal", - "version": "0.1.0", + "version": "0.2.0", "license": "AGPL-3.0-only", "private": true, "type": "module", @@ -21,7 +21,11 @@ "package:static": "node scripts/package-static.mjs" }, "dependencies": { - "@add-ideas/toolbox-contract": "^0.1.0", + "@add-ideas/toolbox-contract": "^0.2.0", + "@add-ideas/toolbox-shell-react": "^0.2.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "react": "^19.2.6", "react-dom": "^19.2.6" }, diff --git a/public/SOURCE.md b/public/SOURCE.md index 9d6ccb1..15bd081 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -1,8 +1,8 @@ # Corresponding source -The source code corresponding to add·ideas Toolbox Portal 0.1.0 is available at: +The source code corresponding to add·ideas Toolbox Portal 0.2.0 is available at: -https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.1.0 +https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.4.0 Each bundled application contains its own `SOURCE.md`, license, and third-party license materials. The application sources are also linked from the portal. diff --git a/public/THIRD_PARTY_NOTICES.md b/public/THIRD_PARTY_NOTICES.md index 97ecb73..eeb8e40 100644 --- a/public/THIRD_PARTY_NOTICES.md +++ b/public/THIRD_PARTY_NOTICES.md @@ -4,8 +4,9 @@ The add·ideas Toolbox portal is licensed under `AGPL-3.0-only`. An assembled toolbox is a collection of independently licensed works, not a relicensing of every included component under one licence. -Portal runtime components include React and React DOM (MIT) and -`@add-ideas/toolbox-contract` (Apache-2.0). Their complete installed license and -notice texts are shipped in `THIRD_PARTY_LICENSES.txt`. Each application remains -under its own licence. Application notices are shipped inside its +Portal runtime components include React and React DOM (MIT), +`@add-ideas/toolbox-contract` and `@add-ideas/toolbox-shell-react` +(Apache-2.0), dnd-kit (MIT), and tslib (0BSD). Their complete installed license +and notice texts are shipped in `THIRD_PARTY_LICENSES.txt`. Each application +remains under its own licence. Application notices are shipped inside its `apps//LICENSES/` directory. diff --git a/release/toolbox.lock.example.json b/release/toolbox.lock.example.json index 8dfb1d1..bcd59e6 100644 --- a/release/toolbox.lock.example.json +++ b/release/toolbox.lock.example.json @@ -1,8 +1,8 @@ { "$schema": "./toolbox-release-lock.schema.json", "schemaVersion": 1, - "releaseVersion": "0.3.1", - "portalVersion": "0.1.0", + "releaseVersion": "0.4.0", + "portalVersion": "0.2.0", "catalogue": { "id": "de.add-ideas.toolbox", "name": "add·ideas Toolbox", @@ -15,23 +15,23 @@ "apps": [ { "id": "de.add-ideas.pdf-tools", - "version": "0.3.4", - "artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.3.4/pdf-tools-0.3.4.zip", - "sha256": "090492aaee4bd5cde51917ee3d9d0c971f071c27b7dbe4c918876ffd752dd930", + "version": "0.4.0", + "artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.0/pdf-tools-0.4.0.zip", + "sha256": "c9a2d3bed41c41dc6664f6ab7ecefebb5ef4895ce49cd7b0726e3d47d62a66ca", "target": "pdf" }, { "id": "de.add-ideas.xslt-tools", - "version": "0.3.2", - "artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.3.2/xslt-tools-0.3.2.zip", - "sha256": "e1746ac60f7143037ce77d7f04c62f7da39a92d8cef42e7167acb2bc00786de1", + "version": "0.4.0", + "artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.0/xslt-tools-0.4.0.zip", + "sha256": "62e02c08d25456cd0a876d4005dfdcadf69471619230cb5f03f62451f501f142", "target": "xslt" }, { "id": "de.add-ideas.onenote-tools", - "version": "0.2.1", - "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.2.1/onenote-tools-0.2.1.zip", - "sha256": "be76b1086f709e6ede4891698c0bcc4b5a23a5665d37fbb860463b9a61b37989", + "version": "0.3.0", + "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.0/onenote-tools-0.3.0.zip", + "sha256": "44fa28252995b4bf7789b7a546091ee70d43990ab893481d9f4831d5cf5525e3", "target": "onenote" } ] diff --git a/release/toolbox.lock.json b/release/toolbox.lock.json index 8dfb1d1..bcd59e6 100644 --- a/release/toolbox.lock.json +++ b/release/toolbox.lock.json @@ -1,8 +1,8 @@ { "$schema": "./toolbox-release-lock.schema.json", "schemaVersion": 1, - "releaseVersion": "0.3.1", - "portalVersion": "0.1.0", + "releaseVersion": "0.4.0", + "portalVersion": "0.2.0", "catalogue": { "id": "de.add-ideas.toolbox", "name": "add·ideas Toolbox", @@ -15,23 +15,23 @@ "apps": [ { "id": "de.add-ideas.pdf-tools", - "version": "0.3.4", - "artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.3.4/pdf-tools-0.3.4.zip", - "sha256": "090492aaee4bd5cde51917ee3d9d0c971f071c27b7dbe4c918876ffd752dd930", + "version": "0.4.0", + "artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.0/pdf-tools-0.4.0.zip", + "sha256": "c9a2d3bed41c41dc6664f6ab7ecefebb5ef4895ce49cd7b0726e3d47d62a66ca", "target": "pdf" }, { "id": "de.add-ideas.xslt-tools", - "version": "0.3.2", - "artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.3.2/xslt-tools-0.3.2.zip", - "sha256": "e1746ac60f7143037ce77d7f04c62f7da39a92d8cef42e7167acb2bc00786de1", + "version": "0.4.0", + "artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.0/xslt-tools-0.4.0.zip", + "sha256": "62e02c08d25456cd0a876d4005dfdcadf69471619230cb5f03f62451f501f142", "target": "xslt" }, { "id": "de.add-ideas.onenote-tools", - "version": "0.2.1", - "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.2.1/onenote-tools-0.2.1.zip", - "sha256": "be76b1086f709e6ede4891698c0bcc4b5a23a5665d37fbb860463b9a61b37989", + "version": "0.3.0", + "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.0/onenote-tools-0.3.0.zip", + "sha256": "44fa28252995b4bf7789b7a546091ee70d43990ab893481d9f4831d5cf5525e3", "target": "onenote" } ] diff --git a/scripts/assembler.test.ts b/scripts/assembler.test.ts index 3a5a91f..26a4dd7 100644 --- a/scripts/assembler.test.ts +++ b/scripts/assembler.test.ts @@ -83,7 +83,7 @@ function makeLock(artifact: string, sha256: string) { return { schemaVersion: 1, releaseVersion: '0.1.0', - portalVersion: '0.1.0', + portalVersion: '0.2.0', catalogue: { id: 'de.add-ideas.toolbox', name: 'Test Toolbox', diff --git a/scripts/licenses.mjs b/scripts/licenses.mjs index c533612..6018214 100644 --- a/scripts/licenses.mjs +++ b/scripts/licenses.mjs @@ -5,9 +5,15 @@ import { fileURLToPath } from 'node:url'; const LICENSE_FILE = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/iu; const RUNTIME_PACKAGES = [ '@add-ideas/toolbox-contract', + '@add-ideas/toolbox-shell-react', + '@dnd-kit/accessibility', + '@dnd-kit/core', + '@dnd-kit/sortable', + '@dnd-kit/utilities', 'react', 'react-dom', 'scheduler', + 'tslib', ]; function compareCodePoints(left, right) { diff --git a/src/App.test.tsx b/src/App.test.tsx index 1008505..d61213d 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,4 +1,10 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; import App from './App'; @@ -16,14 +22,14 @@ describe('portal UI', () => { await screen.findByRole('heading', { name: 'PDF Workbench' }) ).toBeInTheDocument(); expect( - screen.getByText( + screen.getAllByText( 'Local processing · no file uploads declared · no telemetry declared' ) - ).toBeInTheDocument(); + ).toHaveLength(3); const sourceLinks = screen.getAllByRole('link', { name: 'Source and license', }); - expect(sourceLinks).toHaveLength(2); + expect(sourceLinks).toHaveLength(4); expect( sourceLinks.find( (link) => @@ -33,10 +39,15 @@ describe('portal UI', () => { ).toHaveAttribute('rel', 'noopener noreferrer'); expect( sourceLinks.find((link) => - link.getAttribute('href')?.endsWith('/toolbox-portal/src/tag/v0.1.0') + link.getAttribute('href')?.endsWith('/toolbox-portal/src/tag/v0.4.0') ) ).toBeDefined(); - const launch = screen.getByRole('link', { name: /open tool/i }); + expect( + screen.queryByRole('region', { name: 'Pinned' }) + ).not.toBeInTheDocument(); + const launch = screen + .getAllByRole('link', { name: 'PDF Workbench' }) + .find((link) => link.classList.contains('app-card__launch-link'))!; expect( new URL(launch.getAttribute('href')!).searchParams.get('toolbox') ).toContain('toolbox.catalog.json'); @@ -47,7 +58,15 @@ describe('portal UI', () => { 'de.add-ideas.pdf-tools' ) ); - expect(screen.getByText('Pinned')).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Pinned' })).toContainElement( + screen.getByTestId('app-card-de.add-ideas.pdf-tools') + ); + expect( + screen.queryByRole('button', { name: /Move PDF Workbench/i }) + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Reorder PDF Workbench' }) + ).toBeInTheDocument(); await user.click( screen.getByRole('button', { name: 'Hide PDF Workbench' }) @@ -61,6 +80,144 @@ describe('portal UI', () => { ).toBeInTheDocument(); }); + it('lists contextual destinations in the Apps menu', async () => { + vi.stubGlobal('fetch', vi.fn(catalogueFetch())); + const user = userEvent.setup(); + render(); + await screen.findByRole('heading', { name: 'PDF Workbench' }); + await user.click(screen.getByText('Apps').closest('summary')!); + const navigation = screen.getByRole('navigation', { + name: 'Toolbox applications', + }); + const destination = within(navigation).getByRole('link', { + name: 'XSLT Workbench', + }); + expect( + new URL(destination.getAttribute('href')!).searchParams.get('toolbox') + ).toContain('toolbox.catalog.json'); + }); + + it('persists an explicit appearance and accepts cross-tab updates', async () => { + vi.stubGlobal('fetch', vi.fn(catalogueFetch())); + const user = userEvent.setup(); + render(); + await screen.findByRole('heading', { name: 'PDF Workbench' }); + await user.click(screen.getByRole('button', { name: 'Personalize' })); + await user.click(screen.getByRole('button', { name: 'Dark' })); + expect(document.documentElement).toHaveAttribute('data-theme', 'dark'); + await waitFor(() => + expect(localStorage.getItem(PREFERENCES_KEY)).toContain('"theme":"dark"') + ); + await user.click(screen.getByRole('button', { name: 'Close preferences' })); + + fireEvent( + window, + new StorageEvent('storage', { + key: PREFERENCES_KEY, + newValue: JSON.stringify({ + version: 1, + pinned: [], + order: [ + 'de.add-ideas.pdf-tools', + 'de.add-ideas.xslt-tools', + 'de.add-ideas.onenote-tools', + ], + hidden: [], + theme: 'light', + }), + }) + ); + expect(document.documentElement).toHaveAttribute('data-theme', 'light'); + }); + + it('supports keyboard drag ordering from each tile grip', async () => { + vi.stubGlobal('fetch', vi.fn(catalogueFetch())); + render(); + await screen.findByRole('heading', { name: 'PDF Workbench' }); + const cards = [ + screen.getByTestId('app-card-de.add-ideas.pdf-tools'), + screen.getByTestId('app-card-de.add-ideas.xslt-tools'), + screen.getByTestId('app-card-de.add-ideas.onenote-tools'), + ]; + cards.forEach((card, index) => { + const rect = { + x: index * 220, + y: 0, + width: 200, + height: 320, + top: 0, + right: index * 220 + 200, + bottom: 320, + left: index * 220, + toJSON: () => ({}), + } as DOMRect; + card.getBoundingClientRect = () => rect; + card.getClientRects = () => [rect] as unknown as DOMRectList; + }); + + const grip = screen.getByRole('button', { name: 'Reorder PDF Workbench' }); + grip.focus(); + fireEvent.keyDown(grip, { key: ' ', code: 'Space' }); + await waitFor(() => expect(grip).toHaveAttribute('aria-pressed', 'true')); + await new Promise((resolve) => setTimeout(resolve, 0)); + fireEvent.keyDown(document, { key: 'ArrowRight', code: 'ArrowRight' }); + fireEvent.keyDown(document, { key: ' ', code: 'Space' }); + await waitFor(() => { + const tools = screen.getByRole('region', { name: 'Tools' }); + expect( + within(tools) + .getAllByRole('heading', { level: 3 }) + .map((heading) => heading.textContent) + ).toEqual(['XSLT Workbench', 'PDF Workbench', 'OneNote Reader']); + }); + expect( + JSON.parse(localStorage.getItem(PREFERENCES_KEY) ?? '{}').order + ).toEqual([ + 'de.add-ideas.xslt-tools', + 'de.add-ideas.pdf-tools', + 'de.add-ideas.onenote-tools', + ]); + }); + + it('filters by clickable tags and clears an active tag', async () => { + vi.stubGlobal('fetch', vi.fn(catalogueFetch())); + const user = userEvent.setup(); + render(); + const merge = await screen.findByRole('button', { name: '#merge' }); + await user.click(merge); + expect(merge).toHaveAttribute('aria-pressed', 'true'); + expect( + screen.getByRole('heading', { name: 'PDF Workbench' }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: 'XSLT Workbench' }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: 'OneNote Reader' }) + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: '#merge' })); + expect( + await screen.findByRole('heading', { name: 'XSLT Workbench' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'OneNote Reader' }) + ).toBeInTheDocument(); + }); + + it('opens the standard help control and explains tile interaction', async () => { + vi.stubGlobal('fetch', vi.fn(catalogueFetch())); + const user = userEvent.setup(); + render(); + await screen.findByRole('heading', { name: 'PDF Workbench' }); + await user.click(screen.getByRole('button', { name: 'Help' })); + expect( + screen.getByRole('dialog', { name: 'Choose and arrange your tools' }) + ).toHaveTextContent('click anywhere on a tool tile'); + await user.click(screen.getByRole('button', { name: 'Close help' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + it('shows a recoverable catalogue error', async () => { const fetchMock = vi .fn() diff --git a/src/App.tsx b/src/App.tsx index 9544413..9945732 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,33 @@ +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, + rectSortingStrategy, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import { + ToolboxHeader, + type ToolboxHeaderApp, +} from '@add-ideas/toolbox-shell-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { AppCard } from './components/AppCard'; +import { HelpPanel } from './components/HelpPanel'; import { Logo } from './components/Logo'; import { PreferencesPanel } from './components/PreferencesPanel'; import { contextualLaunchUrl, loadCatalogue } from './catalogue'; -import { cleanPreferences, sortAppIds } from './preferences'; +import { cleanPreferences, reorderWithinIds, sortAppIds } from './preferences'; import type { LoadedCatalogue, ResolvedApp } from './types'; import { usePreferences } from './usePreferences'; const CATALOGUE_HREF = './toolbox.catalog.json'; +const PORTAL_SOURCE = 'https://git.add-ideas.de/zemion/toolbox-portal'; type LoadState = | { status: 'loading' } @@ -17,9 +37,11 @@ type LoadState = function appMatches( app: ResolvedApp, query: string, - category: string + category: string, + tag: string ): boolean { if (category && !app.categories.includes(category)) return false; + if (tag && !app.tags.includes(tag)) return false; if (!query) return true; const haystack = [app.name, app.description, ...app.categories, ...app.tags] .join(' ') @@ -27,15 +49,84 @@ function appMatches( return haystack.includes(query.toLocaleLowerCase()); } +interface AppSectionProps { + activeTag: string; + apps: ResolvedApp[]; + catalogueUrl: string; + hiddenIds: readonly string[]; + id: string; + onCategory: (category: string) => void; + onHide: (id: string) => void; + onPin: (id: string) => void; + onTag: (tag: string) => void; + pinned: boolean; + title: string; +} + +function AppSection({ + activeTag, + apps, + catalogueUrl, + hiddenIds, + id, + onCategory, + onHide, + onPin, + onTag, + pinned, + title, +}: AppSectionProps) { + if (apps.length === 0) return null; + return ( +
+
+

{title}

+ {apps.length} +
+ app.id)} + strategy={rectSortingStrategy} + > +
+ {apps.map((app) => ( +
+
+
+ ); +} + export default function App() { const [loadState, setLoadState] = useState({ status: 'loading' }); const [reloadKey, setReloadKey] = useState(0); const [query, setQuery] = useState(''); const [category, setCategory] = useState(''); + const [activeTag, setActiveTag] = useState(''); const [showHidden, setShowHidden] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [helpOpen, setHelpOpen] = useState(false); const [preferences, setPreferences, storageAvailable] = usePreferences(); const closeSettings = useCallback(() => setSettingsOpen(false), []); + const closeHelp = useCallback(() => setHelpOpen(false), []); + const dialogOpen = settingsOpen || helpOpen; + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); useEffect(() => { const controller = new AbortController(); @@ -91,8 +182,26 @@ export default function App() { }, [apps, preferences]); const visibleApps = orderedApps.filter((app) => { const hidden = preferences.hidden.includes(app.id); - return (showHidden || !hidden) && appMatches(app, query.trim(), category); + return ( + (showHidden || !hidden) && + appMatches(app, query.trim(), category, activeTag) + ); }); + const pinnedApps = visibleApps.filter((app) => + preferences.pinned.includes(app.id) + ); + const regularApps = visibleApps.filter( + (app) => !preferences.pinned.includes(app.id) + ); + + const headerApps: ToolboxHeaderApp[] = apps.map((app) => ({ + id: app.id, + name: app.name, + href: app.isExternal + ? app.entryUrl + : contextualLaunchUrl(app.entryUrl, loaded?.catalogueUrl.href ?? ''), + newTab: !app.launchModes.includes('navigate'), + })); function toggleListItem(field: 'pinned' | 'hidden', id: string) { setPreferences((current) => ({ @@ -103,25 +212,26 @@ export default function App() { })); } - function moveApp(id: string, direction: -1 | 1) { - const pinned = preferences.pinned.includes(id); - const peers = visibleApps.filter( - (app) => preferences.pinned.includes(app.id) === pinned + function handleDragEnd(event: DragEndEvent) { + if (!event.over) return; + const activeId = String(event.active.id); + const overId = String(event.over.id); + const activePinned = preferences.pinned.includes(activeId); + if (activePinned !== preferences.pinned.includes(overId)) return; + const peers = (activePinned ? pinnedApps : regularApps).map( + (app) => app.id ); - const currentIndex = peers.findIndex((app) => app.id === id); - const target = peers[currentIndex + direction]; - if (!target) return; - setPreferences((current) => { - const currentPosition = current.order.indexOf(id); - const targetPosition = current.order.indexOf(target.id); - if (currentPosition < 0 || targetPosition < 0) return current; - const order = [...current.order]; - [order[currentPosition], order[targetPosition]] = [ - order[targetPosition]!, - order[currentPosition]!, - ]; - return { ...current, order }; - }); + setPreferences((current) => ({ + ...current, + order: reorderWithinIds(current.order, activeId, overId, peers), + })); + } + + function clearFilters() { + setQuery(''); + setCategory(''); + setActiveTag(''); + if (preferences.hidden.length > 0) setShowHidden(true); } const title = loaded?.catalogue.name ?? 'add·ideas Toolbox'; @@ -131,49 +241,92 @@ export default function App() { <>
Skip to tools -
-
- - - - {brand} - Toolbox - - - -
-
+ setHelpOpen(true) }} + sourceHref={PORTAL_SOURCE} + sourceLabel="Toolbox source on Gitea" + onPersonalize={() => setSettingsOpen(true)} + apps={headerApps} + allAppsHref="#main-content" + />
-

- Privacy details up front · useful by default -

-

- Your document tools, -
- in one calm place. -

-

- Focused browser utilities with a privacy disclosure for each tool. - Choose one and keep your work moving. -

+
+

+ Privacy details up front · useful by default +

+

+ Your document tools, +
+ in one calm place. +

+

+ Focused browser utilities with a privacy disclosure for each + tool. Choose one and keep your work moving. +

+
+ + {loaded && ( +
+ + + {activeTag && ( + + )} + {preferences.hidden.length > 0 && ( + + )} +
+ )}
{loadState.status === 'loading' && ( @@ -205,43 +358,6 @@ export default function App() { {loaded && ( <> -
- - - {preferences.hidden.length > 0 && ( - - )} -
- {apps.length === 0 && loaded.unavailable.length === 0 && (
)} {visibleApps.length > 0 && ( -
- {visibleApps.map((app) => { - const pinned = preferences.pinned.includes(app.id); - const peers = visibleApps.filter( - (peer) => preferences.pinned.includes(peer.id) === pinned - ); - return ( -
+ +
+ toggleListItem('pinned', id)} + onHide={(id) => toggleListItem('hidden', id)} + onTag={(tag) => + setActiveTag((current) => (current === tag ? '' : tag)) + } + /> + toggleListItem('pinned', id)} + onHide={(id) => toggleListItem('hidden', id)} + onTag={(tag) => + setActiveTag((current) => (current === tag ? '' : tag)) + } + /> +
+
)} {loaded.unavailable.length > 0 && ( @@ -332,9 +456,9 @@ export default function App() {

- Portal v0.1.0 ·{' '} + Portal v0.2.0 ·{' '} @@ -351,6 +475,7 @@ export default function App() { onChange={setPreferences} onClose={closeSettings} /> + ); } diff --git a/src/catalogue.test.ts b/src/catalogue.test.ts index e365387..7bfd366 100644 --- a/src/catalogue.test.ts +++ b/src/catalogue.test.ts @@ -5,12 +5,12 @@ import { catalogue, catalogueFetch } from './test/fixtures'; afterEach(() => vi.unstubAllGlobals()); describe('catalogue loading', () => { - it('loads and resolves an internal app using the shared contract', async () => { + it('loads and resolves internal apps using the shared contract', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const loaded = await loadCatalogue('/toolbox.catalog.json'); expect(loaded.catalogue.name).toBe('add·ideas Toolbox'); expect(loaded.homeUrl).toBe('http://localhost:3000/'); - expect(loaded.apps).toHaveLength(1); + expect(loaded.apps).toHaveLength(3); expect(loaded.apps[0]).toMatchObject({ id: 'de.add-ideas.pdf-tools', name: 'PDF Workbench', @@ -38,6 +38,8 @@ describe('catalogue loading', () => { const loaded = await loadCatalogue('/toolbox.catalog.json'); expect(loaded.apps.map((app) => app.id)).toEqual([ 'de.add-ideas.pdf-tools', + 'de.add-ideas.xslt-tools', + 'de.add-ideas.onenote-tools', ]); expect(loaded.unavailable).toHaveLength(1); expect(loaded.unavailable[0]?.reason).toMatch(/HTTP 404/); diff --git a/src/components/AppCard.tsx b/src/components/AppCard.tsx index b4e29d2..2d1a391 100644 --- a/src/components/AppCard.tsx +++ b/src/components/AppCard.tsx @@ -1,3 +1,5 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import type { ResolvedApp } from '../types'; interface AppCardProps { @@ -5,11 +7,11 @@ interface AppCardProps { catalogueUrl: string; hidden: boolean; pinned: boolean; - first: boolean; - last: boolean; + activeTag: string; + onCategory: (category: string) => void; onHide: () => void; - onMove: (direction: -1 | 1) => void; onPin: () => void; + onTag: (tag: string) => void; contextualUrl: (entryUrl: string, catalogueUrl: string) => string; } @@ -55,21 +57,35 @@ export function AppCard({ catalogueUrl, hidden, pinned, - first, - last, + activeTag, + onCategory, onHide, - onMove, onPin, + onTag, contextualUrl, }: AppCardProps) { + const { + attributes, + isDragging, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + } = useSortable({ id: app.id }); const launchUrl = app.isExternal ? app.entryUrl : contextualUrl(app.entryUrl, catalogueUrl); const target = app.launchModes.includes('navigate') ? undefined : '_blank'; + return (
+ )} + {app.tags.length > 0 && ( +
+ {app.tags.map((tag) => ( + + ))} +
+ )}
); diff --git a/src/components/HelpPanel.tsx b/src/components/HelpPanel.tsx new file mode 100644 index 0000000..f97d151 --- /dev/null +++ b/src/components/HelpPanel.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef } from 'react'; + +interface HelpPanelProps { + open: boolean; + onClose: () => void; +} + +export function HelpPanel({ open, onClose }: HelpPanelProps) { + const panel = useRef(null); + + useEffect(() => { + if (!open) return; + const returnFocus = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + const dialog = panel.current; + const focusable = () => + dialog + ? [...dialog.querySelectorAll(FOCUSABLE_SELECTOR)] + : []; + focusable()[0]?.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + if (event.key !== 'Tab') return; + const elements = focusable(); + const first = elements[0]; + const last = elements.at(-1); + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + returnFocus?.focus(); + }; + }, [open, onClose]); + + if (!open) return null; + return ( +
event.target === event.currentTarget && onClose()} + > +
+
+
+

Toolbox guide

+

Choose and arrange your tools

+
+ +
+
+

+ Open: click anywhere on a tool tile that is not a + control. +

+

+ Filter: search, choose a category, or click a tag. + Click the selected tag again to clear it. +

+

+ Arrange: pin frequently used tools into their own + section, then drag the grip to reorder. The grip also supports + keyboard dragging with Space, arrow keys, and Escape. +

+

+ Privacy: each tile reports its declared processing, + uploads, telemetry, and browser requirements before you open it. +

+
+
+
+ ); +} + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); diff --git a/src/main.tsx b/src/main.tsx index 5b585d3..44078aa 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import '@add-ideas/toolbox-shell-react/styles.css'; import App from './App'; import './styles.css'; diff --git a/src/preferences.test.ts b/src/preferences.test.ts index e3bd2d3..2c9b2d4 100644 --- a/src/preferences.test.ts +++ b/src/preferences.test.ts @@ -3,9 +3,9 @@ import { PREFERENCES_KEY, cleanPreferences, defaultPreferences, - moveId, parsePreferences, readPreferences, + reorderWithinIds, sortAppIds, writePreferences, } from './preferences'; @@ -51,7 +51,7 @@ describe('preferences', () => { ).toThrow(/pinned/i); }); - it('cleans stale ids, pins first, and supports personal ordering', () => { + it('cleans stale ids and applies personal ordering without mixing sections', () => { const cleaned = cleanPreferences( { version: 1, @@ -65,14 +65,36 @@ describe('preferences', () => { expect(cleaned.order).toEqual(['app.two', 'app.one', 'app.three']); expect(cleaned.hidden).toEqual([]); expect(sortAppIds(['app.one', 'app.two', 'app.three'], cleaned)).toEqual([ - 'app.three', 'app.two', 'app.one', - ]); - expect(moveId(cleaned.order, 'app.one', -1)).toEqual([ - 'app.one', - 'app.two', 'app.three', ]); + expect( + reorderWithinIds( + ['pin.one', 'tool.one', 'pin.two', 'tool.two'], + 'pin.two', + 'pin.one', + ['pin.one', 'pin.two'] + ) + ).toEqual(['pin.two', 'tool.one', 'pin.one', 'tool.two']); + expect( + reorderWithinIds(cleaned.order, 'app.one', 'app.two', [ + 'app.two', + 'app.one', + ]) + ).toEqual(['app.one', 'app.two', 'app.three']); + }); + + it('does not reorder across peer groups or for an unknown drop target', () => { + const order = ['pin.one', 'tool.one', 'pin.two']; + expect( + reorderWithinIds(order, 'pin.one', 'tool.one', ['pin.one', 'pin.two']) + ).toBe(order); + expect( + reorderWithinIds(order, 'missing', 'pin.one', ['pin.one', 'pin.two']) + ).toBe(order); + expect(reorderWithinIds(order, 'pin.one', 'pin.one', ['pin.one'])).toBe( + order + ); }); }); diff --git a/src/preferences.ts b/src/preferences.ts index e72e5c1..eebe69e 100644 --- a/src/preferences.ts +++ b/src/preferences.ts @@ -1,66 +1,31 @@ -import type { ThemeMode } from './types'; - -export const PREFERENCES_KEY = '@add-ideas/toolbox-portal:v1:preferences'; - -export interface Preferences { - version: 1; - pinned: string[]; - order: string[]; - hidden: string[]; - theme: ThemeMode; -} +import { + TOOLBOX_PREFERENCES_KEY, + defaultToolboxPreferences, + parseToolboxPreferences, + readToolboxPreferences, + writeToolboxPreferences, + type ToolboxPreferences, +} from '@add-ideas/toolbox-contract'; +export const PREFERENCES_KEY = TOOLBOX_PREFERENCES_KEY; +export type Preferences = ToolboxPreferences; export const defaultPreferences: Preferences = { - version: 1, + ...defaultToolboxPreferences, pinned: [], order: [], hidden: [], - theme: 'system', }; - -function uniqueStrings(value: unknown, field: string): string[] { - if ( - !Array.isArray(value) || - value.some((item) => typeof item !== 'string' || item.length === 0) - ) - throw new Error(`${field} must be an array of non-empty strings.`); - return [...new Set(value)]; -} - -export function parsePreferences(value: unknown): Preferences { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error('Preferences must be a JSON object.'); - } - const candidate = value as Record; - if (candidate.version !== 1) - throw new Error('Unsupported preferences version.'); - const theme = candidate.theme; - if (!['light', 'dark', 'system'].includes(String(theme))) - throw new Error('Invalid theme preference.'); - return { - version: 1, - pinned: uniqueStrings(candidate.pinned, 'pinned'), - order: uniqueStrings(candidate.order, 'order'), - hidden: uniqueStrings(candidate.hidden, 'hidden'), - theme: theme as ThemeMode, - }; -} +export const parsePreferences = parseToolboxPreferences; export function readPreferences(storage: Storage = localStorage): Preferences { - const value = storage.getItem(PREFERENCES_KEY); - if (!value) return { ...defaultPreferences }; - try { - return parsePreferences(JSON.parse(value)); - } catch { - return { ...defaultPreferences }; - } + return readToolboxPreferences(storage); } export function writePreferences( preferences: Preferences, storage: Storage = localStorage ): void { - storage.setItem(PREFERENCES_KEY, JSON.stringify(preferences)); + writeToolboxPreferences(preferences, storage); } export function cleanPreferences( @@ -84,27 +49,36 @@ export function sortAppIds( preferences: Preferences ): string[] { const order = new Map(preferences.order.map((id, index) => [id, index])); - const pinned = new Set(preferences.pinned); - return [...appIds].sort((left, right) => { - const pinDifference = Number(pinned.has(right)) - Number(pinned.has(left)); - if (pinDifference) return pinDifference; - return ( + return [...appIds].sort( + (left, right) => (order.get(left) ?? Number.MAX_SAFE_INTEGER) - (order.get(right) ?? Number.MAX_SAFE_INTEGER) - ); - }); + ); } -export function moveId( +export function reorderWithinIds( order: string[], - id: string, - direction: -1 | 1 + activeId: string, + overId: string, + peerIds: readonly string[] ): string[] { - const current = order.indexOf(id); - if (current < 0) return order; - const target = current + direction; - if (target < 0 || target >= order.length) return order; - const next = [...order]; - [next[current], next[target]] = [next[target]!, next[current]!]; - return next; + if (activeId === overId) return order; + const peers = new Set(peerIds); + if (!peers.has(activeId) || !peers.has(overId)) return order; + + const orderedPeers = order.filter((id) => peers.has(id)); + for (const id of peerIds) { + if (!orderedPeers.includes(id)) orderedPeers.push(id); + } + const from = orderedPeers.indexOf(activeId); + const to = orderedPeers.indexOf(overId); + if (from < 0 || to < 0) return order; + const [moved] = orderedPeers.splice(from, 1); + if (!moved) return order; + orderedPeers.splice(to, 0, moved); + + let peerIndex = 0; + return order.map((id) => + peers.has(id) ? (orderedPeers[peerIndex++] ?? id) : id + ); } diff --git a/src/styles.css b/src/styles.css index aa05608..717cb19 100644 --- a/src/styles.css +++ b/src/styles.css @@ -17,6 +17,10 @@ --shadow: 0 16px 44px rgba(24, 34, 68, 0.08); } +:root[data-theme='light'] { + color-scheme: light; +} + :root[data-theme='dark'] { color-scheme: dark; --bg: #0f1422; @@ -119,63 +123,13 @@ summary:focus-visible { transform: translateY(0); } -.site-header { - position: sticky; - z-index: 20; - top: 0; - border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent); - background: color-mix(in srgb, var(--bg) 88%, transparent); - backdrop-filter: blur(16px); -} - -.header-inner, +.portal-header .toolbox-shell__bar, main, .site-footer { width: min(1160px, calc(100% - 2.5rem)); margin-inline: auto; } -.header-inner { - min-height: 74px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; -} - -.brand { - display: inline-flex; - align-items: center; - gap: 0.65rem; - color: var(--text); - text-decoration: none; -} - -.brand .logo { - color: var(--brand); -} - -.brand > span { - display: grid; - line-height: 1.05; -} - -.brand strong { - font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, sans-serif; - font-size: 1.03rem; - letter-spacing: -0.025em; -} - -.brand small { - margin-top: 0.22rem; - color: var(--muted); - font-size: 0.68rem; - font-weight: 600; - letter-spacing: 0.17em; - text-transform: uppercase; -} - -.settings-button, .state-card button, .preferences-actions button { border: 1px solid var(--line); @@ -185,11 +139,6 @@ main, font-weight: 600; } -.settings-button { - padding: 0.62rem 0.82rem; -} - -.settings-button:hover, .state-card button:hover, .preferences-actions button:hover { border-color: var(--brand); @@ -201,8 +150,15 @@ main { } .hero { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(19rem, 26rem); + align-items: end; + gap: clamp(2rem, 6vw, 5rem); + padding: clamp(3.5rem, 7vw, 6.8rem) 0 clamp(2.5rem, 5vw, 4.2rem); +} + +.hero-intro { max-width: 750px; - padding: clamp(4.5rem, 9vw, 8.5rem) 0 clamp(3rem, 6vw, 5.2rem); } .eyebrow { @@ -214,7 +170,7 @@ main { text-transform: uppercase; } -.hero h1 { +.hero h2 { margin: 0; font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, sans-serif; font-size: clamp(2.65rem, 7vw, 5.65rem); @@ -222,7 +178,7 @@ main { letter-spacing: -0.065em; } -.hero h1 span { +.hero h2 span { color: var(--brand); } @@ -235,10 +191,14 @@ main { } .tool-controls { - display: flex; - align-items: center; + display: grid; + align-content: end; gap: 0.75rem; - margin-bottom: 1.5rem; + padding: 1rem; + border: 1px solid var(--line); + border-radius: 1rem; + background: color-mix(in srgb, var(--surface) 88%, transparent); + box-shadow: var(--shadow); } .search-field, @@ -252,7 +212,7 @@ main { } .search-field { - width: min(360px, 100%); + width: 100%; padding: 0 0.85rem; gap: 0.55rem; } @@ -275,7 +235,7 @@ main { } .category-field { - min-width: 175px; + width: 100%; padding: 0 0.65rem; } @@ -287,7 +247,7 @@ main { display: flex; align-items: center; gap: 0.45rem; - margin-left: auto; + margin: 0.15rem 0 0; color: var(--muted); cursor: pointer; font-size: 0.88rem; @@ -299,14 +259,56 @@ main { accent-color: var(--brand); } +.active-filter { + justify-self: start; + padding: 0.4rem 0.65rem; + border: 1px solid color-mix(in srgb, var(--brand) 35%, var(--line)); + border-radius: 999px; + background: var(--brand-soft); + color: var(--brand); + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; +} + +.app-sections { + display: grid; + gap: 2.4rem; +} + +.app-section__header { + display: flex; + align-items: baseline; + gap: 0.65rem; + margin-bottom: 0.85rem; +} + +.app-section__header h2 { + margin: 0; + font-family: Inter, Avenir, 'Segoe UI', sans-serif; + font-size: 1.25rem; + letter-spacing: -0.025em; +} + +.app-section__header span { + min-width: 1.55rem; + padding: 0.16rem 0.4rem; + border-radius: 999px; + background: var(--surface-soft); + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + text-align: center; +} + .app-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.1rem; - padding-bottom: 2rem; } .app-card { + position: relative; min-height: 340px; display: flex; flex-direction: column; @@ -321,6 +323,12 @@ main { box-shadow 180ms ease; } +.app-card--dragging { + z-index: 5; + opacity: 0.82; + box-shadow: 0 24px 65px rgba(24, 34, 68, 0.22); +} + .app-card:hover { border-color: color-mix(in srgb, var(--brand) 35%, var(--line)); transform: translateY(-2px); @@ -360,10 +368,21 @@ main { } .app-card__actions { + position: relative; + z-index: 2; display: flex; gap: 0.15rem; } +.drag-handle { + touch-action: none; + cursor: grab; +} + +.drag-handle:active { + cursor: grabbing; +} + .icon-button, .close-button { width: 32px; @@ -400,15 +419,39 @@ main { gap: 0.45rem; } -.title-line h2 { +.title-line h3 { margin: 0; font-family: Inter, Avenir, 'Segoe UI', sans-serif; font-size: 1.3rem; letter-spacing: -0.025em; } +.app-card__launch-link { + color: var(--text); + text-decoration: none; +} + +.app-card__launch-link::after { + position: absolute; + z-index: 1; + inset: 0; + content: ''; +} + +.app-card__launch-link:focus-visible { + outline: none; +} + +.app-card:has(.app-card__launch-link:focus-visible) { + outline: 3px solid color-mix(in srgb, var(--accent) 70%, var(--brand)); + outline-offset: 3px; +} + .status-chip, -.tag-row span { +.tag-row button, +.category-row button { + position: relative; + z-index: 2; border-radius: 999px; background: var(--brand-soft); color: var(--brand); @@ -419,6 +462,7 @@ main { .status-chip { padding: 0.22rem 0.48rem; text-transform: uppercase; + pointer-events: none; } .status-chip--muted { @@ -442,17 +486,37 @@ main { font-weight: 650; } -.tag-row { +.tag-row, +.category-row { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 1rem; } -.tag-row span { +.tag-row button, +.category-row button { padding: 0.28rem 0.52rem; + border: 0; background: var(--surface-soft); color: var(--muted); + font: inherit; + cursor: pointer; +} + +.category-row { + margin-top: 1rem; +} + +.tag-row { + margin-top: 0.55rem; +} + +.tag-row button:hover, +.category-row button:hover, +.tag-row button[aria-pressed='true'] { + background: var(--brand-soft); + color: var(--brand); } .app-card__footer { @@ -477,28 +541,15 @@ main { .source-link, .site-footer a { + position: relative; + z-index: 2; color: var(--brand); } -.launch-button { - display: inline-flex; - align-items: center; - gap: 0.45rem; - padding: 0.62rem 0.82rem; - border-radius: 0.65rem; - background: var(--brand); - color: var(--surface); +.open-hint { + color: var(--brand); font-size: 0.84rem; font-weight: 700; - text-decoration: none; -} - -:root[data-theme='dark'] .launch-button { - color: #11182a; -} - -.launch-button:hover { - background: var(--brand-hover); } .state-card { @@ -609,7 +660,8 @@ main { backdrop-filter: blur(5px); } -.preferences-panel { +.preferences-panel, +.help-panel { width: min(560px, 100%); max-height: calc(100vh - 2rem); overflow: auto; @@ -620,23 +672,47 @@ main { box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28); } -.preferences-panel > header { +.preferences-panel > header, +.help-panel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } -.preferences-panel h2 { +.preferences-panel h2, +.help-panel h2 { margin: 0; font-family: Inter, Avenir, 'Segoe UI', sans-serif; letter-spacing: -0.025em; } -.preferences-panel .eyebrow { +.preferences-panel .eyebrow, +.help-panel .eyebrow { margin-bottom: 0.5rem; } +.help-steps { + display: grid; + gap: 0.85rem; + margin-top: 1.4rem; +} + +.help-steps p { + margin: 0; + padding: 0.9rem 1rem; + border: 1px solid var(--line); + border-radius: 0.75rem; + background: var(--surface-soft); + color: var(--muted); + font-size: 0.9rem; + line-height: 1.55; +} + +.help-steps strong { + color: var(--text); +} + .close-button { flex: 0 0 auto; background: var(--surface-soft); @@ -754,31 +830,19 @@ main { } @media (max-width: 700px) { - .header-inner, + .portal-header .toolbox-shell__bar, main, .site-footer { width: min(100% - 1.4rem, 1160px); } - .header-inner { - min-height: 66px; - } - - .settings-button { - font-size: 0; - } - - .settings-button span { - font-size: 1.15rem; - } - .hero { + grid-template-columns: 1fr; padding-top: 3.8rem; } .tool-controls { - align-items: stretch; - flex-direction: column; + width: 100%; } .search-field, @@ -790,6 +854,10 @@ main { margin: 0.25rem 0; } + .app-grid { + grid-template-columns: 1fr; + } + .app-card { min-height: 320px; } @@ -802,6 +870,16 @@ main { } } +@media (min-width: 701px) and (max-width: 960px) { + .hero { + grid-template-columns: minmax(0, 1fr) minmax(17rem, 21rem); + } + + .app-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index 4ae6bdb..59367bd 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -7,7 +7,7 @@ export const pdfManifest: ToolboxAppManifest = { schemaVersion: 1, id: 'de.add-ideas.pdf-tools', name: 'PDF Workbench', - version: '0.3.4', + version: '0.4.0', description: 'Page-level PDF operations performed locally in the browser.', entry: './', icon: './favicon.svg', @@ -35,13 +35,47 @@ export const pdfManifest: ToolboxAppManifest = { }, }; +export const xsltManifest: ToolboxAppManifest = { + ...pdfManifest, + id: 'de.add-ideas.xslt-tools', + name: 'XSLT Workbench', + version: '0.4.0', + description: 'Transform XML locally with XSLT in the browser.', + icon: './xslt.svg', + categories: ['documents', 'developer'], + tags: ['transform', 'xml'], + source: { + repository: 'https://git.add-ideas.de/zemion/xslt-tools', + license: 'AGPL-3.0-only', + }, +}; + +export const onenoteManifest: ToolboxAppManifest = { + ...pdfManifest, + id: 'de.add-ideas.onenote-tools', + name: 'OneNote Reader', + version: '0.3.0', + description: 'Inspect OneNote packages locally in the browser.', + icon: './onenote.svg', + categories: ['documents', 'notes'], + tags: ['onepkg', 'import'], + source: { + repository: 'https://git.add-ideas.de/zemion/onenote-tools', + license: 'AGPL-3.0-only', + }, +}; + export const catalogue: ToolboxCatalog = { schemaVersion: 1, id: 'de.add-ideas.toolbox', name: 'add·ideas Toolbox', home: './', theme: { mode: 'system', brand: 'add·ideas' }, - apps: [{ manifest: './apps/pdf/toolbox-app.json', enabled: true }], + apps: [ + { manifest: './apps/pdf/toolbox-app.json', enabled: true }, + { manifest: './apps/xslt/toolbox-app.json', enabled: true }, + { manifest: './apps/onenote/toolbox-app.json', enabled: true }, + ], }; export function catalogueFetch(overrides: Record = {}) { @@ -56,6 +90,10 @@ export function catalogueFetch(overrides: Record = {}) { return Response.json(catalogue); if (url.pathname.endsWith('/apps/pdf/toolbox-app.json')) return Response.json(pdfManifest); + if (url.pathname.endsWith('/apps/xslt/toolbox-app.json')) + return Response.json(xsltManifest); + if (url.pathname.endsWith('/apps/onenote/toolbox-app.json')) + return Response.json(onenoteManifest); return new Response('Not found', { status: 404 }); }; } diff --git a/src/usePreferences.ts b/src/usePreferences.ts index dcfa1bb..8234212 100644 --- a/src/usePreferences.ts +++ b/src/usePreferences.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { PREFERENCES_KEY, defaultPreferences, + parsePreferences, readPreferences, writePreferences, type Preferences, @@ -43,5 +44,22 @@ export function usePreferences(): [ } }, [preferences]); + useEffect(() => { + const handleStorage = (event: StorageEvent) => { + if (event.key !== PREFERENCES_KEY) return; + if (event.newValue === null) { + setPreferences({ ...defaultPreferences }); + return; + } + try { + setPreferences(parsePreferences(JSON.parse(event.newValue))); + } catch { + setPreferences({ ...defaultPreferences }); + } + }; + globalThis.addEventListener('storage', handleStorage); + return () => globalThis.removeEventListener('storage', handleStorage); + }, []); + return [preferences, setPreferences, initial.storageAvailable]; }