Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45dcaff659 | ||
|
|
ea5f76554a | ||
|
|
08a01d843a | ||
|
|
d2d0c9098d | ||
|
|
01f6c59adf | ||
|
|
bc07e13e3b | ||
|
|
7bd34371a9 | ||
|
|
8d68557d5e | ||
|
|
7ba733d9f2 | ||
|
|
0baeda8be8 | ||
|
|
5ed7134d42 | ||
|
|
55b2b12434 | ||
|
|
f0b2ce34e4 | ||
|
|
43670a5824 | ||
|
|
a50944e7cb | ||
|
|
9347554324 | ||
|
|
470ee49ca2 | ||
|
|
5c052bc6d1 | ||
|
|
a9c31c8986 | ||
|
|
1f00e6def1 | ||
|
|
bda9da044d | ||
|
|
2c13c56362 | ||
|
|
803397ff52 | ||
|
|
21283ce25f | ||
|
|
e44d35fdfb | ||
|
|
67ba5e3eca | ||
|
|
587aadb79b | ||
|
|
de51ec0bd5 | ||
|
|
45bc3f831a | ||
|
|
d46f1b61fb | ||
|
|
089ac55b87 | ||
|
|
5feb1eba98 | ||
|
|
4964e86eca | ||
|
|
1d4cacab55 | ||
|
|
b7eefff5ae | ||
|
|
7353f0dc4f |
@@ -0,0 +1,9 @@
|
|||||||
|
# Optional overrides for compose.yaml. Its defaults already match production.
|
||||||
|
TOOLBOX_HOST=toolbox.add-ideas.de
|
||||||
|
AUTH_TOOLS_HOST=auth.toolbox.add-ideas.de
|
||||||
|
TRAEFIK_NETWORK=internal
|
||||||
|
TRAEFIK_CERT_RESOLVER=netcup
|
||||||
|
|
||||||
|
# Keep these two values paired when deploying a newer published toolbox release.
|
||||||
|
TOOLBOX_RELEASE_VERSION=0.19.0
|
||||||
|
TOOLBOX_RELEASE_SHA256=b6003d8b3eee8a13032527b014a381d832ba46108c35d0aee9e1cfa6a1b281e2
|
||||||
@@ -5,4 +5,5 @@ artifacts/
|
|||||||
coverage/
|
coverage/
|
||||||
public/THIRD_PARTY_LICENSES.txt
|
public/THIRD_PARTY_LICENSES.txt
|
||||||
*.log
|
*.log
|
||||||
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/
|
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/
|
||||||
|
|||||||
+9
-1
@@ -2,5 +2,13 @@
|
|||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"trailingComma": "es5",
|
"trailingComma": "es5",
|
||||||
"printWidth": 80
|
"printWidth": 80,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "compose.yaml",
|
||||||
|
"options": {
|
||||||
|
"singleQuote": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
ARG NGINX_IMAGE=nginxinc/nginx-unprivileged:1.31.3-alpine@sha256:18d67281256ded39ff65e010ae4f831be18f19356f83c60bc546492c7eb6dd23
|
||||||
|
|
||||||
|
FROM ${NGINX_IMAGE} AS release
|
||||||
|
|
||||||
|
ARG TOOLBOX_RELEASE_VERSION=0.19.0
|
||||||
|
ARG TOOLBOX_RELEASE_SHA256=b6003d8b3eee8a13032527b014a381d832ba46108c35d0aee9e1cfa6a1b281e2
|
||||||
|
ARG TOOLBOX_RELEASE_BASE_URL=https://git.add-ideas.de/lotobo/toolbox-portal/releases/download
|
||||||
|
|
||||||
|
RUN set -eu; \
|
||||||
|
archive="add-ideas-toolbox-${TOOLBOX_RELEASE_VERSION}.zip"; \
|
||||||
|
curl --fail --location --silent --show-error --proto '=https' \
|
||||||
|
--proto-redir '=https' --tlsv1.2 \
|
||||||
|
--output "/tmp/${archive}" \
|
||||||
|
"${TOOLBOX_RELEASE_BASE_URL}/v${TOOLBOX_RELEASE_VERSION}/${archive}"; \
|
||||||
|
echo "${TOOLBOX_RELEASE_SHA256} /tmp/${archive}" | sha256sum -c -; \
|
||||||
|
mkdir -p /tmp/toolbox; \
|
||||||
|
unzip -q "/tmp/${archive}" -d /tmp/toolbox; \
|
||||||
|
test -f /tmp/toolbox/index.html; \
|
||||||
|
test -f /tmp/toolbox/toolbox.catalog.json; \
|
||||||
|
test -f /tmp/toolbox/toolbox.release.json; \
|
||||||
|
test -f /tmp/toolbox/apps/pdf/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/av/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/regex/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/svg/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/auth/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/sudoku/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/colour/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/office/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/image/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/file/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/epub/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/archive/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/privacy/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/crypto/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/barcode/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/network/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/geo/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/helper/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/rand/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/data/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/diff/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/time/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/text/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/unicode/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/flow/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/subtitle/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/midi/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/3d/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/query/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/scan/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/package/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/label/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/font/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/fixture/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/minimize/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/format-lab/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/repro/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/schema/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/log/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/binary/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/git/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/api/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/mail/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/calendar/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/contact/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/diagram/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/token/toolbox-app.json; \
|
||||||
|
test -f /tmp/toolbox/apps/device/toolbox-app.json; \
|
||||||
|
rm "/tmp/${archive}"
|
||||||
|
|
||||||
|
FROM ${NGINX_IMAGE}
|
||||||
|
|
||||||
|
COPY --chown=101:101 deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=release --chown=101:101 /tmp/toolbox/ /usr/share/nginx/html/
|
||||||
|
|
||||||
|
USER 101:101
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --quiet --spider http://127.0.0.1:8080/toolbox.catalog.json || exit 1
|
||||||
@@ -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.22` 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,14 +30,27 @@ 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.3` SDK ranges to the published `0.2.3`
|
||||||
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/` and `./apps/xslt/`). Those manifests will correctly appear
|
paths (`./apps/pdf/`, `./apps/xslt/`, `./apps/onenote/`, `./apps/av/`,
|
||||||
as unavailable until an assembled release is being served.
|
`./apps/regex/`, `./apps/svg/`, `./apps/auth/`, `./apps/sudoku/`,
|
||||||
|
`./apps/colour/`, `./apps/office/`, `./apps/image/`, `./apps/file/`,
|
||||||
|
`./apps/epub/`, `./apps/archive/`, `./apps/privacy/`, `./apps/crypto/`,
|
||||||
|
`./apps/barcode/`, `./apps/network/`, `./apps/geo/`, `./apps/helper/`,
|
||||||
|
`./apps/rand/`, `./apps/data/`, `./apps/diff/`, `./apps/time/`,
|
||||||
|
`./apps/text/`, `./apps/unicode/`, `./apps/flow/`, `./apps/subtitle/`,
|
||||||
|
`./apps/midi/`, `./apps/3d/`, `./apps/query/`, `./apps/scan/`,
|
||||||
|
`./apps/package/`, `./apps/label/`, `./apps/font/`, `./apps/fixture/`,
|
||||||
|
`./apps/minimize/`, `./apps/format-lab/`, `./apps/repro/`, `./apps/schema/`,
|
||||||
|
`./apps/log/`, `./apps/binary/`, `./apps/git/`, `./apps/api/`, `./apps/mail/`,
|
||||||
|
`./apps/calendar/`, `./apps/contact/`, `./apps/diagram/`, `./apps/token/`, and
|
||||||
|
`./apps/device/`).
|
||||||
|
Those manifests will correctly appear as unavailable until an assembled release
|
||||||
|
is being served.
|
||||||
|
|
||||||
## Catalogue and launches
|
## Catalogue and launches
|
||||||
|
|
||||||
@@ -62,6 +77,56 @@ transmit preferences. Because Toolbox apps share an origin, code explicitly
|
|||||||
trusted in an app can access same-origin browser storage; review each app's
|
trusted in an app can access same-origin browser storage; review each app's
|
||||||
privacy and executable-code warning. Light, dark, and system modes are supported.
|
privacy and executable-code warning. Light, dark, and system modes are supported.
|
||||||
|
|
||||||
|
## Production deployment behind Traefik
|
||||||
|
|
||||||
|
The committed `compose.yaml` is the shortest production path. Its release
|
||||||
|
container downloads the immutable Toolbox 0.19.0 ZIP during the image build,
|
||||||
|
verifies SHA-256 before extracting it, and then copies only the verified static
|
||||||
|
files into the pinned unprivileged nginx image. Node.js and a local portal
|
||||||
|
assembly are not required on the deployment host.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
|
||||||
|
- Docker with the Compose plugin;
|
||||||
|
- Traefik attached to the existing external Docker network `internal`;
|
||||||
|
- Traefik HTTP and HTTPS entrypoints named `web` and `websecure`;
|
||||||
|
- a certificate resolver named `netcup`;
|
||||||
|
- DNS for `toolbox.add-ideas.de` and `auth.toolbox.add-ideas.de` pointing to that Traefik instance; and
|
||||||
|
- outbound HTTPS access to `git.add-ideas.de` while the image is built.
|
||||||
|
|
||||||
|
Deploy a fresh clone with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.add-ideas.de/lotobo/toolbox-portal.git
|
||||||
|
cd toolbox-portal
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
No host port is published. Traefik permanently redirects
|
||||||
|
`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,
|
||||||
|
release version, and matching checksum can be overridden through environment
|
||||||
|
variables; copy `.env.example` to `.env` only when an override is needed. For
|
||||||
|
example, set `TOOLBOX_HOST=staging.toolbox.add-ideas.de` for a staging host.
|
||||||
|
|
||||||
|
The same container also exposes only the packaged authentication app at
|
||||||
|
`https://auth.toolbox.add-ideas.de/`. Traefik adds the internal `/apps/auth`
|
||||||
|
prefix while the public URL remains `/`, so assets remain relative and the browser
|
||||||
|
binds WebAuthn credentials to the dedicated `auth.toolbox.add-ideas.de` RP ID.
|
||||||
|
The main Toolbox copy at `/apps/auth/` remains useful for offline inspection but
|
||||||
|
disables live ceremonies because all Portal apps share that origin. The router
|
||||||
|
hostname can be overridden with `AUTH_TOOLS_HOST`, but the bundled Auth Tools
|
||||||
|
release enables live ceremonies only on its pinned production host (or
|
||||||
|
localhost); other hosts remain inspect-only. A different production host
|
||||||
|
therefore also requires rebuilding Auth Tools with the new exact host. Never
|
||||||
|
widen the RP ID to `add-ideas.de`.
|
||||||
|
|
||||||
|
The external network is deliberately not created by this project. Creating a
|
||||||
|
private project-local network would prevent an independently managed Traefik
|
||||||
|
container from reaching the service.
|
||||||
|
|
||||||
## Exact release assembly
|
## Exact release assembly
|
||||||
|
|
||||||
Every app release ZIP must put these items at its archive root:
|
Every app release ZIP must put these items at its archive root:
|
||||||
@@ -79,8 +144,18 @@ 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.1.0 lock. Its URLs and checksums
|
`release/toolbox.lock.json` is the reviewed Toolbox v0.19.0 / Portal v0.2.22
|
||||||
pin the published PDF Tools 0.3.4 and XSLT Tools 0.3.2 release bytes. Use
|
lock. Its URLs and checksums pin the published PDF Tools 0.4.4, XSLT Tools
|
||||||
|
0.4.3, OneNote Tools 0.3.4,
|
||||||
|
Audio & Video Tools 0.3.0, Regex Tools 0.4.2, SVG Tools 0.1.0, OTP & Passkey
|
||||||
|
Tools 0.3.1, Sudoku Tools 0.2.1, Colour Tools 0.1.0, Office Tools 0.1.0,
|
||||||
|
Random Tools 0.1.1, and the 0.1.0 releases of Image, File, EPUB, Archive,
|
||||||
|
Privacy, Crypto, Barcode, Network, Geo, Helper, Data, Diff, Time, Text, Unicode,
|
||||||
|
Flow, Subtitle, MIDI, 3D, Query, Scan, Package, Label, Font, Fixture, Minimize,
|
||||||
|
Format Lab, Repro, Log, Binary, Git, API, Mail, Calendar, Contact,
|
||||||
|
Diagram, Token, and Device Tools.
|
||||||
|
Schema Tools is pinned at its hardened v0.1.2 release.
|
||||||
|
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:
|
||||||
|
|
||||||
@@ -101,7 +176,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.1.0.zip
|
--archive build/add-ideas-toolbox-0.19.0.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
Existing output is refused. Pass `--force` only when replacing those exact
|
Existing output is refused. Pass `--force` only when replacing those exact
|
||||||
@@ -118,10 +193,58 @@ build/toolbox/
|
|||||||
├── THIRD_PARTY_NOTICES.md
|
├── THIRD_PARTY_NOTICES.md
|
||||||
└── apps/
|
└── apps/
|
||||||
├── pdf/
|
├── pdf/
|
||||||
└── xslt/
|
├── xslt/
|
||||||
|
├── onenote/
|
||||||
|
├── av/
|
||||||
|
├── regex/
|
||||||
|
├── svg/
|
||||||
|
├── auth/
|
||||||
|
├── sudoku/
|
||||||
|
├── colour/
|
||||||
|
├── office/
|
||||||
|
├── image/
|
||||||
|
├── file/
|
||||||
|
├── epub/
|
||||||
|
├── archive/
|
||||||
|
├── privacy/
|
||||||
|
├── crypto/
|
||||||
|
├── barcode/
|
||||||
|
├── network/
|
||||||
|
├── geo/
|
||||||
|
├── helper/
|
||||||
|
├── rand/
|
||||||
|
├── data/
|
||||||
|
├── diff/
|
||||||
|
├── time/
|
||||||
|
├── text/
|
||||||
|
├── unicode/
|
||||||
|
├── flow/
|
||||||
|
├── subtitle/
|
||||||
|
├── midi/
|
||||||
|
├── 3d/
|
||||||
|
├── query/
|
||||||
|
├── scan/
|
||||||
|
├── package/
|
||||||
|
├── label/
|
||||||
|
├── font/
|
||||||
|
├── fixture/
|
||||||
|
├── minimize/
|
||||||
|
├── format-lab/
|
||||||
|
├── repro/
|
||||||
|
├── schema/
|
||||||
|
├── log/
|
||||||
|
├── binary/
|
||||||
|
├── git/
|
||||||
|
├── api/
|
||||||
|
├── mail/
|
||||||
|
├── calendar/
|
||||||
|
├── contact/
|
||||||
|
├── diagram/
|
||||||
|
├── token/
|
||||||
|
└── device/
|
||||||
|
|
||||||
build/add-ideas-toolbox-0.1.0.zip
|
build/add-ideas-toolbox-0.19.0.zip
|
||||||
build/add-ideas-toolbox-0.1.0.zip.sha256
|
build/add-ideas-toolbox-0.19.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
|
||||||
@@ -142,30 +265,49 @@ directory separately:
|
|||||||
```sh
|
```sh
|
||||||
npm run package:static -- \
|
npm run package:static -- \
|
||||||
--input build/toolbox \
|
--input build/toolbox \
|
||||||
--output artifacts/add-ideas-toolbox-0.1.0.zip
|
--output artifacts/add-ideas-toolbox-0.19.0.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
This also emits a `.sha256` sidecar.
|
This also emits a `.sha256` sidecar.
|
||||||
|
|
||||||
## Container image
|
## 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/javascript` for `.mjs` engine modules, `application/wasm`, and
|
||||||
Assemble first, then:
|
`application/gzip` for the bundled Scan OCR language model,
|
||||||
|
immutable caching only for Vite-hashed assets and narrowly matched
|
||||||
|
semver-versioned FFmpeg core files, and revalidation for all other files. The
|
||||||
|
opaque-origin SVG preview iframe loads its packaged controller with a URI-exact
|
||||||
|
CORS and cross-origin resource-policy exception; all other files retain the
|
||||||
|
same-origin policy and every normal security header. Camera access is denied by
|
||||||
|
default. It is granted to the dedicated `auth.toolbox.add-ideas.de` origin and,
|
||||||
|
on the shared Toolbox host, only to `/apps/barcode/`, `/apps/scan/`, and
|
||||||
|
`/apps/device/`. Device Tools alone additionally receives microphone and screen-
|
||||||
|
capture policy at its exact path; every probe remains explicitly user initiated.
|
||||||
|
If `AUTH_TOOLS_HOST` is customized, update
|
||||||
|
the exact host entry in `deploy/nginx.conf` and Auth Tools' pinned WebAuthn host
|
||||||
|
at the same time. Every Toolbox app, including Random Tools, is constrained to
|
||||||
|
same-origin connections. Random Tools implements its random generators locally
|
||||||
|
with browser APIs and never calls a third-party randomness service.
|
||||||
|
|
||||||
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;
|
||||||
normal local XML/XSLT transformations do not require that permission.
|
normal local XML/XSLT transformations do not require that permission.
|
||||||
|
|
||||||
|
Assemble first, then:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
podman build -f Containerfile \
|
podman build -f Containerfile \
|
||||||
-t git.add-ideas.de/zemion/toolbox:0.1.0 .
|
-t git.add-ideas.de/lotobo/toolbox:0.19.0 .
|
||||||
podman run --rm -p 8080:8080 \
|
podman run --rm -p 8080:8080 \
|
||||||
git.add-ideas.de/zemion/toolbox:0.1.0
|
git.add-ideas.de/lotobo/toolbox:0.19.0
|
||||||
```
|
```
|
||||||
|
|
||||||
For a local deployment, copy `compose.example.yaml` to `compose.yaml` and run
|
For a direct-port local deployment after assembly, use the separate example:
|
||||||
`docker compose up --build -d` (or the Podman Compose equivalent).
|
|
||||||
|
```sh
|
||||||
|
docker compose -f compose.example.yaml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
Publish both common architectures from a buildx-enabled workstation or Gitea
|
Publish both common architectures from a buildx-enabled workstation or Gitea
|
||||||
runner:
|
runner:
|
||||||
@@ -174,9 +316,9 @@ runner:
|
|||||||
docker login git.add-ideas.de
|
docker login git.add-ideas.de
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
--file Containerfile \
|
--file Containerfile.release \
|
||||||
--tag git.add-ideas.de/zemion/toolbox:0.1.0 \
|
--tag git.add-ideas.de/lotobo/toolbox:0.19.0 \
|
||||||
--tag git.add-ideas.de/zemion/toolbox:0.1 \
|
--tag git.add-ideas.de/lotobo/toolbox:0.19 \
|
||||||
--push .
|
--push .
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -187,9 +329,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.
|
`lotobo/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.
|
||||||
@@ -198,10 +340,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
|
||||||
both apps, switch between them, and confirm the encoded `toolbox` context.
|
all 50 apps, switch between them, and confirm the encoded `toolbox`
|
||||||
|
context.
|
||||||
5. Verify the distribution with
|
5. Verify the distribution with
|
||||||
`(cd build && sha256sum -c add-ideas-toolbox-0.1.0.zip.sha256)`.
|
`(cd build && sha256sum -c add-ideas-toolbox-0.19.0.zip.sha256)`.
|
||||||
6. Commit the reviewed lock, tag the portal source (for example `v0.1.0`), and
|
6. Commit the reviewed lock, tag the toolbox release (for example `v0.19.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
|
||||||
@@ -214,6 +357,67 @@ 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.
|
||||||
|
|
||||||
|
## Existing tools
|
||||||
|
|
||||||
|
| Tool | State and boundary | Principal workflows | Important concrete scope | Critical considerations and integrations |
|
||||||
|
| -------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **`pdf-tools`** | Existing; dedicated repository | Merge, split, reorder, rotate and export PDF pages | Thumbnail workspace; multi-document operations; ZIP and PDF output; saved local workspace | Workers and IndexedDB; large-document memory control; future signature inspection through `crypto-tools`; metadata and safe-sharing through `privacy-tools` |
|
||||||
|
| **`xslt-tools`** | Existing; dedicated repository | Develop, test and run XSLT transformations | XML/XSLT editors; transformation results; local files; saved projects; validation and diagnostics | Lazy SaxonJS loading; relocatable assets; useful handoffs to `data-tools`, `schema-tools` and `diff-tools` |
|
||||||
|
| **`onenote-tools`** | Existing rich-reader release; dedicated repository | Open, browse, inspect and export OneNote sections and packages locally | Desktop/unfragmented FSSHTTPB `.one`/`.onetoc2`; none/LZX/MSZIP/Quantum and multi-cabinet `.onepkg`; trees, rich text, images, tables, ink, attachments and export | Native TypeScript worker with no Wasm; bounded parsing, rendering and export; fragmented FSSHTTP fails safely; no editing or pixel-perfect fidelity |
|
||||||
|
| **`av-tools`** | Existing local-first v0.3.0 release; dedicated repository | Convert and lightly edit audio and video locally in the browser | Immediate native playback and basic file information; progressive stream inspection on demand; 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 |
|
||||||
|
| **`regex-tools`** | Current local-first v0.4.2 release; dedicated repository | Develop, explain, test and apply JavaScript, PCRE2, PHP, Perl, Python, Ruby, Java, C++, Go, .NET, Rust and Scala/JVM-compatible regular expressions | Deterministic syntax trees; engine-native matching, captures and bounded replacement; stable double-buffered live results; PCRE2 tracing and C17 generation; comparison; corpus apply; tests; risk/growth/benchmark analysis; generated cases; bounded minimization; validated ECMAScript formatting; project import/export and optional local persistence | Open-source regexpp and pinned local WebAssembly runtimes for PCRE2, PHP preg, legacy Perl, CPython, CRuby, TeaVM Java/Scala compatibility, libc++ C++, Go, .NET and Rust in killable workers; explicit source/licence inventories, resource limits and engine-specific offset semantics |
|
||||||
|
| **`svg-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect and edit SVG source, structure, geometry, references and accessibility locally | Synchronized source/tree/canvas/inspector; path and transform tools; reference analysis; optimization; CSS animation preview; exact, sanitized, raster and project export | Untrusted SVG is sanitized into an opaque-origin sandbox; bounded parsing and decompression, explicit security findings and a URI-scoped controller header exception; later milestone slices remain intentionally tracked in the app repository |
|
||||||
|
| **`auth-tools`** | Current local-first v0.3.1 release; dedicated repository | Generate, migrate and diagnose OTP credentials, and inspect, verify and test WebAuthn/passkey ceremonies locally | HOTP/TOTP/OCRA with time travel, resynchronization and rotation planning; QR, Google, Aegis and encrypted PSKC migration; WebAuthn extension experiments and replayable traces; assertion, attestation, signer-chain and historical metadata-policy evaluation | Authentication material remains memory-only unless explicitly exported; redaction is deliberate but not a secrecy guarantee; live ceremonies and camera scanning are enabled only at the exact dedicated `auth.toolbox.add-ideas.de` origin, while the shared Portal path remains inspect-only; v0.3.1 corrects source provenance and cross-timezone artifact reproducibility; no raw CTAP administration |
|
||||||
|
| **`sudoku-tools`** | Current local-first v0.2.1 release; dedicated repository | Set, play, generate, analyse and solve classic, Killer and rich variant Sudoku locally | 4×4–16×16 grids; registry-backed constraint packs including cages, lines, dots, XV, inequalities, indexing and Fog of War; staged hints, candidate maintenance, advanced logical techniques, setter quality checks, mixed-variant generation, source-preserving f-puzzles/SudokuPad interoperability, autosave recovery and searchable local projects | Worker-bounded solving, generation and quality analysis; strict inert imported-visual validation; fog-safe play assistance; IndexedDB history with memory fallback; stable workspace geometry, responsive zoom and pan, a 3×3 standard keypad, tap selection, patterned colours, accessibility controls and an offline-capable PWA shell |
|
||||||
|
| **`colour-tools`** | Initial local-first v0.1.0 release; dedicated repository | Convert, composite, interpolate, pick, sample, analyse and export colours locally | General colour conversion; arbitrary RGBA compositing; multi-stop colour steps; large visual and native pickers; local image sampling and palette extraction; contrast, gamut and colour-vision-deficiency analysis; harmonies and design-token export | Processing and image access remain local; colour spaces, encoded versus linear-light compositing, interpolation and gamut-mapping assumptions are explicit; mathematical conversion is distinguished from display simulation; integrates with SVG, image and token tools |
|
||||||
|
| **`office-tools`** | Initial local-first v0.1.0 read-only release; dedicated repository | Open and view word-processing documents, spreadsheets and presentations locally | DOCX, XLSX and PPTX plus ODT, ODS and ODP; document, sheet and slide views with search, outline or thumbnail navigation and zoom; text, tables, images, styles, metadata, cached formula values and presentation notes | Bounded worker-based inert package and XML parsers never execute macros, scripts, formulas, active content or external resources; legacy binary DOC, XLS and PPT are unsupported; viewing is intentionally read-only and does not promise pixel-perfect Office-suite layout fidelity |
|
||||||
|
| **`image-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect, crop, rotate, resize, compare and batch-convert raster images locally | Static JPEG, PNG and WebP; bounded queues; aspect presets; fit/fill/exact resize; PNG/JPEG/WebP export; per-result operation reports | Animated and multi-picture inputs are inspect-only; canvas re-encoding does not preserve arbitrary metadata, ICC profiles, HDR precision or byte identity |
|
||||||
|
| **`file-tools`** | Initial local-first v0.1.0 release; dedicated repository | Identify, inspect, hash and inventory local files | Signature and claimed-MIME comparison; paged hex/ASCII and string views; SHA-256/SHA-512; deterministic JSON/CSV manifests; duplicate candidates | Detection and entropy are heuristics rather than validation or malware analysis; v0.1 intentionally does not mutate, split, join or rename files |
|
||||||
|
| **`epub-tools`** | Initial local-first v0.1.0 release; dedicated repository | Read, inspect, validate and repair EPUB 2/3 publications | Package, metadata, spine, EPUB 3 nav and EPUB 2 NCX views; sandboxed reading; link checks; metadata and cover editing; text/chapter/report export; normalized rebuilds | Strict bounded ZIP and XML handling; active and external content are blocked; DRM is detected but never bypassed; focused preflight is not a complete EPUBCheck implementation |
|
||||||
|
| **`archive-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect, create, compare and safely extract archive content | ZIP/ZIP64, TAR/USTAR/PAX, gzip and tar.gz; inert previews; deterministic ZIP/TAR output; cross-format inventory comparison; selected-file repackaging | Path, entry-count, expanded-size and compression-ratio limits; no direct filesystem restoration, nested expansion, encrypted or multipart ZIP support; links and special entries are blocked |
|
||||||
|
| **`privacy-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect image metadata and make independently verified sharing copies | Deep JPEG, PNG and WebP scans for EXIF/IPTC/XMP/profiles/previews/provenance and trailing data; orientation-normalized pixel re-encoding; source/output hashes; JSON and ZIP reports | Not an anonymity guarantee; static supported images only; re-encoding can change pixels and remove colour, resolution and authenticity information; reports can themselves contain sensitive metadata |
|
||||||
|
| **`crypto-tools`** | Initial local-first v0.1.0 inspection release; dedicated repository | Inspect certificate, request, revocation-list, key and JWK material | Bounded PEM/DER X.509, CSR and CRL inspection; public/private key identification; JWK/JWKS warnings and RFC 7638 thumbprints; explicit issuer-signature links; DNS SAN hostname checks | No browser/OS trust implication, complete RFC 5280 path validation, revocation service, issuance, key generation, private-key decryption or general signature verification |
|
||||||
|
| **`barcode-tools`** | Initial local-first v0.1.0 release; dedicated repository | Generate and decode QR codes and common barcodes | SVG QR, Data Matrix, PDF417, Aztec and selected linear formats; bounded image or opt-in camera decode; structured QR payloads; CSV batch ZIP; GTIN and quiet-zone assistance | Decoded payloads remain inert text and are never opened automatically; camera is path-scoped and user initiated; generated output, GS1 allocation and print quality still require applicable interoperability checks |
|
||||||
|
| **`network-tools`** | Initial offline v0.1.0 release; dedicated repository | Calculate IP networks and construct or inspect common network values | IPv4/IPv6 canonicalization and CIDR ranges; URL/query parsing without navigation; DNS-record construction; response-header inspection; CSP builder; MIME reference | Performs no DNS, URL, HTTP or scanning request; pasted values remain bounded and inert; results are construction and calculation aids rather than live network observations |
|
||||||
|
| **`geo-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect, convert, simplify and analyse geospatial files | GeoJSON, GPX, KML and coordinate CSV; WGS 84 bounds, distance and elevation; Douglas–Peucker simplification; decimal/DMS conversion; coordinate-only sketch | Small Point/LineString/Polygon model with scalar properties; conversion losses are explicit; no CRS transformation, basemap request or survey-grade claim |
|
||||||
|
| **`helper-tools`** | Initial v0.1.0 app and reusable package release | Encode, convert, inspect and calculate with shared bounded primitives | Base/byte/text/URL conversion; Unicode and line transforms; exact number and unit conversion; hashes; CIDR; timestamps; hardened JSON/CSV; secure and seeded random primitives; deep-linked calculator workspaces | Framework-free `@add-ideas/toolbox-helpers` package is consumed directly by eleven v0.1 apps; operations disclose strictness, limits and non-cryptographic seeded boundaries |
|
||||||
|
| **`rand-tools`** | Local-only v0.1.1 release; dedicated repository | Generate secure or reproducible random values through focused workspaces | Unbiased WebCrypto integers/strings; seeded reproducible generation; UUIDv4/v7 and ULID; dice; sampling, shuffle, passphrases and normal distribution; all generators run entirely in the browser | Secure local generation never falls back; deterministic output is reproducible rather than secret; the app makes no third-party randomness request and the Portal grants it no external network destination |
|
||||||
|
| **`data-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect, format, query, flatten and convert structured data | JSON, YAML 1.2, TOML, XML, CSV, TSV and NDJSON; exact JSON numbers; bounded tree/table/leaf views; JSON Pointer and safe path queries; all-format conversion with loss/coercion reports; spreadsheet-formula neutralization | Disposable bounded parser worker; rejects active XML constructs and unsafe object keys; conversions surface information loss and round-trip instability |
|
||||||
|
| **`diff-tools`** | Initial local-first v0.1.0 release; dedicated repository | Compare text, JSON, XML and delimited data semantically | Line/word/code-point/grapheme text diff; exact newline state; object-aware exact-number JSON and RFC 6902; namespace-aware XML rules; keyed CSV/TSV; unified/side-by-side views and portable reports | Normalization settings and ignored distinctions remain visible; bounded disposable worker; v0.1 intentionally excludes images, binary documents, directories and archives |
|
||||||
|
| **`time-tools`** | Initial local-first v0.1.0 release; dedicated repository | Convert timestamps and explore zones, arithmetic and recurrence | Exact signed nanosecond epochs; IANA-zone comparisons and DST ambiguity; wall-clock versus elapsed arithmetic; bounded cron and RRULE previews; business days; escaped UTC ICS export | Uses the browser's IANA data and does not model leap seconds; regional holidays are never inferred; recurrence and skipped/ambiguous local-time choices are explicit |
|
||||||
|
| **`text-tools`** | Initial local-first v0.1.0 release; dedicated repository | Build ordered, inspectable plain-text transformation pipelines | Line endings, trimming, whitespace, sort/deduplicate, locale case, Unicode normalization, transliteration, escapes, wrapping, columns, explicit file decoding and versioned recipes | Exact source/result and per-step changes stay visible; compatibility normalization, transliteration, narrow encodings and selected transforms warn when they may be lossy |
|
||||||
|
| **`unicode-tools`** | Initial local-first v0.1.0 release; dedicated repository | Search and inspect Unicode characters, emoji, scripts, text and confusables | Checksum-pinned Unicode 17.0 catalogue; character/alias/code-point search; emoji and named sequences; UTF-8/UTF-16/escape inspection; grapheme segmentation; normalization; UTS #39 skeleton; selected Unihan fields; symbol collections | Official Unicode data is redistributed under Unicode-3.0 and is not scraped from symbol sites; private-use and surrogate ranges are not misrepresented as assigned characters; confusable and mixed-script results are review signals, not security verdicts |
|
||||||
|
| **`flow-tools`** | Initial local-first v0.1.0 release; dedicated repository | Design and run deterministic structured-data pipelines | Bounded JSON, CSV, NDJSON and XML sources; select, rename, filter, map, sort, group, join, format and export stages; inspectable snapshot and loss notes at every stage; portable validated recipes | 2 MiB sources, 10,000 input rows, 20,000 join rows, 200 fields and 30 stages; no arbitrary code, eval, plug-in or network stage |
|
||||||
|
| **`subtitle-tools`** | Initial local-first v0.1.0 release; dedicated repository | Edit, validate, synchronize, compare and convert captions | Bounded SRT, WebVTT and ASS/SSA; cue editing, shift/stretch and frame-rate conversion; editorial diagnostics; revision comparison; local media playback and waveform peaks | Preserves format-specific information where possible and reports cross-format loss; no speech recognition, automatic alignment, semantic comparison or pixel-perfect ASS rendering |
|
||||||
|
| **`midi-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect, visualize, edit, audition and export Standard MIDI files | SMF type 0/1 events, tempo/time/key timelines, piano roll; transpose, quantize, tempo, crop and channel operations; deterministic MIDI/CSV/JSON export; Web Audio sine preview | 8 MiB, 256-track and 250,000-event caps; PPQN only; bounded preview is not a General MIDI instrument or soundfont renderer; editing semantics are explicit |
|
||||||
|
| **`3d-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect, preview, repair and export triangle meshes | OBJ, binary/ASCII STL, ASCII/binary little-endian PLY and glTF/GLB 2; mesh statistics; capped WebGL preview; normal repair and transform baking; normalized OBJ/STL export | 32 MiB and one-million-vertex/triangle caps; glTF is an embedded, static TRIANGLES subset with no external assets, sparse accessors, animation, skins, texture or compression decoding; source material graphs and node transforms are not recreated |
|
||||||
|
| **`query-tools`** | Initial local-first v0.1.0 release; dedicated repository | Query structured local data through bounded row and path languages | JSON, CSV, NDJSON and inert XML; focused SQL-like select/filter/group/aggregate/sort/limit; JSONPath-like property/index/wildcard/filter paths; table, tree and JSON results; saved queries and export | Not full SQL, JSONPath or JMESPath compatibility; 2 MiB, 10,000-row/value, 200-field and 200,000-node caps; XML DTD/entities and code evaluation are rejected |
|
||||||
|
| **`scan-tools`** | Initial local-first v0.1.0 release; dedicated repository | Correct photographed pages, run optional OCR and assemble local output | Up to 24 PNG/JPEG/WebP pages; perspective correction, rotate/deskew, tonal and threshold controls; reorder; image and PDF export; bundled same-origin English Tesseract model | Camera is exact-path and opt-in; strict pixel/byte/page caps; OCR is memory-heavy and not an accuracy guarantee; no PDF input, automatic corners, handwriting guarantee, shadow/finger removal or searchable PDF text layer |
|
||||||
|
| **`package-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect and compare ZIP/ZIP64 compound packages | Package trees and ZIP risk diagnostics; EPUB, OOXML, ODF, JAR, APK and WebExtension adapters; relationships/manifests; media preview/download; signature-material inventory; inventory comparison/export | Inspection does not render specialized formats, decode APK signing blocks or establish signature validity/trust; encrypted, split, unsafe, unsupported or over-budget entries remain inventory-only |
|
||||||
|
| **`label-tools`** | Initial local-first v0.1.0 release; dedicated repository | Merge local records into printable labels, badges and asset tags | CSV/JSON mapping; serial/random values; local raster assets; QR and common 1D/2D barcodes; mm/in/pt stock and custom grids; calibration/marks; paginated preview; SVG, ZIP and 144-DPI PDF export | 2 MiB/2,000-row/100-field data bounds and bounded images/pages; stock names are geometries, not vendor certification; system fonts are not embedded and PDF is rasterized, not archival/press quality |
|
||||||
|
| **`font-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect, compare, preview and prepare local fonts | TTF, OTF and WOFF metadata/tables, Unicode coverage, variable axes, fallbacks and embedding signals; arbitrary-text sandbox; safe CSS; rights-confirmed static glyf/CFF subset with loss report | Disposable worker and strict file/table/glyph bounds; WOFF2/TTC unsupported; fsType is a technical signal, not legal advice; no variable flattening or shaping/composite closure, and subsetting discards advanced tables |
|
||||||
|
| **`fixture-tools`** | Initial local-first v0.1.0 release; dedicated repository | Generate deterministic synthetic test fixtures | Focused JSON Schema, SQL DDL, XSD, CSV-heading and portable-model import; relationships, distributions, boundaries and deliberate violations; JSON/CSV/NDJSON/XML/SQL/recipe export | Importers are documented subsets; 2 MiB input, 20 tables, 100 fields/table, 50,000 rows and bounded output; synthetic data is not anonymization and seeded output is reproducible, not secret |
|
||||||
|
| **`minimize-tools`** | Initial local-first v0.1.0 release; dedicated repository | Reduce failing inputs while continuously preserving a selected predicate | Line/token/code-point and structure-aware JSON/XML reduction; literal, invalid syntax, regex, focused JSON Schema and local XSLT predicates; budgets, cancellation, trace and report export | Produces a locally minimal attempted case, not a globally shortest one; focused schema/XSLT subsets fail closed; 2 MiB, 2,000-test and 30-second caps, with a disposable one-second regex worker |
|
||||||
|
| **`format-lab`** | Initial local-first v0.1.0 release; dedicated repository | Explore conversion paths and measure representative round-trip loss | Conservative data/image/AV/subtitle format graphs; executable JSON/NDJSON/CSV/typed-XML record conversion; property selection, path ranking, round-trip evidence and deterministic reports | Media graphs plan handoffs rather than duplicate codecs; executable lab handles bounded records, not arbitrary documents; catalogue expectations are not file measurements and untested properties remain explicit |
|
||||||
|
| **`repro-tools`** | Initial local-first v0.1.0 release; dedicated repository | Build, compare, sign and package reproducible local file inventories | SHA-256/SHA-512 manifests; deterministic path ordering and JSON/CSV; reference comparison; fixed-metadata ZIP; optional memory-only P-256 signature envelope | 64 MiB/file, 256 MiB/selection and 10,000-entry caps; ZIP is capped at 128 MiB; signatures prove integrity relative to an independently authenticated public key and are intentionally not byte-reproducible |
|
||||||
|
| **`schema-tools`** | Hardened local-first v0.1.2 release; dedicated repository | Inspect, validate, compare and derive examples from schema workspaces | Local JSON Schema/OpenAPI reference graphs; focused JSON Schema validation; OpenAPI operation/sample/change analysis; XSD, Relax NG and Schematron structure; bounded heuristic XML samples | Remote/escaping references, entities, code and XPath are blocked; JSON Schema/OpenAPI support is a documented subset, while XML languages receive structural inspection rather than complete instance validation; shared work/node/depth/text/output budgets, linear cached traversal and well-formed XML fallbacks are regression-tested |
|
||||||
|
| **`log-tools`** | Initial local-first v0.1.0 release; dedicated repository | Stream, inspect, correlate, redact and export large logs | Incremental UTF-8 file scan; plain, JSONL, nginx, syslog and logfmt parsing; normalized fields, aggregation, search/filter; deterministic redaction recipes; CSV/NDJSON/text export | Eight-GiB gate and five-million-line stop without whole-file strings; only a bounded 10,000-record/32 MiB preview supports search, redaction and export; parsers and autodetection are focused/heuristic |
|
||||||
|
| **`binary-tools`** | Initial local-first v0.1.0 release; dedicated repository | Convert, inspect and decode byte-oriented values | Synchronized UTF-8/hex/Base64/Base32/Base58; paged byte inspector; CBOR and MessagePack; strict DER tree; Protocol Buffers wire view with optional scalar/nested schema | Inert, no requests or evaluation; 1 MiB, depth-64 and 10,000-node bounds; Base58 capped at 4 KiB; protobuf groups and a complete generated runtime are out of scope |
|
||||||
|
| **`git-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect and author Git interchange text without repository access | Unified/git patch parse, stats and bounded before/after authoring; root `.gitignore` matcher with explanations; SemVer compare/ranges; Conventional Commit builder/lint; changelog normalization | Patch/matrix/count bounds; ignore matching covers one root file, not nested/global/index state; SemVer and changelog behavior is intentionally focused; no workspace or `.git` access |
|
||||||
|
| **`api-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect and compare OpenAPI descriptions and saved HTTP evidence | Bounded JSON/YAML OpenAPI 3.0/3.1; local multi-file `$ref`; navigation, samples, examples and security inventory; inert curl/Fetch/Python text; conservative change report; HAR/exchange summaries | Never executes HTTP, follows URLs, resolves remote references or stores credentials; focused validation/sample generation is not a complete validator, code generator, client, proxy or security scanner |
|
||||||
|
| **`mail-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect, compare, download and redact EML/MIME content | Folded/RFC 2047 headers; bounded multipart/nested-message tree; Base64/quoted-printable; text and opaque sandboxed sanitized HTML; attachment inventory/download; auth-header diagnostics; canonical/redacted export | Remote and active HTML content is removed and never loaded; no mailbox, server, decryption, DKIM verification, identity, malware or anonymity claim; bounded normalization does not promise byte identity |
|
||||||
|
| **`calendar-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect, repair, merge and reconcile iCalendar data | RFC 5545 events/metadata/attendees/timezones; bounded recurrence preview; duplicate/overlap diagnostics; UID/RECURRENCE-ID merge; conservative canonical repair; DST transition/gap/overlap evidence | 4 MiB, line/component/event and recurrence-horizon caps; browser IANA data and focused recurrence interpretation are evidence rather than a complete calendar-server implementation |
|
||||||
|
| **`contact-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect, edit, convert and reconcile sensitive contacts | vCard 3/4 parsing/editing/export; inferred reviewable CSV mapping; duplicate candidates and conflict-reporting merge; bounded local vCard QR; masked overview and memory clearing | 2 MiB, 50,000-line and 2,000-contact caps; binary/quoted-printable values are diagnosed rather than decoded and unsupported properties are not silently round-tripped; no persistence or telemetry |
|
||||||
|
| **`diagram-tools`** | Initial local-first v0.1.0 release; dedicated repository | Author safe deterministic text diagrams and export accessible graphics | Independent Mermaid-like flow/graph, sequence, class and state subset; diagnostics, pan/zoom, SVG/2× PNG and textual outline | Not full Mermaid or Graphviz; directives, clicks, styles, scripts, HTML and remote assets are rejected; 50,000-character, 2,000-line, 200-node, 500-edge and 20-megapixel caps |
|
||||||
|
| **`token-tools`** | Initial local-first v0.1.0 release; dedicated repository | Edit, validate, theme and export design tokens | W3C-style nested JSON plus local sets/themes; alias resolution and cycle/type diagnostics; colour, dimension, typography, shadow and scalar values; JSON, CSS, Sass, Android and Swift export with loss report | The set/theme wrapper is app-specific; 1 MiB, depth-64 and 10,000-token bounds; unknown/conflicting shapes fail explicitly and platform composite losses are never silently omitted |
|
||||||
|
| **`device-tools`** | Initial local-first v0.1.0 release; dedicated repository | Inspect browser capability evidence without creating a device fingerprint | Passive context/display/input/accessibility/media/storage/PWA/API inventory; opt-in permission, storage, WebGL/WebGPU, media, battery, network, camera, microphone and screen probes; redacted reports | No identifier, score, high-entropy report, recording, persistence or passive prompt; sensitive probes are exact-path, individually initiated and immediately stop returned tracks; results are capability evidence, not a reliability guarantee |
|
||||||
|
|
||||||
|
## Planned tools
|
||||||
|
|
||||||
|
No unreleased applications are currently listed here. New ideas should be added
|
||||||
|
only after their intended local-processing boundary and first honest release
|
||||||
|
scope are documented; shipped applications remain in the Existing tools table.
|
||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
Portal source is `AGPL-3.0-only`; see `LICENSE`. The contract is Apache-2.0, and
|
Portal source is `AGPL-3.0-only`; see `LICENSE`. The contract is Apache-2.0, and
|
||||||
|
|||||||
@@ -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.1.0
|
image: git.add-ideas.de/lotobo/toolbox:0.19.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
read_only: true
|
read_only: true
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
name: addideas-toolbox
|
||||||
|
|
||||||
|
services:
|
||||||
|
toolbox:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Containerfile.release
|
||||||
|
args:
|
||||||
|
TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.19.0}"
|
||||||
|
TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-b6003d8b3eee8a13032527b014a381d832ba46108c35d0aee9e1cfa6a1b281e2}"
|
||||||
|
image: "git.add-ideas.de/lotobo/toolbox:${TOOLBOX_RELEASE_VERSION:-0.19.0}"
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=16m,mode=1777
|
||||||
|
- /var/cache/nginx:size=16m,uid=101,gid=101
|
||||||
|
- /var/run:size=1m,uid=101,gid=101
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
labels:
|
||||||
|
traefik.enable: "true"
|
||||||
|
traefik.docker.network: "${TRAEFIK_NETWORK:-internal}"
|
||||||
|
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.tls: "true"
|
||||||
|
traefik.http.routers.addideas-toolbox.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"
|
||||||
|
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.routers.addideas-auth.rule: "Host(`${AUTH_TOOLS_HOST:-auth.toolbox.add-ideas.de}`)"
|
||||||
|
traefik.http.routers.addideas-auth.priority: 1000
|
||||||
|
traefik.http.routers.addideas-auth.entrypoints: websecure
|
||||||
|
traefik.http.routers.addideas-auth.tls: "true"
|
||||||
|
traefik.http.routers.addideas-auth.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"
|
||||||
|
traefik.http.routers.addideas-auth.middlewares: addideas-auth-prefix
|
||||||
|
traefik.http.routers.addideas-auth.service: addideas-toolbox
|
||||||
|
traefik.http.routers.addideas-auth-http.rule: "Host(`${AUTH_TOOLS_HOST:-auth.toolbox.add-ideas.de}`)"
|
||||||
|
traefik.http.routers.addideas-auth-http.priority: 1000
|
||||||
|
traefik.http.routers.addideas-auth-http.entrypoints: web
|
||||||
|
traefik.http.routers.addideas-auth-http.middlewares: addideas-toolbox-https-redirect
|
||||||
|
traefik.http.routers.addideas-auth-http.service: addideas-toolbox
|
||||||
|
traefik.http.middlewares.addideas-auth-prefix.addprefix.prefix: /apps/auth
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
external: true
|
||||||
|
name: "${TRAEFIK_NETWORK:-internal}"
|
||||||
+46
-5
@@ -1,6 +1,25 @@
|
|||||||
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
map $uri $toolbox_resource_policy {
|
||||||
|
default "same-origin";
|
||||||
|
"/apps/svg/canvas-frame-controller.js" "cross-origin";
|
||||||
|
}
|
||||||
|
|
||||||
|
map $uri $toolbox_access_control_allow_origin {
|
||||||
|
default "";
|
||||||
|
"/apps/svg/canvas-frame-controller.js" "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
map "$host:$uri" $toolbox_permissions_policy {
|
||||||
|
default "camera=(), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";
|
||||||
|
"~^auth\.toolbox\.add-ideas\.de:" "camera=(self), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";
|
||||||
|
"~^[^:]+:/apps/barcode(?:/|$)" "camera=(self), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";
|
||||||
|
"~^[^:]+:/apps/scan(?:/|$)" "camera=(self), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";
|
||||||
|
"~^[^:]+:/apps/device(?:/|$)" "camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=(), usb=()";
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
@@ -18,9 +37,10 @@ server {
|
|||||||
add_header X-Frame-Options "DENY" always;
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
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 $toolbox_resource_policy always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
|
add_header Access-Control-Allow-Origin $toolbox_access_control_allow_origin 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 Permissions-Policy $toolbox_permissions_policy 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: blob:; 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 +52,28 @@ 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 ~* \.mjs$ {
|
||||||
|
types {
|
||||||
|
application/javascript mjs;
|
||||||
|
}
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* ^/apps/scan/ocr/lang/[a-z0-9_-]+\.traineddata\.gz$ {
|
||||||
|
types {
|
||||||
|
application/gzip gz;
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<meta name="theme-color" content="#172554" />
|
<meta name="theme-color" content="#172554" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A privacy-transparent browser toolbox for PDF and XSLT workflows."
|
content="A privacy-transparent browser toolbox for PDF, XSLT, and OneNote workflows."
|
||||||
/>
|
/>
|
||||||
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
|
||||||
<title>add·ideas Toolbox</title>
|
<title>add·ideas Toolbox</title>
|
||||||
|
|||||||
Generated
+130
-529
@@ -1,15 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@add-ideas/toolbox-portal",
|
"name": "@add-ideas/toolbox-portal",
|
||||||
"version": "0.1.0",
|
"version": "0.2.22",
|
||||||
"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.22",
|
||||||
"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.3",
|
||||||
|
"@add-ideas/toolbox-shell-react": "^0.2.3",
|
||||||
|
"@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.3",
|
||||||
"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/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
|
||||||
"integrity": "sha512-0q2whA5Y4SqG4PNNleXYR8nGbT3UYsZ1x+6ZWVmxuc/WjMxmujhD7ilIISmFzmnNdbyEyv/Zpr+U0fnHqGODqw==",
|
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||||
|
"version": "0.2.3",
|
||||||
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
|
||||||
|
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@add-ideas/toolbox-contract": "0.2.3"
|
||||||
|
},
|
||||||
|
"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.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"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",
|
||||||
@@ -3563,9 +3432,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.16",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -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",
|
||||||
@@ -3771,9 +3609,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.20",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
"integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==",
|
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -3791,7 +3629,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.16",
|
"nanoid": "^3.3.17",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -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",
|
||||||
@@ -4464,9 +4166,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.28.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -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": {
|
||||||
|
|||||||
+15
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@add-ideas/toolbox-portal",
|
"name": "@add-ideas/toolbox-portal",
|
||||||
"version": "0.1.0",
|
"version": "0.2.22",
|
||||||
"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.3",
|
||||||
|
"@add-ideas/toolbox-shell-react": "^0.2.3",
|
||||||
|
"@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",
|
||||||
@@ -49,5 +53,13 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://git.add-ideas.de/lotobo/toolbox-portal.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://git.add-ideas.de/lotobo/toolbox-portal",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://git.add-ideas.de/lotobo/toolbox-portal/issues"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-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.22 is available at:
|
||||||
|
|
||||||
https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.1.0
|
https://git.add-ideas.de/lotobo/toolbox-portal/src/tag/v0.19.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.
|
||||||
|
|||||||
+193
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
|
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.toolbox",
|
"id": "de.add-ideas.toolbox",
|
||||||
"name": "add·ideas Toolbox",
|
"name": "add·ideas Toolbox",
|
||||||
@@ -16,6 +16,198 @@
|
|||||||
{
|
{
|
||||||
"manifest": "./apps/xslt/toolbox-app.json",
|
"manifest": "./apps/xslt/toolbox-app.json",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/onenote/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/av/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/regex/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/svg/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/auth/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/sudoku/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/colour/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/office/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/image/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/file/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/epub/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/archive/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/privacy/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/crypto/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/barcode/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/network/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/geo/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/helper/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/rand/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/data/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/diff/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/time/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/text/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/unicode/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/flow/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/subtitle/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/midi/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/3d/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/query/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/scan/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/package/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/label/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/font/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/fixture/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/minimize/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/format-lab/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/repro/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/schema/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/log/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/binary/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/git/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/api/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/mail/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/calendar/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/contact/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/diagram/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/token/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": "./apps/device/toolbox-app.json",
|
||||||
|
"enabled": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.add-ideas.de/zemion/toolbox-portal/raw/branch/main/release/toolbox-release-lock.schema.json",
|
"$id": "https://git.add-ideas.de/lotobo/toolbox-portal/raw/branch/main/release/toolbox-release-lock.schema.json",
|
||||||
"title": "add·ideas Toolbox release lock v1",
|
"title": "add·ideas Toolbox release lock v1",
|
||||||
"description": "Pins the exact portal and checksummed application archives used to assemble one static toolbox release.",
|
"description": "Pins the exact portal and checksummed application archives used to assemble one static toolbox release.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./toolbox-release-lock.schema.json",
|
"$schema": "./toolbox-release-lock.schema.json",
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"releaseVersion": "0.1.0",
|
"releaseVersion": "0.19.0",
|
||||||
"portalVersion": "0.1.0",
|
"portalVersion": "0.2.22",
|
||||||
"catalogue": {
|
"catalogue": {
|
||||||
"id": "de.add-ideas.toolbox",
|
"id": "de.add-ideas.toolbox",
|
||||||
"name": "add·ideas Toolbox",
|
"name": "add·ideas Toolbox",
|
||||||
@@ -15,17 +15,353 @@
|
|||||||
"apps": [
|
"apps": [
|
||||||
{
|
{
|
||||||
"id": "de.add-ideas.pdf-tools",
|
"id": "de.add-ideas.pdf-tools",
|
||||||
"version": "0.3.4",
|
"version": "0.4.4",
|
||||||
"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/lotobo/pdf-tools/releases/download/v0.4.4/pdf-tools-0.4.4.zip",
|
||||||
"sha256": "090492aaee4bd5cde51917ee3d9d0c971f071c27b7dbe4c918876ffd752dd930",
|
"sha256": "fa93c4c004be74d6a1f68f62a0f64f44c99218f387441ca3630a7f5af3c09d7d",
|
||||||
"target": "pdf"
|
"target": "pdf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "de.add-ideas.xslt-tools",
|
"id": "de.add-ideas.xslt-tools",
|
||||||
"version": "0.3.2",
|
"version": "0.4.3",
|
||||||
"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/lotobo/xslt-tools/releases/download/v0.4.3/xslt-tools-0.4.3.zip",
|
||||||
"sha256": "e1746ac60f7143037ce77d7f04c62f7da39a92d8cef42e7167acb2bc00786de1",
|
"sha256": "e44012af214d24e3d97d10a6017fa3d9dbf063de36673ab04dd15a0a8643afcc",
|
||||||
"target": "xslt"
|
"target": "xslt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.onenote-tools",
|
||||||
|
"version": "0.3.4",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/onenote-tools/releases/download/v0.3.4/onenote-tools-0.3.4.zip",
|
||||||
|
"sha256": "c0ed791d1ae64fdf6a90bceebf97a7a3252989f2434204d009f87f5a2d1cc341",
|
||||||
|
"target": "onenote"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.av-tools",
|
||||||
|
"version": "0.3.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/av-tools/releases/download/v0.3.0/av-tools-0.3.0.zip",
|
||||||
|
"sha256": "3476c5d43ae43ce66b5aeebe8c11334f7cd4d153586348b72f10bfbdbada9be7",
|
||||||
|
"target": "av"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.regex-tools",
|
||||||
|
"version": "0.4.2",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/regex-tools/releases/download/v0.4.2/regex-tools-0.4.2.zip",
|
||||||
|
"sha256": "a3e7342d1f0746aa9c88e6568e7fbc04ab429c94604ba5fb1094ffdb096a54fe",
|
||||||
|
"target": "regex"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.svg-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/svg-tools/releases/download/v0.1.0/svg-tools-0.1.0.zip",
|
||||||
|
"sha256": "364f14c88c4934d9c847bf76de70b89dcf564a405afd58c6703363a95fbfb6ae",
|
||||||
|
"target": "svg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.auth-tools",
|
||||||
|
"version": "0.3.1",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/auth-tools/releases/download/v0.3.1/auth-tools-0.3.1.zip",
|
||||||
|
"sha256": "60e90492fb50d2ce23298b3a3af84b1e3be02bbf5dea01d478f889d65eb66f3a",
|
||||||
|
"target": "auth"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.sudoku-tools",
|
||||||
|
"version": "0.2.1",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/sudoku-tools/releases/download/v0.2.1/sudoku-tools-0.2.1.zip",
|
||||||
|
"sha256": "3987813a3bcd24056b9ca9607ba5d74a094d54c15b0e8a7966df7373e87df67a",
|
||||||
|
"target": "sudoku"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.colour-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/colour-tools/releases/download/v0.1.0/colour-tools-0.1.0.zip",
|
||||||
|
"sha256": "c933f506362709a031a5cd830657604510b8e4ccb1800be77d40d0eee256490b",
|
||||||
|
"target": "colour"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.office-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/office-tools/releases/download/v0.1.0/office-tools-0.1.0.zip",
|
||||||
|
"sha256": "3f950a413c74bae3b8f190954272fd91c9b141aab4fa50c936d5ad3741539aaf",
|
||||||
|
"target": "office"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.image-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/image-tools/releases/download/v0.1.0/image-tools-0.1.0.zip",
|
||||||
|
"sha256": "8aa67d0c3cfd1060c261e9be108fb443384aaec95f82d3c1b96b63b2d07963e1",
|
||||||
|
"target": "image"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.file-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/file-tools/releases/download/v0.1.0/file-tools-0.1.0.zip",
|
||||||
|
"sha256": "9225ff60c639d563bb514ac390b5b2cbad4b8030a070e6398e7c200763c5f89e",
|
||||||
|
"target": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.epub-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/epub-tools/releases/download/v0.1.0/epub-tools-0.1.0.zip",
|
||||||
|
"sha256": "834512494bbfba9918a9d8131efd512a09619a150823591e2d13ea385ced00e9",
|
||||||
|
"target": "epub"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.archive-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/archive-tools/releases/download/v0.1.0/archive-tools-0.1.0.zip",
|
||||||
|
"sha256": "88a8efb39129bb813c97fb4d80257ee339bbfcec2a73b080a0a89dc76cdd2958",
|
||||||
|
"target": "archive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.privacy-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/privacy-tools/releases/download/v0.1.0/privacy-tools-0.1.0.zip",
|
||||||
|
"sha256": "0d6c071837b8ce705e456d44888378f3a7f9427a352f0bd4b60512f6c41465d4",
|
||||||
|
"target": "privacy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.crypto-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/crypto-tools/releases/download/v0.1.0/crypto-tools-0.1.0.zip",
|
||||||
|
"sha256": "bfced32f44918214ffd31c5d4730aee7bffd75e7952e9a6d16b6eb7eb68fed35",
|
||||||
|
"target": "crypto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.barcode-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/barcode-tools/releases/download/v0.1.0/barcode-tools-0.1.0.zip",
|
||||||
|
"sha256": "22cede58d279f15419b342862bf6545cace831d1e02c0634fbd01aeab3bd3e63",
|
||||||
|
"target": "barcode"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.network-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/network-tools/releases/download/v0.1.0/network-tools-0.1.0.zip",
|
||||||
|
"sha256": "7e493a0aed643c712c38057bcd67c360bc6712d5d31ea82c222bf780464ff4a1",
|
||||||
|
"target": "network"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.geo-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/geo-tools/releases/download/v0.1.0/geo-tools-0.1.0.zip",
|
||||||
|
"sha256": "88a46a56f3d3b5b61dc9bbb8795764b59aae1635da95120a0e499e33ef652e8f",
|
||||||
|
"target": "geo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.helper-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/helper-tools/releases/download/v0.1.0/helper-tools-0.1.0.zip",
|
||||||
|
"sha256": "bcdbd2ffc721ed1a2e9432eea1f90d7b4526d86934c3b5de6f522fd8bd58fd77",
|
||||||
|
"target": "helper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.rand-tools",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/rand-tools/releases/download/v0.1.1/rand-tools-0.1.1.zip",
|
||||||
|
"sha256": "02dffcbbf1c3ff65236a83466aa750c3848777e4a69ac389fe24da02abfc0e77",
|
||||||
|
"target": "rand"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.data-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/data-tools/releases/download/v0.1.0/data-tools-0.1.0.zip",
|
||||||
|
"sha256": "83ab02c9827d122c83b05c230e62c0ee87b330604acfff4990c58310f7956a1f",
|
||||||
|
"target": "data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.diff-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/diff-tools/releases/download/v0.1.0/diff-tools-0.1.0.zip",
|
||||||
|
"sha256": "023311343152aac11bfc00d58029b24ef0efe5ee4113aeb2a9d000ebe2460d72",
|
||||||
|
"target": "diff"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.time-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/time-tools/releases/download/v0.1.0/time-tools-0.1.0.zip",
|
||||||
|
"sha256": "9a5545a3e684a207d7b2d6663d42a117cb5bb3303f35dc889a2651b67bfcf08f",
|
||||||
|
"target": "time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.text-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/text-tools/releases/download/v0.1.0/text-tools-0.1.0.zip",
|
||||||
|
"sha256": "242eb15005a18e0f13ba03544c633834416707e2629585868bfcd6f0c1cc5aec",
|
||||||
|
"target": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.unicode-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/unicode-tools/releases/download/v0.1.0/unicode-tools-0.1.0.zip",
|
||||||
|
"sha256": "1cf557d3ace9a41c7fa72cb5c1b149e669203a682174939f2e3aa376e1c6f22a",
|
||||||
|
"target": "unicode"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.flow-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/flow-tools/releases/download/v0.1.0/flow-tools-0.1.0.zip",
|
||||||
|
"sha256": "abc8c50c95509484afa27606e6dc88087409fd0a8609fea6e99915cac703d2d5",
|
||||||
|
"target": "flow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.subtitle-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/subtitle-tools/releases/download/v0.1.0/subtitle-tools-0.1.0.zip",
|
||||||
|
"sha256": "be251beb06fe62da6b2dde79799e48d28148dc81cba84998ba51ced328509fc6",
|
||||||
|
"target": "subtitle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.midi-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/midi-tools/releases/download/v0.1.0/midi-tools-0.1.0.zip",
|
||||||
|
"sha256": "5f55fe448720d989aaf93cd25c18644b99df97bd3a32b297e1dc6f3d04777536",
|
||||||
|
"target": "midi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.3d-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/3d-tools/releases/download/v0.1.0/3d-tools-0.1.0.zip",
|
||||||
|
"sha256": "63d7c028e5fb8e0644855720d2088a23623b73f9e7f656485c1340b6e51dca1a",
|
||||||
|
"target": "3d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.query-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/query-tools/releases/download/v0.1.0/query-tools-0.1.0.zip",
|
||||||
|
"sha256": "c28fa57a266f32339f5f83c1082920ce3d601167c86a40516fe2e33d867545d6",
|
||||||
|
"target": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.scan-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/scan-tools/releases/download/v0.1.0/scan-tools-0.1.0.zip",
|
||||||
|
"sha256": "f741257053355ed0e2c46261d650c1bfdd70de9cc4355af526fb65bedf6a6096",
|
||||||
|
"target": "scan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.package-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/package-tools/releases/download/v0.1.0/package-tools-0.1.0.zip",
|
||||||
|
"sha256": "bbbba42319f77fc85ca38a87bef2317f8500326c9c781462561abafa37de2c97",
|
||||||
|
"target": "package"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.label-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/label-tools/releases/download/v0.1.0/label-tools-0.1.0.zip",
|
||||||
|
"sha256": "0e658518da0eb9df204cc19bf07ef84e5e84c38d62604403a23e76938ecc8dd1",
|
||||||
|
"target": "label"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.font-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/font-tools/releases/download/v0.1.0/font-tools-0.1.0.zip",
|
||||||
|
"sha256": "ee0b51e2ec63f38ae5fd2e9be8968f58f27e8cc243c429a3fc057e8cbfc3b582",
|
||||||
|
"target": "font"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.fixture-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/fixture-tools/releases/download/v0.1.0/fixture-tools-0.1.0.zip",
|
||||||
|
"sha256": "9d41d63d33430bd8390ca9ddacdc14062205640a7ff8ed259192af943b430541",
|
||||||
|
"target": "fixture"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.minimize-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/minimize-tools/releases/download/v0.1.0/minimize-tools-0.1.0.zip",
|
||||||
|
"sha256": "2e67cfdd8d0d4569729279da834b8c5bfa8ddd659068cfd7013a96f0031ecc05",
|
||||||
|
"target": "minimize"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.format-lab",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/format-lab/releases/download/v0.1.0/format-lab-0.1.0.zip",
|
||||||
|
"sha256": "3fc0037bcdc4c7e58cc7de4a09a69c775ecc2605e820c3cdb5106cbc87de008c",
|
||||||
|
"target": "format-lab"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.repro-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/repro-tools/releases/download/v0.1.0/repro-tools-0.1.0.zip",
|
||||||
|
"sha256": "c5c0dc7874bdeb241894d2475f2fde313e93231f3513205c5122336548c9c758",
|
||||||
|
"target": "repro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.schema-tools",
|
||||||
|
"version": "0.1.2",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/schema-tools/releases/download/v0.1.2/schema-tools-0.1.2.zip",
|
||||||
|
"sha256": "038f369ca19601a1a497d5435f3cee596946f93216cd796864d3e04a8d7bf3c7",
|
||||||
|
"target": "schema"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.log-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/log-tools/releases/download/v0.1.0/log-tools-0.1.0.zip",
|
||||||
|
"sha256": "f16829d6e0a00981c92fa8704897da9aaeb4fcfc9af4ad8e45165e9dd35c2fdd",
|
||||||
|
"target": "log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.binary-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/binary-tools/releases/download/v0.1.0/binary-tools-0.1.0.zip",
|
||||||
|
"sha256": "6a9fdfa6ca87617151a49f9efde4edaca62d547756ed98459b1b355a7d05ac9d",
|
||||||
|
"target": "binary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.git-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/git-tools/releases/download/v0.1.0/git-tools-0.1.0.zip",
|
||||||
|
"sha256": "b71026de7feac68a4b2b66c40dd4b9c79201549a07cb204640e6923cf2a93678",
|
||||||
|
"target": "git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.api-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/api-tools/releases/download/v0.1.0/api-tools-0.1.0.zip",
|
||||||
|
"sha256": "fa544b99b1bf3e92beb95f77fadfaceb55fbadaf35dc3290603b110e6fb965e5",
|
||||||
|
"target": "api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.mail-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/mail-tools/releases/download/v0.1.0/mail-tools-0.1.0.zip",
|
||||||
|
"sha256": "81cf378ef7c63969fc2f13a872ff6b97313f13124623f457461b69111a1f3d12",
|
||||||
|
"target": "mail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.calendar-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/calendar-tools/releases/download/v0.1.0/calendar-tools-0.1.0.zip",
|
||||||
|
"sha256": "f37871322b60d3ef522350634360b8ec5304ecb63ebd342bec2145a29156d8cc",
|
||||||
|
"target": "calendar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.contact-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/contact-tools/releases/download/v0.1.0/contact-tools-0.1.0.zip",
|
||||||
|
"sha256": "7afa6daedabd9553c01c03b735cfc95265cfc862094d82e3e65a840a4c0ed1d2",
|
||||||
|
"target": "contact"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.diagram-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/diagram-tools/releases/download/v0.1.0/diagram-tools-0.1.0.zip",
|
||||||
|
"sha256": "c19313d3a5ef1461bcbe719591cc0f5b27069bd2294f11a4b14c075cfd703929",
|
||||||
|
"target": "diagram"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.token-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/token-tools/releases/download/v0.1.0/token-tools-0.1.0.zip",
|
||||||
|
"sha256": "c3800519f023b40868849a83fbe2631fd78be1b47884f85aee3de98fc72f884f",
|
||||||
|
"target": "token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.device-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/device-tools/releases/download/v0.1.0/device-tools-0.1.0.zip",
|
||||||
|
"sha256": "6d5c587ee991efb291633f12e5e626a1580d18afcee1d1f296451f6fc2c60feb",
|
||||||
|
"target": "device"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+344
-8
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./toolbox-release-lock.schema.json",
|
"$schema": "./toolbox-release-lock.schema.json",
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"releaseVersion": "0.1.0",
|
"releaseVersion": "0.19.0",
|
||||||
"portalVersion": "0.1.0",
|
"portalVersion": "0.2.22",
|
||||||
"catalogue": {
|
"catalogue": {
|
||||||
"id": "de.add-ideas.toolbox",
|
"id": "de.add-ideas.toolbox",
|
||||||
"name": "add·ideas Toolbox",
|
"name": "add·ideas Toolbox",
|
||||||
@@ -15,17 +15,353 @@
|
|||||||
"apps": [
|
"apps": [
|
||||||
{
|
{
|
||||||
"id": "de.add-ideas.pdf-tools",
|
"id": "de.add-ideas.pdf-tools",
|
||||||
"version": "0.3.4",
|
"version": "0.4.4",
|
||||||
"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/lotobo/pdf-tools/releases/download/v0.4.4/pdf-tools-0.4.4.zip",
|
||||||
"sha256": "090492aaee4bd5cde51917ee3d9d0c971f071c27b7dbe4c918876ffd752dd930",
|
"sha256": "fa93c4c004be74d6a1f68f62a0f64f44c99218f387441ca3630a7f5af3c09d7d",
|
||||||
"target": "pdf"
|
"target": "pdf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "de.add-ideas.xslt-tools",
|
"id": "de.add-ideas.xslt-tools",
|
||||||
"version": "0.3.2",
|
"version": "0.4.3",
|
||||||
"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/lotobo/xslt-tools/releases/download/v0.4.3/xslt-tools-0.4.3.zip",
|
||||||
"sha256": "e1746ac60f7143037ce77d7f04c62f7da39a92d8cef42e7167acb2bc00786de1",
|
"sha256": "e44012af214d24e3d97d10a6017fa3d9dbf063de36673ab04dd15a0a8643afcc",
|
||||||
"target": "xslt"
|
"target": "xslt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.onenote-tools",
|
||||||
|
"version": "0.3.4",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/onenote-tools/releases/download/v0.3.4/onenote-tools-0.3.4.zip",
|
||||||
|
"sha256": "c0ed791d1ae64fdf6a90bceebf97a7a3252989f2434204d009f87f5a2d1cc341",
|
||||||
|
"target": "onenote"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.av-tools",
|
||||||
|
"version": "0.3.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/av-tools/releases/download/v0.3.0/av-tools-0.3.0.zip",
|
||||||
|
"sha256": "3476c5d43ae43ce66b5aeebe8c11334f7cd4d153586348b72f10bfbdbada9be7",
|
||||||
|
"target": "av"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.regex-tools",
|
||||||
|
"version": "0.4.2",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/regex-tools/releases/download/v0.4.2/regex-tools-0.4.2.zip",
|
||||||
|
"sha256": "a3e7342d1f0746aa9c88e6568e7fbc04ab429c94604ba5fb1094ffdb096a54fe",
|
||||||
|
"target": "regex"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.svg-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/svg-tools/releases/download/v0.1.0/svg-tools-0.1.0.zip",
|
||||||
|
"sha256": "364f14c88c4934d9c847bf76de70b89dcf564a405afd58c6703363a95fbfb6ae",
|
||||||
|
"target": "svg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.auth-tools",
|
||||||
|
"version": "0.3.1",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/auth-tools/releases/download/v0.3.1/auth-tools-0.3.1.zip",
|
||||||
|
"sha256": "60e90492fb50d2ce23298b3a3af84b1e3be02bbf5dea01d478f889d65eb66f3a",
|
||||||
|
"target": "auth"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.sudoku-tools",
|
||||||
|
"version": "0.2.1",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/sudoku-tools/releases/download/v0.2.1/sudoku-tools-0.2.1.zip",
|
||||||
|
"sha256": "3987813a3bcd24056b9ca9607ba5d74a094d54c15b0e8a7966df7373e87df67a",
|
||||||
|
"target": "sudoku"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.colour-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/colour-tools/releases/download/v0.1.0/colour-tools-0.1.0.zip",
|
||||||
|
"sha256": "c933f506362709a031a5cd830657604510b8e4ccb1800be77d40d0eee256490b",
|
||||||
|
"target": "colour"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.office-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/office-tools/releases/download/v0.1.0/office-tools-0.1.0.zip",
|
||||||
|
"sha256": "3f950a413c74bae3b8f190954272fd91c9b141aab4fa50c936d5ad3741539aaf",
|
||||||
|
"target": "office"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.image-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/image-tools/releases/download/v0.1.0/image-tools-0.1.0.zip",
|
||||||
|
"sha256": "8aa67d0c3cfd1060c261e9be108fb443384aaec95f82d3c1b96b63b2d07963e1",
|
||||||
|
"target": "image"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.file-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/file-tools/releases/download/v0.1.0/file-tools-0.1.0.zip",
|
||||||
|
"sha256": "9225ff60c639d563bb514ac390b5b2cbad4b8030a070e6398e7c200763c5f89e",
|
||||||
|
"target": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.epub-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/epub-tools/releases/download/v0.1.0/epub-tools-0.1.0.zip",
|
||||||
|
"sha256": "834512494bbfba9918a9d8131efd512a09619a150823591e2d13ea385ced00e9",
|
||||||
|
"target": "epub"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.archive-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/archive-tools/releases/download/v0.1.0/archive-tools-0.1.0.zip",
|
||||||
|
"sha256": "88a8efb39129bb813c97fb4d80257ee339bbfcec2a73b080a0a89dc76cdd2958",
|
||||||
|
"target": "archive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.privacy-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/privacy-tools/releases/download/v0.1.0/privacy-tools-0.1.0.zip",
|
||||||
|
"sha256": "0d6c071837b8ce705e456d44888378f3a7f9427a352f0bd4b60512f6c41465d4",
|
||||||
|
"target": "privacy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.crypto-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/crypto-tools/releases/download/v0.1.0/crypto-tools-0.1.0.zip",
|
||||||
|
"sha256": "bfced32f44918214ffd31c5d4730aee7bffd75e7952e9a6d16b6eb7eb68fed35",
|
||||||
|
"target": "crypto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.barcode-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/barcode-tools/releases/download/v0.1.0/barcode-tools-0.1.0.zip",
|
||||||
|
"sha256": "22cede58d279f15419b342862bf6545cace831d1e02c0634fbd01aeab3bd3e63",
|
||||||
|
"target": "barcode"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.network-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/network-tools/releases/download/v0.1.0/network-tools-0.1.0.zip",
|
||||||
|
"sha256": "7e493a0aed643c712c38057bcd67c360bc6712d5d31ea82c222bf780464ff4a1",
|
||||||
|
"target": "network"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.geo-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/geo-tools/releases/download/v0.1.0/geo-tools-0.1.0.zip",
|
||||||
|
"sha256": "88a46a56f3d3b5b61dc9bbb8795764b59aae1635da95120a0e499e33ef652e8f",
|
||||||
|
"target": "geo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.helper-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/helper-tools/releases/download/v0.1.0/helper-tools-0.1.0.zip",
|
||||||
|
"sha256": "bcdbd2ffc721ed1a2e9432eea1f90d7b4526d86934c3b5de6f522fd8bd58fd77",
|
||||||
|
"target": "helper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.rand-tools",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/rand-tools/releases/download/v0.1.1/rand-tools-0.1.1.zip",
|
||||||
|
"sha256": "02dffcbbf1c3ff65236a83466aa750c3848777e4a69ac389fe24da02abfc0e77",
|
||||||
|
"target": "rand"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.data-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/data-tools/releases/download/v0.1.0/data-tools-0.1.0.zip",
|
||||||
|
"sha256": "83ab02c9827d122c83b05c230e62c0ee87b330604acfff4990c58310f7956a1f",
|
||||||
|
"target": "data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.diff-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/diff-tools/releases/download/v0.1.0/diff-tools-0.1.0.zip",
|
||||||
|
"sha256": "023311343152aac11bfc00d58029b24ef0efe5ee4113aeb2a9d000ebe2460d72",
|
||||||
|
"target": "diff"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.time-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/time-tools/releases/download/v0.1.0/time-tools-0.1.0.zip",
|
||||||
|
"sha256": "9a5545a3e684a207d7b2d6663d42a117cb5bb3303f35dc889a2651b67bfcf08f",
|
||||||
|
"target": "time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.text-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/text-tools/releases/download/v0.1.0/text-tools-0.1.0.zip",
|
||||||
|
"sha256": "242eb15005a18e0f13ba03544c633834416707e2629585868bfcd6f0c1cc5aec",
|
||||||
|
"target": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.unicode-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/unicode-tools/releases/download/v0.1.0/unicode-tools-0.1.0.zip",
|
||||||
|
"sha256": "1cf557d3ace9a41c7fa72cb5c1b149e669203a682174939f2e3aa376e1c6f22a",
|
||||||
|
"target": "unicode"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.flow-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/flow-tools/releases/download/v0.1.0/flow-tools-0.1.0.zip",
|
||||||
|
"sha256": "abc8c50c95509484afa27606e6dc88087409fd0a8609fea6e99915cac703d2d5",
|
||||||
|
"target": "flow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.subtitle-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/subtitle-tools/releases/download/v0.1.0/subtitle-tools-0.1.0.zip",
|
||||||
|
"sha256": "be251beb06fe62da6b2dde79799e48d28148dc81cba84998ba51ced328509fc6",
|
||||||
|
"target": "subtitle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.midi-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/midi-tools/releases/download/v0.1.0/midi-tools-0.1.0.zip",
|
||||||
|
"sha256": "5f55fe448720d989aaf93cd25c18644b99df97bd3a32b297e1dc6f3d04777536",
|
||||||
|
"target": "midi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.3d-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/3d-tools/releases/download/v0.1.0/3d-tools-0.1.0.zip",
|
||||||
|
"sha256": "63d7c028e5fb8e0644855720d2088a23623b73f9e7f656485c1340b6e51dca1a",
|
||||||
|
"target": "3d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.query-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/query-tools/releases/download/v0.1.0/query-tools-0.1.0.zip",
|
||||||
|
"sha256": "c28fa57a266f32339f5f83c1082920ce3d601167c86a40516fe2e33d867545d6",
|
||||||
|
"target": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.scan-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/scan-tools/releases/download/v0.1.0/scan-tools-0.1.0.zip",
|
||||||
|
"sha256": "f741257053355ed0e2c46261d650c1bfdd70de9cc4355af526fb65bedf6a6096",
|
||||||
|
"target": "scan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.package-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/package-tools/releases/download/v0.1.0/package-tools-0.1.0.zip",
|
||||||
|
"sha256": "bbbba42319f77fc85ca38a87bef2317f8500326c9c781462561abafa37de2c97",
|
||||||
|
"target": "package"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.label-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/label-tools/releases/download/v0.1.0/label-tools-0.1.0.zip",
|
||||||
|
"sha256": "0e658518da0eb9df204cc19bf07ef84e5e84c38d62604403a23e76938ecc8dd1",
|
||||||
|
"target": "label"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.font-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/font-tools/releases/download/v0.1.0/font-tools-0.1.0.zip",
|
||||||
|
"sha256": "ee0b51e2ec63f38ae5fd2e9be8968f58f27e8cc243c429a3fc057e8cbfc3b582",
|
||||||
|
"target": "font"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.fixture-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/fixture-tools/releases/download/v0.1.0/fixture-tools-0.1.0.zip",
|
||||||
|
"sha256": "9d41d63d33430bd8390ca9ddacdc14062205640a7ff8ed259192af943b430541",
|
||||||
|
"target": "fixture"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.minimize-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/minimize-tools/releases/download/v0.1.0/minimize-tools-0.1.0.zip",
|
||||||
|
"sha256": "2e67cfdd8d0d4569729279da834b8c5bfa8ddd659068cfd7013a96f0031ecc05",
|
||||||
|
"target": "minimize"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.format-lab",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/format-lab/releases/download/v0.1.0/format-lab-0.1.0.zip",
|
||||||
|
"sha256": "3fc0037bcdc4c7e58cc7de4a09a69c775ecc2605e820c3cdb5106cbc87de008c",
|
||||||
|
"target": "format-lab"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.repro-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/repro-tools/releases/download/v0.1.0/repro-tools-0.1.0.zip",
|
||||||
|
"sha256": "c5c0dc7874bdeb241894d2475f2fde313e93231f3513205c5122336548c9c758",
|
||||||
|
"target": "repro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.schema-tools",
|
||||||
|
"version": "0.1.2",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/schema-tools/releases/download/v0.1.2/schema-tools-0.1.2.zip",
|
||||||
|
"sha256": "038f369ca19601a1a497d5435f3cee596946f93216cd796864d3e04a8d7bf3c7",
|
||||||
|
"target": "schema"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.log-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/log-tools/releases/download/v0.1.0/log-tools-0.1.0.zip",
|
||||||
|
"sha256": "f16829d6e0a00981c92fa8704897da9aaeb4fcfc9af4ad8e45165e9dd35c2fdd",
|
||||||
|
"target": "log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.binary-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/binary-tools/releases/download/v0.1.0/binary-tools-0.1.0.zip",
|
||||||
|
"sha256": "6a9fdfa6ca87617151a49f9efde4edaca62d547756ed98459b1b355a7d05ac9d",
|
||||||
|
"target": "binary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.git-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/git-tools/releases/download/v0.1.0/git-tools-0.1.0.zip",
|
||||||
|
"sha256": "b71026de7feac68a4b2b66c40dd4b9c79201549a07cb204640e6923cf2a93678",
|
||||||
|
"target": "git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.api-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/api-tools/releases/download/v0.1.0/api-tools-0.1.0.zip",
|
||||||
|
"sha256": "fa544b99b1bf3e92beb95f77fadfaceb55fbadaf35dc3290603b110e6fb965e5",
|
||||||
|
"target": "api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.mail-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/mail-tools/releases/download/v0.1.0/mail-tools-0.1.0.zip",
|
||||||
|
"sha256": "81cf378ef7c63969fc2f13a872ff6b97313f13124623f457461b69111a1f3d12",
|
||||||
|
"target": "mail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.calendar-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/calendar-tools/releases/download/v0.1.0/calendar-tools-0.1.0.zip",
|
||||||
|
"sha256": "f37871322b60d3ef522350634360b8ec5304ecb63ebd342bec2145a29156d8cc",
|
||||||
|
"target": "calendar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.contact-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/contact-tools/releases/download/v0.1.0/contact-tools-0.1.0.zip",
|
||||||
|
"sha256": "7afa6daedabd9553c01c03b735cfc95265cfc862094d82e3e65a840a4c0ed1d2",
|
||||||
|
"target": "contact"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.diagram-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/diagram-tools/releases/download/v0.1.0/diagram-tools-0.1.0.zip",
|
||||||
|
"sha256": "c19313d3a5ef1461bcbe719591cc0f5b27069bd2294f11a4b14c075cfd703929",
|
||||||
|
"target": "diagram"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.token-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/token-tools/releases/download/v0.1.0/token-tools-0.1.0.zip",
|
||||||
|
"sha256": "c3800519f023b40868849a83fbe2631fd78be1b47884f85aee3de98fc72f884f",
|
||||||
|
"target": "token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "de.add-ideas.device-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"artifact": "https://git.add-ideas.de/lotobo/device-tools/releases/download/v0.1.0/device-tools-0.1.0.zip",
|
||||||
|
"sha256": "6d5c587ee991efb291633f12e5e626a1580d18afcee1d1f296451f6fc2c60feb",
|
||||||
|
"target": "device"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -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.22',
|
||||||
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' })
|
||||||
@@ -290,7 +290,7 @@ describe('release assembly integrity', () => {
|
|||||||
expect(await sha256File(utcArchive)).toBe(
|
expect(await sha256File(utcArchive)).toBe(
|
||||||
await sha256File(honoluluArchive)
|
await sha256File(honoluluArchive)
|
||||||
);
|
);
|
||||||
});
|
}, 30_000);
|
||||||
|
|
||||||
it('rejects a valid archive whose manifest identity is not the lock identity', async () => {
|
it('rejects a valid archive whose manifest identity is not the lock identity', async () => {
|
||||||
const directory = await temporaryDirectory();
|
const directory = await temporaryDirectory();
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const compose = readFileSync(path.join(projectRoot, 'compose.yaml'), 'utf8');
|
||||||
|
const composeExample = readFileSync(
|
||||||
|
path.join(projectRoot, 'compose.example.yaml'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const containerfile = readFileSync(
|
||||||
|
path.join(projectRoot, 'Containerfile.release'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const environmentExample = readFileSync(
|
||||||
|
path.join(projectRoot, '.env.example'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const nginxConfig = readFileSync(
|
||||||
|
path.join(projectRoot, 'deploy', 'nginx.conf'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const releaseLock = JSON.parse(
|
||||||
|
readFileSync(path.join(projectRoot, 'release', 'toolbox.lock.json'), 'utf8')
|
||||||
|
) as { releaseVersion: string; apps: Array<{ target: string }> };
|
||||||
|
const developmentCatalogue = JSON.parse(
|
||||||
|
readFileSync(path.join(projectRoot, 'public', 'toolbox.catalog.json'), 'utf8')
|
||||||
|
) as { apps: Array<{ manifest: string; enabled: boolean }> };
|
||||||
|
|
||||||
|
const expectedAppTargets = [
|
||||||
|
'pdf',
|
||||||
|
'xslt',
|
||||||
|
'onenote',
|
||||||
|
'av',
|
||||||
|
'regex',
|
||||||
|
'svg',
|
||||||
|
'auth',
|
||||||
|
'sudoku',
|
||||||
|
'colour',
|
||||||
|
'office',
|
||||||
|
'image',
|
||||||
|
'file',
|
||||||
|
'epub',
|
||||||
|
'archive',
|
||||||
|
'privacy',
|
||||||
|
'crypto',
|
||||||
|
'barcode',
|
||||||
|
'network',
|
||||||
|
'geo',
|
||||||
|
'helper',
|
||||||
|
'rand',
|
||||||
|
'data',
|
||||||
|
'diff',
|
||||||
|
'time',
|
||||||
|
'text',
|
||||||
|
'unicode',
|
||||||
|
'flow',
|
||||||
|
'subtitle',
|
||||||
|
'midi',
|
||||||
|
'3d',
|
||||||
|
'query',
|
||||||
|
'scan',
|
||||||
|
'package',
|
||||||
|
'label',
|
||||||
|
'font',
|
||||||
|
'fixture',
|
||||||
|
'minimize',
|
||||||
|
'format-lab',
|
||||||
|
'repro',
|
||||||
|
'schema',
|
||||||
|
'log',
|
||||||
|
'binary',
|
||||||
|
'git',
|
||||||
|
'api',
|
||||||
|
'mail',
|
||||||
|
'calendar',
|
||||||
|
'contact',
|
||||||
|
'diagram',
|
||||||
|
'token',
|
||||||
|
'device',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
it('keeps the release version and checksum pins synchronized', () => {
|
||||||
|
const containerVersion = containerfile.match(
|
||||||
|
/^ARG TOOLBOX_RELEASE_VERSION=(\S+)$/mu
|
||||||
|
)?.[1];
|
||||||
|
const composeVersion = compose.match(
|
||||||
|
/\$\{TOOLBOX_RELEASE_VERSION:-([^}]+)\}/u
|
||||||
|
)?.[1];
|
||||||
|
const containerChecksum = containerfile.match(
|
||||||
|
/^ARG TOOLBOX_RELEASE_SHA256=([a-f0-9]{64})$/mu
|
||||||
|
)?.[1];
|
||||||
|
const composeChecksum = compose.match(
|
||||||
|
/\$\{TOOLBOX_RELEASE_SHA256:-([a-f0-9]{64})\}/u
|
||||||
|
)?.[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\/lotobo\/toolbox:(\S+)$/mu
|
||||||
|
)?.[1];
|
||||||
|
|
||||||
|
expect(containerVersion).toBe(releaseLock.releaseVersion);
|
||||||
|
expect(composeVersion).toBe(containerVersion);
|
||||||
|
expect(containerChecksum).toMatch(/^[a-f0-9]{64}$/u);
|
||||||
|
expect(composeChecksum).toMatch(/^[a-f0-9]{64}$/u);
|
||||||
|
expect(environmentChecksum).toMatch(/^[a-f0-9]{64}$/u);
|
||||||
|
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', () => {
|
||||||
|
expect(containerfile).toContain("--proto-redir '=https' --tlsv1.2");
|
||||||
|
expect(containerfile.indexOf('sha256sum -c -')).toBeGreaterThan(0);
|
||||||
|
expect(containerfile.indexOf('unzip -q')).toBeGreaterThan(
|
||||||
|
containerfile.indexOf('sha256sum -c -')
|
||||||
|
);
|
||||||
|
expect(containerfile).toContain(
|
||||||
|
'COPY --from=release --chown=101:101 /tmp/toolbox/'
|
||||||
|
);
|
||||||
|
for (const app of expectedAppTargets) {
|
||||||
|
expect(containerfile).toContain(
|
||||||
|
`test -f /tmp/toolbox/apps/${app}/toolbox-app.json`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(containerfile).toMatch(/\nUSER 101:101\nEXPOSE 8080\n/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the development catalogue aligned with all release targets', () => {
|
||||||
|
expect(releaseLock.apps.map(({ target }) => target)).toEqual(
|
||||||
|
expectedAppTargets
|
||||||
|
);
|
||||||
|
expect(developmentCatalogue.apps).toEqual(
|
||||||
|
expectedAppTargets.map((target) => ({
|
||||||
|
manifest: `./apps/${target}/toolbox-app.json`,
|
||||||
|
enabled: true,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses only the requested external Traefik network and redirects HTTP to HTTPS', () => {
|
||||||
|
expect(compose).toContain('dockerfile: Containerfile.release');
|
||||||
|
expect(compose).toContain('traefik.enable: "true"');
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.docker.network: "${TRAEFIK_NETWORK:-internal}"'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.routers.addideas-toolbox.rule: "Host(`${TOOLBOX_HOST:-toolbox.add-ideas.de}`)"'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.routers.addideas-toolbox.entrypoints: websecure'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'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).not.toMatch(/^\s+ports:/mu);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates live WebAuthn behind a dedicated hostname and path prefix', () => {
|
||||||
|
expect(environmentExample).toContain(
|
||||||
|
'AUTH_TOOLS_HOST=auth.toolbox.add-ideas.de'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.routers.addideas-auth.rule: "Host(`${AUTH_TOOLS_HOST:-auth.toolbox.add-ideas.de}`)"'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.routers.addideas-auth.entrypoints: websecure'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.routers.addideas-auth.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.routers.addideas-auth.middlewares: addideas-auth-prefix'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.middlewares.addideas-auth-prefix.addprefix.prefix: /apps/auth'
|
||||||
|
);
|
||||||
|
expect(compose).toContain(
|
||||||
|
'traefik.http.routers.addideas-auth-http.middlewares: addideas-toolbox-https-redirect'
|
||||||
|
);
|
||||||
|
expect(compose).not.toContain('addideas-auth-stripprefix');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes camera and device probes while denying sensitive features by default', () => {
|
||||||
|
expect(nginxConfig).toContain(
|
||||||
|
'default "camera=(), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";'
|
||||||
|
);
|
||||||
|
expect(nginxConfig).toContain(
|
||||||
|
'"~^auth\\.toolbox\\.add-ideas\\.de:" "camera=(self), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";'
|
||||||
|
);
|
||||||
|
expect(nginxConfig).toContain(
|
||||||
|
'"~^[^:]+:/apps/barcode(?:/|$)" "camera=(self), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";'
|
||||||
|
);
|
||||||
|
expect(nginxConfig).toContain(
|
||||||
|
'"~^[^:]+:/apps/scan(?:/|$)" "camera=(self), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";'
|
||||||
|
);
|
||||||
|
expect(nginxConfig).toContain(
|
||||||
|
'"~^[^:]+:/apps/device(?:/|$)" "camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=(), usb=()";'
|
||||||
|
);
|
||||||
|
expect(nginxConfig).toContain(
|
||||||
|
'add_header Permissions-Policy $toolbox_permissions_policy always;'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
nginxConfig.match(/^\s*add_header Permissions-Policy /gmu)
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps application network connections same-origin', () => {
|
||||||
|
expect(nginxConfig).toMatch(
|
||||||
|
/^\s*add_header Content-Security-Policy "[^"]*connect-src 'self';[^"]*" always;$/mu
|
||||||
|
);
|
||||||
|
expect(nginxConfig).not.toContain('$toolbox_connect_sources');
|
||||||
|
expect(nginxConfig).not.toMatch(/https?:\/\//u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quotes nginx regular expressions that contain brace quantifiers', () => {
|
||||||
|
expect(nginxConfig).toContain(
|
||||||
|
'"~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\\.[^/]+$"'
|
||||||
|
);
|
||||||
|
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('serves ES modules as JavaScript before the broad static location', () => {
|
||||||
|
const moduleLocation =
|
||||||
|
/location ~\* \\\.mjs\$\s*\{\s*types\s*\{\s*application\/javascript mjs;\s*\}\s*try_files \$uri =404;\s*\}/mu;
|
||||||
|
const moduleLocationIndex = nginxConfig.search(moduleLocation);
|
||||||
|
const staticLocationIndex = nginxConfig.search(
|
||||||
|
/location ~\* \\\.\(\?:css\|js\|mjs\|/u
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(moduleLocationIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(staticLocationIndex).toBeGreaterThan(moduleLocationIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves bundled Scan OCR language data as gzip without widening the static route', () => {
|
||||||
|
const languageLocation =
|
||||||
|
/location ~\* \^\/apps\/scan\/ocr\/lang\/\[a-z0-9_-\]\+\\\.traineddata\\\.gz\$\s*\{\s*types\s*\{\s*application\/gzip gz;\s*\}\s*try_files \$uri =404;\s*\}/mu;
|
||||||
|
expect(nginxConfig).toMatch(languageLocation);
|
||||||
|
expect(nginxConfig).not.toMatch(
|
||||||
|
/location ~\* \\.\(\?:[^\n)]*\bgz\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).toContain("font-src 'self' data: blob:");
|
||||||
|
expect(nginxConfig).not.toContain("'unsafe-eval'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grants only the sandboxed SVG canvas controller cross-origin loading headers', () => {
|
||||||
|
expect(nginxConfig).toMatch(
|
||||||
|
/map \$uri \$toolbox_resource_policy\s*\{\s*default "same-origin";\s*"\/apps\/svg\/canvas-frame-controller\.js" "cross-origin";\s*\}/mu
|
||||||
|
);
|
||||||
|
expect(nginxConfig).toMatch(
|
||||||
|
/map \$uri \$toolbox_access_control_allow_origin\s*\{\s*default "";\s*"\/apps\/svg\/canvas-frame-controller\.js" "\*";\s*\}/mu
|
||||||
|
);
|
||||||
|
expect(nginxConfig).toContain(
|
||||||
|
'add_header Cross-Origin-Resource-Policy $toolbox_resource_policy always;'
|
||||||
|
);
|
||||||
|
expect(nginxConfig).toContain(
|
||||||
|
'add_header Access-Control-Allow-Origin $toolbox_access_control_allow_origin always;'
|
||||||
|
);
|
||||||
|
expect(nginxConfig).not.toMatch(
|
||||||
|
/location[^\n{]*canvas-frame-controller[^\n{]*\{/u
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
nginxConfig.match(/^\s*add_header X-Content-Type-Options /gmu)
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
nginxConfig.match(/^\s*add_header Content-Security-Policy /gmu)
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function buildCatalogue(lock) {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
$schema:
|
$schema:
|
||||||
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
|
'https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
|
||||||
...parseToolboxCatalog(catalogue),
|
...parseToolboxCatalog(catalogue),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+545
-43
@@ -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,82 @@ 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.getByTestId('app-card-de.add-ideas.svg-tools')
|
||||||
|
).toHaveTextContent('SVG');
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('app-card-de.add-ideas.auth-tools')
|
||||||
|
).toHaveTextContent('OTP & Passkeys');
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('app-card-de.add-ideas.colour-tools')
|
||||||
|
).toHaveTextContent('Colour');
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('app-card-de.add-ideas.office-tools')
|
||||||
|
).toHaveTextContent('Office');
|
||||||
|
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 +96,462 @@ 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(within(card).getByText('Explain and test locally.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
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.queryByRole('heading', { name: 'PDF Workbench' })
|
within(dialog).getByRole('button', { name: 'documents' })
|
||||||
).not.toBeInTheDocument();
|
|
||||||
fireEvent.click(screen.getByRole('checkbox', { name: /show hidden/i }));
|
|
||||||
expect(
|
|
||||||
screen.getByRole('heading', { name: 'PDF Workbench' })
|
|
||||||
).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/lotobo/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 local tools, in one calm place.',
|
||||||
|
})
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getAllByRole('heading', {
|
||||||
|
name: 'Your local 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',
|
||||||
|
'de.add-ideas.svg-tools',
|
||||||
|
'de.add-ideas.auth-tools',
|
||||||
|
'de.add-ideas.sudoku-tools',
|
||||||
|
'de.add-ideas.colour-tools',
|
||||||
|
'de.add-ideas.office-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'),
|
||||||
|
screen.getByTestId('app-card-de.add-ideas.svg-tools'),
|
||||||
|
screen.getByTestId('app-card-de.add-ideas.auth-tools'),
|
||||||
|
screen.getByTestId('app-card-de.add-ideas.sudoku-tools'),
|
||||||
|
screen.getByTestId('app-card-de.add-ideas.colour-tools'),
|
||||||
|
screen.getByTestId('app-card-de.add-ideas.office-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',
|
||||||
|
'SVG',
|
||||||
|
'OTP & Passkeys',
|
||||||
|
'Sudoku',
|
||||||
|
'Colour',
|
||||||
|
'Office',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
JSON.parse(localStorage.getItem(PREFERENCES_KEY) ?? '{}').order
|
||||||
|
).toEqual([
|
||||||
|
'de.add-ideas.xslt-tools',
|
||||||
|
'de.add-ideas.pdf-tools',
|
||||||
|
'de.add-ideas.onenote-tools',
|
||||||
|
'de.add-ideas.svg-tools',
|
||||||
|
'de.add-ideas.auth-tools',
|
||||||
|
'de.add-ideas.sudoku-tools',
|
||||||
|
'de.add-ideas.colour-tools',
|
||||||
|
'de.add-ideas.office-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 +565,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 +583,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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+312
-142
@@ -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/lotobo/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 local 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.22 ·{' '}
|
||||||
<a
|
<a
|
||||||
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.1.0"
|
href="https://git.add-ideas.de/lotobo/toolbox-portal/src/tag/v0.19.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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -5,17 +5,17 @@ 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(8);
|
||||||
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',
|
||||||
entryUrl: 'http://localhost:3000/apps/pdf/',
|
entryUrl: 'http://localhost:3000/apps/pdf/',
|
||||||
sourceUrl: 'https://git.add-ideas.de/zemion/pdf-tools',
|
sourceUrl: 'https://git.add-ideas.de/lotobo/pdf-tools',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,6 +38,13 @@ 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',
|
||||||
|
'de.add-ideas.svg-tools',
|
||||||
|
'de.add-ideas.auth-tools',
|
||||||
|
'de.add-ideas.sudoku-tools',
|
||||||
|
'de.add-ideas.colour-tools',
|
||||||
|
'de.add-ideas.office-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/);
|
||||||
|
|||||||
+146
-124
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
+29
-7
@@ -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
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+40
-66
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+452
-224
@@ -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,
|
||||||
|
|||||||
+265
-3
@@ -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',
|
||||||
@@ -30,18 +30,264 @@ export const pdfManifest: ToolboxAppManifest = {
|
|||||||
telemetry: false,
|
telemetry: false,
|
||||||
},
|
},
|
||||||
source: {
|
source: {
|
||||||
repository: 'https://git.add-ideas.de/zemion/pdf-tools',
|
repository: 'https://git.add-ideas.de/lotobo/pdf-tools',
|
||||||
license: 'AGPL-3.0-only',
|
license: 'AGPL-3.0-only',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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/lotobo/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/lotobo/onenote-tools',
|
||||||
|
license: 'AGPL-3.0-only',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const regexManifest: ToolboxAppManifest = {
|
||||||
|
...pdfManifest,
|
||||||
|
id: 'de.add-ideas.regex-tools',
|
||||||
|
name: 'Regex Tools',
|
||||||
|
version: '0.4.1',
|
||||||
|
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/lotobo/regex-tools',
|
||||||
|
license: 'GPL-3.0-or-later',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const svgManifest: ToolboxAppManifest = {
|
||||||
|
...pdfManifest,
|
||||||
|
id: 'de.add-ideas.svg-tools',
|
||||||
|
name: 'SVG Tools',
|
||||||
|
version: '0.1.0',
|
||||||
|
description:
|
||||||
|
'Inspect, edit, optimize and transform SVG documents locally in the browser.',
|
||||||
|
icon: './favicon.svg',
|
||||||
|
categories: ['graphics', 'design', 'developer'],
|
||||||
|
tags: [
|
||||||
|
'svg',
|
||||||
|
'vector',
|
||||||
|
'path',
|
||||||
|
'xml',
|
||||||
|
'transform',
|
||||||
|
'optimize',
|
||||||
|
'accessibility',
|
||||||
|
],
|
||||||
|
requirements: {
|
||||||
|
secureContext: false,
|
||||||
|
workers: true,
|
||||||
|
indexedDb: false,
|
||||||
|
crossOriginIsolated: false,
|
||||||
|
topLevelContext: false,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
repository: 'https://git.add-ideas.de/lotobo/svg-tools',
|
||||||
|
license: 'GPL-3.0-or-later',
|
||||||
|
},
|
||||||
|
assets: ['./canvas-frame-controller.js'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authManifest: ToolboxAppManifest = {
|
||||||
|
...pdfManifest,
|
||||||
|
id: 'de.add-ideas.auth-tools',
|
||||||
|
name: 'OTP & Passkey Tools',
|
||||||
|
version: '0.1.0',
|
||||||
|
description:
|
||||||
|
'Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.',
|
||||||
|
icon: './favicon.svg',
|
||||||
|
categories: ['security', 'authentication', 'developer'],
|
||||||
|
tags: [
|
||||||
|
'otp',
|
||||||
|
'hotp',
|
||||||
|
'totp',
|
||||||
|
'ocra',
|
||||||
|
'oath',
|
||||||
|
'webauthn',
|
||||||
|
'fido2',
|
||||||
|
'passkey',
|
||||||
|
'attestation',
|
||||||
|
'assertion',
|
||||||
|
],
|
||||||
|
requirements: {
|
||||||
|
secureContext: true,
|
||||||
|
workers: false,
|
||||||
|
indexedDb: false,
|
||||||
|
crossOriginIsolated: false,
|
||||||
|
topLevelContext: true,
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
processing: 'local',
|
||||||
|
fileUploads: false,
|
||||||
|
telemetry: false,
|
||||||
|
label:
|
||||||
|
'Authentication material stays in this tab; nothing is uploaded or saved unless you explicitly export it.',
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
repository: 'https://git.add-ideas.de/lotobo/auth-tools',
|
||||||
|
license: 'GPL-3.0-or-later',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sudokuManifest: ToolboxAppManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
id: 'de.add-ideas.sudoku-tools',
|
||||||
|
name: 'Sudoku Tools',
|
||||||
|
version: '0.1.0',
|
||||||
|
description:
|
||||||
|
'Set, play, solve and analyse Sudoku puzzles locally in the browser.',
|
||||||
|
entry: './',
|
||||||
|
icon: './favicon.svg',
|
||||||
|
categories: ['games', 'puzzles', 'logic'],
|
||||||
|
tags: ['sudoku', 'killer', 'solver', 'setter', 'generator', 'candidates'],
|
||||||
|
integration: {
|
||||||
|
contextVersion: 1,
|
||||||
|
launchModes: ['navigate', 'new-tab'],
|
||||||
|
embedding: 'unsupported',
|
||||||
|
},
|
||||||
|
requirements: {
|
||||||
|
secureContext: false,
|
||||||
|
workers: true,
|
||||||
|
indexedDb: true,
|
||||||
|
crossOriginIsolated: false,
|
||||||
|
topLevelContext: false,
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
processing: 'local',
|
||||||
|
fileUploads: false,
|
||||||
|
telemetry: false,
|
||||||
|
label: 'Puzzles and progress stay in this browser; nothing is uploaded.',
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
repository: 'https://git.add-ideas.de/lotobo/sudoku-tools',
|
||||||
|
license: 'GPL-3.0-or-later',
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'source',
|
||||||
|
label: 'Source',
|
||||||
|
url: 'https://git.add-ideas.de/lotobo/sudoku-tools',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const colourManifest: ToolboxAppManifest = {
|
||||||
|
...sudokuManifest,
|
||||||
|
id: 'de.add-ideas.colour-tools',
|
||||||
|
name: 'Colour Tools',
|
||||||
|
version: '0.1.0',
|
||||||
|
description:
|
||||||
|
'Convert, composite, compare and build colours locally in the browser.',
|
||||||
|
categories: ['graphics', 'design', 'developer'],
|
||||||
|
tags: ['colour', 'rgba', 'oklch', 'palette', 'gradient', 'contrast'],
|
||||||
|
requirements: {
|
||||||
|
secureContext: false,
|
||||||
|
workers: true,
|
||||||
|
indexedDb: false,
|
||||||
|
crossOriginIsolated: false,
|
||||||
|
topLevelContext: false,
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
processing: 'local',
|
||||||
|
fileUploads: false,
|
||||||
|
telemetry: false,
|
||||||
|
label: 'Colours and images stay in this browser; nothing is uploaded.',
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
repository: 'https://git.add-ideas.de/lotobo/colour-tools',
|
||||||
|
license: 'GPL-3.0-or-later',
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'source',
|
||||||
|
label: 'Source',
|
||||||
|
url: 'https://git.add-ideas.de/lotobo/colour-tools',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const officeManifest: ToolboxAppManifest = {
|
||||||
|
...sudokuManifest,
|
||||||
|
id: 'de.add-ideas.office-tools',
|
||||||
|
name: 'Office Tools',
|
||||||
|
version: '0.1.0',
|
||||||
|
description:
|
||||||
|
'Open and inspect documents, spreadsheets and presentations locally in the browser.',
|
||||||
|
categories: ['documents', 'office', 'productivity'],
|
||||||
|
tags: ['document', 'spreadsheet', 'presentation', 'docx', 'xlsx', 'pptx'],
|
||||||
|
requirements: {
|
||||||
|
secureContext: false,
|
||||||
|
workers: true,
|
||||||
|
indexedDb: false,
|
||||||
|
crossOriginIsolated: false,
|
||||||
|
topLevelContext: false,
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
processing: 'local',
|
||||||
|
fileUploads: false,
|
||||||
|
telemetry: false,
|
||||||
|
label: 'Office files stay in this browser; nothing is uploaded.',
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
repository: 'https://git.add-ideas.de/lotobo/office-tools',
|
||||||
|
license: 'GPL-3.0-or-later',
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'source',
|
||||||
|
label: 'Source',
|
||||||
|
url: 'https://git.add-ideas.de/lotobo/office-tools',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
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 },
|
||||||
|
{ manifest: './apps/svg/toolbox-app.json', enabled: true },
|
||||||
|
{ manifest: './apps/auth/toolbox-app.json', enabled: true },
|
||||||
|
{ manifest: './apps/sudoku/toolbox-app.json', enabled: true },
|
||||||
|
{ manifest: './apps/colour/toolbox-app.json', enabled: true },
|
||||||
|
{ manifest: './apps/office/toolbox-app.json', enabled: true },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function catalogueFetch(overrides: Record<string, Response> = {}) {
|
export function catalogueFetch(overrides: Record<string, Response> = {}) {
|
||||||
@@ -56,6 +302,22 @@ 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);
|
||||||
|
if (url.pathname.endsWith('/apps/svg/toolbox-app.json'))
|
||||||
|
return Response.json(svgManifest);
|
||||||
|
if (url.pathname.endsWith('/apps/auth/toolbox-app.json'))
|
||||||
|
return Response.json(authManifest);
|
||||||
|
if (url.pathname.endsWith('/apps/sudoku/toolbox-app.json'))
|
||||||
|
return Response.json(sudokuManifest);
|
||||||
|
if (url.pathname.endsWith('/apps/colour/toolbox-app.json'))
|
||||||
|
return Response.json(colourManifest);
|
||||||
|
if (url.pathname.endsWith('/apps/office/toolbox-app.json'))
|
||||||
|
return Response.json(officeManifest);
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { shortToolDescription, shortToolTitle } from './toolPresentation';
|
||||||
|
import type { ResolvedApp } from './types';
|
||||||
|
|
||||||
|
function app(id: string, name: string, description: string): ResolvedApp {
|
||||||
|
return { id, name, description } as ResolvedApp;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('compact tool presentation', () => {
|
||||||
|
it('uses the requested Audio & Video tile copy', () => {
|
||||||
|
const audioVideo = app(
|
||||||
|
'de.add-ideas.av-tools',
|
||||||
|
'Audio & Video Tools',
|
||||||
|
'Convert and lightly edit audio and video locally in the browser.'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shortToolTitle(audioVideo)).toBe('Audio & Video');
|
||||||
|
expect(shortToolDescription(audioVideo)).toBe('Convert and edit locally.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the requested Regex tile copy', () => {
|
||||||
|
const regex = app(
|
||||||
|
'de.add-ideas.regex-tools',
|
||||||
|
'Regex Tools',
|
||||||
|
'Develop, explain, test and apply regular expressions locally in the browser.'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shortToolTitle(regex)).toBe('Regex');
|
||||||
|
expect(shortToolDescription(regex)).toBe('Explain and test locally.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the compact SVG tile copy', () => {
|
||||||
|
const svg = app(
|
||||||
|
'de.add-ideas.svg-tools',
|
||||||
|
'SVG Tools',
|
||||||
|
'Inspect, edit, optimize and transform SVG documents locally in the browser.'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shortToolTitle(svg)).toBe('SVG');
|
||||||
|
expect(shortToolDescription(svg)).toBe('Inspect and edit vectors locally.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the compact authentication tile copy', () => {
|
||||||
|
const auth = app(
|
||||||
|
'de.add-ideas.auth-tools',
|
||||||
|
'OTP & Passkey Tools',
|
||||||
|
'Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shortToolTitle(auth)).toBe('OTP & Passkeys');
|
||||||
|
expect(shortToolDescription(auth)).toBe(
|
||||||
|
'Inspect and test authentication locally.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the compact Sudoku tile copy', () => {
|
||||||
|
const sudoku = app(
|
||||||
|
'de.add-ideas.sudoku-tools',
|
||||||
|
'Sudoku Tools',
|
||||||
|
'Set, play, solve and analyse Sudoku puzzles locally in the browser.'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shortToolTitle(sudoku)).toBe('Sudoku');
|
||||||
|
expect(shortToolDescription(sudoku)).toBe('Set, play and solve locally.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the compact Colour tile copy', () => {
|
||||||
|
const colour = app(
|
||||||
|
'de.add-ideas.colour-tools',
|
||||||
|
'Colour Tools',
|
||||||
|
'Convert, composite, compare and build colours locally in the browser.'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shortToolTitle(colour)).toBe('Colour');
|
||||||
|
expect(shortToolDescription(colour)).toBe(
|
||||||
|
'Convert and build colours locally.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the compact Office tile copy', () => {
|
||||||
|
const office = app(
|
||||||
|
'de.add-ideas.office-tools',
|
||||||
|
'Office Tools',
|
||||||
|
'Open and inspect documents, spreadsheets and presentations locally in the browser.'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shortToolTitle(office)).toBe('Office');
|
||||||
|
expect(shortToolDescription(office)).toBe('Open office files locally.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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.av-tools': {
|
||||||
|
title: 'Audio & Video',
|
||||||
|
description: 'Convert and edit locally.',
|
||||||
|
},
|
||||||
|
'de.add-ideas.regex-tools': {
|
||||||
|
title: 'Regex',
|
||||||
|
description: 'Explain and test locally.',
|
||||||
|
},
|
||||||
|
'de.add-ideas.svg-tools': {
|
||||||
|
title: 'SVG',
|
||||||
|
description: 'Inspect and edit vectors locally.',
|
||||||
|
},
|
||||||
|
'de.add-ideas.auth-tools': {
|
||||||
|
title: 'OTP & Passkeys',
|
||||||
|
description: 'Inspect and test authentication locally.',
|
||||||
|
},
|
||||||
|
'de.add-ideas.sudoku-tools': {
|
||||||
|
title: 'Sudoku',
|
||||||
|
description: 'Set, play and solve locally.',
|
||||||
|
},
|
||||||
|
'de.add-ideas.colour-tools': {
|
||||||
|
title: 'Colour',
|
||||||
|
description: 'Convert and build colours locally.',
|
||||||
|
},
|
||||||
|
'de.add-ideas.office-tools': {
|
||||||
|
title: 'Office',
|
||||||
|
description: 'Open office files 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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,7 @@ export default defineConfig({
|
|||||||
setupFiles: './src/test/setup.ts',
|
setupFiles: './src/test/setup.ts',
|
||||||
css: true,
|
css: true,
|
||||||
restoreMocks: true,
|
restoreMocks: true,
|
||||||
|
pool: 'threads',
|
||||||
|
maxWorkers: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user