Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 470ee49ca2 | |||
| 5c052bc6d1 | |||
| a9c31c8986 | |||
| 1f00e6def1 | |||
| bda9da044d | |||
| 2c13c56362 | |||
| 803397ff52 | |||
| 21283ce25f | |||
| e44d35fdfb | |||
| 67ba5e3eca | |||
| 587aadb79b | |||
| de51ec0bd5 |
@@ -4,5 +4,5 @@ TRAEFIK_NETWORK=internal
|
|||||||
TRAEFIK_CERT_RESOLVER=netcup
|
TRAEFIK_CERT_RESOLVER=netcup
|
||||||
|
|
||||||
# Keep these two values paired when deploying a newer published toolbox release.
|
# Keep these two values paired when deploying a newer published toolbox release.
|
||||||
TOOLBOX_RELEASE_VERSION=0.3.0
|
TOOLBOX_RELEASE_VERSION=0.7.0
|
||||||
TOOLBOX_RELEASE_SHA256=718a3c8b891e66f5601115270f25acaec9dbe1b9640a72c17954353bb10db93e
|
TOOLBOX_RELEASE_SHA256=505a1ff1fa10d8831a6110eb52bb1eff7be58ca495ce50f7d67977c893a36e99
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ ARG NGINX_IMAGE=nginxinc/nginx-unprivileged:1.31.3-alpine@sha256:18d67281256ded3
|
|||||||
|
|
||||||
FROM ${NGINX_IMAGE} AS release
|
FROM ${NGINX_IMAGE} AS release
|
||||||
|
|
||||||
ARG TOOLBOX_RELEASE_VERSION=0.3.0
|
ARG TOOLBOX_RELEASE_VERSION=0.7.0
|
||||||
ARG TOOLBOX_RELEASE_SHA256=718a3c8b891e66f5601115270f25acaec9dbe1b9640a72c17954353bb10db93e
|
ARG TOOLBOX_RELEASE_SHA256=505a1ff1fa10d8831a6110eb52bb1eff7be58ca495ce50f7d67977c893a36e99
|
||||||
ARG TOOLBOX_RELEASE_BASE_URL=https://git.add-ideas.de/zemion/toolbox-portal/releases/download
|
ARG TOOLBOX_RELEASE_BASE_URL=https://git.add-ideas.de/zemion/toolbox-portal/releases/download
|
||||||
|
|
||||||
RUN set -eu; \
|
RUN set -eu; \
|
||||||
@@ -21,6 +21,8 @@ RUN set -eu; \
|
|||||||
test -f /tmp/toolbox/apps/pdf/toolbox-app.json; \
|
test -f /tmp/toolbox/apps/pdf/toolbox-app.json; \
|
||||||
test -f /tmp/toolbox/apps/xslt/toolbox-app.json; \
|
test -f /tmp/toolbox/apps/xslt/toolbox-app.json; \
|
||||||
test -f /tmp/toolbox/apps/onenote/toolbox-app.json; \
|
test -f /tmp/toolbox/apps/onenote/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/av/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/regex/toolbox-app.json; \
|
||||||
rm "/tmp/${archive}"
|
rm "/tmp/${archive}"
|
||||||
|
|
||||||
FROM ${NGINX_IMAGE}
|
FROM ${NGINX_IMAGE}
|
||||||
|
|||||||
183
README.md
183
README.md
@@ -1,9 +1,11 @@
|
|||||||
# add·ideas Toolbox Portal
|
# add·ideas Toolbox Portal
|
||||||
|
|
||||||
`toolbox-portal` is the static launcher and release assembler for the add·ideas
|
`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
|
browser toolbox. Version `0.2.6` reads one same-origin catalogue, shows each app
|
||||||
as a full-page launch card, and keeps personal pins, ordering, visibility, and
|
as a compact launch tile, and keeps personal pins, drag-and-drop ordering,
|
||||||
appearance in the current browser.
|
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:
|
The v1 boundary is intentionally small:
|
||||||
|
|
||||||
@@ -28,15 +30,16 @@ npm run build
|
|||||||
npm run dev
|
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.2` SDK ranges to the published `0.2.2`
|
||||||
packages. A sibling SDK checkout is only needed when intentionally developing
|
packages. A sibling SDK checkout is only needed when intentionally developing
|
||||||
the SDK and portal together.
|
the SDK and portal together.
|
||||||
|
|
||||||
Vite uses `base: './'`, so the built portal works at `/` or a nested static path.
|
Vite uses `base: './'`, so the built portal works at `/` or a nested static path.
|
||||||
The development catalogue at `public/toolbox.catalog.json` points to assembled
|
The development catalogue at `public/toolbox.catalog.json` points to assembled
|
||||||
paths (`./apps/pdf/`, `./apps/xslt/`, and `./apps/onenote/`). Those manifests
|
paths (`./apps/pdf/`, `./apps/xslt/`, `./apps/onenote/`, `./apps/av/`, and
|
||||||
will correctly appear as unavailable until an assembled release is being
|
`./apps/regex/`).
|
||||||
served.
|
Those manifests will correctly appear as unavailable until an assembled release
|
||||||
|
is being served.
|
||||||
|
|
||||||
## Catalogue and launches
|
## Catalogue and launches
|
||||||
|
|
||||||
@@ -66,7 +69,7 @@ privacy and executable-code warning. Light, dark, and system modes are supported
|
|||||||
## Production deployment behind Traefik
|
## Production deployment behind Traefik
|
||||||
|
|
||||||
The committed `compose.yaml` is the shortest production path. Its release
|
The committed `compose.yaml` is the shortest production path. Its release
|
||||||
container downloads the immutable Toolbox 0.3.0 ZIP during the image build,
|
container downloads the immutable Toolbox 0.7.0 ZIP during the image build,
|
||||||
verifies SHA-256 before extracting it, and then copies only the verified static
|
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
|
files into the pinned unprivileged nginx image. Node.js and a local portal
|
||||||
assembly are not required on the deployment host.
|
assembly are not required on the deployment host.
|
||||||
@@ -75,7 +78,7 @@ Prerequisites:
|
|||||||
|
|
||||||
- Docker with the Compose plugin;
|
- Docker with the Compose plugin;
|
||||||
- Traefik attached to the existing external Docker network `internal`;
|
- Traefik attached to the existing external Docker network `internal`;
|
||||||
- a Traefik HTTPS entrypoint named `websecure`;
|
- Traefik HTTP and HTTPS entrypoints named `web` and `websecure`;
|
||||||
- a certificate resolver named `netcup`;
|
- a certificate resolver named `netcup`;
|
||||||
- DNS for `toolbox.add-ideas.de` pointing to that Traefik instance; and
|
- DNS for `toolbox.add-ideas.de` pointing to that Traefik instance; and
|
||||||
- outbound HTTPS access to `git.add-ideas.de` while the image is built.
|
- outbound HTTPS access to `git.add-ideas.de` while the image is built.
|
||||||
@@ -89,8 +92,9 @@ docker compose up -d --build
|
|||||||
docker compose ps
|
docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
No host port is published. Traefik routes
|
No host port is published. Traefik permanently redirects
|
||||||
`https://toolbox.add-ideas.de` to nginx on the shared network at port 8080 and
|
`http://toolbox.add-ideas.de` to HTTPS, routes
|
||||||
|
`https://toolbox.add-ideas.de` to nginx on the shared network at port 8080, and
|
||||||
obtains its TLS certificate through `netcup`. The hostname, network, resolver,
|
obtains its TLS certificate through `netcup`. The hostname, network, resolver,
|
||||||
release version, and matching checksum can be overridden through environment
|
release version, and matching checksum can be overridden through environment
|
||||||
variables; copy `.env.example` to `.env` only when an override is needed. For
|
variables; copy `.env.example` to `.env` only when an override is needed. For
|
||||||
@@ -117,9 +121,9 @@ The app release should also publish a matching `.sha256` sidecar. The manifest
|
|||||||
must declare the pinned reverse-DNS id and version. Application and portal
|
must declare the pinned reverse-DNS id and version. Application and portal
|
||||||
versions are independent.
|
versions are independent.
|
||||||
|
|
||||||
`release/toolbox.lock.json` is the reviewed v0.3.0 lock. Its URLs and checksums
|
`release/toolbox.lock.json` is the reviewed v0.7.0 lock. Its URLs and checksums
|
||||||
pin the published PDF Tools 0.3.4, XSLT Tools 0.3.2, and OneNote Tools 0.2.0
|
pin the published PDF Tools 0.4.3, XSLT Tools 0.4.2, OneNote Tools 0.3.3, and
|
||||||
release bytes. Use
|
Audio & Video Tools 0.2.0 and Regex Tools 0.2.0 release bytes. Use
|
||||||
`release/toolbox.lock.example.json` as the template for a future release and
|
`release/toolbox.lock.example.json` as the template for a future release and
|
||||||
verify every downloaded asset before committing updated values. Artifacts may be:
|
verify every downloaded asset before committing updated values. Artifacts may be:
|
||||||
|
|
||||||
@@ -140,7 +144,7 @@ npm run assemble -- \
|
|||||||
--lock release/toolbox.lock.json \
|
--lock release/toolbox.lock.json \
|
||||||
--portal-dist dist \
|
--portal-dist dist \
|
||||||
--output build/toolbox \
|
--output build/toolbox \
|
||||||
--archive build/add-ideas-toolbox-0.3.0.zip
|
--archive build/add-ideas-toolbox-0.7.0.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
Existing output is refused. Pass `--force` only when replacing those exact
|
Existing output is refused. Pass `--force` only when replacing those exact
|
||||||
@@ -158,10 +162,12 @@ build/toolbox/
|
|||||||
└── apps/
|
└── apps/
|
||||||
├── pdf/
|
├── pdf/
|
||||||
├── xslt/
|
├── xslt/
|
||||||
└── onenote/
|
├── onenote/
|
||||||
|
├── av/
|
||||||
|
└── regex/
|
||||||
|
|
||||||
build/add-ideas-toolbox-0.3.0.zip
|
build/add-ideas-toolbox-0.7.0.zip
|
||||||
build/add-ideas-toolbox-0.3.0.zip.sha256
|
build/add-ideas-toolbox-0.7.0.zip.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
The assembler verifies SHA-256 before opening an artifact, validates every app
|
The assembler verifies SHA-256 before opening an artifact, validates every app
|
||||||
@@ -182,16 +188,17 @@ directory separately:
|
|||||||
```sh
|
```sh
|
||||||
npm run package:static -- \
|
npm run package:static -- \
|
||||||
--input build/toolbox \
|
--input build/toolbox \
|
||||||
--output artifacts/add-ideas-toolbox-0.3.0.zip
|
--output artifacts/add-ideas-toolbox-0.7.0.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
This also emits a `.sha256` sidecar.
|
This also emits a `.sha256` sidecar.
|
||||||
|
|
||||||
## Local assembled container and publication
|
## Local assembled container and publication
|
||||||
|
|
||||||
`Containerfile` serves only the assembled files with unprivileged nginx on port 8080. It adds restrictive browser headers, same-origin isolation, immutable
|
`Containerfile` serves only the assembled files with unprivileged nginx on port 8080. It adds restrictive browser headers, same-origin isolation, explicit
|
||||||
caching only for Vite-hashed assets, and revalidation for all other files.
|
`application/wasm`, immutable caching only for Vite-hashed assets and narrowly
|
||||||
Assemble first, then:
|
matched semver-versioned FFmpeg core files, and revalidation for all other
|
||||||
|
files. Assemble first, then:
|
||||||
|
|
||||||
The packaged policy intentionally does not grant CSP `unsafe-eval`. SaxonJS's
|
The packaged policy intentionally does not grant CSP `unsafe-eval`. SaxonJS's
|
||||||
`ixsl:eval()` extension is therefore unsupported in this deployment profile;
|
`ixsl:eval()` extension is therefore unsupported in this deployment profile;
|
||||||
@@ -199,9 +206,9 @@ normal local XML/XSLT transformations do not require that permission.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
podman build -f Containerfile \
|
podman build -f Containerfile \
|
||||||
-t git.add-ideas.de/zemion/toolbox:0.3.0 .
|
-t git.add-ideas.de/zemion/toolbox:0.7.0 .
|
||||||
podman run --rm -p 8080:8080 \
|
podman run --rm -p 8080:8080 \
|
||||||
git.add-ideas.de/zemion/toolbox:0.3.0
|
git.add-ideas.de/zemion/toolbox:0.7.0
|
||||||
```
|
```
|
||||||
|
|
||||||
For a direct-port local deployment after assembly, use the separate example:
|
For a direct-port local deployment after assembly, use the separate example:
|
||||||
@@ -218,8 +225,8 @@ docker login git.add-ideas.de
|
|||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
--file Containerfile.release \
|
--file Containerfile.release \
|
||||||
--tag git.add-ideas.de/zemion/toolbox:0.3.0 \
|
--tag git.add-ideas.de/zemion/toolbox:0.7.0 \
|
||||||
--tag git.add-ideas.de/zemion/toolbox:0.3 \
|
--tag git.add-ideas.de/zemion/toolbox:0.7 \
|
||||||
--push .
|
--push .
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -230,9 +237,9 @@ notes.
|
|||||||
|
|
||||||
## Manual Gitea publication checklist
|
## Manual Gitea publication checklist
|
||||||
|
|
||||||
No credentials belong in this repository. Before the first release, create the
|
No credentials belong in this repository. The publishing workstation or runner
|
||||||
`zemion/toolbox-portal` Gitea repository and grant the workstation or runner
|
needs permission to push code, releases, and container packages to
|
||||||
permission to push code, releases, and container packages.
|
`zemion/toolbox-portal`.
|
||||||
|
|
||||||
1. Run `npm ci`, `npm test`, `npm run lint`, and `npm run build` from a clean
|
1. Run `npm ci`, `npm test`, `npm run lint`, and `npm run build` from a clean
|
||||||
checkout of the intended portal tag.
|
checkout of the intended portal tag.
|
||||||
@@ -241,11 +248,11 @@ permission to push code, releases, and container packages.
|
|||||||
3. Verify downloaded app assets with `sha256sum -c <asset>.sha256`; copy those
|
3. Verify downloaded app assets with `sha256sum -c <asset>.sha256`; copy those
|
||||||
exact values into a reviewed toolbox lock.
|
exact values into a reviewed toolbox lock.
|
||||||
4. Run the assembler and serve `build/toolbox` from a nested test path. Open
|
4. Run the assembler and serve `build/toolbox` from a nested test path. Open
|
||||||
all three apps, switch between them, and confirm the encoded `toolbox`
|
all five apps, switch between them, and confirm the encoded `toolbox`
|
||||||
context.
|
context.
|
||||||
5. Verify the distribution with
|
5. Verify the distribution with
|
||||||
`(cd build && sha256sum -c add-ideas-toolbox-0.3.0.zip.sha256)`.
|
`(cd build && sha256sum -c add-ideas-toolbox-0.7.0.zip.sha256)`.
|
||||||
6. Commit the reviewed lock, tag the toolbox release (for example `v0.3.0`), and
|
6. Commit the reviewed lock, tag the toolbox release (for example `v0.7.0`), and
|
||||||
push the branch and tag to Gitea.
|
push the branch and tag to Gitea.
|
||||||
7. In Gitea, open **Releases → New release**, select the tag, and upload the
|
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
|
static ZIP plus its `.sha256` file. Do not use a mutable “latest” URL in a
|
||||||
@@ -258,57 +265,69 @@ 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
|
and upload only the already-created ZIP/checksum and OCI image. The workflow
|
||||||
must not re-resolve app versions or substitute a newer release.
|
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 |
|
| 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` |
|
| **`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` |
|
| **`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` |
|
| **`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 |
|
||||||
| **`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` |
|
| **`av-tools`** | Existing local-first v0.2.0 release; dedicated repository | Convert and lightly edit audio and video locally in the browser | Quick conversion; trim, split and crossfade concatenate; crop, resize, normalize, fades, waveform, metadata, chapters, subtitles, scene thumbnails/contact sheets, presets and export | Reviewed ffmpeg.wasm 0.12.10 ST/MT build with verified Opus and bundled label font; bounded queue, temporary storage and resource policy; isolation enables MT with automatic ST fallback; versioned core assets are immutable |
|
||||||
| **`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 |
|
| **`regex-tools`** | Existing local-first v0.2.0 release; dedicated repository | Develop, explain, test and apply ECMAScript and PCRE2 regular expressions | Deterministic syntax tree; native matching/tracing and exact captures; comparison, PCRE2 C17 generation, corpus apply, replacement/list previews, tests, risk/growth/benchmark analysis, deterministic generated cases, bounded minimization, validated ECMAScript formatting, project import/export and optional local persistence | Open-source regexpp plus pinned PCRE2 10.47 WASM in killable workers; explicit resource/presentation limits and honest flavour boundaries; more engines remain roadmap work |
|
||||||
| **`rand-tools`** | Planned; small dedicated repository | Generate random numbers, strings, identifiers and samples | Cryptographically secure generation; deterministic seeded generation; UUIDs, ULIDs, passphrases, dice notation, distributions, weighted selection and shuffling | Clearly separate secure randomness from reproducible pseudorandomness; no misleading “strength” claims; natural foundation for `fixture-tools` |
|
|
||||||
| **`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` |
|
## Planned 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 |
|
| Tool | State and boundary | Principal workflows | Important concrete scope | Critical considerations and integrations |
|
||||||
| **`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` |
|
| **`flow-tools`** | Later platform layer | Compose local processing operations across tools | Example: CSV → filter → QR generation → SVG template → PDF → ZIP; reusable recipes; typed inputs and outputs; progress and cancellation | Do not load complete React applications into one runtime; expose separate worker-safe operation packages only after several apps have stable operations |
|
||||||
| **`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 |
|
| **`file-tools`** | High priority; dedicated repository | Identify, inspect and organize local files | Magic-byte detection; hex view; decoded strings; checksums; directory manifests; split/join; duplicate detection; trailing-data detection; batch-rename preview | All inputs are untrusted; format inspectors require strict size and nesting limits; natural entry point to archive, privacy, crypto and package 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 |
|
| **`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` |
|
||||||
| **`file-tools`** | High priority; dedicated repository | Identify, inspect and organize local files | Magic-byte detection; hex view; decoded strings; checksums; directory manifests; split/join; duplicate detection; trailing-data detection; batch-rename preview | All inputs are untrusted; format inspectors require strict size and nesting limits; natural entry point to archive, privacy, crypto and package 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 |
|
||||||
| **`archive-tools`** | Medium-to-high priority; dedicated repository | Inspect, create, compare and extract archives | ZIP, TAR, gzip and selected additional formats; selective extraction; content preview; deterministic archives; timestamp normalization; archive comparison | Expansion-ratio, entry-count and total-size limits; traversal-safe extraction; never execute extracted files |
|
| **`subtitle-tools`** | Medium priority; dedicated repository | Edit, validate and synchronize captions | SRT, WebVTT and ASS; timing shifts; stretching; frame-rate conversion; overlap and reading-speed checks; waveform synchronization; revision comparison | Direct integration with `av-tools`; preserve style information when supported and disclose conversion losses |
|
||||||
| **`text-tools`** | Medium priority; one app with deep-linked panels | Transform and normalize plain text | Unicode normalization; line-ending conversion; encoding; sorting; deduplication; case changes; transliteration; escaping; column operations; transformation pipelines | Preserve exact input/output visibility; warn about lossy encodings and Unicode confusables; integrate regex operations |
|
| **`midi-tools`** | Medium specialist app | MIDI event inspection and editing; transpose, quantize, tempo maps and controller data; device access should remain optional | |
|
||||||
| **`query-tools`** | High-value but large; dedicated repository | Analyse local tabular and relational data with SQL | CSV, JSON, NDJSON, Parquet and SQLite; schema inference; joins; aggregation; pivots; charts; export to common data formats | WASM memory and streaming; distinguish this analytical product from document-oriented `data-tools`; query work must remain local |
|
| **`3d-tools`** | Large specialist app | Inspect STL, OBJ and glTF; dimensions, bounding boxes, mesh simplification and common geometry defects; worker/GPU use and file limits | |
|
||||||
| **`privacy-tools`** | High priority; dedicated repository | Inspect and remove metadata before sharing | EXIF and GPS; document author/comments; embedded thumbnails; PDF metadata and attachments; hidden package entries; AV tags; personal filenames; structured safe-sharing report | Never promise absolute anonymity; explicitly state inspected, removed, preserved and unsupported structures; integrate with most file-oriented apps |
|
| **`query-tools`** | High-value but large; dedicated repository | Analyse local tabular and relational data with SQL | CSV, JSON, NDJSON, Parquet and SQLite; schema inference; joins; aggregation; pivots; charts; export to common data formats | WASM memory and streaming; distinguish this analytical product from document-oriented `data-tools`; query work must remain local |
|
||||||
| **`time-tools`** | Medium priority; small dedicated app or helper family | Work with dates, timestamps, time zones and recurrences | Unix timestamps; date arithmetic; durations; ISO 8601; timezone comparison; cron; RRULE; ICS generation; business days; DST-transition visualization | Visualize actual recurrence instances and ambiguous/skipped local times; do not rely on simplified fixed-offset calculations |
|
| **`epub-tools`** | Medium app | EPUB inspection, metadata and cover editing, link validation, chapter extraction and structural repair; sanitize embedded HTML and SVG | |
|
||||||
| **`subtitle-tools`** | Medium priority; dedicated repository | Edit, validate and synchronize captions | SRT, WebVTT and ASS; timing shifts; stretching; frame-rate conversion; overlap and reading-speed checks; waveform synchronization; revision comparison | Direct integration with `av-tools`; preserve style information when supported and disclose conversion losses |
|
| **`scan-tools`** | Large app | Camera/document correction; crop, deskew and threshold; page assembly; local OCR; PDF output; model downloads and memory use must be explicit | |
|
||||||
| **`font-tools`** | Medium priority; dedicated repository | Inspect, preview and prepare fonts | Glyph coverage; variable axes; metadata; fallback comparison; subsetting; CSS generation; arbitrary-text previews | Font licensing and embedding restrictions must be visible; use untrusted-font isolation and strict parser limits |
|
| **`office-tools`** | Medium-to-large app | Inspect rather than reproduce an office suite: package structure, relationships, metadata, comments, embedded images and basic repairs | |
|
||||||
| **`label-tools`** | Medium priority; dedicated repository | Generate labels and badges from templates and data | CSV/JSON merge into SVG templates; QR/barcode fields; serial numbers; A4 label sheets; badges; asset tags; cut marks; calibration | Strong suite demonstration linking data, SVG, barcode, random and PDF capabilities; print dimensions must be explicit and testable |
|
| **`package-tools`** | Medium priority | Inspect compound and package-based formats | EPUB; DOCX/XLSX/PPTX; ODF; JAR; APK; browser extensions; package trees; manifests; relationships; embedded media; signatures; orphaned parts | Generic container inspector with format adapters; complements rather than replaces `onenote-tools`, `epub-tools` and `office-tools` |
|
||||||
| **`flow-tools`** | Later platform layer | Compose local processing operations across tools | Example: CSV → filter → QR generation → SVG template → PDF → ZIP; reusable recipes; typed inputs and outputs; progress and cancellation | Do not load complete React applications into one runtime; expose separate worker-safe operation packages only after several apps have stable operations |
|
| **`archive-tools`** | Medium-to-high priority; dedicated repository | Inspect, create, compare and extract archives | ZIP, TAR, gzip and selected additional formats; selective extraction; content preview; deterministic archives; timestamp normalization; archive comparison | Expansion-ratio, entry-count and total-size limits; traversal-safe extraction; never execute extracted files |
|
||||||
| **`fixture-tools`** | Medium priority | Generate deterministic test data | JSON Schema, XSD, SQL DDL, CSV headings or interactive models to JSON, CSV, XML, SQL and NDJSON; foreign keys; distributions; boundary and invalid cases | Seeded reproducibility; uniqueness constraints; privacy-safe synthetic data; natural extension of `rand-tools` |
|
| **`privacy-tools`** | High priority; dedicated repository | Inspect and remove metadata before sharing | EXIF and GPS; document author/comments; embedded thumbnails; PDF metadata and attachments; hidden package entries; AV tags; personal filenames; structured safe-sharing report | Never promise absolute anonymity; explicitly state inspected, removed, preserved and unsupported structures; integrate with most file-oriented apps |
|
||||||
| **`minimize-tools`** | Distinctive specialist tool | Reduce a failing input while preserving a failure | Minimize XML triggering an XSLT error; shortest regex pathological input; smallest JSON schema failure; selectable failure predicates | Expensive repeated execution must be bounded and cancellable; record the exact predicate and transformation versions |
|
| **`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 |
|
||||||
| **`format-lab`** | Later, distinctive product | Explore conversion paths and information loss | Format graph; round-trip tests; property preservation; type coercion; metadata loss; recommended path selection across data, image, AV and subtitle formats | No conversion should be described as lossless without testing relevant properties |
|
| **`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` |
|
||||||
| **`repro-tools`** | Medium-to-later | Create manifests and provenance records | File hashes; directory manifests; operation history; tool/version/parameter records; deterministic package creation; verification reports | Useful foundation for reproducible workflows and signed manifests; integrates with crypto, archive and flow tools |
|
| **`label-tools`** | Medium priority; dedicated repository | Generate labels and badges from templates and data | CSV/JSON merge into SVG templates; QR/barcode fields; serial numbers; A4 label sheets; badges; asset tags; cut marks; calibration | Strong suite demonstration linking data, SVG, barcode, random and PDF capabilities; print dimensions must be explicit and testable |
|
||||||
| **`package-tools`** | Medium priority | Inspect compound and package-based formats | EPUB; DOCX/XLSX/PPTX; ODF; JAR; APK; browser extensions; package trees; manifests; relationships; embedded media; signatures; orphaned parts | Generic container inspector with format adapters; complements rather than replaces `onenote-tools`, `epub-tools` and `office-tools` |
|
| **`font-tools`** | Medium priority; dedicated repository | Inspect, preview and prepare fonts | Glyph coverage; variable axes; metadata; fallback comparison; subsetting; CSS generation; arbitrary-text previews | Font licensing and embedding restrictions must be visible; use untrusted-font isolation and strict parser limits |
|
||||||
| **`schema-tools`** | Dedicated specialist app | Validate, inspect and generate examples for JSON Schema, XSD, Relax NG, Schematron and OpenAPI; visualize references and incompatibilities | |
|
| **`fixture-tools`** | Medium priority | Generate deterministic test data | JSON Schema, XSD, SQL DDL, CSV headings or interactive models to JSON, CSV, XML, SQL and NDJSON; foreign keys; distributions; boundary and invalid cases | Seeded reproducibility; uniqueness constraints; privacy-safe synthetic data; natural extension of `rand-tools` |
|
||||||
| **`log-tools`** | Dedicated app | Stream and filter large logs; parse common formats; correlate records; build timelines; extract fields; anonymize values; avoid loading the complete file into memory | |
|
| **`minimize-tools`** | Distinctive specialist tool | Reduce a failing input while preserving a failure | Minimize XML triggering an XSLT error; shortest regex pathological input; smallest JSON schema failure; selectable failure predicates | Expensive repeated execution must be bounded and cancellable; record the exact predicate and transformation versions |
|
||||||
| **`network-tools`** | Helper family or small app | IPv4/IPv6 subnetting; CIDR ranges; URL and query parsing; DNS-record construction; HTTP headers; CSP generation; MIME lookup; avoid turning it into an intrusive scanner | |
|
| **`format-lab`** | Later, distinctive product | Explore conversion paths and information loss | Format graph; round-trip tests; property preservation; type coercion; metadata loss; recommended path selection across data, image, AV and subtitle formats | No conversion should be described as lossless without testing relevant properties |
|
||||||
| **`binary-tools`** | Dedicated specialist app | Base64, hexadecimal, CBOR, MessagePack, ASN.1 and Protocol Buffers; schema-assisted decoding; byte-range highlighting; strict depth and size limits | |
|
| **`repro-tools`** | Medium-to-later | Create manifests and provenance records | File hashes; directory manifests; operation history; tool/version/parameter records; deterministic package creation; verification reports | Useful foundation for reproducible workflows and signed manifests; integrates with crypto, archive and flow tools |
|
||||||
| **`git-tools`** | Small-to-medium app | Patch inspection and editing; `.gitignore` testing; semantic-version comparison; conventional commits; changelog normalization; no repository-hosting dependency | |
|
| **`schema-tools`** | Dedicated specialist app | Validate, inspect and generate examples for JSON Schema, XSD, Relax NG, Schematron and OpenAPI; visualize references and incompatibilities | |
|
||||||
| **`api-tools`** | Medium app | OpenAPI validation; request and response examples; curl and client-command generation; saved HTTP-exchange inspection; direct browser requests remain subject to CORS | |
|
| **`log-tools`** | Dedicated app | Stream and filter large logs; parse common formats; correlate records; build timelines; extract fields; anonymize values; avoid loading the complete file into memory | |
|
||||||
| **`calendar-tools`** | Small-to-medium app | ICS inspection, merging, deduplication, repair, recurrence visualization and timezone diagnostics | |
|
| **`network-tools`** | Helper family or small app | IPv4/IPv6 subnetting; CIDR ranges; URL and query parsing; DNS-record construction; HTTP headers; CSP generation; MIME lookup; avoid turning it into an intrusive scanner | |
|
||||||
| **`contact-tools`** | Small-to-medium app | vCard inspection and editing; CSV mapping; deduplication; contact QR generation; treat all contact data as sensitive | |
|
| **`binary-tools`** | Dedicated specialist app | Base64, hexadecimal, CBOR, MessagePack, ASN.1 and Protocol Buffers; schema-assisted decoding; byte-range highlighting; strict depth and size limits | |
|
||||||
| **`mail-tools`** | Medium app | EML and MIME inspection; header analysis; attachment extraction; body-part comparison; HTML mail must be heavily sandboxed | |
|
| **`git-tools`** | Small-to-medium app | Patch inspection and editing; `.gitignore` testing; semantic-version comparison; conventional commits; changelog normalization; no repository-hosting dependency | |
|
||||||
| **`epub-tools`** | Medium app | EPUB inspection, metadata and cover editing, link validation, chapter extraction and structural repair; sanitize embedded HTML and SVG | |
|
| **`api-tools`** | Medium app | OpenAPI validation; request and response examples; curl and client-command generation; saved HTTP-exchange inspection; direct browser requests remain subject to CORS | |
|
||||||
| **`scan-tools`** | Large app | Camera/document correction; crop, deskew and threshold; page assembly; local OCR; PDF output; model downloads and memory use must be explicit | |
|
| **`mail-tools`** | Medium app | EML and MIME inspection; header analysis; attachment extraction; body-part comparison; HTML mail must be heavily sandboxed | |
|
||||||
| **`office-tools`** | Medium-to-large app | Inspect rather than reproduce an office suite: package structure, relationships, metadata, comments, embedded images and basic repairs | |
|
| **`calendar-tools`** | Small-to-medium app | ICS inspection, merging, deduplication, repair, recurrence visualization and timezone diagnostics | |
|
||||||
| **`geo-tools`** | Medium app | GeoJSON, GPX, KML and coordinate CSV; format conversion; track simplification; distance/elevation analysis; coordinate-reference assumptions must be explicit | |
|
| **`contact-tools`** | Small-to-medium app | vCard inspection and editing; CSV mapping; deduplication; contact QR generation; treat all contact data as sensitive | |
|
||||||
| **`diagram-tools`** | Medium app | Code and visual editing for Mermaid, Graphviz and related representations; renderers require sanitization and sandboxing | |
|
| **`geo-tools`** | Medium app | GeoJSON, GPX, KML and coordinate CSV; format conversion; track simplification; distance/elevation analysis; coordinate-reference assumptions must be explicit | |
|
||||||
| **`token-tools`** | Small-to-medium app | Design-token editing and conversion between JSON, CSS custom properties, Sass, Android and iOS forms; integrate with colour and SVG tools | |
|
| **`diagram-tools`** | Medium app | Code and visual editing for Mermaid, Graphviz and related representations; renderers require sanitization and sandboxing | |
|
||||||
| **`3d-tools`** | Large specialist app | Inspect STL, OBJ and glTF; dimensions, bounding boxes, mesh simplification and common geometry defects; worker/GPU use and file limits | |
|
| **`token-tools`** | Small-to-medium app | Design-token editing and conversion between JSON, CSS custom properties, Sass, Android and iOS forms; integrate with colour and SVG tools | |
|
||||||
| **`midi-tools`** | Medium specialist app | MIDI event inspection and editing; transpose, quantize, tempo maps and controller data; device access should remain optional | |
|
| **`device-tools`** | Specialist app | Serial terminal, sensor logger, microcontroller console and controlled firmware installation; explicit permission and browser-capability checks | |
|
||||||
| **`device-tools`** | Specialist app | Serial terminal, sensor logger, microcontroller console and controlled firmware installation; explicit permission and browser-capability checks | |
|
|
||||||
| **`helpers-tools`** | One shared artifact with deep links | Stateless Base64, URL encoding, case conversion, number bases, Unicode code points, unit conversion, small checksums, subnet and timestamp calculators; avoid a repository per calculator | |
|
## Planned helpers
|
||||||
|
|
||||||
|
Helpers export funcionality for other tools to use. They may also present a tool surface to be used directly
|
||||||
|
|
||||||
|
| Tool | State and boundary | Principal workflows | Important concrete scope | Critical considerations and integrations |
|
||||||
|
| ------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **`helpers-tools`** | One shared artifact with deep links | Stateless Base64, URL encoding, case conversion, number bases, Unicode code points, unit conversion, small checksums, subnet and timestamp calculators; avoid a repository per calculator | |
|
||||||
|
| **`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 |
|
||||||
|
| **`rand-tools`** | Planned; small dedicated repository | Generate random numbers, strings, identifiers and samples | Cryptographically secure generation; deterministic seeded generation; UUIDs, ULIDs, passphrases, dice notation, distributions, weighted selection and shuffling | Clearly separate secure randomness from reproducible pseudorandomness; no misleading “strength” claims; natural foundation for `fixture-tools` |
|
||||||
|
| **`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` |
|
||||||
|
| **`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 |
|
||||||
|
| **`text-tools`** | Medium priority; one app with deep-linked panels | Transform and normalize plain text | Unicode normalization; line-ending conversion; encoding; sorting; deduplication; case changes; transliteration; escaping; column operations; transformation pipelines | Preserve exact input/output visibility; warn about lossy encodings and Unicode confusables; integrate regex operations |
|
||||||
|
| **`time-tools`** | Medium priority; small dedicated app or helper family | Work with dates, timestamps, time zones and recurrences | Unix timestamps; date arithmetic; durations; ISO 8601; timezone comparison; cron; RRULE; ICS generation; business days; DST-transition visualization | Visualize actual recurrence instances and ambiguous/skipped local times; do not rely on simplified fixed-offset calculations |
|
||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ Runtime components used by the portal include:
|
|||||||
|
|
||||||
- React and React DOM — MIT
|
- React and React DOM — MIT
|
||||||
- `@add-ideas/toolbox-contract` — Apache-2.0
|
- `@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
|
Their complete installed license and notice texts are generated into
|
||||||
`THIRD_PARTY_LICENSES.txt` for every production build.
|
`THIRD_PARTY_LICENSES.txt` for every production build.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Containerfile
|
dockerfile: Containerfile
|
||||||
image: git.add-ideas.de/zemion/toolbox:0.3.0
|
image: git.add-ideas.de/zemion/toolbox:0.7.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
read_only: true
|
read_only: true
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
14
compose.yaml
14
compose.yaml
@@ -6,9 +6,9 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Containerfile.release
|
dockerfile: Containerfile.release
|
||||||
args:
|
args:
|
||||||
TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.3.0}"
|
TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.7.0}"
|
||||||
TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-718a3c8b891e66f5601115270f25acaec9dbe1b9640a72c17954353bb10db93e}"
|
TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-505a1ff1fa10d8831a6110eb52bb1eff7be58ca495ce50f7d67977c893a36e99}"
|
||||||
image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.3.0}"
|
image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.7.0}"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
@@ -25,10 +25,18 @@ services:
|
|||||||
traefik.enable: "true"
|
traefik.enable: "true"
|
||||||
traefik.docker.network: "${TRAEFIK_NETWORK:-internal}"
|
traefik.docker.network: "${TRAEFIK_NETWORK:-internal}"
|
||||||
traefik.http.routers.addideas-toolbox.rule: "Host(`${TOOLBOX_HOST:-toolbox.add-ideas.de}`)"
|
traefik.http.routers.addideas-toolbox.rule: "Host(`${TOOLBOX_HOST:-toolbox.add-ideas.de}`)"
|
||||||
|
traefik.http.routers.addideas-toolbox.priority: 900
|
||||||
traefik.http.routers.addideas-toolbox.entrypoints: websecure
|
traefik.http.routers.addideas-toolbox.entrypoints: websecure
|
||||||
traefik.http.routers.addideas-toolbox.tls: "true"
|
traefik.http.routers.addideas-toolbox.tls: "true"
|
||||||
traefik.http.routers.addideas-toolbox.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"
|
traefik.http.routers.addideas-toolbox.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"
|
||||||
traefik.http.routers.addideas-toolbox.service: addideas-toolbox
|
traefik.http.routers.addideas-toolbox.service: addideas-toolbox
|
||||||
|
traefik.http.routers.addideas-toolbox-http.rule: "Host(`${TOOLBOX_HOST:-toolbox.add-ideas.de}`)"
|
||||||
|
traefik.http.routers.addideas-toolbox-http.priority: 900
|
||||||
|
traefik.http.routers.addideas-toolbox-http.entrypoints: web
|
||||||
|
traefik.http.routers.addideas-toolbox-http.middlewares: addideas-toolbox-https-redirect
|
||||||
|
traefik.http.routers.addideas-toolbox-http.service: addideas-toolbox
|
||||||
|
traefik.http.middlewares.addideas-toolbox-https-redirect.redirectscheme.scheme: https
|
||||||
|
traefik.http.middlewares.addideas-toolbox-https-redirect.redirectscheme.permanent: "true"
|
||||||
traefik.http.services.addideas-toolbox.loadbalancer.server.port: "8080"
|
traefik.http.services.addideas-toolbox.loadbalancer.server.port: "8080"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
map $uri $toolbox_cache_control {
|
map $uri $toolbox_cache_control {
|
||||||
default "no-cache";
|
default "no-cache";
|
||||||
"~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$" "public, max-age=31536000, immutable";
|
"~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$" "public, max-age=31536000, immutable";
|
||||||
|
"~^/apps/[a-z0-9][a-z0-9-]*/vendor/ffmpeg/(?:(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)|0\.12\.10-reviewed-stack5m\.1)/(?:version\.json|(?:st|mt)/ffmpeg-core\.(?:js|wasm)|mt/ffmpeg-core\.worker\.js)$" "public, max-age=31536000, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
@@ -20,7 +21,7 @@ server {
|
|||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'" always;
|
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'" always;
|
||||||
add_header Cache-Control $toolbox_cache_control always;
|
add_header Cache-Control $toolbox_cache_control always;
|
||||||
|
|
||||||
gzip on;
|
gzip on;
|
||||||
@@ -32,7 +33,14 @@ server {
|
|||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.(?:css|js|mjs|wasm|woff2?|png|jpe?g|gif|webp|svg|ico|webmanifest)$ {
|
location ~* \.wasm$ {
|
||||||
|
types {
|
||||||
|
application/wasm wasm;
|
||||||
|
}
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(?:css|js|mjs|woff2?|png|jpe?g|gif|webp|svg|ico|webmanifest)$ {
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
639
package-lock.json
generated
639
package-lock.json
generated
@@ -1,15 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@add-ideas/toolbox-portal",
|
"name": "@add-ideas/toolbox-portal",
|
||||||
"version": "0.1.0",
|
"version": "0.2.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@add-ideas/toolbox-portal",
|
"name": "@add-ideas/toolbox-portal",
|
||||||
"version": "0.1.0",
|
"version": "0.2.6",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "^0.1.0",
|
"@add-ideas/toolbox-contract": "^0.2.2",
|
||||||
|
"@add-ideas/toolbox-shell-react": "^0.2.2",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6"
|
||||||
},
|
},
|
||||||
@@ -22,7 +26,7 @@
|
|||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^8.0.0",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
@@ -40,11 +44,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-contract": {
|
"node_modules/@add-ideas/toolbox-contract": {
|
||||||
"version": "0.1.1",
|
"version": "0.2.2",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.1.1/toolbox-contract-0.1.1.tgz",
|
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.2/toolbox-contract-0.2.2.tgz",
|
||||||
"integrity": "sha512-0q2whA5Y4SqG4PNNleXYR8nGbT3UYsZ1x+6ZWVmxuc/WjMxmujhD7ilIISmFzmnNdbyEyv/Zpr+U0fnHqGODqw==",
|
"integrity": "sha512-VcZ8j4O2PgFaIYgxs6r9u0uPxA+hHKwhCW+omfeVCtbjAAYhH2KhRlBGm0ERVVYcK5kchyzZLXrC4LKWRotVzg==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.2/toolbox-shell-react-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-w/xbLCd50a2Jq7vQ9Z9ygUOuqlOCRkIYlk4vSJx0mf4REJNeMYi3PE2MbaLUC4DkQWyrmatsa5HNT89o0l91BA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@add-ideas/toolbox-contract": "0.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18 <20",
|
||||||
|
"react-dom": ">=18 <20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
"node_modules/@adobe/css-tools": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
|
||||||
@@ -506,6 +523,59 @@
|
|||||||
"node": ">=20.19.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
@@ -752,24 +822,6 @@
|
|||||||
"url": "https://github.com/sponsors/nzakas"
|
"url": "https://github.com/sponsors/nzakas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
|
||||||
"version": "8.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
|
||||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"string-width": "^5.1.2",
|
|
||||||
"string-width-cjs": "npm:string-width@^4.2.0",
|
|
||||||
"strip-ansi": "^7.0.1",
|
|
||||||
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
|
||||||
"wrap-ansi": "^8.1.0",
|
|
||||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -849,17 +901,6 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
|
||||||
"version": "0.11.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
|
||||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||||
@@ -1768,6 +1809,7 @@
|
|||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -1787,41 +1829,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/archiver": {
|
"node_modules/archiver": {
|
||||||
"version": "7.0.1",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz",
|
||||||
"integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==",
|
"integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver-utils": "^5.0.2",
|
|
||||||
"async": "^3.2.4",
|
"async": "^3.2.4",
|
||||||
"buffer-crc32": "^1.0.0",
|
"buffer-crc32": "^1.0.0",
|
||||||
"readable-stream": "^4.0.0",
|
"is-stream": "^4.0.0",
|
||||||
"readdir-glob": "^1.1.2",
|
|
||||||
"tar-stream": "^3.0.0",
|
|
||||||
"zip-stream": "^6.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/archiver-utils": {
|
|
||||||
"version": "5.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz",
|
|
||||||
"integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"glob": "^10.0.0",
|
|
||||||
"graceful-fs": "^4.2.0",
|
|
||||||
"is-stream": "^2.0.1",
|
|
||||||
"lazystream": "^1.0.0",
|
"lazystream": "^1.0.0",
|
||||||
"lodash": "^4.17.15",
|
|
||||||
"normalize-path": "^3.0.0",
|
"normalize-path": "^3.0.0",
|
||||||
"readable-stream": "^4.0.0"
|
"readable-stream": "^4.0.0",
|
||||||
|
"readdir-glob": "^3.0.0",
|
||||||
|
"tar-stream": "^3.0.0",
|
||||||
|
"zip-stream": "^7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/aria-query": {
|
"node_modules/aria-query": {
|
||||||
@@ -2006,16 +2031,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
@@ -2118,41 +2143,21 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/color-convert": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-name": "~1.1.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=7.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-name": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/compress-commons": {
|
"node_modules/compress-commons": {
|
||||||
"version": "6.0.2",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz",
|
||||||
"integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==",
|
"integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"crc-32": "^1.2.0",
|
"crc-32": "^1.2.0",
|
||||||
"crc32-stream": "^6.0.0",
|
"crc32-stream": "^7.0.1",
|
||||||
"is-stream": "^2.0.1",
|
"is-stream": "^4.0.0",
|
||||||
"normalize-path": "^3.0.0",
|
"normalize-path": "^3.0.0",
|
||||||
"readable-stream": "^4.0.0"
|
"readable-stream": "^4.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
@@ -2183,9 +2188,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/crc32-stream": {
|
"node_modules/crc32-stream": {
|
||||||
"version": "6.0.0",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz",
|
||||||
"integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==",
|
"integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2193,7 +2198,7 @@
|
|||||||
"readable-stream": "^4.0.0"
|
"readable-stream": "^4.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
@@ -2313,13 +2318,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/eastasianwidth": {
|
|
||||||
"version": "0.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
|
||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.393",
|
"version": "1.5.393",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz",
|
||||||
@@ -2327,13 +2325,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/emoji-regex": {
|
|
||||||
"version": "9.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
|
||||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||||
@@ -2709,23 +2700,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/foreground-child": {
|
|
||||||
"version": "3.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
|
||||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"cross-spawn": "^7.0.6",
|
|
||||||
"signal-exit": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -2751,28 +2725,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob": {
|
|
||||||
"version": "10.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
|
||||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
|
||||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"foreground-child": "^3.1.0",
|
|
||||||
"jackspeak": "^3.1.2",
|
|
||||||
"minimatch": "^9.0.4",
|
|
||||||
"minipass": "^7.1.2",
|
|
||||||
"package-json-from-dist": "^1.0.0",
|
|
||||||
"path-scurry": "^1.11.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"glob": "dist/esm/bin.mjs"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -2786,39 +2738,6 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob/node_modules/balanced-match": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/glob/node_modules/brace-expansion": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/glob/node_modules/minimatch": {
|
|
||||||
"version": "9.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
|
||||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16 || 14 >=14.17"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "17.7.0",
|
"version": "17.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
|
||||||
@@ -2832,13 +2751,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/graceful-fs": {
|
|
||||||
"version": "4.2.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/hermes-estree": {
|
"node_modules/hermes-estree": {
|
||||||
"version": "0.25.1",
|
"version": "0.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||||
@@ -2937,16 +2849,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-fullwidth-code-point": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-glob": {
|
"node_modules/is-glob": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
@@ -2968,13 +2870,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/is-stream": {
|
"node_modules/is-stream": {
|
||||||
"version": "2.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
|
||||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
@@ -2994,22 +2896,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/jackspeak": {
|
|
||||||
"version": "3.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
|
||||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@isaacs/cliui": "^8.0.2"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@pkgjs/parseargs": "^0.11.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -3474,13 +3360,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
|
||||||
"version": "4.18.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
|
||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -3545,16 +3424,6 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minipass": {
|
|
||||||
"version": "7.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
|
||||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16 || 14 >=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -3672,13 +3541,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/package-json-from-dist": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0"
|
|
||||||
},
|
|
||||||
"node_modules/parse5": {
|
"node_modules/parse5": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||||
@@ -3712,30 +3574,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-scurry": {
|
|
||||||
"version": "1.11.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
|
||||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"lru-cache": "^10.2.0",
|
|
||||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16 || 14 >=14.18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/path-scurry/node_modules/lru-cache": {
|
|
||||||
"version": "10.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
|
||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/pathe": {
|
"node_modules/pathe": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
@@ -3915,43 +3753,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readdir-glob": {
|
"node_modules/readdir-glob": {
|
||||||
"version": "1.1.3",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz",
|
||||||
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
"integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimatch": "^5.1.0"
|
"minimatch": "^10.2.2"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readdir-glob/node_modules/balanced-match": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/readdir-glob/node_modules/brace-expansion": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readdir-glob/node_modules/minimatch": {
|
|
||||||
"version": "5.1.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
|
||||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^2.0.1"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/yqnn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
@@ -4092,19 +3906,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/signal-exit": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -4151,103 +3952,6 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/string-width": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"eastasianwidth": "^0.2.0",
|
|
||||||
"emoji-regex": "^9.2.2",
|
|
||||||
"strip-ansi": "^7.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/string-width-cjs": {
|
|
||||||
"name": "string-width",
|
|
||||||
"version": "4.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"emoji-regex": "^8.0.0",
|
|
||||||
"is-fullwidth-code-point": "^3.0.0",
|
|
||||||
"strip-ansi": "^6.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
|
||||||
"version": "8.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-regex": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/strip-ansi": {
|
|
||||||
"version": "7.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
|
||||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-regex": "^6.2.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/strip-ansi-cjs": {
|
|
||||||
"name": "strip-ansi",
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-regex": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/strip-ansi/node_modules/ansi-regex": {
|
|
||||||
"version": "6.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
|
||||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/strip-indent": {
|
"node_modules/strip-indent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||||
@@ -4408,9 +4112,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
@@ -4787,107 +4489,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wrap-ansi": {
|
|
||||||
"version": "8.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
|
||||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-styles": "^6.1.0",
|
|
||||||
"string-width": "^5.0.1",
|
|
||||||
"strip-ansi": "^7.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wrap-ansi-cjs": {
|
|
||||||
"name": "wrap-ansi",
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-styles": "^4.0.0",
|
|
||||||
"string-width": "^4.1.0",
|
|
||||||
"strip-ansi": "^6.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
|
|
||||||
"version": "4.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-convert": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
|
||||||
"version": "8.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
|
||||||
"version": "4.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"emoji-regex": "^8.0.0",
|
|
||||||
"is-fullwidth-code-point": "^3.0.0",
|
|
||||||
"strip-ansi": "^6.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-regex": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
|
||||||
"version": "6.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
|
||||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xml-name-validator": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
@@ -4939,18 +4540,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zip-stream": {
|
"node_modules/zip-stream": {
|
||||||
"version": "6.0.1",
|
"version": "7.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz",
|
||||||
"integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==",
|
"integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver-utils": "^5.0.0",
|
"compress-commons": "^7.0.0",
|
||||||
"compress-commons": "^6.0.2",
|
"normalize-path": "^3.0.0",
|
||||||
"readable-stream": "^4.0.0"
|
"readable-stream": "^4.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@add-ideas/toolbox-portal",
|
"name": "@add-ideas/toolbox-portal",
|
||||||
"version": "0.1.0",
|
"version": "0.2.6",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -21,7 +21,11 @@
|
|||||||
"package:static": "node scripts/package-static.mjs"
|
"package:static": "node scripts/package-static.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "^0.1.0",
|
"@add-ideas/toolbox-contract": "^0.2.2",
|
||||||
|
"@add-ideas/toolbox-shell-react": "^0.2.2",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6"
|
||||||
},
|
},
|
||||||
@@ -34,7 +38,7 @@
|
|||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^8.0.0",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Corresponding source
|
# 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.6 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.7.0
|
||||||
|
|
||||||
Each bundled application contains its own `SOURCE.md`, license, and third-party
|
Each bundled application contains its own `SOURCE.md`, license, and third-party
|
||||||
license materials. The application sources are also linked from the portal.
|
license materials. The application sources are also linked from the portal.
|
||||||
|
|||||||
@@ -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
|
toolbox is a collection of independently licensed works, not a relicensing of
|
||||||
every included component under one licence.
|
every included component under one licence.
|
||||||
|
|
||||||
Portal runtime components include React and React DOM (MIT) and
|
Portal runtime components include React and React DOM (MIT),
|
||||||
`@add-ideas/toolbox-contract` (Apache-2.0). Their complete installed license and
|
`@add-ideas/toolbox-contract` and `@add-ideas/toolbox-shell-react`
|
||||||
notice texts are shipped in `THIRD_PARTY_LICENSES.txt`. Each application remains
|
(Apache-2.0), dnd-kit (MIT), and tslib (0BSD). Their complete installed license
|
||||||
under its own licence. Application notices are shipped inside its
|
and notice texts are shipped in `THIRD_PARTY_LICENSES.txt`. Each application
|
||||||
|
remains under its own licence. Application notices are shipped inside its
|
||||||
`apps/<slug>/LICENSES/` directory.
|
`apps/<slug>/LICENSES/` directory.
|
||||||
|
|||||||
@@ -20,6 +20,14 @@
|
|||||||
{
|
{
|
||||||
"manifest": "./apps/onenote/toolbox-app.json",
|
"manifest": "./apps/onenote/toolbox-app.json",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/av/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/regex/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./toolbox-release-lock.schema.json",
|
"$schema": "./toolbox-release-lock.schema.json",
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"releaseVersion": "0.3.0",
|
"releaseVersion": "0.7.0",
|
||||||
"portalVersion": "0.1.0",
|
"portalVersion": "0.2.6",
|
||||||
"catalogue": {
|
"catalogue": {
|
||||||
"id": "de.add-ideas.toolbox",
|
"id": "de.add-ideas.toolbox",
|
||||||
"name": "add·ideas Toolbox",
|
"name": "add·ideas Toolbox",
|
||||||
@@ -15,24 +15,38 @@
|
|||||||
"apps": [
|
"apps": [
|
||||||
{
|
{
|
||||||
"id": "de.add-ideas.pdf-tools",
|
"id": "de.add-ideas.pdf-tools",
|
||||||
"version": "0.3.4",
|
"version": "0.4.3",
|
||||||
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.3.4/pdf-tools-0.3.4.zip",
|
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.3/pdf-tools-0.4.3.zip",
|
||||||
"sha256": "090492aaee4bd5cde51917ee3d9d0c971f071c27b7dbe4c918876ffd752dd930",
|
"sha256": "f2dfd3bade49a4b8be9c0c54c6c2bf1bbb153fc0505a38af14de5ab52d5183cf",
|
||||||
"target": "pdf"
|
"target": "pdf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "de.add-ideas.xslt-tools",
|
"id": "de.add-ideas.xslt-tools",
|
||||||
"version": "0.3.2",
|
"version": "0.4.2",
|
||||||
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.3.2/xslt-tools-0.3.2.zip",
|
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.2/xslt-tools-0.4.2.zip",
|
||||||
"sha256": "e1746ac60f7143037ce77d7f04c62f7da39a92d8cef42e7167acb2bc00786de1",
|
"sha256": "daaa821c10c6c6285f97128ba3d923e48efa24449f1fb0a21bf478abaf27499f",
|
||||||
"target": "xslt"
|
"target": "xslt"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "de.add-ideas.onenote-tools",
|
"id": "de.add-ideas.onenote-tools",
|
||||||
"version": "0.2.0",
|
"version": "0.3.3",
|
||||||
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.2.0/onenote-tools-0.2.0.zip",
|
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.3/onenote-tools-0.3.3.zip",
|
||||||
"sha256": "660c8c53bfd2f21875d41910d208d6638995f8dc31ba150ffa57d5591f56b76b",
|
"sha256": "37c0ef7cdc7f435d0d4f5a341523f4e522118d5b258c374b5a7ef7b8e6c27cec",
|
||||||
"target": "onenote"
|
"target": "onenote"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.av-tools",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/zemion/av-tools/releases/download/v0.2.0/av-tools-0.2.0.zip",
|
||||||
|
"sha256": "40f9e7f48617930ddcba25538bd12d0a24197c7c4824989f20fd23fd25349a54",
|
||||||
|
"target": "av"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.regex-tools",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/zemion/regex-tools/releases/download/v0.2.0/regex-tools-0.2.0.zip",
|
||||||
|
"sha256": "b65820ee0913f352d4fad8aacc5b62eed8b3328957e3f4c4d7bee722e6537acd",
|
||||||
|
"target": "regex"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./toolbox-release-lock.schema.json",
|
"$schema": "./toolbox-release-lock.schema.json",
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"releaseVersion": "0.3.0",
|
"releaseVersion": "0.7.0",
|
||||||
"portalVersion": "0.1.0",
|
"portalVersion": "0.2.6",
|
||||||
"catalogue": {
|
"catalogue": {
|
||||||
"id": "de.add-ideas.toolbox",
|
"id": "de.add-ideas.toolbox",
|
||||||
"name": "add·ideas Toolbox",
|
"name": "add·ideas Toolbox",
|
||||||
@@ -15,24 +15,38 @@
|
|||||||
"apps": [
|
"apps": [
|
||||||
{
|
{
|
||||||
"id": "de.add-ideas.pdf-tools",
|
"id": "de.add-ideas.pdf-tools",
|
||||||
"version": "0.3.4",
|
"version": "0.4.3",
|
||||||
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.3.4/pdf-tools-0.3.4.zip",
|
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.3/pdf-tools-0.4.3.zip",
|
||||||
"sha256": "090492aaee4bd5cde51917ee3d9d0c971f071c27b7dbe4c918876ffd752dd930",
|
"sha256": "f2dfd3bade49a4b8be9c0c54c6c2bf1bbb153fc0505a38af14de5ab52d5183cf",
|
||||||
"target": "pdf"
|
"target": "pdf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "de.add-ideas.xslt-tools",
|
"id": "de.add-ideas.xslt-tools",
|
||||||
"version": "0.3.2",
|
"version": "0.4.2",
|
||||||
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.3.2/xslt-tools-0.3.2.zip",
|
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.2/xslt-tools-0.4.2.zip",
|
||||||
"sha256": "e1746ac60f7143037ce77d7f04c62f7da39a92d8cef42e7167acb2bc00786de1",
|
"sha256": "daaa821c10c6c6285f97128ba3d923e48efa24449f1fb0a21bf478abaf27499f",
|
||||||
"target": "xslt"
|
"target": "xslt"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "de.add-ideas.onenote-tools",
|
"id": "de.add-ideas.onenote-tools",
|
||||||
"version": "0.2.0",
|
"version": "0.3.3",
|
||||||
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.2.0/onenote-tools-0.2.0.zip",
|
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.3/onenote-tools-0.3.3.zip",
|
||||||
"sha256": "660c8c53bfd2f21875d41910d208d6638995f8dc31ba150ffa57d5591f56b76b",
|
"sha256": "37c0ef7cdc7f435d0d4f5a341523f4e522118d5b258c374b5a7ef7b8e6c27cec",
|
||||||
"target": "onenote"
|
"target": "onenote"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.av-tools",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/zemion/av-tools/releases/download/v0.2.0/av-tools-0.2.0.zip",
|
||||||
|
"sha256": "40f9e7f48617930ddcba25538bd12d0a24197c7c4824989f20fd23fd25349a54",
|
||||||
|
"target": "av"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.regex-tools",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/zemion/regex-tools/releases/download/v0.2.0/regex-tools-0.2.0.zip",
|
||||||
|
"sha256": "b65820ee0913f352d4fad8aacc5b62eed8b3328957e3f4c4d7bee722e6537acd",
|
||||||
|
"target": "regex"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createReadStream, createWriteStream } from 'node:fs';
|
|||||||
import { mkdir, readdir, stat } from 'node:fs/promises';
|
import { mkdir, readdir, stat } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { pipeline } from 'node:stream/promises';
|
import { pipeline } from 'node:stream/promises';
|
||||||
import archiver from 'archiver';
|
import { ZipArchive } from 'archiver';
|
||||||
import {
|
import {
|
||||||
canonicalExistingPath,
|
canonicalExistingPath,
|
||||||
canonicalPathsOverlap,
|
canonicalPathsOverlap,
|
||||||
@@ -56,7 +56,7 @@ export async function createStaticArchive(inputDirectory, outputFile) {
|
|||||||
throw new Error(`Archive input is not a directory: ${source}`);
|
throw new Error(`Archive input is not a directory: ${source}`);
|
||||||
await mkdir(path.dirname(output), { recursive: true });
|
await mkdir(path.dirname(output), { recursive: true });
|
||||||
|
|
||||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
const archive = new ZipArchive({ zlib: { level: 9 } });
|
||||||
const destination = createWriteStream(output, { flags: 'wx' });
|
const destination = createWriteStream(output, { flags: 'wx' });
|
||||||
archive.on('warning', (error) => {
|
archive.on('warning', (error) => {
|
||||||
if (error.code !== 'ENOENT') destination.destroy(error);
|
if (error.code !== 'ENOENT') destination.destroy(error);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { pipeline } from 'node:stream/promises';
|
import { pipeline } from 'node:stream/promises';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
import archiver from 'archiver';
|
import { ZipArchive } from 'archiver';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { assembleRelease } from './assemble.mjs';
|
import { assembleRelease } from './assemble.mjs';
|
||||||
import { createStaticArchive } from './archive.mjs';
|
import { createStaticArchive } from './archive.mjs';
|
||||||
@@ -46,7 +46,7 @@ async function zipEntries(
|
|||||||
output: string,
|
output: string,
|
||||||
entries: Array<{ name: string; content: string }>
|
entries: Array<{ name: string; content: string }>
|
||||||
) {
|
) {
|
||||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
const archive = new ZipArchive({ zlib: { level: 9 } });
|
||||||
const writing = pipeline(archive, createWriteStream(output, { flags: 'wx' }));
|
const writing = pipeline(archive, createWriteStream(output, { flags: 'wx' }));
|
||||||
for (const entry of entries)
|
for (const entry of entries)
|
||||||
archive.append(entry.content, { name: entry.name });
|
archive.append(entry.content, { name: entry.name });
|
||||||
@@ -83,7 +83,7 @@ function makeLock(artifact: string, sha256: string) {
|
|||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
releaseVersion: '0.1.0',
|
releaseVersion: '0.1.0',
|
||||||
portalVersion: '0.1.0',
|
portalVersion: '0.2.6',
|
||||||
catalogue: {
|
catalogue: {
|
||||||
id: 'de.add-ideas.toolbox',
|
id: 'de.add-ideas.toolbox',
|
||||||
name: 'Test Toolbox',
|
name: 'Test Toolbox',
|
||||||
@@ -238,7 +238,7 @@ describe('release assembly integrity', () => {
|
|||||||
it('blocks symbolic links before extraction', async () => {
|
it('blocks symbolic links before extraction', async () => {
|
||||||
const directory = await temporaryDirectory();
|
const directory = await temporaryDirectory();
|
||||||
const artifact = path.join(directory, 'symlink.zip');
|
const artifact = path.join(directory, 'symlink.zip');
|
||||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
const archive = new ZipArchive({ zlib: { level: 9 } });
|
||||||
const writing = pipeline(
|
const writing = pipeline(
|
||||||
archive,
|
archive,
|
||||||
createWriteStream(artifact, { flags: 'wx' })
|
createWriteStream(artifact, { flags: 'wx' })
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
const compose = readFileSync(path.join(projectRoot, 'compose.yaml'), 'utf8');
|
const compose = readFileSync(path.join(projectRoot, 'compose.yaml'), 'utf8');
|
||||||
|
const composeExample = readFileSync(
|
||||||
|
path.join(projectRoot, 'compose.example.yaml'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
const containerfile = readFileSync(
|
const containerfile = readFileSync(
|
||||||
path.join(projectRoot, 'Containerfile.release'),
|
path.join(projectRoot, 'Containerfile.release'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
const environmentExample = readFileSync(
|
||||||
|
path.join(projectRoot, '.env.example'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
const nginxConfig = readFileSync(
|
const nginxConfig = readFileSync(
|
||||||
path.join(projectRoot, 'deploy', 'nginx.conf'),
|
path.join(projectRoot, 'deploy', 'nginx.conf'),
|
||||||
'utf8'
|
'utf8'
|
||||||
@@ -16,6 +24,23 @@ const releaseLock = JSON.parse(
|
|||||||
readFileSync(path.join(projectRoot, 'release', 'toolbox.lock.json'), 'utf8')
|
readFileSync(path.join(projectRoot, 'release', 'toolbox.lock.json'), 'utf8')
|
||||||
) as { releaseVersion: string };
|
) as { releaseVersion: string };
|
||||||
|
|
||||||
|
const immutableCacheControl = 'public, max-age=31536000, immutable';
|
||||||
|
const immutableUriPatterns = Array.from(
|
||||||
|
nginxConfig.matchAll(
|
||||||
|
new RegExp(
|
||||||
|
`^\\s*"~([^"]+)"\\s+"${immutableCacheControl.replaceAll(
|
||||||
|
', ',
|
||||||
|
',\\s+'
|
||||||
|
)}";$`,
|
||||||
|
'gmu'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(match) => new RegExp(match[1], 'u')
|
||||||
|
);
|
||||||
|
|
||||||
|
const isImmutable = (uri: string): boolean =>
|
||||||
|
immutableUriPatterns.some((pattern) => pattern.test(uri));
|
||||||
|
|
||||||
describe('production deployment', () => {
|
describe('production deployment', () => {
|
||||||
it('keeps the release version and checksum pins synchronized', () => {
|
it('keeps the release version and checksum pins synchronized', () => {
|
||||||
const containerVersion = containerfile.match(
|
const containerVersion = containerfile.match(
|
||||||
@@ -30,10 +55,22 @@ describe('production deployment', () => {
|
|||||||
const composeChecksum = compose.match(
|
const composeChecksum = compose.match(
|
||||||
/\$\{TOOLBOX_RELEASE_SHA256:-([a-f0-9]{64})\}/u
|
/\$\{TOOLBOX_RELEASE_SHA256:-([a-f0-9]{64})\}/u
|
||||||
)?.[1];
|
)?.[1];
|
||||||
|
const environmentVersion = environmentExample.match(
|
||||||
|
/^TOOLBOX_RELEASE_VERSION=(\S+)$/mu
|
||||||
|
)?.[1];
|
||||||
|
const environmentChecksum = environmentExample.match(
|
||||||
|
/^TOOLBOX_RELEASE_SHA256=([a-f0-9]{64})$/mu
|
||||||
|
)?.[1];
|
||||||
|
const localImageVersion = composeExample.match(
|
||||||
|
/^\s+image: git\.add-ideas\.de\/zemion\/toolbox:(\S+)$/mu
|
||||||
|
)?.[1];
|
||||||
|
|
||||||
expect(containerVersion).toBe(releaseLock.releaseVersion);
|
expect(containerVersion).toBe(releaseLock.releaseVersion);
|
||||||
expect(composeVersion).toBe(containerVersion);
|
expect(composeVersion).toBe(containerVersion);
|
||||||
expect(composeChecksum).toBe(containerChecksum);
|
expect(composeChecksum).toBe(containerChecksum);
|
||||||
|
expect(environmentVersion).toBe(containerVersion);
|
||||||
|
expect(environmentChecksum).toBe(containerChecksum);
|
||||||
|
expect(localImageVersion).toBe(containerVersion);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('verifies the release before extraction and retains a rootless final image', () => {
|
it('verifies the release before extraction and retains a rootless final image', () => {
|
||||||
@@ -45,10 +82,15 @@ describe('production deployment', () => {
|
|||||||
expect(containerfile).toContain(
|
expect(containerfile).toContain(
|
||||||
'COPY --from=release --chown=101:101 /tmp/toolbox/'
|
'COPY --from=release --chown=101:101 /tmp/toolbox/'
|
||||||
);
|
);
|
||||||
|
for (const app of ['pdf', 'xslt', 'onenote', 'av', 'regex']) {
|
||||||
|
expect(containerfile).toContain(
|
||||||
|
`test -f /tmp/toolbox/apps/${app}/toolbox-app.json`
|
||||||
|
);
|
||||||
|
}
|
||||||
expect(containerfile).toMatch(/\nUSER 101:101\nEXPOSE 8080\n/u);
|
expect(containerfile).toMatch(/\nUSER 101:101\nEXPOSE 8080\n/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses only the requested external Traefik network and HTTPS router', () => {
|
it('uses only the requested external Traefik network and redirects HTTP to HTTPS', () => {
|
||||||
expect(compose).toContain('dockerfile: Containerfile.release');
|
expect(compose).toContain('dockerfile: Containerfile.release');
|
||||||
expect(compose).toContain('traefik.enable: "true"');
|
expect(compose).toContain('traefik.enable: "true"');
|
||||||
expect(compose).toContain(
|
expect(compose).toContain(
|
||||||
@@ -63,6 +105,27 @@ describe('production deployment', () => {
|
|||||||
expect(compose).toContain(
|
expect(compose).toContain(
|
||||||
'traefik.http.routers.addideas-toolbox.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"'
|
'traefik.http.routers.addideas-toolbox.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"'
|
||||||
);
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.routers.addideas-toolbox-http.rule: "Host(`${TOOLBOX_HOST:-toolbox.add-ideas.de}`)"'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.routers.addideas-toolbox-http.entrypoints: web'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.routers.addideas-toolbox-http.priority: 900'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.routers.addideas-toolbox-http.middlewares: addideas-toolbox-https-redirect'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.routers.addideas-toolbox-http.service: addideas-toolbox'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.middlewares.addideas-toolbox-https-redirect.redirectscheme.scheme: https'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.middlewares.addideas-toolbox-https-redirect.redirectscheme.permanent: "true"'
|
||||||
|
);
|
||||||
expect(compose).toContain('external: true');
|
expect(compose).toContain('external: true');
|
||||||
expect(compose).not.toMatch(/^\s+ports:/mu);
|
expect(compose).not.toMatch(/^\s+ports:/mu);
|
||||||
});
|
});
|
||||||
@@ -73,4 +136,67 @@ describe('production deployment', () => {
|
|||||||
);
|
);
|
||||||
expect(nginxConfig).not.toMatch(/^\s*~[^\n]*\{\d/mu);
|
expect(nginxConfig).not.toMatch(/^\s*~[^\n]*\{\d/mu);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('caches only hashed assets and the versioned FFmpeg runtime immutably', () => {
|
||||||
|
expect(immutableUriPatterns).toHaveLength(2);
|
||||||
|
|
||||||
|
for (const uri of [
|
||||||
|
'/assets/index-Bp9nK_3a.js',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10/version.json',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.js',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/version.json',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
|
||||||
|
]) {
|
||||||
|
expect(isImmutable(uri), uri).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const uri of [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/SOURCE.md',
|
||||||
|
'/LICENSE',
|
||||||
|
'/toolbox.catalog.json',
|
||||||
|
'/toolbox.release.json',
|
||||||
|
'/apps/av/toolbox-app.json',
|
||||||
|
'/apps/av/THIRD_PARTY_NOTICES.md',
|
||||||
|
'/apps/av/vendor/ffmpeg/version.json',
|
||||||
|
'/apps/av/vendor/ffmpeg/latest/st/ffmpeg-core.wasm',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12/st/ffmpeg-core.wasm',
|
||||||
|
'/apps/av/vendor/ffmpeg/01.2.3/st/ffmpeg-core.wasm',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10/st/other.wasm',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.wasm',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10/ffmpeg-core.wasm',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10-/st/ffmpeg-core.wasm',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10-reviewed_/st/ffmpeg-core.wasm',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10-latest/st/ffmpeg-core.wasm',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10-SNAPSHOT/st/ffmpeg-core.wasm',
|
||||||
|
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.2/st/ffmpeg-core.wasm',
|
||||||
|
]) {
|
||||||
|
expect(isImmutable(uri), uri).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves WebAssembly through an explicit MIME-mapped location', () => {
|
||||||
|
expect(nginxConfig).toMatch(
|
||||||
|
/location ~\* \\\.wasm\$\s*\{\s*types\s*\{\s*application\/wasm wasm;\s*\}\s*try_files \$uri =404;\s*\}/mu
|
||||||
|
);
|
||||||
|
expect(nginxConfig).not.toMatch(
|
||||||
|
/location ~\* [^\n]*\(\?:[^\n|)]*\bwasm\b[^\n)]*\)/u
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows same-origin WebAssembly workers and local blob media previews', () => {
|
||||||
|
expect(nginxConfig).toContain("script-src 'self' 'wasm-unsafe-eval'");
|
||||||
|
expect(nginxConfig).toContain("worker-src 'self' blob:");
|
||||||
|
expect(nginxConfig).toContain("media-src 'self' blob:");
|
||||||
|
expect(nginxConfig).not.toContain("'unsafe-eval'");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import { fileURLToPath } from 'node:url';
|
|||||||
const LICENSE_FILE = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/iu;
|
const LICENSE_FILE = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/iu;
|
||||||
const RUNTIME_PACKAGES = [
|
const RUNTIME_PACKAGES = [
|
||||||
'@add-ideas/toolbox-contract',
|
'@add-ideas/toolbox-contract',
|
||||||
|
'@add-ideas/toolbox-shell-react',
|
||||||
|
'@dnd-kit/accessibility',
|
||||||
|
'@dnd-kit/core',
|
||||||
|
'@dnd-kit/sortable',
|
||||||
|
'@dnd-kit/utilities',
|
||||||
'react',
|
'react',
|
||||||
'react-dom',
|
'react-dom',
|
||||||
'scheduler',
|
'scheduler',
|
||||||
|
'tslib',
|
||||||
];
|
];
|
||||||
|
|
||||||
function compareCodePoints(left, right) {
|
function compareCodePoints(left, right) {
|
||||||
|
|||||||
552
src/App.test.tsx
552
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 userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
@@ -7,39 +13,70 @@ import { catalogue, catalogueFetch } from './test/fixtures';
|
|||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
function toolLink(name: string) {
|
||||||
|
return screen.getByRole('link', { name: `Open ${name}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryToolLink(name: string) {
|
||||||
|
return screen.queryByRole('link', { name: `Open ${name}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
function findToolLink(name = 'PDF Workbench') {
|
||||||
|
return screen.findByRole('link', { name: `Open ${name}` });
|
||||||
|
}
|
||||||
|
|
||||||
describe('portal UI', () => {
|
describe('portal UI', () => {
|
||||||
it('renders app cards and persists accessible pin/hide controls', async () => {
|
it('renders compact actionable tiles and persists pin/hide controls', async () => {
|
||||||
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
const launch = await findToolLink();
|
||||||
|
const card = screen.getByTestId('app-card-de.add-ideas.pdf-tools');
|
||||||
|
expect(within(card).getByRole('heading', { level: 3 })).toHaveTextContent(
|
||||||
|
'PDF'
|
||||||
|
);
|
||||||
|
expect(within(card).getByText('Arrange PDF pages locally.')).toBeVisible();
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('heading', { name: 'PDF Workbench' })
|
screen.getByTestId('app-card-de.add-ideas.xslt-tools')
|
||||||
).toBeInTheDocument();
|
).toHaveTextContent('XSLT');
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByTestId('app-card-de.add-ideas.onenote-tools')
|
||||||
|
).toHaveTextContent('OneNote');
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
'Page-level PDF operations performed locally in the browser.'
|
||||||
|
)
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
'Local processing · no file uploads declared · no telemetry declared'
|
'Local processing · no file uploads declared · no telemetry declared'
|
||||||
)
|
)
|
||||||
).toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
const sourceLinks = screen.getAllByRole('link', {
|
expect(
|
||||||
name: 'Source and license',
|
screen.getAllByRole('link', { name: 'Source and license' })
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('region', { name: 'Pinned' })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
const showHidden = screen.getByRole('checkbox', {
|
||||||
|
name: 'Show hidden (0)',
|
||||||
});
|
});
|
||||||
expect(sourceLinks).toHaveLength(2);
|
expect(showHidden).toBeDisabled();
|
||||||
expect(
|
expect(showHidden).not.toBeChecked();
|
||||||
sourceLinks.find(
|
expect(launch).toHaveClass('app-card__launch-link');
|
||||||
(link) =>
|
|
||||||
link.getAttribute('href') ===
|
|
||||||
'https://git.add-ideas.de/zemion/pdf-tools'
|
|
||||||
)
|
|
||||||
).toHaveAttribute('rel', 'noopener noreferrer');
|
|
||||||
expect(
|
|
||||||
sourceLinks.find((link) =>
|
|
||||||
link.getAttribute('href')?.endsWith('/toolbox-portal/src/tag/v0.1.0')
|
|
||||||
)
|
|
||||||
).toBeDefined();
|
|
||||||
const launch = screen.getByRole('link', { name: /open tool/i });
|
|
||||||
expect(
|
expect(
|
||||||
new URL(launch.getAttribute('href')!).searchParams.get('toolbox')
|
new URL(launch.getAttribute('href')!).searchParams.get('toolbox')
|
||||||
).toContain('toolbox.catalog.json');
|
).toContain('toolbox.catalog.json');
|
||||||
|
const actions = within(card).getByRole('group', {
|
||||||
|
name: 'Actions for PDF Workbench',
|
||||||
|
});
|
||||||
|
const info = within(actions).getByRole('button', {
|
||||||
|
name: 'More information about PDF Workbench',
|
||||||
|
});
|
||||||
|
expect(info).toHaveAttribute('aria-haspopup', 'dialog');
|
||||||
|
for (const control of within(actions).getAllByRole('button'))
|
||||||
|
expect(control.closest('a')).toBeNull();
|
||||||
|
expect(launch).not.toContainElement(info);
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Pin PDF Workbench' }));
|
await user.click(screen.getByRole('button', { name: 'Pin PDF Workbench' }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -47,18 +84,440 @@ describe('portal UI', () => {
|
|||||||
'de.add-ideas.pdf-tools'
|
'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();
|
||||||
|
const unpin = screen.getByRole('button', {
|
||||||
|
name: 'Unpin PDF Workbench',
|
||||||
|
});
|
||||||
|
expect(unpin).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(unpin).toHaveClass('is-active');
|
||||||
|
await waitFor(() => expect(unpin).toHaveFocus());
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'Reorder PDF Workbench' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(
|
await user.click(
|
||||||
screen.getByRole('button', { name: 'Hide PDF Workbench' })
|
screen.getByRole('button', { name: 'Hide PDF Workbench' })
|
||||||
);
|
);
|
||||||
|
expect(queryToolLink('PDF Workbench')).not.toBeInTheDocument();
|
||||||
|
expect(showHidden).toBeEnabled();
|
||||||
|
expect(showHidden).toHaveAccessibleName('Show hidden (1)');
|
||||||
|
await waitFor(() => expect(showHidden).toHaveFocus());
|
||||||
|
fireEvent.click(showHidden);
|
||||||
|
expect(toolLink('PDF Workbench')).toBeInTheDocument();
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: 'Show PDF Workbench' })
|
||||||
|
);
|
||||||
|
expect(showHidden).toBeDisabled();
|
||||||
|
expect(showHidden).not.toBeChecked();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'Hide PDF Workbench' })
|
||||||
|
).toHaveFocus()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the compact Regex presentation for the released tool', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(
|
||||||
|
catalogueFetch({
|
||||||
|
'/toolbox.catalog.json': Response.json({
|
||||||
|
...catalogue,
|
||||||
|
apps: [
|
||||||
|
...catalogue.apps,
|
||||||
|
{ manifest: './apps/regex/toolbox-app.json', enabled: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await findToolLink();
|
||||||
|
const card = screen.getByTestId('app-card-de.add-ideas.regex-tools');
|
||||||
|
expect(within(card).getByRole('heading', { level: 3 })).toHaveTextContent(
|
||||||
|
'Regex'
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole('heading', { name: 'PDF Workbench' })
|
within(card).getByText('Explain and test regex locally.')
|
||||||
).not.toBeInTheDocument();
|
).toBeVisible();
|
||||||
fireEvent.click(screen.getByRole('checkbox', { name: /show hidden/i }));
|
});
|
||||||
|
|
||||||
|
it('moves the full disclosure into an accessible modal', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { container } = render(<App />);
|
||||||
|
await findToolLink();
|
||||||
|
const info = screen.getByRole('button', {
|
||||||
|
name: 'More information about PDF Workbench',
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(info);
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'PDF Workbench' });
|
||||||
|
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||||
|
expect(dialog).toHaveTextContent(
|
||||||
|
'Page-level PDF operations performed locally in the browser.'
|
||||||
|
);
|
||||||
|
expect(dialog).toHaveTextContent(
|
||||||
|
'Local processing · no file uploads declared · no telemetry declared'
|
||||||
|
);
|
||||||
|
expect(dialog).toHaveTextContent(
|
||||||
|
'Needs Secure context · Web workers · IndexedDB'
|
||||||
|
);
|
||||||
|
expect(dialog).toHaveTextContent('v0.4.3');
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('heading', { name: 'PDF Workbench' })
|
within(dialog).getByRole('button', { name: 'documents' })
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole('button', { name: '#merge' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
const sourceLink = within(dialog).getByRole('link', {
|
||||||
|
name: 'Source and license',
|
||||||
|
});
|
||||||
|
expect(sourceLink).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'https://git.add-ideas.de/zemion/pdf-tools'
|
||||||
|
);
|
||||||
|
expect(sourceLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole('link', { name: 'Open PDF' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
|
||||||
|
expect(container.querySelector('.site-shell')).toHaveAttribute(
|
||||||
|
'aria-hidden',
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
expect(document.documentElement).toHaveStyle({ overflow: 'hidden' });
|
||||||
|
|
||||||
|
const close = within(dialog).getByRole('button', {
|
||||||
|
name: 'Close information for PDF Workbench',
|
||||||
|
});
|
||||||
|
expect(close).toHaveFocus();
|
||||||
|
within(dialog).getByRole('link', { name: 'Open PDF' }).focus();
|
||||||
|
await user.tab();
|
||||||
|
expect(close).toHaveFocus();
|
||||||
|
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('dialog', { name: 'PDF Workbench' })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(info).toHaveFocus();
|
||||||
|
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
|
||||||
|
expect(document.documentElement).not.toHaveStyle({ overflow: 'hidden' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps search and category controls together and filters with each', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await findToolLink();
|
||||||
|
|
||||||
|
const search = screen.getByRole('searchbox', { name: 'Search tools' });
|
||||||
|
const category = screen.getByRole('combobox', {
|
||||||
|
name: 'Filter by category',
|
||||||
|
});
|
||||||
|
expect(search.closest('.tool-controls')).toBe(
|
||||||
|
category.closest('.tool-controls')
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.type(search, 'xslt');
|
||||||
|
expect(toolLink('XSLT Workbench')).toBeInTheDocument();
|
||||||
|
expect(queryToolLink('PDF Workbench')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.clear(search);
|
||||||
|
await user.selectOptions(category, 'notes');
|
||||||
|
expect(toolLink('OneNote Reader')).toBeInTheDocument();
|
||||||
|
expect(queryToolLink('XSLT Workbench')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.selectOptions(category, '');
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'More information about PDF Workbench',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByRole('dialog', { name: 'PDF Workbench' })).getByRole(
|
||||||
|
'button',
|
||||||
|
{ name: 'pdf' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
expect(category).toHaveValue('pdf');
|
||||||
|
expect(toolLink('PDF Workbench')).toBeInTheDocument();
|
||||||
|
expect(queryToolLink('OneNote Reader')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps remaining hidden tools visible while editing the hidden set', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await findToolLink();
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: 'Hide PDF Workbench' })
|
||||||
|
);
|
||||||
|
const showHidden = screen.getByRole('checkbox', {
|
||||||
|
name: 'Show hidden (1)',
|
||||||
|
});
|
||||||
|
await user.click(showHidden);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: 'Hide XSLT Workbench' })
|
||||||
|
);
|
||||||
|
expect(showHidden).toHaveAccessibleName('Show hidden (2)');
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: 'Show PDF Workbench' })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(showHidden).toBeChecked();
|
||||||
|
expect(showHidden).toHaveAccessibleName('Show hidden (1)');
|
||||||
|
expect(toolLink('XSLT Workbench')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears search filters without revealing hidden tools', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await findToolLink();
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: 'Hide PDF Workbench' })
|
||||||
|
);
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole('searchbox', { name: 'Search tools' }),
|
||||||
|
'no tool has this phrase'
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Clear filters' }));
|
||||||
|
|
||||||
|
expect(queryToolLink('PDF Workbench')).not.toBeInTheDocument();
|
||||||
|
expect(toolLink('XSLT Workbench')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('checkbox', { name: 'Show hidden (1)' })
|
||||||
|
).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists contextual destinations in the Apps menu', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await findToolLink();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Apps' }));
|
||||||
|
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('uses the shared accessible header identity and control order', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||||
|
render(<App />);
|
||||||
|
await findToolLink();
|
||||||
|
|
||||||
|
const header = document.querySelector<HTMLElement>('.portal-header')!;
|
||||||
|
expect(
|
||||||
|
within(header).getByRole('heading', { level: 1, name: 'Toolbox' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(header).getByText('Local-first browser tools')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', {
|
||||||
|
level: 2,
|
||||||
|
name: 'Your document tools, in one calm place.',
|
||||||
|
})
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getAllByRole('heading', {
|
||||||
|
name: 'Your document tools, in one calm place.',
|
||||||
|
})
|
||||||
|
).toHaveLength(1);
|
||||||
|
|
||||||
|
const personalize = within(header).getByRole('button', {
|
||||||
|
name: 'Personalize',
|
||||||
|
});
|
||||||
|
const apps = within(header).getByRole('button', { name: 'Apps' });
|
||||||
|
const source = within(header).getByRole('link', {
|
||||||
|
name: 'Toolbox source on Gitea',
|
||||||
|
});
|
||||||
|
const help = within(header).getByRole('button', { name: 'Help' });
|
||||||
|
const brand = within(header).getByRole('link', {
|
||||||
|
name: 'add·ideas Toolbox',
|
||||||
|
});
|
||||||
|
const controls = within(header).getByRole('group', {
|
||||||
|
name: 'Toolbox controls',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
Array.from(controls.children, (element) =>
|
||||||
|
element.getAttribute('aria-label')
|
||||||
|
)
|
||||||
|
).toEqual(['Help', 'Toolbox source on Gitea', 'Apps', 'Personalize']);
|
||||||
|
expect(help.compareDocumentPosition(source)).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
);
|
||||||
|
expect(source.compareDocumentPosition(apps)).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
);
|
||||||
|
expect(apps.compareDocumentPosition(personalize)).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
Array.from(
|
||||||
|
header.querySelector('.toolbox-shell__bar')?.children ?? [],
|
||||||
|
(element) => element.className
|
||||||
|
)
|
||||||
|
).toEqual([
|
||||||
|
'toolbox-shell__brand',
|
||||||
|
'toolbox-shell__identity',
|
||||||
|
'toolbox-shell__controls',
|
||||||
|
]);
|
||||||
|
expect(brand.querySelector('img')).toHaveAttribute('src', './favicon.svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists an explicit appearance and accepts cross-tab updates', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await findToolLink();
|
||||||
|
const personalize = screen.getByRole('button', { name: 'Personalize' });
|
||||||
|
await user.click(personalize);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', { name: 'Personalize your toolbox' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
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(personalize);
|
||||||
|
|
||||||
|
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(<App />);
|
||||||
|
await findToolLink();
|
||||||
|
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: 188,
|
||||||
|
height: 188,
|
||||||
|
top: 0,
|
||||||
|
right: index * 220 + 188,
|
||||||
|
bottom: 188,
|
||||||
|
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', 'PDF', 'OneNote']);
|
||||||
|
});
|
||||||
|
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(<App />);
|
||||||
|
await findToolLink();
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'More information about PDF Workbench',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'PDF Workbench' });
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: '#merge' }));
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
expect(toolLink('PDF Workbench')).toBeInTheDocument();
|
||||||
|
expect(queryToolLink('XSLT Workbench')).not.toBeInTheDocument();
|
||||||
|
expect(queryToolLink('OneNote Reader')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const activeTag = screen.getByRole('button', {
|
||||||
|
name: /#merge.*Clear tag filter/i,
|
||||||
|
});
|
||||||
|
await user.click(activeTag);
|
||||||
|
expect(await findToolLink('XSLT Workbench')).toBeInTheDocument();
|
||||||
|
expect(toolLink('OneNote Reader')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the standard help control and explains tile interaction', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { container } = render(<App />);
|
||||||
|
await findToolLink();
|
||||||
|
const help = screen.getByRole('button', { name: 'Help' });
|
||||||
|
await user.click(help);
|
||||||
|
const dialog = screen.getByRole('dialog', {
|
||||||
|
name: 'Choose and arrange your tools',
|
||||||
|
});
|
||||||
|
expect(dialog).toHaveTextContent('click anywhere on a tool tile');
|
||||||
|
expect(dialog).toHaveTextContent('use the light bulb');
|
||||||
|
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
|
||||||
|
expect(container.querySelector('.site-shell')).toHaveAttribute(
|
||||||
|
'aria-hidden',
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
const close = within(dialog).getByRole('button', { name: 'Close help' });
|
||||||
|
expect(close).toHaveFocus();
|
||||||
|
await user.tab();
|
||||||
|
expect(close).toHaveFocus();
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
|
||||||
|
expect(help).toHaveFocus();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a recoverable catalogue error', async () => {
|
it('shows a recoverable catalogue error', async () => {
|
||||||
@@ -72,9 +531,7 @@ describe('portal UI', () => {
|
|||||||
await screen.findByRole('heading', { name: 'Toolbox unavailable' })
|
await screen.findByRole('heading', { name: 'Toolbox unavailable' })
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||||
expect(
|
expect(await findToolLink()).toBeInTheDocument();
|
||||||
await screen.findByRole('heading', { name: 'PDF Workbench' })
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a dedicated empty state for a valid catalogue', async () => {
|
it('shows a dedicated empty state for a valid catalogue', async () => {
|
||||||
@@ -92,31 +549,42 @@ describe('portal UI', () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains preference focus and restores it to Personalize', async () => {
|
it('hosts compact preferences in the shared Personalize popover', async () => {
|
||||||
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { container } = render(<App />);
|
const { container } = render(<App />);
|
||||||
const personalize = screen.getByRole('button', { name: 'Personalize' });
|
const personalize = screen.getByRole('button', { name: 'Personalize' });
|
||||||
|
|
||||||
await user.click(personalize);
|
await user.click(personalize);
|
||||||
const dialog = screen.getByRole('dialog', {
|
const heading = screen.getByRole('heading', {
|
||||||
name: 'Personalize your toolbox',
|
name: 'Personalize your toolbox',
|
||||||
});
|
});
|
||||||
const close = screen.getByRole('button', { name: 'Close preferences' });
|
const popover = screen.getByRole('dialog', { name: 'Personalize Toolbox' });
|
||||||
expect(close).toHaveFocus();
|
expect(popover).toContainElement(heading);
|
||||||
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
|
expect(popover).toContainElement(screen.getByText('On this device'));
|
||||||
expect(dialog).toBeInTheDocument();
|
expect(
|
||||||
|
within(popover).getByRole('group', { name: 'Appearance' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
popover.querySelector('.toolbox-shell__preferences-content')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(popover).not.toHaveAttribute('aria-modal');
|
||||||
|
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
|
||||||
expect(screen.getByText(/does not transmit them/i)).toHaveTextContent(
|
expect(screen.getByText(/does not transmit them/i)).toHaveTextContent(
|
||||||
/Trusted code in another Toolbox app on this site can access same-origin storage/i
|
/Trusted code in another Toolbox app on this site can access same-origin storage/i
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
const reset = screen.getByRole('button', { name: 'Reset' });
|
screen.getByRole('button', { name: 'Export JSON' })
|
||||||
reset.focus();
|
).toBeInTheDocument();
|
||||||
await user.tab();
|
expect(
|
||||||
expect(close).toHaveFocus();
|
screen.getByRole('button', { name: 'Import JSON' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Reset' })).toBeInTheDocument();
|
||||||
|
|
||||||
await user.keyboard('{Escape}');
|
await user.keyboard('{Escape}');
|
||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(
|
||||||
|
screen.queryByRole('dialog', { name: 'Personalize Toolbox' })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
expect(personalize).toHaveFocus();
|
expect(personalize).toHaveFocus();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
454
src/App.tsx
454
src/App.tsx
@@ -1,25 +1,51 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
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, useRef, useState } from 'react';
|
||||||
import { AppCard } from './components/AppCard';
|
import { AppCard } from './components/AppCard';
|
||||||
|
import { AppDetailsPanel } from './components/AppDetailsPanel';
|
||||||
|
import { HelpPanel } from './components/HelpPanel';
|
||||||
import { Logo } from './components/Logo';
|
import { Logo } from './components/Logo';
|
||||||
import { PreferencesPanel } from './components/PreferencesPanel';
|
import { PreferencesPanel } from './components/PreferencesPanel';
|
||||||
import { contextualLaunchUrl, loadCatalogue } from './catalogue';
|
import { contextualLaunchUrl, loadCatalogue } from './catalogue';
|
||||||
import { cleanPreferences, sortAppIds } from './preferences';
|
import { cleanPreferences, reorderWithinIds, sortAppIds } from './preferences';
|
||||||
import type { LoadedCatalogue, ResolvedApp } from './types';
|
import type { LoadedCatalogue, ResolvedApp } from './types';
|
||||||
import { usePreferences } from './usePreferences';
|
import { usePreferences } from './usePreferences';
|
||||||
|
|
||||||
const CATALOGUE_HREF = './toolbox.catalog.json';
|
const CATALOGUE_HREF = './toolbox.catalog.json';
|
||||||
|
const PORTAL_SOURCE = 'https://git.add-ideas.de/zemion/toolbox-portal';
|
||||||
|
|
||||||
type LoadState =
|
type LoadState =
|
||||||
| { status: 'loading' }
|
| { status: 'loading' }
|
||||||
| { status: 'error'; message: string }
|
| { status: 'error'; message: string }
|
||||||
| { status: 'ready'; value: LoadedCatalogue };
|
| { status: 'ready'; value: LoadedCatalogue };
|
||||||
|
|
||||||
|
type PortalOverlay =
|
||||||
|
{ kind: 'help' } | { kind: 'details'; appId: string } | null;
|
||||||
|
|
||||||
function appMatches(
|
function appMatches(
|
||||||
app: ResolvedApp,
|
app: ResolvedApp,
|
||||||
query: string,
|
query: string,
|
||||||
category: string
|
category: string,
|
||||||
|
tag: string
|
||||||
): boolean {
|
): boolean {
|
||||||
if (category && !app.categories.includes(category)) return false;
|
if (category && !app.categories.includes(category)) return false;
|
||||||
|
if (tag && !app.tags.includes(tag)) return false;
|
||||||
if (!query) return true;
|
if (!query) return true;
|
||||||
const haystack = [app.name, app.description, ...app.categories, ...app.tags]
|
const haystack = [app.name, app.description, ...app.categories, ...app.tags]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -27,15 +53,81 @@ function appMatches(
|
|||||||
return haystack.includes(query.toLocaleLowerCase());
|
return haystack.includes(query.toLocaleLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AppSectionProps {
|
||||||
|
apps: ResolvedApp[];
|
||||||
|
catalogueUrl: string;
|
||||||
|
hiddenIds: readonly string[];
|
||||||
|
id: string;
|
||||||
|
onDetails: (id: string) => void;
|
||||||
|
onHide: (id: string) => void;
|
||||||
|
onPin: (id: string) => void;
|
||||||
|
pinned: boolean;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppSection({
|
||||||
|
apps,
|
||||||
|
catalogueUrl,
|
||||||
|
hiddenIds,
|
||||||
|
id,
|
||||||
|
onDetails,
|
||||||
|
onHide,
|
||||||
|
onPin,
|
||||||
|
pinned,
|
||||||
|
title,
|
||||||
|
}: AppSectionProps) {
|
||||||
|
if (apps.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section className="app-section" aria-labelledby={`${id}-title`}>
|
||||||
|
<header className="app-section__header">
|
||||||
|
<h2 id={`${id}-title`}>{title}</h2>
|
||||||
|
<span>{apps.length}</span>
|
||||||
|
</header>
|
||||||
|
<SortableContext
|
||||||
|
id={id}
|
||||||
|
items={apps.map((app) => app.id)}
|
||||||
|
strategy={rectSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="app-grid">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<AppCard
|
||||||
|
key={app.id}
|
||||||
|
app={app}
|
||||||
|
catalogueUrl={catalogueUrl}
|
||||||
|
contextualUrl={contextualLaunchUrl}
|
||||||
|
pinned={pinned}
|
||||||
|
hidden={hiddenIds.includes(app.id)}
|
||||||
|
onDetails={() => onDetails(app.id)}
|
||||||
|
onPin={() => onPin(app.id)}
|
||||||
|
onHide={() => onHide(app.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [loadState, setLoadState] = useState<LoadState>({ status: 'loading' });
|
const [loadState, setLoadState] = useState<LoadState>({ status: 'loading' });
|
||||||
const [reloadKey, setReloadKey] = useState(0);
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState('');
|
||||||
const [showHidden, setShowHidden] = useState(false);
|
const [activeTag, setActiveTag] = useState('');
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [shownHiddenIds, setShownHiddenIds] = useState<
|
||||||
|
readonly string[] | null
|
||||||
|
>(null);
|
||||||
|
const [overlay, setOverlay] = useState<PortalOverlay>(null);
|
||||||
|
const hiddenToggle = useRef<HTMLInputElement>(null);
|
||||||
const [preferences, setPreferences, storageAvailable] = usePreferences();
|
const [preferences, setPreferences, storageAvailable] = usePreferences();
|
||||||
const closeSettings = useCallback(() => setSettingsOpen(false), []);
|
const closeOverlay = useCallback(() => setOverlay(null), []);
|
||||||
|
const showHidden =
|
||||||
|
preferences.hidden.length > 0 &&
|
||||||
|
shownHiddenIds?.some((id) => preferences.hidden.includes(id)) === true;
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -57,6 +149,11 @@ export default function App() {
|
|||||||
|
|
||||||
const loaded = loadState.status === 'ready' ? loadState.value : undefined;
|
const loaded = loadState.status === 'ready' ? loadState.value : undefined;
|
||||||
const apps = useMemo(() => loaded?.apps ?? [], [loaded]);
|
const apps = useMemo(() => loaded?.apps ?? [], [loaded]);
|
||||||
|
const detailsApp =
|
||||||
|
overlay?.kind === 'details'
|
||||||
|
? apps.find((app) => app.id === overlay.appId)
|
||||||
|
: undefined;
|
||||||
|
const overlayOpen = overlay?.kind === 'help' || detailsApp !== undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loaded) return;
|
if (!loaded) return;
|
||||||
@@ -91,91 +188,184 @@ export default function App() {
|
|||||||
}, [apps, preferences]);
|
}, [apps, preferences]);
|
||||||
const visibleApps = orderedApps.filter((app) => {
|
const visibleApps = orderedApps.filter((app) => {
|
||||||
const hidden = preferences.hidden.includes(app.id);
|
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) {
|
function toggleListItem(field: 'pinned' | 'hidden', id: string) {
|
||||||
|
const keepCardVisible = field === 'pinned' || showHidden;
|
||||||
|
if (field === 'hidden' && showHidden && !preferences.hidden.includes(id)) {
|
||||||
|
setShownHiddenIds((current) => [...new Set([...(current ?? []), id])]);
|
||||||
|
}
|
||||||
setPreferences((current) => ({
|
setPreferences((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[field]: current[field].includes(id)
|
[field]: current[field].includes(id)
|
||||||
? current[field].filter((item) => item !== id)
|
? current[field].filter((item) => item !== id)
|
||||||
: [...current[field], id],
|
: [...current[field], id],
|
||||||
}));
|
}));
|
||||||
}
|
window.requestAnimationFrame(() => {
|
||||||
|
if (!keepCardVisible) {
|
||||||
function moveApp(id: string, direction: -1 | 1) {
|
hiddenToggle.current?.focus();
|
||||||
const pinned = preferences.pinned.includes(id);
|
return;
|
||||||
const peers = visibleApps.filter(
|
}
|
||||||
(app) => preferences.pinned.includes(app.id) === pinned
|
const action = field === 'pinned' ? 'pin' : 'visibility';
|
||||||
);
|
[
|
||||||
const currentIndex = peers.findIndex((app) => app.id === id);
|
...document.querySelectorAll<HTMLButtonElement>(
|
||||||
const target = peers[currentIndex + direction];
|
`[data-card-action="${action}"]`
|
||||||
if (!target) return;
|
),
|
||||||
setPreferences((current) => {
|
]
|
||||||
const currentPosition = current.order.indexOf(id);
|
.find((button) => button.dataset.appId === id)
|
||||||
const targetPosition = current.order.indexOf(target.id);
|
?.focus();
|
||||||
if (currentPosition < 0 || targetPosition < 0) return current;
|
|
||||||
const order = [...current.order];
|
|
||||||
[order[currentPosition], order[targetPosition]] = [
|
|
||||||
order[targetPosition]!,
|
|
||||||
order[currentPosition]!,
|
|
||||||
];
|
|
||||||
return { ...current, order };
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = loaded?.catalogue.name ?? 'add·ideas Toolbox';
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
const brand = loaded?.catalogue.theme.brand ?? 'add·ideas';
|
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
|
||||||
|
);
|
||||||
|
setPreferences((current) => ({
|
||||||
|
...current,
|
||||||
|
order: reorderWithinIds(current.order, activeId, overId, peers),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
setQuery('');
|
||||||
|
setCategory('');
|
||||||
|
setActiveTag('');
|
||||||
|
setShownHiddenIds(null);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="site-shell"
|
className="site-shell"
|
||||||
inert={settingsOpen}
|
inert={overlayOpen}
|
||||||
aria-hidden={settingsOpen || undefined}
|
aria-hidden={overlayOpen || undefined}
|
||||||
>
|
>
|
||||||
<a className="skip-link" href="#main-content">
|
<a className="skip-link" href="#main-content">
|
||||||
Skip to tools
|
Skip to tools
|
||||||
</a>
|
</a>
|
||||||
<header className="site-header">
|
<ToolboxHeader
|
||||||
<div className="header-inner">
|
className="portal-header"
|
||||||
<a
|
title="Toolbox"
|
||||||
className="brand"
|
subtitle="Local-first browser tools"
|
||||||
href={loaded ? loaded.homeUrl : './'}
|
theme={preferences.theme}
|
||||||
aria-label={`${title} home`}
|
homeHref={loaded?.homeUrl ?? './'}
|
||||||
>
|
homeLabel="add·ideas Toolbox"
|
||||||
<Logo />
|
brandIconUrl="./favicon.svg"
|
||||||
<span>
|
helpAction={{ onClick: () => setOverlay({ kind: 'help' }) }}
|
||||||
<strong>{brand}</strong>
|
sourceHref={PORTAL_SOURCE}
|
||||||
<small>Toolbox</small>
|
sourceLabel="Toolbox source on Gitea"
|
||||||
</span>
|
personalizeContent={
|
||||||
</a>
|
<PreferencesPanel
|
||||||
<button
|
preferences={preferences}
|
||||||
className="settings-button"
|
storageAvailable={storageAvailable}
|
||||||
type="button"
|
onChange={setPreferences}
|
||||||
onClick={() => setSettingsOpen(true)}
|
/>
|
||||||
>
|
}
|
||||||
<span aria-hidden="true">☼</span> Personalize
|
apps={headerApps}
|
||||||
</button>
|
allAppsHref="#main-content"
|
||||||
</div>
|
/>
|
||||||
</header>
|
|
||||||
|
|
||||||
<main id="main-content">
|
<main id="main-content">
|
||||||
<section className="hero" aria-labelledby="page-title">
|
<section className="hero" aria-labelledby="page-title">
|
||||||
<p className="eyebrow">
|
<div className="hero-intro">
|
||||||
Privacy details up front · useful by default
|
<p className="eyebrow">
|
||||||
</p>
|
Privacy details up front · useful by default
|
||||||
<h1 id="page-title">
|
</p>
|
||||||
Your document tools,
|
<h2 id="page-title">
|
||||||
<br />
|
Your document tools,
|
||||||
<span>in one calm place.</span>
|
<br /> <span>in one calm place.</span>
|
||||||
</h1>
|
</h2>
|
||||||
<p className="hero-copy">
|
<p className="hero-copy">
|
||||||
Focused browser utilities with a privacy disclosure for each tool.
|
Focused browser utilities with a privacy disclosure for each
|
||||||
Choose one and keep your work moving.
|
tool. Choose one and keep your work moving.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{loaded && (
|
||||||
|
<section className="tool-controls" aria-label="Find a tool">
|
||||||
|
<label className="search-field">
|
||||||
|
<span className="visually-hidden">Search tools</span>
|
||||||
|
<span aria-hidden="true">⌕</span>
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Search tools"
|
||||||
|
type="search"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="category-field">
|
||||||
|
<span className="visually-hidden">Filter by category</span>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(event) => setCategory(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All categories</option>
|
||||||
|
{categories.map((item) => (
|
||||||
|
<option value={item} key={item}>
|
||||||
|
{item}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{activeTag && (
|
||||||
|
<button
|
||||||
|
className="active-filter"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTag('')}
|
||||||
|
>
|
||||||
|
#{activeTag} <span aria-hidden="true">×</span>
|
||||||
|
<span className="visually-hidden">Clear tag filter</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<label
|
||||||
|
className={`hidden-toggle${
|
||||||
|
preferences.hidden.length === 0
|
||||||
|
? ' hidden-toggle--disabled'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={hiddenToggle}
|
||||||
|
type="checkbox"
|
||||||
|
checked={showHidden}
|
||||||
|
disabled={preferences.hidden.length === 0}
|
||||||
|
onChange={(event) =>
|
||||||
|
setShownHiddenIds(
|
||||||
|
event.target.checked ? [...preferences.hidden] : null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Show hidden ({preferences.hidden.length})
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{loadState.status === 'loading' && (
|
{loadState.status === 'loading' && (
|
||||||
<section className="state-card" aria-live="polite">
|
<section className="state-card" aria-live="polite">
|
||||||
<div className="loader" aria-hidden="true" />
|
<div className="loader" aria-hidden="true" />
|
||||||
@@ -205,43 +395,6 @@ export default function App() {
|
|||||||
|
|
||||||
{loaded && (
|
{loaded && (
|
||||||
<>
|
<>
|
||||||
<section className="tool-controls" aria-label="Find a tool">
|
|
||||||
<label className="search-field">
|
|
||||||
<span className="visually-hidden">Search tools</span>
|
|
||||||
<span aria-hidden="true">⌕</span>
|
|
||||||
<input
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder="Search tools"
|
|
||||||
type="search"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="category-field">
|
|
||||||
<span className="visually-hidden">Filter by category</span>
|
|
||||||
<select
|
|
||||||
value={category}
|
|
||||||
onChange={(event) => setCategory(event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">All categories</option>
|
|
||||||
{categories.map((item) => (
|
|
||||||
<option value={item} key={item}>
|
|
||||||
{item}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{preferences.hidden.length > 0 && (
|
|
||||||
<label className="hidden-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showHidden}
|
|
||||||
onChange={(event) => setShowHidden(event.target.checked)}
|
|
||||||
/>
|
|
||||||
Show hidden ({preferences.hidden.length})
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{apps.length === 0 && loaded.unavailable.length === 0 && (
|
{apps.length === 0 && loaded.unavailable.length === 0 && (
|
||||||
<section className="state-card">
|
<section className="state-card">
|
||||||
<span className="state-icon" aria-hidden="true">
|
<span className="state-icon" aria-hidden="true">
|
||||||
@@ -264,43 +417,47 @@ export default function App() {
|
|||||||
<p>
|
<p>
|
||||||
Try another search or category, or show your hidden tools.
|
Try another search or category, or show your hidden tools.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button type="button" onClick={clearFilters}>
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setQuery('');
|
|
||||||
setCategory('');
|
|
||||||
setShowHidden(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear filters
|
Clear filters
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visibleApps.length > 0 && (
|
{visibleApps.length > 0 && (
|
||||||
<section className="app-grid" aria-label="Available tools">
|
<DndContext
|
||||||
{visibleApps.map((app) => {
|
sensors={sensors}
|
||||||
const pinned = preferences.pinned.includes(app.id);
|
collisionDetection={closestCenter}
|
||||||
const peers = visibleApps.filter(
|
onDragEnd={handleDragEnd}
|
||||||
(peer) => preferences.pinned.includes(peer.id) === pinned
|
>
|
||||||
);
|
<div className="app-sections" aria-label="Available tools">
|
||||||
return (
|
<AppSection
|
||||||
<AppCard
|
id="pinned-apps"
|
||||||
key={app.id}
|
title="Pinned"
|
||||||
app={app}
|
apps={pinnedApps}
|
||||||
catalogueUrl={loaded.catalogueUrl.href}
|
pinned
|
||||||
contextualUrl={contextualLaunchUrl}
|
catalogueUrl={loaded.catalogueUrl.href}
|
||||||
pinned={pinned}
|
hiddenIds={preferences.hidden}
|
||||||
hidden={preferences.hidden.includes(app.id)}
|
onDetails={(id) =>
|
||||||
first={peers[0]?.id === app.id}
|
setOverlay({ kind: 'details', appId: id })
|
||||||
last={peers.at(-1)?.id === app.id}
|
}
|
||||||
onPin={() => toggleListItem('pinned', app.id)}
|
onPin={(id) => toggleListItem('pinned', id)}
|
||||||
onHide={() => toggleListItem('hidden', app.id)}
|
onHide={(id) => toggleListItem('hidden', id)}
|
||||||
onMove={(direction) => moveApp(app.id, direction)}
|
/>
|
||||||
/>
|
<AppSection
|
||||||
);
|
id="regular-apps"
|
||||||
})}
|
title="Tools"
|
||||||
</section>
|
apps={regularApps}
|
||||||
|
pinned={false}
|
||||||
|
catalogueUrl={loaded.catalogueUrl.href}
|
||||||
|
hiddenIds={preferences.hidden}
|
||||||
|
onDetails={(id) =>
|
||||||
|
setOverlay({ kind: 'details', appId: id })
|
||||||
|
}
|
||||||
|
onPin={(id) => toggleListItem('pinned', id)}
|
||||||
|
onHide={(id) => toggleListItem('hidden', id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loaded.unavailable.length > 0 && (
|
{loaded.unavailable.length > 0 && (
|
||||||
@@ -332,9 +489,9 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<span>
|
<span>
|
||||||
Portal v0.1.0 ·{' '}
|
Portal v0.2.6 ·{' '}
|
||||||
<a
|
<a
|
||||||
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.1.0"
|
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.7.0"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
@@ -344,13 +501,26 @@ export default function App() {
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PreferencesPanel
|
<HelpPanel open={overlay?.kind === 'help'} onClose={closeOverlay} />
|
||||||
open={settingsOpen}
|
{detailsApp && loaded ? (
|
||||||
preferences={preferences}
|
<AppDetailsPanel
|
||||||
storageAvailable={storageAvailable}
|
app={detailsApp}
|
||||||
onChange={setPreferences}
|
catalogueUrl={loaded.catalogueUrl.href}
|
||||||
onClose={closeSettings}
|
contextualUrl={contextualLaunchUrl}
|
||||||
/>
|
pinned={preferences.pinned.includes(detailsApp.id)}
|
||||||
|
hidden={preferences.hidden.includes(detailsApp.id)}
|
||||||
|
activeTag={activeTag}
|
||||||
|
onCategory={(nextCategory) => {
|
||||||
|
setCategory(nextCategory);
|
||||||
|
closeOverlay();
|
||||||
|
}}
|
||||||
|
onTag={(tag) => {
|
||||||
|
setActiveTag((current) => (current === tag ? '' : tag));
|
||||||
|
closeOverlay();
|
||||||
|
}}
|
||||||
|
onClose={closeOverlay}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { catalogue, catalogueFetch } from './test/fixtures';
|
|||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
describe('catalogue loading', () => {
|
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()));
|
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||||
const loaded = await loadCatalogue('/toolbox.catalog.json');
|
const loaded = await loadCatalogue('/toolbox.catalog.json');
|
||||||
expect(loaded.catalogue.name).toBe('add·ideas Toolbox');
|
expect(loaded.catalogue.name).toBe('add·ideas Toolbox');
|
||||||
expect(loaded.homeUrl).toBe('http://localhost:3000/');
|
expect(loaded.homeUrl).toBe('http://localhost:3000/');
|
||||||
expect(loaded.apps).toHaveLength(1);
|
expect(loaded.apps).toHaveLength(3);
|
||||||
expect(loaded.apps[0]).toMatchObject({
|
expect(loaded.apps[0]).toMatchObject({
|
||||||
id: 'de.add-ideas.pdf-tools',
|
id: 'de.add-ideas.pdf-tools',
|
||||||
name: 'PDF Workbench',
|
name: 'PDF Workbench',
|
||||||
@@ -38,6 +38,8 @@ describe('catalogue loading', () => {
|
|||||||
const loaded = await loadCatalogue('/toolbox.catalog.json');
|
const loaded = await loadCatalogue('/toolbox.catalog.json');
|
||||||
expect(loaded.apps.map((app) => app.id)).toEqual([
|
expect(loaded.apps.map((app) => app.id)).toEqual([
|
||||||
'de.add-ideas.pdf-tools',
|
'de.add-ideas.pdf-tools',
|
||||||
|
'de.add-ideas.xslt-tools',
|
||||||
|
'de.add-ideas.onenote-tools',
|
||||||
]);
|
]);
|
||||||
expect(loaded.unavailable).toHaveLength(1);
|
expect(loaded.unavailable).toHaveLength(1);
|
||||||
expect(loaded.unavailable[0]?.reason).toMatch(/HTTP 404/);
|
expect(loaded.unavailable[0]?.reason).toMatch(/HTTP 404/);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { useId } from 'react';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { shortToolDescription, shortToolTitle } from '../toolPresentation';
|
||||||
import type { ResolvedApp } from '../types';
|
import type { ResolvedApp } from '../types';
|
||||||
|
|
||||||
interface AppCardProps {
|
interface AppCardProps {
|
||||||
@@ -5,48 +9,74 @@ interface AppCardProps {
|
|||||||
catalogueUrl: string;
|
catalogueUrl: string;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
first: boolean;
|
onDetails: () => void;
|
||||||
last: boolean;
|
|
||||||
onHide: () => void;
|
onHide: () => void;
|
||||||
onMove: (direction: -1 | 1) => void;
|
|
||||||
onPin: () => void;
|
onPin: () => void;
|
||||||
contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
|
contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RequirementSummary({ app }: { app: ResolvedApp }) {
|
function MoveIcon() {
|
||||||
if (!app.requirements) return null;
|
return (
|
||||||
const requirements = [
|
<svg viewBox="0 0 20 80" aria-hidden="true">
|
||||||
app.requirements.secureContext && 'Secure context',
|
<circle cx="7" cy="5" r="1.2" />
|
||||||
app.requirements.workers && 'Web workers',
|
<circle cx="13" cy="5" r="1.2" />
|
||||||
app.requirements.indexedDb && 'IndexedDB',
|
<circle cx="7" cy="10" r="1.2" />
|
||||||
app.requirements.crossOriginIsolated && 'Cross-origin isolation',
|
<circle cx="13" cy="10" r="1.2" />
|
||||||
].filter(Boolean);
|
<circle cx="7" cy="15" r="1.2" />
|
||||||
if (requirements.length === 0) return null;
|
<circle cx="13" cy="15" r="1.2" />
|
||||||
return <p className="requirements">Needs {requirements.join(' · ')}</p>;
|
<circle cx="7" cy="20" r="1.2" />
|
||||||
|
<circle cx="13" cy="20" r="1.2" />
|
||||||
|
<circle cx="7" cy="25" r="1.2" />
|
||||||
|
<circle cx="13" cy="25" r="1.2" />
|
||||||
|
<circle cx="7" cy="30" r="1.2" />
|
||||||
|
<circle cx="13" cy="30" r="1.2" />
|
||||||
|
<circle cx="7" cy="35" r="1.2" />
|
||||||
|
<circle cx="13" cy="35" r="1.2" />
|
||||||
|
<circle cx="7" cy="40" r="1.2" />
|
||||||
|
<circle cx="13" cy="40" r="1.2" />
|
||||||
|
<circle cx="7" cy="45" r="1.2" />
|
||||||
|
<circle cx="13" cy="45" r="1.2" />
|
||||||
|
<circle cx="7" cy="50" r="1.2" />
|
||||||
|
<circle cx="13" cy="50" r="1.2" />
|
||||||
|
<circle cx="7" cy="55" r="1.2" />
|
||||||
|
<circle cx="13" cy="55" r="1.2" />
|
||||||
|
<circle cx="7" cy="60" r="1.2" />
|
||||||
|
<circle cx="13" cy="60" r="1.2" />
|
||||||
|
<circle cx="7" cy="65" r="1.2" />
|
||||||
|
<circle cx="13" cy="65" r="1.2" />
|
||||||
|
<circle cx="7" cy="70" r="1.2" />
|
||||||
|
<circle cx="13" cy="70" r="1.2" />
|
||||||
|
<circle cx="7" cy="75" r="1.2" />
|
||||||
|
<circle cx="13" cy="75" r="1.2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrivacySummary({ app }: { app: ResolvedApp }) {
|
function LightBulbIcon() {
|
||||||
if (!app.privacy)
|
|
||||||
return (
|
|
||||||
<p className="privacy-summary">
|
|
||||||
External destination · review its privacy terms
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
if (app.privacy.label)
|
|
||||||
return <p className="privacy-summary">{app.privacy.label}</p>;
|
|
||||||
const processing = {
|
|
||||||
local: 'Local processing',
|
|
||||||
remote: 'Remote processing',
|
|
||||||
mixed: 'Local and remote capabilities',
|
|
||||||
}[app.privacy.processing];
|
|
||||||
return (
|
return (
|
||||||
<p className="privacy-summary">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
{processing} ·{' '}
|
<path d="M9 18h6M10 21h4" />
|
||||||
{app.privacy.fileUploads
|
<path d="M8.4 14.8A6 6 0 1 1 15.6 14.8c-.9.7-1.4 1.4-1.6 2.2h-4c-.2-.8-.7-1.5-1.6-2.2Z" />
|
||||||
? 'file/data transmission declared'
|
</svg>
|
||||||
: 'no file uploads declared'}{' '}
|
);
|
||||||
· {app.privacy.telemetry ? 'telemetry declared' : 'no telemetry declared'}
|
}
|
||||||
</p>
|
|
||||||
|
function PinIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M8 3h8l-1 6 3 3v2H6v-2l3-3-1-6Z" />
|
||||||
|
<path d="M12 14v7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VisibilityIcon({ hidden }: { hidden: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3 12s3.2-5 9-5 9 5 9 5-3.2 5-9 5-9-5-9-5Z" />
|
||||||
|
<circle cx="12" cy="12" r="2.3" />
|
||||||
|
{!hidden && <path d="m4 4 16 16" />}
|
||||||
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,23 +85,49 @@ export function AppCard({
|
|||||||
catalogueUrl,
|
catalogueUrl,
|
||||||
hidden,
|
hidden,
|
||||||
pinned,
|
pinned,
|
||||||
first,
|
onDetails,
|
||||||
last,
|
|
||||||
onHide,
|
onHide,
|
||||||
onMove,
|
|
||||||
onPin,
|
onPin,
|
||||||
contextualUrl,
|
contextualUrl,
|
||||||
}: AppCardProps) {
|
}: AppCardProps) {
|
||||||
|
const titleId = useId();
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
isDragging,
|
||||||
|
listeners,
|
||||||
|
setActivatorNodeRef,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
} = useSortable({ id: app.id });
|
||||||
const launchUrl = app.isExternal
|
const launchUrl = app.isExternal
|
||||||
? app.entryUrl
|
? app.entryUrl
|
||||||
: contextualUrl(app.entryUrl, catalogueUrl);
|
: contextualUrl(app.entryUrl, catalogueUrl);
|
||||||
const target = app.launchModes.includes('navigate') ? undefined : '_blank';
|
const target = app.launchModes.includes('navigate') ? undefined : '_blank';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`app-card${hidden ? ' app-card--hidden' : ''}`}
|
ref={setNodeRef}
|
||||||
|
className={`app-card${hidden ? ' app-card--hidden' : ''}${
|
||||||
|
isDragging ? ' app-card--dragging' : ''
|
||||||
|
}`}
|
||||||
|
aria-labelledby={titleId}
|
||||||
data-testid={`app-card-${app.id}`}
|
data-testid={`app-card-${app.id}`}
|
||||||
|
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||||
>
|
>
|
||||||
<div className="app-card__top">
|
<button
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="drag-handle"
|
||||||
|
type="button"
|
||||||
|
aria-label={`Reorder ${app.name}`}
|
||||||
|
title="Drag to reorder"
|
||||||
|
>
|
||||||
|
<MoveIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="app-card__face">
|
||||||
<div className="app-card__icon" aria-hidden="true">
|
<div className="app-card__icon" aria-hidden="true">
|
||||||
{app.iconUrl ? (
|
{app.iconUrl ? (
|
||||||
<img src={app.iconUrl} alt="" loading="lazy" />
|
<img src={app.iconUrl} alt="" loading="lazy" />
|
||||||
@@ -79,94 +135,60 @@ export function AppCard({
|
|||||||
<span>{app.name.slice(0, 1).toLocaleUpperCase()}</span>
|
<span>{app.name.slice(0, 1).toLocaleUpperCase()}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<h3 id={titleId}>
|
||||||
className="app-card__actions"
|
<a
|
||||||
aria-label={`Personalize ${app.name}`}
|
className="app-card__launch-link"
|
||||||
>
|
href={launchUrl}
|
||||||
<button
|
target={target}
|
||||||
className={`icon-button${pinned ? ' is-active' : ''}`}
|
rel={target ? 'noopener noreferrer' : undefined}
|
||||||
type="button"
|
aria-label={`Open ${app.name}`}
|
||||||
aria-label={pinned ? `Unpin ${app.name}` : `Pin ${app.name}`}
|
|
||||||
aria-pressed={pinned}
|
|
||||||
title={pinned ? 'Unpin' : 'Pin'}
|
|
||||||
onClick={onPin}
|
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">⌁</span>
|
{shortToolTitle(app)}
|
||||||
</button>
|
</a>
|
||||||
<button
|
</h3>
|
||||||
className="icon-button"
|
<p>{shortToolDescription(app)}</p>
|
||||||
type="button"
|
|
||||||
aria-label={`Move ${app.name} earlier`}
|
|
||||||
disabled={first}
|
|
||||||
onClick={() => onMove(-1)}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">←</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="icon-button"
|
|
||||||
type="button"
|
|
||||||
aria-label={`Move ${app.name} later`}
|
|
||||||
disabled={last}
|
|
||||||
onClick={() => onMove(1)}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">→</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="icon-button"
|
|
||||||
type="button"
|
|
||||||
aria-label={hidden ? `Show ${app.name}` : `Hide ${app.name}`}
|
|
||||||
onClick={onHide}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">{hidden ? '○' : '—'}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="app-card__body">
|
|
||||||
<div className="title-line">
|
<div
|
||||||
<h2>{app.name}</h2>
|
className="app-card__actions"
|
||||||
{pinned && <span className="status-chip">Pinned</span>}
|
role="group"
|
||||||
{hidden && (
|
aria-label={`Actions for ${app.name}`}
|
||||||
<span className="status-chip status-chip--muted">Hidden</span>
|
>
|
||||||
)}
|
<button
|
||||||
{app.isExternal && (
|
className="icon-button info-button"
|
||||||
<span className="status-chip status-chip--muted">External</span>
|
type="button"
|
||||||
)}
|
aria-label={`More information about ${app.name}`}
|
||||||
</div>
|
aria-haspopup="dialog"
|
||||||
<p>{app.description}</p>
|
title="More information"
|
||||||
<PrivacySummary app={app} />
|
data-details-trigger={app.id}
|
||||||
<RequirementSummary app={app} />
|
onClick={onDetails}
|
||||||
<div className="tag-row" aria-label="Categories">
|
|
||||||
{app.categories.slice(0, 3).map((category) => (
|
|
||||||
<span key={category}>{category}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer className="app-card__footer">
|
|
||||||
<div className="app-card__meta">
|
|
||||||
<span className="version">
|
|
||||||
{app.version ? `v${app.version}` : 'External service'}
|
|
||||||
</span>
|
|
||||||
{app.sourceUrl && (
|
|
||||||
<a
|
|
||||||
className="source-link"
|
|
||||||
href={app.sourceUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Source and license
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
className="launch-button"
|
|
||||||
href={launchUrl}
|
|
||||||
target={target}
|
|
||||||
rel={target ? 'noopener noreferrer' : undefined}
|
|
||||||
>
|
>
|
||||||
{target ? 'Open in new tab' : 'Open tool'}{' '}
|
<LightBulbIcon />
|
||||||
<span aria-hidden="true">↗</span>
|
</button>
|
||||||
</a>
|
<button
|
||||||
</footer>
|
className={`icon-button pin-button${pinned ? ' is-active' : ''}`}
|
||||||
|
type="button"
|
||||||
|
data-app-id={app.id}
|
||||||
|
data-card-action="pin"
|
||||||
|
aria-label={pinned ? `Unpin ${app.name}` : `Pin ${app.name}`}
|
||||||
|
aria-pressed={pinned}
|
||||||
|
title={pinned ? 'Unpin' : 'Pin'}
|
||||||
|
onClick={onPin}
|
||||||
|
>
|
||||||
|
<PinIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-button visibility-button"
|
||||||
|
type="button"
|
||||||
|
data-app-id={app.id}
|
||||||
|
data-card-action="visibility"
|
||||||
|
aria-label={hidden ? `Show ${app.name}` : `Hide ${app.name}`}
|
||||||
|
title={hidden ? 'Show' : 'Hide'}
|
||||||
|
onClick={onHide}
|
||||||
|
>
|
||||||
|
<VisibilityIcon hidden={hidden} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
172
src/components/AppDetailsPanel.tsx
Normal file
172
src/components/AppDetailsPanel.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useId } from 'react';
|
||||||
|
import type { ResolvedApp } from '../types';
|
||||||
|
import { shortToolTitle } from '../toolPresentation';
|
||||||
|
import { ModalPanel } from './ModalPanel';
|
||||||
|
|
||||||
|
interface AppDetailsPanelProps {
|
||||||
|
activeTag: string;
|
||||||
|
app: ResolvedApp;
|
||||||
|
catalogueUrl: string;
|
||||||
|
hidden: boolean;
|
||||||
|
pinned: boolean;
|
||||||
|
contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
|
||||||
|
onCategory: (category: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onTag: (tag: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequirementSummary({ app }: { app: ResolvedApp }) {
|
||||||
|
if (!app.requirements) return null;
|
||||||
|
const requirements = [
|
||||||
|
app.requirements.secureContext && 'Secure context',
|
||||||
|
app.requirements.workers && 'Web workers',
|
||||||
|
app.requirements.indexedDb && 'IndexedDB',
|
||||||
|
app.requirements.crossOriginIsolated && 'Cross-origin isolation',
|
||||||
|
].filter(Boolean);
|
||||||
|
if (requirements.length === 0) return null;
|
||||||
|
return <p className="requirements">Needs {requirements.join(' · ')}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrivacySummary({ app }: { app: ResolvedApp }) {
|
||||||
|
if (!app.privacy)
|
||||||
|
return (
|
||||||
|
<p className="privacy-summary">
|
||||||
|
External destination · review its privacy terms
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
if (app.privacy.label)
|
||||||
|
return <p className="privacy-summary">{app.privacy.label}</p>;
|
||||||
|
const processing = {
|
||||||
|
local: 'Local processing',
|
||||||
|
remote: 'Remote processing',
|
||||||
|
mixed: 'Local and remote capabilities',
|
||||||
|
}[app.privacy.processing];
|
||||||
|
return (
|
||||||
|
<p className="privacy-summary">
|
||||||
|
{processing} ·{' '}
|
||||||
|
{app.privacy.fileUploads
|
||||||
|
? 'file/data transmission declared'
|
||||||
|
: 'no file uploads declared'}{' '}
|
||||||
|
· {app.privacy.telemetry ? 'telemetry declared' : 'no telemetry declared'}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppDetailsPanel({
|
||||||
|
activeTag,
|
||||||
|
app,
|
||||||
|
catalogueUrl,
|
||||||
|
hidden,
|
||||||
|
pinned,
|
||||||
|
contextualUrl,
|
||||||
|
onCategory,
|
||||||
|
onClose,
|
||||||
|
onTag,
|
||||||
|
}: AppDetailsPanelProps) {
|
||||||
|
const titleId = useId();
|
||||||
|
const launchUrl = app.isExternal
|
||||||
|
? app.entryUrl
|
||||||
|
: contextualUrl(app.entryUrl, catalogueUrl);
|
||||||
|
const target = app.launchModes.includes('navigate') ? undefined : '_blank';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalPanel
|
||||||
|
className="app-info-panel"
|
||||||
|
labelledBy={titleId}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<header className="app-info-panel__header">
|
||||||
|
<div className="app-info-panel__identity">
|
||||||
|
<div className="app-info-panel__icon" aria-hidden="true">
|
||||||
|
{app.iconUrl ? (
|
||||||
|
<img src={app.iconUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<span>{app.name.slice(0, 1).toLocaleUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Tool information</p>
|
||||||
|
<div className="title-line">
|
||||||
|
<h2 id={titleId}>{app.name}</h2>
|
||||||
|
{pinned && <span className="status-chip">Pinned</span>}
|
||||||
|
{hidden && (
|
||||||
|
<span className="status-chip status-chip--muted">Hidden</span>
|
||||||
|
)}
|
||||||
|
{app.isExternal && (
|
||||||
|
<span className="status-chip status-chip--muted">External</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="close-button"
|
||||||
|
type="button"
|
||||||
|
aria-label={`Close information for ${app.name}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="app-info-panel__body">
|
||||||
|
<p className="app-info-panel__description">{app.description}</p>
|
||||||
|
<PrivacySummary app={app} />
|
||||||
|
<RequirementSummary app={app} />
|
||||||
|
|
||||||
|
{app.categories.length > 0 && (
|
||||||
|
<div className="category-row" role="group" aria-label="Categories">
|
||||||
|
{app.categories.slice(0, 3).map((category) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={category}
|
||||||
|
onClick={() => onCategory(category)}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{app.tags.length > 0 && (
|
||||||
|
<div className="tag-row" role="group" aria-label="Tags">
|
||||||
|
{app.tags.map((tag) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={tag}
|
||||||
|
aria-pressed={activeTag === tag}
|
||||||
|
onClick={() => onTag(tag)}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="app-info-panel__footer">
|
||||||
|
<div className="app-info-panel__meta">
|
||||||
|
<span className="version">
|
||||||
|
{app.version ? `v${app.version}` : 'External service'}
|
||||||
|
</span>
|
||||||
|
{app.sourceUrl && (
|
||||||
|
<a
|
||||||
|
className="source-link"
|
||||||
|
href={app.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Source and license
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
className="app-info-panel__launch"
|
||||||
|
href={launchUrl}
|
||||||
|
target={target}
|
||||||
|
rel={target ? 'noopener noreferrer' : undefined}
|
||||||
|
>
|
||||||
|
Open {shortToolTitle(app)} <span aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</ModalPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/HelpPanel.tsx
Normal file
51
src/components/HelpPanel.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { ModalPanel } from './ModalPanel';
|
||||||
|
|
||||||
|
interface HelpPanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelpPanel({ open, onClose }: HelpPanelProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<ModalPanel
|
||||||
|
className="help-panel"
|
||||||
|
labelledBy="help-title"
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Toolbox guide</p>
|
||||||
|
<h2 id="help-title">Choose and arrange your tools</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="close-button"
|
||||||
|
type="button"
|
||||||
|
aria-label="Close help"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="help-steps">
|
||||||
|
<p>
|
||||||
|
<strong>Open:</strong> click anywhere on a tool tile that is not a
|
||||||
|
control.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Details:</strong> use the light bulb to review privacy,
|
||||||
|
requirements, categories, tags, version, and source before opening.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Filter:</strong> search, choose a category, or select a tag
|
||||||
|
from a tool's information panel. Select the tag again to clear it.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Arrange:</strong> 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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ModalPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/components/ModalPanel.tsx
Normal file
102
src/components/ModalPanel.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useRef, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface ModalPanelProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className: string;
|
||||||
|
labelledBy: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModalPanel({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
labelledBy,
|
||||||
|
onClose,
|
||||||
|
}: ModalPanelProps) {
|
||||||
|
const panel = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const returnFocus =
|
||||||
|
document.activeElement instanceof HTMLElement
|
||||||
|
? document.activeElement
|
||||||
|
: null;
|
||||||
|
const dialog = panel.current;
|
||||||
|
const focusable = () =>
|
||||||
|
dialog
|
||||||
|
? [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
|
||||||
|
(element) => !element.hasAttribute('disabled')
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
(focusable()[0] ?? dialog)?.focus();
|
||||||
|
const previousOverflow = document.documentElement.style.overflow;
|
||||||
|
document.documentElement.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
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) {
|
||||||
|
event.preventDefault();
|
||||||
|
dialog?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (!dialog?.contains(active)) {
|
||||||
|
event.preventDefault();
|
||||||
|
(event.shiftKey ? last : first).focus();
|
||||||
|
} else if (event.shiftKey && active === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && active === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
document.documentElement.style.overflow = previousOverflow;
|
||||||
|
if (returnFocus?.isConnected) returnFocus.focus();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="dialog-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onPointerDown={(event) =>
|
||||||
|
event.target === event.currentTarget && onClose()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
ref={panel}
|
||||||
|
className={className}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={labelledBy}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR = [
|
||||||
|
'a[href]',
|
||||||
|
'button:not([disabled])',
|
||||||
|
'input:not([disabled])',
|
||||||
|
'select:not([disabled])',
|
||||||
|
'textarea:not([disabled])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(',');
|
||||||
@@ -1,93 +1,25 @@
|
|||||||
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
import { useRef, useState, type ChangeEvent } from 'react';
|
||||||
|
import { ToolboxPersonalizePanel } from '@add-ideas/toolbox-shell-react';
|
||||||
import {
|
import {
|
||||||
defaultPreferences,
|
defaultPreferences,
|
||||||
parsePreferences,
|
parsePreferences,
|
||||||
type Preferences,
|
type Preferences,
|
||||||
} from '../preferences';
|
} from '../preferences';
|
||||||
import type { ThemeMode } from '../types';
|
|
||||||
|
|
||||||
interface PreferencesPanelProps {
|
interface PreferencesPanelProps {
|
||||||
open: boolean;
|
|
||||||
preferences: Preferences;
|
preferences: Preferences;
|
||||||
storageAvailable: boolean;
|
storageAvailable: boolean;
|
||||||
onChange: (preferences: Preferences) => void;
|
onChange: (preferences: Preferences) => void;
|
||||||
onClose: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PreferencesPanel({
|
export function PreferencesPanel({
|
||||||
open,
|
|
||||||
preferences,
|
preferences,
|
||||||
storageAvailable,
|
storageAvailable,
|
||||||
onChange,
|
onChange,
|
||||||
onClose,
|
|
||||||
}: PreferencesPanelProps) {
|
}: PreferencesPanelProps) {
|
||||||
const fileInput = useRef<HTMLInputElement>(null);
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
const panel = useRef<HTMLElement>(null);
|
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
const returnFocus =
|
|
||||||
document.activeElement instanceof HTMLElement
|
|
||||||
? document.activeElement
|
|
||||||
: null;
|
|
||||||
const dialog = panel.current;
|
|
||||||
const focusableElements = () =>
|
|
||||||
dialog
|
|
||||||
? [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
|
|
||||||
(element) => !element.hasAttribute('disabled')
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
focusableElements()[0]?.focus();
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
event.preventDefault();
|
|
||||||
onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key !== 'Tab') return;
|
|
||||||
|
|
||||||
const elements = focusableElements();
|
|
||||||
if (elements.length === 0) {
|
|
||||||
event.preventDefault();
|
|
||||||
dialog?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = elements[0]!;
|
|
||||||
const last = elements.at(-1)!;
|
|
||||||
const activeElement = document.activeElement;
|
|
||||||
if (
|
|
||||||
event.shiftKey &&
|
|
||||||
(activeElement === first || !dialog?.contains(activeElement))
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (
|
|
||||||
!event.shiftKey &&
|
|
||||||
(activeElement === last || !dialog?.contains(activeElement))
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
returnFocus?.focus();
|
|
||||||
};
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
function setTheme(theme: ThemeMode) {
|
|
||||||
onChange({ ...preferences, theme });
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportPreferences() {
|
function exportPreferences() {
|
||||||
const blob = new Blob([`${JSON.stringify(preferences, null, 2)}\n`], {
|
const blob = new Blob([`${JSON.stringify(preferences, null, 2)}\n`], {
|
||||||
type: 'application/json',
|
type: 'application/json',
|
||||||
@@ -119,119 +51,62 @@ export function PreferencesPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ToolboxPersonalizePanel
|
||||||
className="dialog-backdrop"
|
theme={preferences.theme}
|
||||||
role="presentation"
|
onThemeChange={(theme) => onChange({ ...preferences, theme })}
|
||||||
onMouseDown={(event) => event.target === event.currentTarget && onClose()}
|
storageAvailable={storageAvailable}
|
||||||
>
|
>
|
||||||
<section
|
<div className="preference-summary">
|
||||||
ref={panel}
|
<div>
|
||||||
className="preferences-panel"
|
<strong>{preferences.pinned.length}</strong>
|
||||||
role="dialog"
|
<span>Pinned</span>
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="preferences-title"
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">On this device</p>
|
|
||||||
<h2 id="preferences-title">Personalize your toolbox</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="close-button"
|
|
||||||
type="button"
|
|
||||||
aria-label="Close preferences"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{!storageAvailable && (
|
|
||||||
<p className="inline-warning" role="status">
|
|
||||||
Browser storage is unavailable. Changes will last only until this
|
|
||||||
page closes.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<fieldset className="theme-choice">
|
|
||||||
<legend>Appearance</legend>
|
|
||||||
<div>
|
|
||||||
{(['system', 'light', 'dark'] as const).map((theme) => (
|
|
||||||
<button
|
|
||||||
key={theme}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={preferences.theme === theme}
|
|
||||||
className={preferences.theme === theme ? 'is-selected' : ''}
|
|
||||||
onClick={() => setTheme(theme)}
|
|
||||||
>
|
|
||||||
{theme.slice(0, 1).toLocaleUpperCase() + theme.slice(1)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="preference-summary">
|
|
||||||
<div>
|
|
||||||
<strong>{preferences.pinned.length}</strong>
|
|
||||||
<span>Pinned</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>{preferences.hidden.length}</strong>
|
|
||||||
<span>Hidden</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>{preferences.order.length}</strong>
|
|
||||||
<span>Ordered</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<p className="preferences-copy">
|
<strong>{preferences.hidden.length}</strong>
|
||||||
The portal stores pins, visibility, order, and appearance in this
|
<span>Hidden</span>
|
||||||
browser and does not transmit them. Trusted code in another Toolbox
|
|
||||||
app on this site can access same-origin storage. Export a JSON backup
|
|
||||||
to move them to another device.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="preferences-actions">
|
|
||||||
<button type="button" onClick={exportPreferences}>
|
|
||||||
Export JSON
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => fileInput.current?.click()}>
|
|
||||||
Import JSON
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={fileInput}
|
|
||||||
className="visually-hidden"
|
|
||||||
type="file"
|
|
||||||
tabIndex={-1}
|
|
||||||
accept="application/json,.json"
|
|
||||||
onChange={importPreferences}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="danger-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onChange({ ...defaultPreferences });
|
|
||||||
setMessage('Preferences reset.');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="action-message" role="status" aria-live="polite">
|
<div>
|
||||||
{message}
|
<strong>{preferences.order.length}</strong>
|
||||||
</p>
|
<span>Ordered</span>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="preferences-copy">
|
||||||
|
The portal stores pins, visibility, order, and appearance in this
|
||||||
|
browser and does not transmit them. Trusted code in another Toolbox app
|
||||||
|
on this site can access same-origin storage. Export a JSON backup to
|
||||||
|
move them to another device.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="preferences-actions">
|
||||||
|
<button type="button" onClick={exportPreferences}>
|
||||||
|
Export JSON
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => fileInput.current?.click()}>
|
||||||
|
Import JSON
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
className="visually-hidden"
|
||||||
|
type="file"
|
||||||
|
tabIndex={-1}
|
||||||
|
accept="application/json,.json"
|
||||||
|
onChange={importPreferences}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="danger-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange({ ...defaultPreferences });
|
||||||
|
setMessage('Preferences reset.');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="action-message" role="status" aria-live="polite">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</ToolboxPersonalizePanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FOCUSABLE_SELECTOR = [
|
|
||||||
'a[href]',
|
|
||||||
'button:not([disabled])',
|
|
||||||
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
|
|
||||||
'select:not([disabled])',
|
|
||||||
'textarea:not([disabled])',
|
|
||||||
'[tabindex]:not([tabindex="-1"])',
|
|
||||||
].join(',');
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import '@add-ideas/toolbox-shell-react/styles.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import {
|
|||||||
PREFERENCES_KEY,
|
PREFERENCES_KEY,
|
||||||
cleanPreferences,
|
cleanPreferences,
|
||||||
defaultPreferences,
|
defaultPreferences,
|
||||||
moveId,
|
|
||||||
parsePreferences,
|
parsePreferences,
|
||||||
readPreferences,
|
readPreferences,
|
||||||
|
reorderWithinIds,
|
||||||
sortAppIds,
|
sortAppIds,
|
||||||
writePreferences,
|
writePreferences,
|
||||||
} from './preferences';
|
} from './preferences';
|
||||||
@@ -51,7 +51,7 @@ describe('preferences', () => {
|
|||||||
).toThrow(/pinned/i);
|
).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(
|
const cleaned = cleanPreferences(
|
||||||
{
|
{
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -65,14 +65,36 @@ describe('preferences', () => {
|
|||||||
expect(cleaned.order).toEqual(['app.two', 'app.one', 'app.three']);
|
expect(cleaned.order).toEqual(['app.two', 'app.one', 'app.three']);
|
||||||
expect(cleaned.hidden).toEqual([]);
|
expect(cleaned.hidden).toEqual([]);
|
||||||
expect(sortAppIds(['app.one', 'app.two', 'app.three'], cleaned)).toEqual([
|
expect(sortAppIds(['app.one', 'app.two', 'app.three'], cleaned)).toEqual([
|
||||||
'app.three',
|
|
||||||
'app.two',
|
'app.two',
|
||||||
'app.one',
|
'app.one',
|
||||||
]);
|
|
||||||
expect(moveId(cleaned.order, 'app.one', -1)).toEqual([
|
|
||||||
'app.one',
|
|
||||||
'app.two',
|
|
||||||
'app.three',
|
'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
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,66 +1,31 @@
|
|||||||
import type { ThemeMode } from './types';
|
import {
|
||||||
|
TOOLBOX_PREFERENCES_KEY,
|
||||||
export const PREFERENCES_KEY = '@add-ideas/toolbox-portal:v1:preferences';
|
defaultToolboxPreferences,
|
||||||
|
parseToolboxPreferences,
|
||||||
export interface Preferences {
|
readToolboxPreferences,
|
||||||
version: 1;
|
writeToolboxPreferences,
|
||||||
pinned: string[];
|
type ToolboxPreferences,
|
||||||
order: string[];
|
} from '@add-ideas/toolbox-contract';
|
||||||
hidden: string[];
|
|
||||||
theme: ThemeMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export const PREFERENCES_KEY = TOOLBOX_PREFERENCES_KEY;
|
||||||
|
export type Preferences = ToolboxPreferences;
|
||||||
export const defaultPreferences: Preferences = {
|
export const defaultPreferences: Preferences = {
|
||||||
version: 1,
|
...defaultToolboxPreferences,
|
||||||
pinned: [],
|
pinned: [],
|
||||||
order: [],
|
order: [],
|
||||||
hidden: [],
|
hidden: [],
|
||||||
theme: 'system',
|
|
||||||
};
|
};
|
||||||
|
export const parsePreferences = parseToolboxPreferences;
|
||||||
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<string, unknown>;
|
|
||||||
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 function readPreferences(storage: Storage = localStorage): Preferences {
|
export function readPreferences(storage: Storage = localStorage): Preferences {
|
||||||
const value = storage.getItem(PREFERENCES_KEY);
|
return readToolboxPreferences(storage);
|
||||||
if (!value) return { ...defaultPreferences };
|
|
||||||
try {
|
|
||||||
return parsePreferences(JSON.parse(value));
|
|
||||||
} catch {
|
|
||||||
return { ...defaultPreferences };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writePreferences(
|
export function writePreferences(
|
||||||
preferences: Preferences,
|
preferences: Preferences,
|
||||||
storage: Storage = localStorage
|
storage: Storage = localStorage
|
||||||
): void {
|
): void {
|
||||||
storage.setItem(PREFERENCES_KEY, JSON.stringify(preferences));
|
writeToolboxPreferences(preferences, storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanPreferences(
|
export function cleanPreferences(
|
||||||
@@ -84,27 +49,36 @@ export function sortAppIds(
|
|||||||
preferences: Preferences
|
preferences: Preferences
|
||||||
): string[] {
|
): string[] {
|
||||||
const order = new Map(preferences.order.map((id, index) => [id, index]));
|
const order = new Map(preferences.order.map((id, index) => [id, index]));
|
||||||
const pinned = new Set(preferences.pinned);
|
return [...appIds].sort(
|
||||||
return [...appIds].sort((left, right) => {
|
(left, right) =>
|
||||||
const pinDifference = Number(pinned.has(right)) - Number(pinned.has(left));
|
|
||||||
if (pinDifference) return pinDifference;
|
|
||||||
return (
|
|
||||||
(order.get(left) ?? Number.MAX_SAFE_INTEGER) -
|
(order.get(left) ?? Number.MAX_SAFE_INTEGER) -
|
||||||
(order.get(right) ?? Number.MAX_SAFE_INTEGER)
|
(order.get(right) ?? Number.MAX_SAFE_INTEGER)
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveId(
|
export function reorderWithinIds(
|
||||||
order: string[],
|
order: string[],
|
||||||
id: string,
|
activeId: string,
|
||||||
direction: -1 | 1
|
overId: string,
|
||||||
|
peerIds: readonly string[]
|
||||||
): string[] {
|
): string[] {
|
||||||
const current = order.indexOf(id);
|
if (activeId === overId) return order;
|
||||||
if (current < 0) return order;
|
const peers = new Set(peerIds);
|
||||||
const target = current + direction;
|
if (!peers.has(activeId) || !peers.has(overId)) return order;
|
||||||
if (target < 0 || target >= order.length) return order;
|
|
||||||
const next = [...order];
|
const orderedPeers = order.filter((id) => peers.has(id));
|
||||||
[next[current], next[target]] = [next[target]!, next[current]!];
|
for (const id of peerIds) {
|
||||||
return next;
|
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
676
src/styles.css
676
src/styles.css
@@ -17,6 +17,10 @@
|
|||||||
--shadow: 0 16px 44px rgba(24, 34, 68, 0.08);
|
--shadow: 0 16px 44px rgba(24, 34, 68, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme='light'] {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] {
|
:root[data-theme='dark'] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--bg: #0f1422;
|
--bg: #0f1422;
|
||||||
@@ -119,63 +123,13 @@ summary:focus-visible {
|
|||||||
transform: translateY(0);
|
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,
|
|
||||||
main,
|
main,
|
||||||
.site-footer {
|
.site-footer {
|
||||||
width: min(1160px, calc(100% - 2.5rem));
|
width: min(100%, 90rem);
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
|
padding-inline: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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,
|
.state-card button,
|
||||||
.preferences-actions button {
|
.preferences-actions button {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -185,11 +139,6 @@ main,
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-button {
|
|
||||||
padding: 0.62rem 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-button:hover,
|
|
||||||
.state-card button:hover,
|
.state-card button:hover,
|
||||||
.preferences-actions button:hover {
|
.preferences-actions button:hover {
|
||||||
border-color: var(--brand);
|
border-color: var(--brand);
|
||||||
@@ -201,8 +150,15 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
max-width: 750px;
|
display: grid;
|
||||||
padding: clamp(4.5rem, 9vw, 8.5rem) 0 clamp(3rem, 6vw, 5.2rem);
|
grid-template-columns: 1fr;
|
||||||
|
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: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
@@ -214,7 +170,7 @@ main {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1 {
|
.hero h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, sans-serif;
|
font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, sans-serif;
|
||||||
font-size: clamp(2.65rem, 7vw, 5.65rem);
|
font-size: clamp(2.65rem, 7vw, 5.65rem);
|
||||||
@@ -222,7 +178,7 @@ main {
|
|||||||
letter-spacing: -0.065em;
|
letter-spacing: -0.065em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1 span {
|
.hero h2 span {
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,10 +191,10 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tool-controls {
|
.tool-controls {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(8.75rem, 10.5rem);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-field,
|
.search-field,
|
||||||
@@ -252,7 +208,7 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-field {
|
.search-field {
|
||||||
width: min(360px, 100%);
|
min-width: 0;
|
||||||
padding: 0 0.85rem;
|
padding: 0 0.85rem;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
}
|
}
|
||||||
@@ -275,7 +231,7 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.category-field {
|
.category-field {
|
||||||
min-width: 175px;
|
min-width: 0;
|
||||||
padding: 0 0.65rem;
|
padding: 0 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,72 +240,137 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hidden-toggle {
|
.hidden-toggle {
|
||||||
|
grid-column: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-self: end;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
margin-left: auto;
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden-toggle--disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
.hidden-toggle input {
|
.hidden-toggle input {
|
||||||
width: 1.05rem;
|
width: 1.05rem;
|
||||||
height: 1.05rem;
|
height: 1.05rem;
|
||||||
accent-color: var(--brand);
|
accent-color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active-filter {
|
||||||
|
grid-column: 1;
|
||||||
|
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 {
|
.app-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr));
|
grid-template-columns: repeat(
|
||||||
gap: 1.1rem;
|
auto-fill,
|
||||||
padding-bottom: 2rem;
|
minmax(min(10.5rem, 100%), 11.75rem)
|
||||||
|
);
|
||||||
|
align-items: start;
|
||||||
|
justify-content: start;
|
||||||
|
gap: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card {
|
.app-card {
|
||||||
min-height: 340px;
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding: 1rem 0.85rem 0.75rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 1.2rem;
|
border-radius: 1rem;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
transition:
|
transition:
|
||||||
border-color 180ms ease,
|
border-color 180ms ease,
|
||||||
transform 180ms ease,
|
|
||||||
box-shadow 180ms ease;
|
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 {
|
.app-card:hover {
|
||||||
border-color: color-mix(in srgb, var(--brand) 35%, var(--line));
|
border-color: color-mix(in srgb, var(--brand) 35%, var(--line));
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 20px 54px rgba(24, 34, 68, 0.13);
|
box-shadow: 0 20px 54px rgba(24, 34, 68, 0.13);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card--hidden {
|
.app-card--hidden {
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
opacity: 0.7;
|
background: color-mix(in srgb, var(--surface-soft) 55%, var(--surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card__top {
|
.app-card__face {
|
||||||
display: flex;
|
min-height: 0;
|
||||||
align-items: flex-start;
|
display: grid;
|
||||||
justify-content: space-between;
|
flex: 1;
|
||||||
padding: 1.35rem 1.35rem 0;
|
align-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
padding: 0.2rem 0.25rem 0;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card__icon {
|
.app-card__icon {
|
||||||
width: 52px;
|
width: 3.6rem;
|
||||||
height: 52px;
|
height: 3.6rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 0.95rem;
|
border-radius: 0.9rem;
|
||||||
background: var(--brand-soft);
|
background: var(--brand-soft);
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||||
font-size: 1.45rem;
|
font-size: 1.35rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,40 +380,130 @@ main {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-card__face h3 {
|
||||||
|
margin: 0.7rem 0 0;
|
||||||
|
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card__face p {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 50%;
|
||||||
|
left: 0.35rem;
|
||||||
|
width: 1rem;
|
||||||
|
height: 5rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0;
|
||||||
|
cursor: grab;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: none;
|
||||||
|
transform: translate(-0.2rem, -50%);
|
||||||
|
transition:
|
||||||
|
opacity 150ms ease,
|
||||||
|
color 150ms ease,
|
||||||
|
transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card:hover .drag-handle,
|
||||||
|
.app-card:focus-within .drag-handle,
|
||||||
|
.drag-handle:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translate(0, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:hover {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle svg {
|
||||||
|
width: calc(1rem - 2px);
|
||||||
|
height: 4rem;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.app-card__actions {
|
.app-card__actions {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.15rem;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button,
|
.icon-button,
|
||||||
.close-button {
|
.close-button {
|
||||||
width: 32px;
|
width: 2.15rem;
|
||||||
height: 32px;
|
height: 2.15rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 0;
|
padding: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: transparent;
|
background: var(--surface-soft);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-card__actions .icon-button {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-button:hover:not(:disabled),
|
.icon-button:hover:not(:disabled),
|
||||||
.icon-button.is-active {
|
.icon-button.is-active {
|
||||||
|
border-color: color-mix(in srgb, var(--brand) 30%, var(--line));
|
||||||
background: var(--brand-soft);
|
background: var(--brand-soft);
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-button svg {
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-button svg {
|
||||||
|
transform: rotate(0);
|
||||||
|
transition: transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-button.is-active svg {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
.icon-button:disabled {
|
.icon-button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.28;
|
opacity: 0.28;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card__body {
|
|
||||||
flex: 1;
|
|
||||||
padding: 1.25rem 1.35rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-line {
|
.title-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -403,12 +514,36 @@ main {
|
|||||||
.title-line h2 {
|
.title-line h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||||
font-size: 1.3rem;
|
font-size: 1.35rem;
|
||||||
letter-spacing: -0.025em;
|
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,
|
.status-chip,
|
||||||
.tag-row span {
|
.tag-row button,
|
||||||
|
.category-row button {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--brand-soft);
|
background: var(--brand-soft);
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
@@ -419,6 +554,7 @@ main {
|
|||||||
.status-chip {
|
.status-chip {
|
||||||
padding: 0.22rem 0.48rem;
|
padding: 0.22rem 0.48rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-chip--muted {
|
.status-chip--muted {
|
||||||
@@ -426,43 +562,37 @@ main {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card__body > p {
|
.tag-row,
|
||||||
margin: 0.75rem 0 0;
|
.category-row {
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card__body .requirements {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card__body .privacy-summary {
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.76rem;
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-row span {
|
.tag-row button,
|
||||||
|
.category-row button {
|
||||||
padding: 0.28rem 0.52rem;
|
padding: 0.28rem 0.52rem;
|
||||||
|
border: 0;
|
||||||
background: var(--surface-soft);
|
background: var(--surface-soft);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card__footer {
|
.category-row {
|
||||||
min-height: 68px;
|
margin-top: 1rem;
|
||||||
display: flex;
|
}
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
.tag-row {
|
||||||
gap: 1rem;
|
margin-top: 0.55rem;
|
||||||
padding: 0.85rem 1.35rem;
|
}
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
|
.tag-row button:hover,
|
||||||
|
.category-row button:hover,
|
||||||
|
.tag-row button[aria-pressed='true'] {
|
||||||
|
background: var(--brand-soft);
|
||||||
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.version {
|
.version {
|
||||||
@@ -470,37 +600,13 @@ main {
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card__meta {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-link,
|
.source-link,
|
||||||
.site-footer a {
|
.site-footer a {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
color: var(--brand);
|
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);
|
|
||||||
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 {
|
.state-card {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto 5rem;
|
margin: 0 auto 5rem;
|
||||||
@@ -604,14 +710,16 @@ main {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: rgba(5, 9, 22, 0.56);
|
background: rgba(5, 9, 22, 0.56);
|
||||||
backdrop-filter: blur(5px);
|
backdrop-filter: blur(5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preferences-panel {
|
.help-panel,
|
||||||
width: min(560px, 100%);
|
.app-info-panel {
|
||||||
max-height: calc(100vh - 2rem);
|
max-height: calc(100vh - 2rem);
|
||||||
|
max-height: calc(100dvh - 2rem);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -620,23 +728,152 @@ main {
|
|||||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preferences-panel > header {
|
.help-panel {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel {
|
||||||
|
position: relative;
|
||||||
|
width: min(680px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-panel > header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preferences-panel h2 {
|
.help-panel h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||||
letter-spacing: -0.025em;
|
letter-spacing: -0.025em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preferences-panel .eyebrow {
|
.app-info-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel__identity {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel__icon {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--brand-soft);
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 1.45rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel__icon img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel .eyebrow {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel__body {
|
||||||
|
margin-top: 1.35rem;
|
||||||
|
padding: 1.1rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel__description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel__body .privacy-summary {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel__body .requirements {
|
||||||
|
margin: 0.55rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel__meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel__launch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
background: var(--brand);
|
||||||
|
color: var(--surface) !important;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel__launch:hover {
|
||||||
|
background: var(--brand-hover);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-panel .eyebrow {
|
||||||
margin-bottom: 0.5rem;
|
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 {
|
.close-button {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
background: var(--surface-soft);
|
background: var(--surface-soft);
|
||||||
@@ -644,44 +881,6 @@ main {
|
|||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-choice {
|
|
||||||
margin: 1.5rem 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-choice legend {
|
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-choice > div {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 0.45rem;
|
|
||||||
padding: 0.35rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: var(--surface-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-choice button {
|
|
||||||
padding: 0.62rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-choice button.is-selected {
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--brand);
|
|
||||||
box-shadow: 0 2px 8px rgba(20, 30, 60, 0.09);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preference-summary {
|
.preference-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
@@ -691,7 +890,7 @@ main {
|
|||||||
.preference-summary > div {
|
.preference-summary > div {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
padding: 0.9rem;
|
padding: 0.65rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 0.7rem;
|
border-radius: 0.7rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -699,7 +898,7 @@ main {
|
|||||||
|
|
||||||
.preference-summary strong {
|
.preference-summary strong {
|
||||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||||
font-size: 1.25rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preference-summary span,
|
.preference-summary span,
|
||||||
@@ -709,8 +908,11 @@ main {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preferences-copy {
|
.portal-header .preferences-copy {
|
||||||
margin: 1.1rem 0;
|
margin: 0.9rem 0;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--toolbox-muted, var(--muted));
|
||||||
|
font-size: 0.78rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,7 +923,7 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preferences-actions button {
|
.preferences-actions button {
|
||||||
padding: 0.62rem 0.78rem;
|
padding: 0.52rem 0.68rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preferences-actions .danger-button {
|
.preferences-actions .danger-button {
|
||||||
@@ -729,17 +931,12 @@ main {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-message {
|
.portal-header .action-message {
|
||||||
min-height: 1.2em;
|
min-height: 1.2em;
|
||||||
margin: 0.8rem 0 0;
|
margin: 0.8rem 0 0;
|
||||||
}
|
padding: 0;
|
||||||
|
color: var(--toolbox-muted, var(--muted));
|
||||||
.inline-warning {
|
font-size: 0.78rem;
|
||||||
padding: 0.7rem 0.8rem;
|
|
||||||
border-radius: 0.6rem;
|
|
||||||
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
|
|
||||||
color: var(--danger);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
@@ -754,31 +951,19 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.header-inner,
|
|
||||||
main,
|
main,
|
||||||
.site-footer {
|
.site-footer {
|
||||||
width: min(100% - 1.4rem, 1160px);
|
width: min(100%, 90rem);
|
||||||
}
|
padding-inline: 0.7rem;
|
||||||
|
|
||||||
.header-inner {
|
|
||||||
min-height: 66px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-button {
|
|
||||||
font-size: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-button span {
|
|
||||||
font-size: 1.15rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
padding-top: 3.8rem;
|
padding-top: 3.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-controls {
|
.tool-controls {
|
||||||
align-items: stretch;
|
width: 100%;
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-field,
|
.search-field,
|
||||||
@@ -790,10 +975,6 @@ main {
|
|||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card {
|
|
||||||
min-height: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-footer {
|
.site-footer {
|
||||||
min-height: 130px;
|
min-height: 130px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -802,6 +983,53 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.tool-controls {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field,
|
||||||
|
.category-field,
|
||||||
|
.active-filter,
|
||||||
|
.hidden-toggle {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-toggle {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel__header,
|
||||||
|
.app-info-panel__footer {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel__header .close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.1rem;
|
||||||
|
right: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-panel__identity {
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 701px) and (max-width: 960px) {
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.drag-handle {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translate(0, -50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const pdfManifest: ToolboxAppManifest = {
|
|||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
id: 'de.add-ideas.pdf-tools',
|
id: 'de.add-ideas.pdf-tools',
|
||||||
name: 'PDF Workbench',
|
name: 'PDF Workbench',
|
||||||
version: '0.3.4',
|
version: '0.4.3',
|
||||||
description: 'Page-level PDF operations performed locally in the browser.',
|
description: 'Page-level PDF operations performed locally in the browser.',
|
||||||
entry: './',
|
entry: './',
|
||||||
icon: './favicon.svg',
|
icon: './favicon.svg',
|
||||||
@@ -35,13 +35,69 @@ export const pdfManifest: ToolboxAppManifest = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const xsltManifest: ToolboxAppManifest = {
|
||||||
|
...pdfManifest,
|
||||||
|
id: 'de.add-ideas.xslt-tools',
|
||||||
|
name: 'XSLT Workbench',
|
||||||
|
version: '0.4.2',
|
||||||
|
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.3',
|
||||||
|
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 regexManifest: ToolboxAppManifest = {
|
||||||
|
...pdfManifest,
|
||||||
|
id: 'de.add-ideas.regex-tools',
|
||||||
|
name: 'Regex Tools',
|
||||||
|
version: '0.1.0',
|
||||||
|
description:
|
||||||
|
'Develop, explain, test and apply regular expressions locally in the browser.',
|
||||||
|
icon: './regex.svg',
|
||||||
|
categories: ['developer', 'text', 'regex'],
|
||||||
|
tags: ['regular-expression', 'match', 'replace', 'explain', 'test'],
|
||||||
|
requirements: {
|
||||||
|
secureContext: false,
|
||||||
|
workers: true,
|
||||||
|
indexedDb: true,
|
||||||
|
crossOriginIsolated: false,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
repository: 'https://git.add-ideas.de/zemion/regex-tools',
|
||||||
|
license: 'GPL-3.0-or-later',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const catalogue: ToolboxCatalog = {
|
export const catalogue: ToolboxCatalog = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
id: 'de.add-ideas.toolbox',
|
id: 'de.add-ideas.toolbox',
|
||||||
name: 'add·ideas Toolbox',
|
name: 'add·ideas Toolbox',
|
||||||
home: './',
|
home: './',
|
||||||
theme: { mode: 'system', brand: 'add·ideas' },
|
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<string, Response> = {}) {
|
export function catalogueFetch(overrides: Record<string, Response> = {}) {
|
||||||
@@ -56,6 +112,12 @@ export function catalogueFetch(overrides: Record<string, Response> = {}) {
|
|||||||
return Response.json(catalogue);
|
return Response.json(catalogue);
|
||||||
if (url.pathname.endsWith('/apps/pdf/toolbox-app.json'))
|
if (url.pathname.endsWith('/apps/pdf/toolbox-app.json'))
|
||||||
return Response.json(pdfManifest);
|
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);
|
||||||
|
if (url.pathname.endsWith('/apps/regex/toolbox-app.json'))
|
||||||
|
return Response.json(regexManifest);
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/toolPresentation.ts
Normal file
31
src/toolPresentation.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ResolvedApp } from './types';
|
||||||
|
|
||||||
|
const PRESENTATION: Record<string, { title: string; description: string }> = {
|
||||||
|
'de.add-ideas.pdf-tools': {
|
||||||
|
title: 'PDF',
|
||||||
|
description: 'Arrange PDF pages locally.',
|
||||||
|
},
|
||||||
|
'de.add-ideas.xslt-tools': {
|
||||||
|
title: 'XSLT',
|
||||||
|
description: 'Transform XML locally.',
|
||||||
|
},
|
||||||
|
'de.add-ideas.onenote-tools': {
|
||||||
|
title: 'OneNote',
|
||||||
|
description: 'Open OneNote files locally.',
|
||||||
|
},
|
||||||
|
'de.add-ideas.regex-tools': {
|
||||||
|
title: 'Regex',
|
||||||
|
description: 'Explain and test regex locally.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function shortToolTitle(app: ResolvedApp): string {
|
||||||
|
return (
|
||||||
|
PRESENTATION[app.id]?.title ??
|
||||||
|
app.name.replace(/\s+(?:reader|tools?|workbench)$/iu, '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortToolDescription(app: ResolvedApp): string {
|
||||||
|
return PRESENTATION[app.id]?.description ?? app.description;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
PREFERENCES_KEY,
|
PREFERENCES_KEY,
|
||||||
defaultPreferences,
|
defaultPreferences,
|
||||||
|
parsePreferences,
|
||||||
readPreferences,
|
readPreferences,
|
||||||
writePreferences,
|
writePreferences,
|
||||||
type Preferences,
|
type Preferences,
|
||||||
@@ -43,5 +44,22 @@ export function usePreferences(): [
|
|||||||
}
|
}
|
||||||
}, [preferences]);
|
}, [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];
|
return [preferences, setPreferences, initial.storageAvailable];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user