mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a243056c3e | ||
|
|
aa6ac5c7a3 | ||
|
|
71ef9dcde8 | ||
|
|
b9d3a3128b | ||
|
|
0bf35a98c0 | ||
|
|
10d9127d29 |
@@ -32,7 +32,7 @@ vs `main`. Flags: `--html` (also emit a rendered HTML twin), `--no-screens`.
|
||||
- Read the PR description / commit messages for stated intent. Do **not** invent
|
||||
history or motivation that isn't evidenced (state current behavior in present tense).
|
||||
- Classify touched files by layer:
|
||||
- **Frontend**: tools (`frontend/editor/src/core/components/tools/*` or `.../core/tools/*`),
|
||||
- **Frontend**: tools (`frontend/src/editor/core/components/tools/*` or `.../core/tools/*`),
|
||||
hooks (`core/hooks/tools/*`, `useToolOperation`), contexts, routes, i18n
|
||||
(`public/locales/en-US`).
|
||||
- **Java backend**: controllers (`.../controller/api/...`), services, models, config.
|
||||
|
||||
@@ -53,11 +53,11 @@ gh pr diff <pr> --name-only # or: git diff --name-only <base>...HEAD
|
||||
Map changed frontend files to URLs generically:
|
||||
- **Tools**: a changed `components/tools/<toolDir>/…` or `hooks/tools/<tool>/…` →
|
||||
toolId → URL via the repo's own rule `getToolUrlPath` in
|
||||
[toolsTaxonomy.ts:200](frontend/editor/src/core/data/toolsTaxonomy.ts): `/` + the
|
||||
[toolsTaxonomy.ts:200](frontend/src/editor/core/data/toolsTaxonomy.ts): `/` + the
|
||||
id kebab-cased (`addPageNumbers` → `/add-page-numbers`).
|
||||
- **Pages/routes**: changed `filesPage/*` → `/files`, etc.
|
||||
- `--all`: enumerate every tool in the registry instead of just changed ones.
|
||||
Write `frontend/editor/screenshots/ui-diff/targets.json` =
|
||||
Write `frontend/screenshots/ui-diff/targets.json` =
|
||||
`[{ "id":"compress", "url":"/compress", "name":"Compress" }]`. This is what makes
|
||||
it generic - the spec never names a tool.
|
||||
|
||||
@@ -69,12 +69,12 @@ the full viewport - or the `--scope` container if given). Ensure the harness is
|
||||
(node_modules + icons).
|
||||
```
|
||||
# after = current head
|
||||
cd frontend/editor && PR_SHOT_SIDE=after PR_SHOT_THEME=light \
|
||||
cd frontend && PR_SHOT_SIDE=after PR_SHOT_THEME=light \
|
||||
npx playwright test --project=stubbed ui-before-after.spec.ts
|
||||
# before = base, in an isolated worktree (copy the spec + targets.json in)
|
||||
git worktree add ../ba-base origin/<baseRefName> # or the merge-base
|
||||
# set up its frontend, copy spec + screenshots/ui-diff/targets.json across, then:
|
||||
cd ../ba-base/frontend/editor && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
|
||||
cd ../ba-base/frontend && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
|
||||
npx playwright test --project=stubbed ui-before-after.spec.ts
|
||||
# copy its screenshots/ui-diff/before/ back next to after/. Repeat with
|
||||
# PR_SHOT_THEME=dark if --theme includes dark. Remove worktree when done.
|
||||
@@ -82,7 +82,7 @@ cd ../ba-base/frontend/editor && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
|
||||
|
||||
### 4. Auto-diff (surface what changed)
|
||||
```
|
||||
cd frontend/editor && node <skill>/diff-shots.mjs \
|
||||
cd frontend && node <skill>/diff-shots.mjs \
|
||||
screenshots/ui-diff/before screenshots/ui-diff/after screenshots/ui-diff
|
||||
```
|
||||
Produces `diff-report.json` classifying each view `unchanged | changed | added |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// unchanged | changed | added | removed, and CROP each changed pair to the
|
||||
// affected region (bounding box of differing pixels + padding) - unless the
|
||||
// change spans most of the page, in which case the full frame is kept.
|
||||
// Run from frontend/editor (so deps resolve):
|
||||
// Run from frontend (so deps resolve):
|
||||
// node <skill>/diff-shots.mjs <beforeDir> <afterDir> [outDir]
|
||||
// Env:
|
||||
// DIFF_THRESHOLD min fraction of differing pixels to count as changed (default 0.001)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Render each .tab-section of a montage HTML into its own PNG (the PR-ready image).
|
||||
// Run from frontend/editor (so @playwright/test resolves):
|
||||
// Run from frontend (so @playwright/test resolves):
|
||||
// node <skill>/shoot-sections.mjs <montage.html> <outDir>
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
@@ -28,13 +28,13 @@ current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both),
|
||||
## What this repo gives you (use it, don't reinvent)
|
||||
|
||||
- **Stubbed Playwright project** = backend-free screenshots via `page.route()` mocks.
|
||||
Reference implementation: `frontend/editor/src/core/tests/stubbed/files-page-screenshots.spec.ts`.
|
||||
Reference implementation: `frontend/src/editor/core/tests/stubbed/files-page-screenshots.spec.ts`.
|
||||
It already shows the light / **dark** / **RTL** passes, JWT seeding, IndexedDB
|
||||
seeding, and dumping PNGs to a `screenshots/<area>/` folder. Copy its shape.
|
||||
- Helpers: `frontend/editor/src/core/tests/helpers/ui-helpers.ts`
|
||||
- Helpers: `frontend/src/editor/core/tests/helpers/ui-helpers.ts`
|
||||
(`uploadFiles`, `openSettings`, `waitForModalOpen`, `dismissTourTooltip`, …)
|
||||
and the `stub-test-base` fixtures (`autoGoto`, `seedJwt`, `viewport`).
|
||||
- Config: `frontend/editor/playwright.config.ts` (run from `frontend/editor/`).
|
||||
- Config: `frontend/playwright.config.ts` (run from `frontend/`).
|
||||
- Report template: [report-template.html](report-template.html) - self-contained,
|
||||
one big image at a time, a global light/dark slider that flips every shot,
|
||||
thumbnail rail, prev/next + arrow keys, and a Findings tab.
|
||||
@@ -54,13 +54,13 @@ current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both),
|
||||
Worktrees have no `node_modules` and no generated icons. From repo root:
|
||||
```
|
||||
cd frontend && npm ci # or junction main's node_modules (see memory)
|
||||
cd frontend/editor && node scripts/generate-icons.js
|
||||
cd frontend && node scripts/generate-icons.js
|
||||
```
|
||||
Kill any stale dev server first (it serves old modules):
|
||||
`Get-NetTCPConnection -LocalPort 5173 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }`
|
||||
|
||||
### 3. Write the capture spec
|
||||
Create `frontend/editor/src/core/tests/stubbed/<feature>-walkthrough.spec.ts`,
|
||||
Create `frontend/src/editor/core/tests/stubbed/<feature>-walkthrough.spec.ts`,
|
||||
modeled on `files-page-screenshots.spec.ts`. For each enumerated view:
|
||||
- stub the APIs it needs, drive the UI to that state, wait on a real locator
|
||||
(not a fixed sleep), `await settle(page)` for Mantine portals, then
|
||||
@@ -71,7 +71,7 @@ modeled on `files-page-screenshots.spec.ts`. For each enumerated view:
|
||||
- Name shots `NN_<view>_<theme>.png` so light/dark pair up by suffix.
|
||||
- Prefer **stable test-ids** over translated accessible names (RTL/i18n breaks text locators).
|
||||
|
||||
Run it: `cd frontend/editor && npx playwright test --project=stubbed <feature>-walkthrough.spec.ts`.
|
||||
Run it: `cd frontend && npx playwright test --project=stubbed <feature>-walkthrough.spec.ts`.
|
||||
Add `--project=stubbed-firefox`/`-webkit` only if cross-browser layout matters.
|
||||
|
||||
### 4. Build the report
|
||||
|
||||
+4
-4
@@ -26,16 +26,16 @@ version_builds/
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/editor/dist/
|
||||
frontend/editor/playwright-report/
|
||||
frontend/dist/
|
||||
frontend/playwright-report/
|
||||
.npm/
|
||||
.yarn/
|
||||
|
||||
# Tauri/desktop builds
|
||||
src-tauri/target/
|
||||
src-tauri/dist/
|
||||
frontend/editor/src-tauri/target/
|
||||
frontend/editor/src-tauri/dist/
|
||||
frontend/src-tauri/target/
|
||||
frontend/src-tauri/dist/
|
||||
|
||||
# IDE and editor
|
||||
.idea/
|
||||
|
||||
+13
-23
@@ -1,28 +1,18 @@
|
||||
# Review ownership is assigned to teams where possible.
|
||||
# Teams can only contain org members, so outside collaborators are listed by hand.
|
||||
#
|
||||
# @Stirling-Tools/maintainers - Frooodle, jbrunton96, ConnorYoh
|
||||
# @Stirling-Tools/backend-reviewers - Frooodle, jbrunton96, ConnorYoh
|
||||
# @Stirling-Tools/frontend-reviewers - Frooodle, jbrunton96, ConnorYoh, reecebrowne, EthanHealy01
|
||||
# @Stirling-Tools/devops-reviewers - Frooodle, jbrunton96, ConnorYoh
|
||||
# @Stirling-Tools/all - all of the above
|
||||
#
|
||||
# Outside collaborators (need Write access to count as owners): @Ludy87 @balazs-szucs
|
||||
|
||||
# Default owners for everything
|
||||
* @Stirling-Tools/maintainers @Ludy87
|
||||
# All PRs must be approved by Frooodle or Ludy87
|
||||
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
|
||||
|
||||
# Backend
|
||||
/app/** @Stirling-Tools/backend-reviewers @Ludy87 @balazs-szucs
|
||||
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh @balazs-szucs
|
||||
|
||||
# V2 frontend
|
||||
/frontend/** @Stirling-Tools/frontend-reviewers @balazs-szucs
|
||||
/app/core/src/main/resources/static/** @Stirling-Tools/frontend-reviewers @Ludy87 @balazs-szucs
|
||||
#V2 frontend
|
||||
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @balazs-szucs
|
||||
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
|
||||
|
||||
# V2 docker
|
||||
/docker/backend/** @Stirling-Tools/devops-reviewers @Ludy87
|
||||
/docker/frontend/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87
|
||||
/docker/compose/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87
|
||||
#V2 docker
|
||||
/docker/backend/** @Frooodle @Ludy87 @DarioGii
|
||||
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
|
||||
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
|
||||
|
||||
# GHA (all users)
|
||||
/.github/** @Stirling-Tools/all @Ludy87 @balazs-szucs
|
||||
|
||||
#GHA (All users)
|
||||
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
|
||||
|
||||
+10
-10
@@ -92,12 +92,12 @@ frontend: &frontend
|
||||
# job on changes to any of these.
|
||||
tauri: &tauri
|
||||
- *ci
|
||||
- frontend/editor/src-tauri/**
|
||||
- frontend/editor/src/desktop/**
|
||||
- frontend/editor/tsconfig.desktop.vite.json
|
||||
- frontend/src-tauri/**
|
||||
- frontend/src/editor/desktop/**
|
||||
- frontend/tsconfig.desktop.vite.json
|
||||
- frontend/package.json
|
||||
- frontend/package-lock.json
|
||||
- frontend/editor/vite.config.ts
|
||||
- frontend/vite.config.ts
|
||||
- .github/workflows/tauri-build.yml
|
||||
- Taskfile.yml
|
||||
- .taskfiles/desktop.yml
|
||||
@@ -121,9 +121,9 @@ engine: &engine
|
||||
generated-models: &generated-models
|
||||
- *ci
|
||||
- *openapi
|
||||
- frontend/editor/scripts/generate-tool-api-types.mts
|
||||
- frontend/editor/src/core/types/toolApiTypes.ts
|
||||
- frontend/editor/src/core/types/toolIO.ts
|
||||
- frontend/scripts/generate-tool-api-types.mts
|
||||
- frontend/src/editor/core/types/toolApiTypes.ts
|
||||
- frontend/src/editor/core/types/toolIO.ts
|
||||
- engine/scripts/generate_tool_models.py
|
||||
- engine/src/stirling/models/tool_models.py
|
||||
- engine/src/stirling/models/tool_io.py
|
||||
@@ -135,7 +135,7 @@ licenses-frontend: &licenses-frontend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/editor/scripts/generate-licenses.js"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
|
||||
licenses-backend: &licenses-backend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
@@ -146,8 +146,8 @@ licenses-backend: &licenses-backend
|
||||
proprietary: &proprietary
|
||||
- *ci
|
||||
- app/proprietary/**
|
||||
- frontend/editor/src/proprietary/**
|
||||
- frontend/editor/src/core/tests/enterprise/**
|
||||
- frontend/src/editor/proprietary/**
|
||||
- frontend/src/editor/core/tests/enterprise/**
|
||||
- testing/compose/docker-compose-keycloak-oauth.yml
|
||||
- testing/compose/docker-compose-keycloak-saml.yml
|
||||
- testing/compose/keycloak-realm-oauth.json
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"LaserKaspar",
|
||||
"sbplat",
|
||||
"reecebrowne",
|
||||
"DarioGii",
|
||||
"ConnorYoh",
|
||||
"EthanHealy01",
|
||||
"jbrunton96",
|
||||
|
||||
+11
-3
@@ -73,6 +73,14 @@ updates:
|
||||
- "react-dom"
|
||||
- "@types/react"
|
||||
- "@types/react-dom"
|
||||
typescript-eslint:
|
||||
patterns:
|
||||
- "@typescript-eslint/*"
|
||||
- "typescript-eslint"
|
||||
eslint:
|
||||
patterns:
|
||||
- "eslint"
|
||||
- "@eslint/*"
|
||||
vite:
|
||||
patterns:
|
||||
- "vite"
|
||||
@@ -120,9 +128,9 @@ updates:
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directories:
|
||||
- /frontend/editor/src-tauri
|
||||
- /frontend/editor/src-tauri/thumbnail-handler
|
||||
- /frontend/editor/src-tauri/provisioner
|
||||
- /frontend/src-tauri
|
||||
- /frontend/src-tauri/thumbnail-handler
|
||||
- /frontend/src-tauri/provisioner
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
|
||||
@@ -51,7 +51,7 @@ labels:
|
||||
|
||||
- label: 'Translation'
|
||||
files:
|
||||
- 'frontend/editor/public/locales/[a-zA-Z]{2}-[a-zA-Z\-]{2,7}/translation.toml'
|
||||
- 'frontend/public/locales/[a-zA-Z]{2}-[a-zA-Z\-]{2,7}/translation.toml'
|
||||
- 'scripts/ignore_translation.toml'
|
||||
- 'scripts/remove_translation_keys.sh'
|
||||
- 'scripts/replace_translation_line.sh'
|
||||
@@ -70,8 +70,8 @@ labels:
|
||||
|
||||
- label: 'Tauri'
|
||||
files:
|
||||
- 'frontend/editor/src-tauri/**'
|
||||
- 'frontend/editor/src-tauri/.*'
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src-tauri/.*'
|
||||
|
||||
- label: 'engine'
|
||||
files:
|
||||
|
||||
@@ -13,7 +13,7 @@ Usage:
|
||||
"""
|
||||
|
||||
# Sample for Windows:
|
||||
# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-US/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml
|
||||
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-US/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
@@ -308,7 +308,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
report.append("## ❌ Overall Check Status: **_Failed_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-US/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/editor/public/locales/en-US/translation.toml)"
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-US/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-US/translation.toml)"
|
||||
)
|
||||
else:
|
||||
report.append("## ✅ Overall Check Status: **_Success_**")
|
||||
|
||||
@@ -14,9 +14,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
|
||||
ART_ROOT = Path(sys.argv[1])
|
||||
CONF = Path(
|
||||
sys.argv[2] if len(sys.argv) > 2 else "frontend/editor/src-tauri/tauri.conf.json"
|
||||
)
|
||||
CONF = Path(sys.argv[2] if len(sys.argv) > 2 else "frontend/src-tauri/tauri.conf.json")
|
||||
|
||||
|
||||
def load_pubkey():
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
else
|
||||
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
|
||||
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
|
||||
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
|
||||
if [ "$is_auth" = true ]; then
|
||||
should=true
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
@@ -54,6 +54,7 @@ jobs:
|
||||
github.event.comment.user.login == 'Ludy87' ||
|
||||
github.event.comment.user.login == 'balazs-szucs' ||
|
||||
github.event.comment.user.login == 'reecebrowne' ||
|
||||
github.event.comment.user.login == 'DarioGii' ||
|
||||
github.event.comment.user.login == 'EthanHealy01' ||
|
||||
github.event.comment.user.login == 'jbrunton96' ||
|
||||
github.event.comment.user.login == 'ConnorYoh'
|
||||
@@ -205,7 +206,7 @@ jobs:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
@@ -241,7 +242,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK ${{ matrix.jdk-version }}
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
@@ -294,7 +294,7 @@ jobs:
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: >
|
||||
npx tsx editor/scripts/report-flaky-tests.mts
|
||||
npx tsx scripts/report-flaky-tests.mts
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-saml.json"
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-feature.json"
|
||||
@@ -330,7 +330,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
cache-suffix: generated-models
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "frontend/editor/public/locales/*/translation.toml"
|
||||
- "frontend/public/locales/*/translation.toml"
|
||||
- ".github/scripts/check_language_toml.py"
|
||||
- ".github/workflows/check_toml.yml"
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
# Get changed files and filter for TOML translation files
|
||||
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/editor/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR"
|
||||
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR"
|
||||
# Check if any files were found
|
||||
if [ ! -s changed_files.txt ]; then
|
||||
echo "No TOML translation files changed in this PR"
|
||||
@@ -166,12 +166,12 @@ jobs:
|
||||
|
||||
// Determine reference file
|
||||
let referenceFilePath;
|
||||
if (changedFiles.includes("frontend/editor/public/locales/en-US/translation.toml")) {
|
||||
if (changedFiles.includes("frontend/public/locales/en-US/translation.toml")) {
|
||||
console.log("Using PR branch reference file.");
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: prRepoOwner,
|
||||
repo: prRepoName,
|
||||
path: "frontend/editor/public/locales/en-US/translation.toml",
|
||||
path: "frontend/public/locales/en-US/translation.toml",
|
||||
ref: branch,
|
||||
});
|
||||
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
path: "frontend/editor/public/locales/en-US/translation.toml",
|
||||
path: "frontend/public/locales/en-US/translation.toml",
|
||||
ref: "main",
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: 25
|
||||
distribution: temurin
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
# ::warning:: annotations + a job summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
|
||||
run: npx tsx scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
- name: Generate JaCoCo report from e2e:live .exec
|
||||
@@ -230,8 +230,8 @@ jobs:
|
||||
run: |
|
||||
echo "::group::Playwright output dirs"
|
||||
# Playwright anchors its default outputDir + HTML report to the
|
||||
# nearest package.json, which is frontend/ (frontend/editor has
|
||||
# none), so artifacts land under frontend/, not frontend/editor/.
|
||||
# nearest package.json, which is frontend/ (frontend has
|
||||
# none), so artifacts land under frontend/, not frontend/.
|
||||
ls -la frontend/playwright-report 2>/dev/null \
|
||||
|| echo "no playwright-report at frontend/"
|
||||
ls -la frontend/test-results 2>/dev/null \
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
# test-results/ holds the per-test trace.zip (with browser console
|
||||
# logs) + screenshots/video; playwright-report/ is the HTML report.
|
||||
# Both live under frontend/ (Playwright anchors them to the nearest
|
||||
# package.json, which is frontend/; frontend/editor has none).
|
||||
# package.json, which is frontend/; frontend has none).
|
||||
path: |
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
# ::warning:: annotations + a job summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
|
||||
run: npx tsx scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
- name: Upload Playwright report
|
||||
|
||||
@@ -113,31 +113,31 @@ jobs:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
working-directory: frontend
|
||||
run: |
|
||||
mkdir -p editor/src/assets
|
||||
npx --yes license-report --only=prod --output=json > editor/src/assets/3rdPartyLicenses.json
|
||||
mkdir -p src/assets
|
||||
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Postprocess with project script (BASE version)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
PR_IS_FORK: "true"
|
||||
run: |
|
||||
node base/frontend/editor/scripts/generate-licenses.js \
|
||||
--input frontend/editor/src/assets/3rdPartyLicenses.json
|
||||
node base/frontend/scripts/generate-licenses.js \
|
||||
--input frontend/src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Copy postprocessed artifacts back (fork PRs)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
run: |
|
||||
mkdir -p frontend/editor/src/assets
|
||||
if [ -f "base/frontend/editor/src/assets/3rdPartyLicenses.json" ]; then
|
||||
cp base/frontend/editor/src/assets/3rdPartyLicenses.json frontend/editor/src/assets/3rdPartyLicenses.json
|
||||
mkdir -p frontend/src/assets
|
||||
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
|
||||
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
|
||||
fi
|
||||
if [ -f "base/frontend/editor/src/assets/license-warnings.json" ]; then
|
||||
cp base/frontend/editor/src/assets/license-warnings.json frontend/editor/src/assets/license-warnings.json
|
||||
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
|
||||
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
|
||||
fi
|
||||
|
||||
- name: Check for license warnings
|
||||
run: |
|
||||
if [ -f "frontend/editor/src/assets/license-warnings.json" ]; then
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
|
||||
@@ -185,10 +185,10 @@ jobs:
|
||||
echo ""
|
||||
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
|
||||
echo "❌ **Failed** – incompatible or unknown licenses found."
|
||||
if [ -f "frontend/editor/src/assets/license-warnings.json" ]; then
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo ""
|
||||
echo "### Warnings"
|
||||
jq -r '.warnings[] | "- \(.message)"' frontend/editor/src/assets/license-warnings.json || true
|
||||
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
|
||||
fi
|
||||
else
|
||||
echo "✅ **Passed** – no license warnings detected."
|
||||
@@ -214,7 +214,7 @@ jobs:
|
||||
const fs = require('fs');
|
||||
let warningDetails = '';
|
||||
try {
|
||||
const warnings = JSON.parse(fs.readFileSync('frontend/editor/src/assets/license-warnings.json', 'utf8'));
|
||||
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
|
||||
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
|
||||
} catch (e) {
|
||||
warningDetails = 'Unable to read warning details';
|
||||
@@ -254,7 +254,7 @@ jobs:
|
||||
- name: Commit changes (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
git add frontend/editor/src/assets/3rdPartyLicenses.json
|
||||
git add frontend/src/assets/3rdPartyLicenses.json
|
||||
# Note: Do NOT commit license-warnings.json - it's only for PR review
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
@@ -272,7 +272,7 @@ jobs:
|
||||
|
||||
The following licenses may require review for corporate compatibility:
|
||||
|
||||
$(cat frontend/editor/src/assets/license-warnings.json | jq -r '.warnings[].message')
|
||||
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
|
||||
|
||||
Please review these licenses to ensure they are acceptable for your use case."
|
||||
fi
|
||||
@@ -345,7 +345,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
@@ -130,19 +130,19 @@ jobs:
|
||||
run: |
|
||||
python scripts/coverage-summary.py \
|
||||
--title "Frontend Vitest coverage" \
|
||||
--vitest frontend/editor/coverage/coverage-summary.json \
|
||||
--vitest frontend/coverage/coverage-summary.json \
|
||||
--github-step-summary
|
||||
- name: Upload vitest coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-coverage
|
||||
path: frontend/editor/coverage/
|
||||
path: frontend/coverage/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
- name: Upload frontend build artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-build
|
||||
path: frontend/editor/dist/
|
||||
path: frontend/dist/
|
||||
retention-days: 3
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
# before the second setup-java overwrites JAVA_HOME.
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
@@ -252,7 +252,7 @@ jobs:
|
||||
|
||||
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
@@ -441,7 +441,7 @@ jobs:
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
run: |
|
||||
cat > ./frontend/editor/src-tauri/tauri.windows.conf.json <<EOF
|
||||
cat > ./frontend/src-tauri/tauri.windows.conf.json <<EOF
|
||||
{
|
||||
"bundle": {
|
||||
"windows": {
|
||||
@@ -454,7 +454,7 @@ jobs:
|
||||
}
|
||||
EOF
|
||||
echo "Generated tauri.windows.conf.json (alias masked):"
|
||||
sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/editor/src-tauri/tauri.windows.conf.json
|
||||
sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/src-tauri/tauri.windows.conf.json
|
||||
|
||||
- name: Import release GPG signing key (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
@@ -475,7 +475,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -502,9 +502,10 @@ jobs:
|
||||
SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend/editor
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: ${{ matrix.args }}
|
||||
updaterJsonKeepUniversal: true
|
||||
|
||||
# Bundled libwayland conflicts with the host's on some distros (Fedora
|
||||
# Wayland: EGL_BAD_PARAMETER, blank window - #6878). Repack without it,
|
||||
@@ -521,7 +522,7 @@ jobs:
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AI=$(find "$PWD/frontend/editor/src-tauri/target" -name "*.AppImage" | head -1)
|
||||
AI=$(find "$PWD/frontend/src-tauri/target" -name "*.AppImage" | head -1)
|
||||
if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi
|
||||
chmod +x "$AI"
|
||||
WORK=$(mktemp -d)
|
||||
@@ -578,7 +579,7 @@ jobs:
|
||||
# inner exe before packing and the setup exe after, so verifying the setup
|
||||
# exe is the arm64 equivalent of the MSI + inner-exe check below.
|
||||
if ("${{ matrix.platform }}" -eq "windows-11-arm") {
|
||||
$setupExes = Get-ChildItem -Path "./frontend/editor/src-tauri/target" -Filter "*-setup.exe" -Recurse -File
|
||||
$setupExes = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*-setup.exe" -Recurse -File
|
||||
if ($setupExes.Count -eq 0) {
|
||||
Write-Host "[ERROR] No NSIS installer found under target/"
|
||||
exit 1
|
||||
@@ -598,7 +599,7 @@ jobs:
|
||||
$allSigned = $true
|
||||
|
||||
# Check MSI installer (outer wrapper - what users download)
|
||||
$msiFiles = Get-ChildItem -Path "./frontend/editor/src-tauri/target" -Filter "*.msi" -Recurse -File
|
||||
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
|
||||
if ($msiFiles.Count -eq 0) {
|
||||
Write-Host "[ERROR] No MSI found under target/"
|
||||
exit 1
|
||||
@@ -681,7 +682,7 @@ jobs:
|
||||
# Absolute dist path so the cd below can't break the copy targets.
|
||||
DIST="$GITHUB_WORKSPACE/dist"
|
||||
mkdir -p "$DIST"
|
||||
cd ./frontend/editor/src-tauri/target
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
echo "=== tauri bundle artifacts ==="
|
||||
find . -path "*/bundle/*" \( -name "*.msi" -o -name "*.deb" \
|
||||
@@ -742,7 +743,7 @@ jobs:
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/scripts/verify-updater-signatures.py
|
||||
frontend/editor/src-tauri/tauri.conf.json
|
||||
frontend/src-tauri/tauri.conf.json
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Download all Tauri artifacts
|
||||
@@ -891,7 +892,7 @@ jobs:
|
||||
run: |
|
||||
python3 -m pip install --quiet 'cryptography==44.0.0'
|
||||
python3 .github/scripts/verify-updater-signatures.py \
|
||||
./artifacts/tauri frontend/editor/src-tauri/tauri.conf.json
|
||||
./artifacts/tauri frontend/src-tauri/tauri.conf.json
|
||||
|
||||
# workflow_dispatch path requires platform=='all' so a single-platform
|
||||
# dispatch can't overwrite an existing release's full latest.json with a
|
||||
|
||||
@@ -50,13 +50,13 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for base image
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-base
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
@@ -103,20 +103,20 @@ jobs:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
- name: Generate tags for latest
|
||||
id: meta
|
||||
if: env.RUN_MAIN_APP == 'true'
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest-fat
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
@@ -217,7 +217,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for ultra-lite
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
|
||||
@@ -26,13 +26,13 @@ jobs:
|
||||
uses: imjasonh/setup-crane@31b88afe9de28ae0ffa220711af4b60be9435f6e # v0.4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
@@ -84,12 +84,12 @@ jobs:
|
||||
body: |
|
||||
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot].
|
||||
|
||||
Regenerates `frontend/editor/src/portal/generated/docsManifest.json`
|
||||
Regenerates `frontend/src/processor/proprietary/generated/docsManifest.json`
|
||||
from the Stirling docs repo via `npm run docs:sync`.
|
||||
labels: |
|
||||
Documentation
|
||||
github-actions
|
||||
Front End
|
||||
add-paths: frontend/editor/src/portal/generated/docsManifest.json
|
||||
add-paths: frontend/src/processor/proprietary/generated/docsManifest.json
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
- "app/proprietary/build.gradle"
|
||||
- "gradle/spotless.gradle"
|
||||
- "README.md"
|
||||
- "frontend/editor/public/locales/*/translation.toml"
|
||||
- "frontend/public/locales/*/translation.toml"
|
||||
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
|
||||
- "scripts/ignore_translation.toml"
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
|
||||
python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-US/translation.toml" --branch main
|
||||
|
||||
- name: Sort translation TOML files
|
||||
run: |
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
|
||||
- name: Commit translation files
|
||||
run: |
|
||||
git add frontend/editor/public/locales/*/translation.toml
|
||||
git add frontend/public/locales/*/translation.toml
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"
|
||||
|
||||
- name: Sync README.md
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:
|
||||
|
||||
#### **1. Synchronization of Translation Files**
|
||||
- Updated translation files (`frontend/editor/public/locales/*/translation.toml`) to reflect changes in the reference file `en-US/translation.toml`.
|
||||
- Updated translation files (`frontend/public/locales/*/translation.toml`) to reflect changes in the reference file `en-US/translation.toml`.
|
||||
- Ensured consistency and synchronization across all supported language files.
|
||||
- Highlighted any missing or incomplete translations.
|
||||
- **Format**: TOML
|
||||
@@ -135,5 +135,5 @@ jobs:
|
||||
sign-commits: true
|
||||
add-paths: |
|
||||
README.md
|
||||
frontend/editor/public/locales/*/translation.toml
|
||||
frontend/public/locales/*/translation.toml
|
||||
scripts/ignore_translation.toml
|
||||
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
- name: Cache Rust build
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
workspaces: frontend/editor/src-tauri
|
||||
workspaces: frontend/src-tauri
|
||||
# Stable key shared across workflows so the nightly warmer.
|
||||
# rust-cache still appends OS + rustc + Cargo.lock.
|
||||
shared-key: tauri-${{ matrix.name }}
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
|
||||
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
@@ -353,7 +353,7 @@ jobs:
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
run: |
|
||||
cat > ./frontend/editor/src-tauri/tauri.windows.conf.json <<EOF
|
||||
cat > ./frontend/src-tauri/tauri.windows.conf.json <<EOF
|
||||
{
|
||||
"bundle": {
|
||||
"windows": {
|
||||
@@ -386,7 +386,7 @@ jobs:
|
||||
|
||||
- name: Build Tauri app (signed)
|
||||
if: env.SIGN_BUNDLE == 'true'
|
||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -412,7 +412,7 @@ jobs:
|
||||
SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend/editor
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
|
||||
# AppImage runs in its own continue-on-error step below so its
|
||||
@@ -421,7 +421,7 @@ jobs:
|
||||
|
||||
- name: Build Tauri app (unsigned)
|
||||
if: env.SIGN_BUNDLE != 'true'
|
||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SIGN: "0"
|
||||
@@ -432,7 +432,7 @@ jobs:
|
||||
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend/editor
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
|
||||
# AppImage runs in its own continue-on-error step below so its
|
||||
@@ -447,7 +447,7 @@ jobs:
|
||||
- name: Build Tauri app (Linux AppImage)
|
||||
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
|
||||
continue-on-error: true
|
||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
|
||||
@@ -460,7 +460,7 @@ jobs:
|
||||
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend/editor
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: --bundles appimage
|
||||
|
||||
@@ -472,7 +472,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AI=$(find "$PWD/frontend/editor/src-tauri/target" -name "*.AppImage" | head -1)
|
||||
AI=$(find "$PWD/frontend/src-tauri/target" -name "*.AppImage" | head -1)
|
||||
if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi
|
||||
chmod +x "$AI"
|
||||
WORK=$(mktemp -d)
|
||||
@@ -509,7 +509,7 @@ jobs:
|
||||
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
|
||||
run: |
|
||||
echo "🔍 Verifying notarization status..."
|
||||
cd ./frontend/editor/src-tauri/target
|
||||
cd ./frontend/src-tauri/target
|
||||
DMG_FILE=$(find . -name "*.dmg" | head -1)
|
||||
if [ -n "$DMG_FILE" ]; then
|
||||
echo "Found DMG: $DMG_FILE"
|
||||
@@ -526,7 +526,7 @@ jobs:
|
||||
# Absolute dist path so the cd below can't break the copy targets.
|
||||
DIST="$GITHUB_WORKSPACE/dist"
|
||||
mkdir -p "$DIST"
|
||||
cd ./frontend/editor/src-tauri/target
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
# Find and rename artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
@@ -638,7 +638,7 @@ jobs:
|
||||
- name: Verify build artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
cd ./frontend/editor/src-tauri/target
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
# Check for expected artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ] || [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
|
||||
@@ -669,7 +669,7 @@ jobs:
|
||||
- name: Test artifact sizes
|
||||
shell: bash
|
||||
run: |
|
||||
cd ./frontend/editor/src-tauri/target
|
||||
cd ./frontend/src-tauri/target
|
||||
echo "Artifact sizes for ${{ matrix.name }}:"
|
||||
find . -name "*.exe" -o -name "*.dmg" -o -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.msi" | while read file; do
|
||||
if [ -f "$file" ]; then
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
echo "Disk space after cleanup:" && df -h
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
+5
-5
@@ -24,7 +24,7 @@ configs/
|
||||
watchedFolders/
|
||||
# The rule above targets the app's runtime watched-folders working dir, but it
|
||||
# also matches this frontend source component dir; keep the source tracked.
|
||||
!frontend/editor/src/proprietary/components/watchedFolders/
|
||||
!frontend/src/editor/proprietary/components/watchedFolders/
|
||||
clientWebUI/
|
||||
# Scratch dir used by local fixture-regeneration runs (see
|
||||
# app/proprietary/src/test/resources/db-migration-fixtures/README.md).
|
||||
@@ -174,7 +174,7 @@ app/proprietary/build
|
||||
common/build
|
||||
proprietary/build
|
||||
stirling-pdf/build
|
||||
frontend/editor/src-tauri/provisioner/target
|
||||
frontend/src-tauri/provisioner/target
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -225,7 +225,7 @@ out/
|
||||
*.asc
|
||||
|
||||
# Allow test fixture certificates (synthetic, no real credentials)
|
||||
!frontend/editor/src/core/tests/test-fixtures/certs/**
|
||||
!frontend/src/editor/core/tests/test-fixtures/certs/**
|
||||
|
||||
# SSH Keys
|
||||
*.pub
|
||||
@@ -267,7 +267,7 @@ node_modules/
|
||||
*compact*.json
|
||||
test_batch.json
|
||||
*.backup.*.json
|
||||
frontend/editor/public/locales/*/translation.backup*.json
|
||||
frontend/public/locales/*/translation.backup*.json
|
||||
|
||||
# Development/build artifacts
|
||||
.gradle-cache/
|
||||
@@ -292,4 +292,4 @@ docs/type3/signatures/
|
||||
*.playwright-mcp.png
|
||||
|
||||
# Local screenshot artifacts from *-screenshots.spec.ts
|
||||
frontend/editor/screenshots/
|
||||
frontend/screenshots/
|
||||
|
||||
+2
-2
@@ -15,10 +15,10 @@ testing/compose/validate-mcp-test.sh:curl-auth-header:92
|
||||
testing/compose/validate-mcp-test.sh:curl-auth-header:116
|
||||
|
||||
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
|
||||
frontend/editor/src/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5
|
||||
frontend/src/editor/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5
|
||||
|
||||
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
|
||||
frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api-key:30
|
||||
frontend/src/processor/proprietary/components/docs/GettingStartedSection.tsx:generic-api-key:30
|
||||
|
||||
# False positive: generic-api-key matches the Java type name "X509Certificate"
|
||||
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"ignoredFiles": [
|
||||
"frontend/editor/src-tauri/icons/icon.png"
|
||||
"frontend/src-tauri/icons/icon.png"
|
||||
]
|
||||
}
|
||||
|
||||
+6
-16
@@ -39,7 +39,6 @@ tasks:
|
||||
provisioner:
|
||||
desc: "Build installer provisioner"
|
||||
platforms: [windows]
|
||||
dir: editor
|
||||
cmds:
|
||||
- node scripts/build-provisioner.mjs
|
||||
|
||||
@@ -47,55 +46,48 @@ tasks:
|
||||
desc: "Start Tauri desktop dev mode"
|
||||
deps: [prepare]
|
||||
ignore_error: true
|
||||
dir: editor
|
||||
cmds:
|
||||
- npx tauri dev --no-watch
|
||||
|
||||
build:
|
||||
desc: "Build Tauri desktop app (production)"
|
||||
deps: [prepare]
|
||||
dir: editor
|
||||
cmds:
|
||||
- npx tauri build
|
||||
|
||||
build:dev:
|
||||
desc: "Build Tauri desktop app (dev, no bundling)"
|
||||
deps: [prepare]
|
||||
dir: editor
|
||||
cmds:
|
||||
- npx tauri build --no-bundle
|
||||
|
||||
build:dev:mac:
|
||||
desc: "Build Tauri desktop .app bundle (macOS)"
|
||||
deps: [prepare]
|
||||
dir: editor
|
||||
cmds:
|
||||
- npx tauri build --bundles app --config '{"bundle":{"createUpdaterArtifacts":false}}'
|
||||
|
||||
build:dev:windows:
|
||||
desc: "Build Tauri desktop NSIS installer (Windows)"
|
||||
deps: [prepare]
|
||||
dir: editor
|
||||
cmds:
|
||||
- npx tauri build --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":false}}'
|
||||
|
||||
build:dev:linux:
|
||||
desc: "Build Tauri desktop AppImage (Linux)"
|
||||
deps: [prepare]
|
||||
dir: editor
|
||||
cmds:
|
||||
- npx tauri build --bundles appimage --config '{"bundle":{"createUpdaterArtifacts":false}}'
|
||||
|
||||
test:
|
||||
desc: "Run Tauri/Cargo tests"
|
||||
deps: [prepare]
|
||||
dir: editor/src-tauri
|
||||
dir: src-tauri
|
||||
cmds:
|
||||
- cargo test
|
||||
|
||||
clean:
|
||||
desc: "Clean Tauri/Cargo build artifacts"
|
||||
dir: editor
|
||||
cmds:
|
||||
- task: jlink:clean
|
||||
- cd src-tauri && cargo clean
|
||||
@@ -117,7 +109,6 @@ tasks:
|
||||
|
||||
jlink:verify:
|
||||
desc: "Fail the build if the bundled JRE is older than the app JAR requires"
|
||||
dir: editor
|
||||
env:
|
||||
REQUIRED_JAVA: "{{.REQUIRED_JAVA}}"
|
||||
cmds:
|
||||
@@ -135,15 +126,15 @@ tasks:
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew bootJar --no-daemon -PjpdfiumPlatforms={{.JPDFIUM_PLATFORMS}}
|
||||
platforms: [linux, darwin]
|
||||
- mkdir -p frontend/editor/src-tauri/libs
|
||||
- cp app/core/build/libs/stirling-pdf-*.jar frontend/editor/src-tauri/libs/
|
||||
- mkdir -p frontend/src-tauri/libs
|
||||
- cp app/core/build/libs/stirling-pdf-*.jar frontend/src-tauri/libs/
|
||||
status:
|
||||
- test -f frontend/editor/src-tauri/libs/stirling-pdf-*.jar
|
||||
- test -f frontend/src-tauri/libs/stirling-pdf-*.jar
|
||||
|
||||
jlink:runtime:
|
||||
desc: "Create custom JRE with jlink"
|
||||
deps: [jlink:jar]
|
||||
dir: editor/src-tauri
|
||||
dir: src-tauri
|
||||
cmds:
|
||||
- rm -rf runtime/jre
|
||||
- mkdir -p runtime
|
||||
@@ -202,7 +193,7 @@ tasks:
|
||||
|
||||
jlink:clean:
|
||||
desc: "Remove JLink runtime and bundled JARs"
|
||||
dir: editor/src-tauri
|
||||
dir: src-tauri
|
||||
cmds:
|
||||
- rm -rf libs runtime
|
||||
|
||||
@@ -215,7 +206,6 @@ tasks:
|
||||
desc: "Create universal (arm64+x86_64) JRE for the macOS Tauri build"
|
||||
deps: [jlink:jar]
|
||||
platforms: [darwin]
|
||||
dir: editor
|
||||
env:
|
||||
JLINK_MODULES: "{{.JLINK_MODULES}}"
|
||||
OUTPUT_DIR: src-tauri/runtime/jre
|
||||
|
||||
+5
-5
@@ -3,14 +3,14 @@ version: '3'
|
||||
tasks:
|
||||
install:
|
||||
desc: "Install Playwright browsers"
|
||||
dir: frontend/editor
|
||||
dir: frontend
|
||||
deps: [ ':frontend:install' ]
|
||||
cmds:
|
||||
- npx playwright install {{.CLI_ARGS}} --with-deps
|
||||
|
||||
stubbed:
|
||||
desc: "Run stubbed E2E tests"
|
||||
dir: frontend/editor
|
||||
dir: frontend
|
||||
deps: [ ':frontend:prepare' ]
|
||||
cmds:
|
||||
- npx playwright test --project=stubbed {{.CLI_ARGS}}
|
||||
@@ -82,7 +82,7 @@ tasks:
|
||||
live:runner:
|
||||
internal: true
|
||||
deps: [ ':frontend:prepare' ]
|
||||
dir: frontend/editor
|
||||
dir: frontend
|
||||
vars:
|
||||
BASE_DIR: '{{.ROOT_DIR}}/.test-state/playwright'
|
||||
cmds:
|
||||
@@ -151,14 +151,14 @@ tasks:
|
||||
up first with:
|
||||
task e2e:oauth:up
|
||||
task e2e:saml:up
|
||||
dir: frontend/editor
|
||||
dir: frontend
|
||||
deps: [ ':frontend:prepare' ]
|
||||
cmds:
|
||||
- npx playwright test --project=enterprise {{.CLI_ARGS}}
|
||||
|
||||
cross-browser:
|
||||
desc: "Run stubbed E2E tests on Chromium, Firefox, and WebKit"
|
||||
dir: frontend/editor
|
||||
dir: frontend
|
||||
deps: [ ':frontend:prepare' ]
|
||||
cmds:
|
||||
- npx playwright test --project=stubbed --project=stubbed-firefox --project=stubbed-webkit {{.CLI_ARGS}}
|
||||
|
||||
+62
-53
@@ -3,7 +3,7 @@ version: '3'
|
||||
# Tasks operate from the workspace root (frontend/). Editor commands pass
|
||||
# `editor` as the vite project root (positional after `build` / before the
|
||||
# mode flag) or use `--project editor/...` for tsc — so the editor lives
|
||||
# under frontend/editor/ without each task needing a cd.
|
||||
# under frontend/ without each task needing a cd.
|
||||
|
||||
tasks:
|
||||
install:
|
||||
@@ -26,34 +26,34 @@ tasks:
|
||||
vars:
|
||||
MODE: '{{.MODE | default ""}}'
|
||||
cmds:
|
||||
- npx tsx editor/scripts/setup-env.mts{{if .MODE}} --{{.MODE}}{{end}}
|
||||
- npx tsx scripts/setup-env.mts{{if .MODE}} --{{.MODE}}{{end}}
|
||||
sources:
|
||||
- editor/scripts/setup-env.mts
|
||||
- scripts/setup-env.mts
|
||||
generates:
|
||||
- editor/.env.local
|
||||
- editor/.env{{if .MODE}}.{{.MODE}}{{end}}.local
|
||||
- .env.local
|
||||
- .env{{if .MODE}}.{{.MODE}}{{end}}.local
|
||||
|
||||
prepare:icons:
|
||||
internal: true
|
||||
run: once
|
||||
deps: [install]
|
||||
cmds:
|
||||
- node editor/scripts/generate-icons.js
|
||||
- node scripts/generate-icons.js
|
||||
|
||||
prepare:og:
|
||||
internal: true
|
||||
run: when_changed
|
||||
desc: "Regenerate OG/social-preview metadata from the tool registry"
|
||||
cmds:
|
||||
- node editor/scripts/generate-og-metadata.mjs
|
||||
- node scripts/generate-og-metadata.mjs
|
||||
sources:
|
||||
- editor/src/core/types/toolId.ts
|
||||
- editor/src/core/utils/urlMapping.ts
|
||||
- editor/src/core/data/useTranslatedToolRegistry.tsx
|
||||
- editor/public/og_images/*.png
|
||||
- src/editor/core/types/toolId.ts
|
||||
- src/editor/core/utils/urlMapping.ts
|
||||
- src/editor/core/data/useTranslatedToolRegistry.tsx
|
||||
- public/og_images/*.png
|
||||
generates:
|
||||
- editor/src/core/data/ogImageMap.json
|
||||
- editor/public/og-metadata.json
|
||||
- src/editor/core/data/ogImageMap.json
|
||||
- public/og-metadata.json
|
||||
|
||||
prepare:
|
||||
desc: "Set up dev environment"
|
||||
@@ -88,7 +88,7 @@ tasks:
|
||||
sh: >-
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
|
||||
cmds:
|
||||
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
|
||||
- npx vite --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
|
||||
|
||||
dev:
|
||||
desc: "Start frontend dev server"
|
||||
@@ -143,13 +143,13 @@ tasks:
|
||||
desc: "Production build (default mode)"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vite build editor
|
||||
- npx vite build
|
||||
|
||||
build:core:
|
||||
desc: "Build for core mode"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vite build editor --mode core
|
||||
- npx vite build --mode core
|
||||
|
||||
build:proprietary:
|
||||
desc: "Build for proprietary mode"
|
||||
@@ -157,7 +157,7 @@ tasks:
|
||||
vars:
|
||||
PREVIEW: '{{.PREVIEW | default ""}}'
|
||||
cmds:
|
||||
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary'
|
||||
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build --mode proprietary'
|
||||
|
||||
build:saas:
|
||||
desc: "Build for SaaS mode"
|
||||
@@ -165,7 +165,7 @@ tasks:
|
||||
- task: prepare
|
||||
vars: { MODE: saas }
|
||||
cmds:
|
||||
- npx vite build editor --mode saas
|
||||
- npx vite build --mode saas
|
||||
|
||||
build:desktop:
|
||||
desc: "Build for desktop mode"
|
||||
@@ -173,13 +173,13 @@ tasks:
|
||||
- task: prepare
|
||||
vars: { MODE: desktop }
|
||||
cmds:
|
||||
- npx vite build editor --mode desktop
|
||||
- npx vite build --mode desktop
|
||||
|
||||
build:prototypes:
|
||||
desc: "Build for prototypes mode"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vite build editor --mode prototypes
|
||||
- npx vite build --mode prototypes
|
||||
|
||||
|
||||
storybook:
|
||||
@@ -260,7 +260,8 @@ tasks:
|
||||
desc: "Run linting"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- task: lint:oxlint
|
||||
- task: lint:eslint
|
||||
- task: lint:dpdm
|
||||
- task: lint:colors
|
||||
- task: lint:css
|
||||
|
||||
@@ -271,35 +272,43 @@ tasks:
|
||||
# Covers the whole editor tree, including the portal/processor layer and
|
||||
# public/css. Vendored CSS and build output are excluded via ignoreFiles
|
||||
# in stylelint.config.mjs.
|
||||
- npx stylelint "editor/**/*.css"
|
||||
- npx stylelint "src/**/*.css"
|
||||
|
||||
lint:colors:
|
||||
desc: "Enforce theme tokens — no hardcoded colours or raw primitives in components"
|
||||
aliases: [lint:colours]
|
||||
deps: [install]
|
||||
cmds:
|
||||
- node editor/scripts/lint/theme-lint.mjs
|
||||
- node editor/scripts/lint/theme-lint.mjs css-colors
|
||||
- node editor/scripts/lint/theme-lint.mjs code-colors
|
||||
- node editor/scripts/lint/theme-lint.mjs no-primitives
|
||||
- node scripts/lint/theme-lint.mjs
|
||||
- node scripts/lint/theme-lint.mjs css-colors
|
||||
- node scripts/lint/theme-lint.mjs code-colors
|
||||
- node scripts/lint/theme-lint.mjs no-primitives
|
||||
|
||||
contrast:
|
||||
desc: "Report low-contrast theme token pairs (warning only, never blocks)"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- node editor/scripts/lint/theme-lint.mjs contrast
|
||||
- node scripts/lint/theme-lint.mjs contrast
|
||||
|
||||
lint:oxlint:
|
||||
desc: "Run oxlint linting"
|
||||
lint:eslint:
|
||||
desc: "Run ESLint linting"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx oxlint --config oxlint.config.ts --max-warnings=0
|
||||
- npx eslint --max-warnings=0
|
||||
|
||||
lint:dpdm:
|
||||
desc: "Run circular import linting"
|
||||
deps: [install]
|
||||
cmds:
|
||||
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
|
||||
# shell-agnostic. Covers the whole editor tree, including the portal layer.
|
||||
- npx dpdm "src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
|
||||
|
||||
lint:fix:
|
||||
desc: "Auto-fix lint issues"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx oxlint --config oxlint.config.ts --fix
|
||||
- npx eslint --fix
|
||||
|
||||
format:
|
||||
desc: "Auto-fix code formatting"
|
||||
@@ -334,14 +343,14 @@ tasks:
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/core/tsconfig.json }
|
||||
vars: { PROJECT: src/editor/core/tsconfig.json }
|
||||
|
||||
typecheck:proprietary:
|
||||
desc: "Typecheck proprietary build variant"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/proprietary/tsconfig.json }
|
||||
vars: { PROJECT: src/editor/proprietary/tsconfig.json }
|
||||
|
||||
typecheck:saas:
|
||||
desc: "Typecheck SaaS build variant"
|
||||
@@ -350,7 +359,7 @@ tasks:
|
||||
vars: { MODE: saas }
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/saas/tsconfig.json }
|
||||
vars: { PROJECT: src/editor/saas/tsconfig.json }
|
||||
|
||||
typecheck:desktop:
|
||||
desc: "Typecheck desktop build variant"
|
||||
@@ -359,35 +368,35 @@ tasks:
|
||||
vars: { MODE: desktop }
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/desktop/tsconfig.json }
|
||||
vars: { PROJECT: src/editor/desktop/tsconfig.json }
|
||||
|
||||
typecheck:cloud:
|
||||
desc: "Typecheck cloud shared layer (standalone)"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/cloud/tsconfig.json }
|
||||
vars: { PROJECT: src/editor/cloud/tsconfig.json }
|
||||
|
||||
typecheck:scripts:
|
||||
desc: "Typecheck scripts"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/scripts/tsconfig.json }
|
||||
vars: { PROJECT: scripts/tsconfig.json }
|
||||
|
||||
typecheck:prototypes:
|
||||
desc: "Typecheck prototypes build variant"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/prototypes/tsconfig.json }
|
||||
vars: { PROJECT: src/editor/prototypes/tsconfig.json }
|
||||
|
||||
typecheck:portal:
|
||||
desc: "Typecheck developer portal build variant"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/portal/tsconfig.json }
|
||||
vars: { PROJECT: src/processor/proprietary/tsconfig.json }
|
||||
|
||||
typecheck:storybook:
|
||||
desc: "Typecheck Storybook config and stories"
|
||||
@@ -424,7 +433,7 @@ tasks:
|
||||
og:check:
|
||||
desc: "Fail if committed OG/social-preview metadata is out of date"
|
||||
cmds:
|
||||
- node editor/scripts/generate-og-metadata.mjs --check
|
||||
- node scripts/generate-og-metadata.mjs --check
|
||||
|
||||
check:all:
|
||||
desc: "Full CI quality gate"
|
||||
@@ -452,13 +461,13 @@ tasks:
|
||||
desc: "Run editor tests"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vitest run --root editor
|
||||
- npx vitest run --root .
|
||||
|
||||
test:watch:
|
||||
desc: "Run tests in watch mode"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vitest --watch --root editor
|
||||
- npx vitest --watch --root .
|
||||
|
||||
test:coverage:
|
||||
desc: "Run tests with coverage (one-shot; CI-friendly)."
|
||||
@@ -469,12 +478,12 @@ tasks:
|
||||
# coverage-summary.py helper consumes; html/text are kept for humans.
|
||||
#
|
||||
# reportsDirectory is pinned to ./coverage relative to vitest's root
|
||||
# (--root editor), so output lands at frontend/editor/coverage/. The
|
||||
# (--root .), so output lands at frontend/coverage/. The
|
||||
# CI upload step reads from that path. An earlier attempt with
|
||||
# `./editor/coverage` double-nested into frontend/editor/editor/coverage;
|
||||
# `./editor/coverage` double-nested into frontend/coverage;
|
||||
# pinning future-proofs against vitest changing the default.
|
||||
- >
|
||||
npx vitest run --root editor --coverage
|
||||
npx vitest run --root . --coverage
|
||||
--coverage.provider=v8
|
||||
--coverage.reporter=text-summary
|
||||
--coverage.reporter=json-summary
|
||||
@@ -489,25 +498,25 @@ tasks:
|
||||
desc: "Generate tool API types from the Java OpenAPI spec"
|
||||
deps: [install, ":backend:swagger"]
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts
|
||||
- npx tsx scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output src/editor/core/types/toolApiTypes.ts --io-output src/editor/core/types/toolIO.ts
|
||||
sources:
|
||||
- editor/scripts/generate-tool-api-types.mts
|
||||
- scripts/generate-tool-api-types.mts
|
||||
- ../SwaggerDoc.json
|
||||
generates:
|
||||
- editor/src/core/types/toolApiTypes.ts
|
||||
- editor/src/core/types/toolIO.ts
|
||||
- src/editor/core/types/toolApiTypes.ts
|
||||
- src/editor/core/types/toolIO.ts
|
||||
|
||||
tool-models:check:
|
||||
desc: "Fail if committed tool API types are out of date"
|
||||
deps: [install, ":backend:swagger"]
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts --check
|
||||
- npx tsx scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output src/editor/core/types/toolApiTypes.ts --io-output src/editor/core/types/toolIO.ts --check
|
||||
|
||||
licenses:generate:
|
||||
desc: "Generate frontend license report"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- node editor/scripts/generate-licenses.js
|
||||
- node scripts/generate-licenses.js
|
||||
|
||||
# ============================================================
|
||||
# Clean
|
||||
@@ -516,7 +525,7 @@ tasks:
|
||||
clean:
|
||||
desc: "Clean build artifacts and caches"
|
||||
cmds:
|
||||
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist
|
||||
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, dist, dist
|
||||
platforms: [windows]
|
||||
- cmd: rm -rf node_modules/.vite editor/dist dist
|
||||
- cmd: rm -rf node_modules/.vite dist dist
|
||||
platforms: [linux, darwin]
|
||||
|
||||
@@ -21,7 +21,7 @@ vars:
|
||||
':(exclude).devcontainer/*'
|
||||
':(exclude)app/core/src/main/resources/*'
|
||||
':(exclude)app/proprietary/src/main/resources/*'
|
||||
':(exclude)frontend/editor/public/vendor/*'
|
||||
':(exclude)frontend/public/vendor/*'
|
||||
':(exclude)*Dockerfile*'
|
||||
':(exclude)*pdfjs*'
|
||||
':(exclude)*thirdParty*'
|
||||
@@ -39,7 +39,7 @@ vars:
|
||||
':(exclude)*.min.*'
|
||||
':(exclude)*diff.js'
|
||||
':(exclude).github/workflows/*'
|
||||
LOCALE_TOML: 'frontend/editor/public/locales/*/translation.toml'
|
||||
LOCALE_TOML: 'frontend/public/locales/*/translation.toml'
|
||||
|
||||
# gitleaks is pinned + checksum-verified by scripts/pre-commit/install_gitleaks.py,
|
||||
# which owns the version and caches the binary here.
|
||||
|
||||
Vendored
+1
-1
@@ -19,6 +19,6 @@
|
||||
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
|
||||
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
|
||||
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
|
||||
"oxc.oxc-vscode", // Oxc (oxlint) extension for JavaScript/TypeScript linting
|
||||
"dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting
|
||||
]
|
||||
}
|
||||
|
||||
+6
-6
@@ -10,14 +10,14 @@ When adding tools, follow this systematic approach using the established pattern
|
||||
|
||||
Create these files in the correct directories:
|
||||
```
|
||||
frontend/editor/src/hooks/tools/[toolName]/
|
||||
frontend/src/hooks/tools/[toolName]/
|
||||
├── use[ToolName]Parameters.ts # Parameter definitions and validation
|
||||
└── use[ToolName]Operation.ts # Tool operation logic using useToolOperation
|
||||
|
||||
frontend/editor/src/components/tools/[toolName]/
|
||||
frontend/src/components/tools/[toolName]/
|
||||
└── [ToolName]Settings.tsx # Settings UI component (if needed)
|
||||
|
||||
frontend/editor/src/tools/
|
||||
frontend/src/tools/
|
||||
└── [ToolName].tsx # Main tool component
|
||||
```
|
||||
|
||||
@@ -128,7 +128,7 @@ export default [ToolName] as ToolComponent;
|
||||
## 3. Register Tool in System
|
||||
Update these files to register your new tool:
|
||||
|
||||
**Tool Registry** (`frontend/editor/src/data/useTranslatedToolRegistry.tsx`):
|
||||
**Tool Registry** (`frontend/src/data/useTranslatedToolRegistry.tsx`):
|
||||
1. Add imports at the top:
|
||||
```typescript
|
||||
import [ToolName] from "../tools/[ToolName]";
|
||||
@@ -155,7 +155,7 @@ import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Setting
|
||||
## 4. Add Tooltips (Optional but Recommended)
|
||||
Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:**
|
||||
|
||||
**Tooltip Hook** (`frontend/editor/src/components/tooltips/use[ToolName]Tips.ts`):
|
||||
**Tooltip Hook** (`frontend/src/components/tooltips/use[ToolName]Tips.ts`):
|
||||
```typescript
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
@@ -202,7 +202,7 @@ const [ToolName] = (props: BaseToolProps) => {
|
||||
## 5. Add Translations
|
||||
Update translation files. **Important: Only update `en-US` files** - other languages are handled separately.
|
||||
|
||||
**File to update:** `frontend/editor/public/locales/en-US/translation.toml`
|
||||
**File to update:** `frontend/public/locales/en-US/translation.toml`
|
||||
|
||||
**Required Translation Keys**:
|
||||
```toml
|
||||
|
||||
@@ -139,10 +139,10 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
|
||||
|
||||
#### Environment Variables
|
||||
- All `VITE_*` variables must be declared in the appropriate committed env file:
|
||||
- `frontend/editor/.env` — core and shared vars (base, loaded in every mode)
|
||||
- `frontend/editor/.env.proprietary` — proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of `.env` in proprietary mode)
|
||||
- `frontend/editor/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode)
|
||||
- `frontend/editor/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode)
|
||||
- `frontend/.env` — core and shared vars (base, loaded in every mode)
|
||||
- `frontend/.env.proprietary` — proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of `.env` in proprietary mode)
|
||||
- `frontend/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode)
|
||||
- `frontend/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode)
|
||||
- These files are committed to Git and must not contain private keys
|
||||
- Local overrides (API keys, machine-specific settings) go in uncommitted sibling `.env.local` / `.env.saas.local` / `.env.desktop.local` files — Vite automatically layers them on top
|
||||
- Never use `|| 'hardcoded-fallback'` inline — put defaults in the committed env files
|
||||
@@ -153,9 +153,9 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
|
||||
#### Import Paths - CRITICAL
|
||||
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
|
||||
|
||||
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
|
||||
For a broader explanation of the frontend layering and override architecture, read @frontend/DeveloperGuide.md
|
||||
|
||||
Before touching colours or theming (tokens, dark mode, accent colours), read @frontend/editor/src/core/theme/README.md — it explains the palette/`--c-*` token system and the rule that literal colours live only in `primitives.css`.
|
||||
Before touching colours or theming (tokens, dark mode, accent colours), read @frontend/src/editor/core/theme/README.md — it explains the palette/`--c-*` token system and the rule that literal colours live only in `primitives.css`.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
@@ -192,7 +192,7 @@ What goes where:
|
||||
- **saas** — web-only: Supabase web auth, AuthCallback, avatar canvas, `window.location`.
|
||||
- **desktop** — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing.
|
||||
|
||||
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (all enforced by the linter). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
|
||||
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
|
||||
|
||||
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
|
||||
|
||||
@@ -270,7 +270,7 @@ Frontend designed for **stateful document processing**:
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `frontend/editor/src/core/contexts/FileContext.tsx`
|
||||
**Location**: `frontend/src/editor/core/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
@@ -295,7 +295,7 @@ Without cleanup: browser crashes with memory leaks.
|
||||
|
||||
**Architecture**: Modular hook-based system with clear separation of concerns:
|
||||
|
||||
- **useToolOperation** (`frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- **useToolOperation** (`frontend/src/editor/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- Coordinates all tool operations with consistent interface
|
||||
- Integrates with FileContext for operation tracking
|
||||
- Handles validation, error handling, and UI state management
|
||||
@@ -383,7 +383,7 @@ return useToolOperation({
|
||||
### Frontend Directory Structure
|
||||
The frontend is organized with a clear separation of concerns:
|
||||
|
||||
- **`frontend/editor/src/core/`**: Main application code (shared, production-ready components)
|
||||
- **`frontend/src/editor/core/`**: Main application code (shared, production-ready components)
|
||||
- **`core/components/`**: React components organized by feature
|
||||
- `core/components/tools/`: Individual PDF tool implementations
|
||||
- `core/components/viewer/`: PDF viewer components
|
||||
@@ -401,17 +401,17 @@ The frontend is organized with a clear separation of concerns:
|
||||
- **`core/data/`**: Static data (tool taxonomy, etc.)
|
||||
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
|
||||
|
||||
- **`frontend/editor/src/desktop/`**: Desktop-specific (Tauri) code
|
||||
- **`frontend/editor/src/proprietary/`**: Proprietary/licensed features
|
||||
- **`frontend/editor/src-tauri/`**: Tauri (Rust) native desktop application code
|
||||
- **`frontend/editor/public/`**: Static assets served directly
|
||||
- **`frontend/src/editor/desktop/`**: Desktop-specific (Tauri) code
|
||||
- **`frontend/src/editor/proprietary/`**: Proprietary/licensed features
|
||||
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
|
||||
- **`frontend/public/`**: Static assets served directly
|
||||
- `public/locales/`: Translation JSON files
|
||||
|
||||
### Component Architecture
|
||||
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/editor/public/` (modern)
|
||||
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||
- **Internationalization**:
|
||||
- Backend: `messages_*.properties` files
|
||||
- Frontend: JSON files in `frontend/editor/public/locales/` (converted from .properties)
|
||||
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
|
||||
- Conversion Script: `scripts/convert_properties_to_json.py`
|
||||
|
||||
### Configuration Modes
|
||||
@@ -436,7 +436,7 @@ The frontend is organized with a clear separation of concerns:
|
||||
4. **Code Style**: Spotless enforces Google Java Format automatically (`task backend:format`)
|
||||
5. **Translations**:
|
||||
- Backend: Use helper scripts in `/scripts` for multi-language updates
|
||||
- Frontend: Update JSON files in `frontend/editor/public/locales/` or use conversion script
|
||||
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
|
||||
6. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
|
||||
|
||||
## Frontend Architecture Status
|
||||
@@ -454,7 +454,7 @@ The frontend is organized with a clear separation of concerns:
|
||||
## Translation Rules
|
||||
|
||||
- **CRITICAL**: Always update translations in `en-US` only - all other languages (including `en-GB`) are handled separately
|
||||
- Translation files are located in `frontend/editor/public/locales/`
|
||||
- Translation files are located in `frontend/public/locales/`
|
||||
- After changing any translation file, run `task pre-commit:fix`
|
||||
|
||||
## Important Notes
|
||||
|
||||
+2
-2
@@ -158,7 +158,7 @@ Stirling-PDF/
|
||||
│ │ │ └── locales/ # Internationalization files (JSON)
|
||||
│ │ └── vite.config.ts # Vite configuration
|
||||
│ ├── package.json # Shared workspace dependencies
|
||||
│ └── oxlint.config.ts # Shared lint config
|
||||
│ └── eslint.config.mjs # Shared lint config
|
||||
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
|
||||
├── docs/ # Documentation files
|
||||
├── exampleYmlFiles/ # Example YAML configuration files
|
||||
@@ -458,7 +458,7 @@ For Stirling 2.0, new features are built as React components:
|
||||
|
||||
1. **Create the React Component:**
|
||||
```typescript
|
||||
// frontend/editor/src/tools/NewTool.tsx
|
||||
// frontend/src/tools/NewTool.tsx
|
||||
import { useState } from 'react';
|
||||
import { Button, FileInput, Container } from '@mantine/core';
|
||||
|
||||
|
||||
@@ -10,20 +10,18 @@ if that directory exists, is licensed under the license defined in "app/propriet
|
||||
if that directory exists, is licensed under the license defined in "app/saas/LICENSE".
|
||||
* All content that resides under the "engine/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "engine/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/proprietary/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/desktop/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/saas/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/cloud/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/portal/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/portal-saas/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal-saas/LICENSE".
|
||||
* All content that resides under the "frontend/src/editor/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/editor/proprietary/LICENSE".
|
||||
* All content that resides under the "frontend/src/editor/desktop/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/editor/desktop/LICENSE".
|
||||
* All content that resides under the "frontend/src/editor/saas/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/editor/saas/LICENSE".
|
||||
* All content that resides under the "frontend/src/editor/cloud/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/editor/cloud/LICENSE".
|
||||
* All content that resides under the "frontend/src/editor/prototypes/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/editor/prototypes/LICENSE".
|
||||
* All content that resides under the "frontend/src/processor/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/processor/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is
|
||||
available under the MIT License as defined below.
|
||||
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ If you're using Tauri's built-in updater feature:
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### 1. Tauri Configuration (frontend/editor/src-tauri/tauri.conf.json)
|
||||
### 1. Tauri Configuration (frontend/src-tauri/tauri.conf.json)
|
||||
|
||||
The Windows signing configuration is already set up:
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ dependencies {
|
||||
api 'com.fathzer:javaluator:3.0.6'
|
||||
api 'com.posthog.java:posthog:1.2.0'
|
||||
api "org.apache.commons:commons-lang3:${commonsLang3}"
|
||||
api 'com.drewnoakes:metadata-extractor:2.21.0' // Image metadata extractor
|
||||
api 'com.drewnoakes:metadata-extractor:2.20.0' // Image metadata extractor
|
||||
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
|
||||
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
|
||||
|
||||
@@ -156,7 +156,7 @@ if (buildWithPortal) {
|
||||
}
|
||||
// Workspace root holds package.json and node_modules (shared across editor /
|
||||
// future portal). Editor-specific paths (src, public, dist, tauri) live one
|
||||
// level deeper under frontend/editor/.
|
||||
// level deeper under frontend/.
|
||||
|
||||
// Vite mode: -PprototypesMode > -PfrontendMode > enableSaas > disableAdditional > proprietary.
|
||||
def frontendModeOverride = project.findProperty('frontendMode')?.toString()?.toLowerCase()
|
||||
@@ -176,11 +176,11 @@ def frontendBuildTask = "frontend:build:${frontendMode}"
|
||||
|
||||
// Workspace root holds package.json and node_modules (shared across editor /
|
||||
// future portal). Editor-specific paths (src, public, dist, tauri) live one
|
||||
// level deeper under frontend/editor/. When the portal lands as an embedded
|
||||
// level deeper under frontend/. When the portal lands as an embedded
|
||||
// app, add a sibling frontendPortalDir / frontendPortalDistDir alongside.
|
||||
def frontendDir = file('../../frontend')
|
||||
def frontendEditorDir = file('../../frontend/editor')
|
||||
def frontendEditorDistDir = file('../../frontend/editor/dist')
|
||||
def frontendEditorDir = file('../../frontend')
|
||||
def frontendEditorDistDir = file('../../frontend/dist')
|
||||
def resourcesStaticDir = file('src/main/resources/static')
|
||||
def generatedFrontendPaths = [
|
||||
'assets',
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Types of resources whose access can be gated by {@link ResourceGrant}. */
|
||||
public enum ResourceType {
|
||||
// The admin portal / processor (frontend/editor/src/portal). Singleton resource (empty
|
||||
// The admin portal / processor (frontend/src/processor/proprietary). Singleton resource (empty
|
||||
// resourceId).
|
||||
PORTAL,
|
||||
// A stored S3/MCP/API integration configuration.
|
||||
|
||||
+8
-28
@@ -298,44 +298,24 @@ class LiveValkeyIntegrationTest {
|
||||
ValkeyRateLimitStore store = newRateLimitStore(factoryA);
|
||||
String key = "boundary-" + java.util.UUID.randomUUID();
|
||||
long capacity = 5;
|
||||
// refillGreedy tops the bucket up continuously, one token every window/capacity. A 500ms
|
||||
// window left the drain loop only 100ms before a 6th token appeared, so a slow Valkey
|
||||
// round-trip broke the count; 4s spaces refills 800ms apart, clear of any burst.
|
||||
Duration window = Duration.ofSeconds(4);
|
||||
long refillIntervalMs = window.toMillis() / capacity;
|
||||
Duration window = Duration.ofMillis(500);
|
||||
|
||||
long drainStart = System.nanoTime();
|
||||
int firstAllowed = 0;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (store.tryConsume(key, capacity, window).allowed()) firstAllowed++;
|
||||
}
|
||||
long drainMs = (System.nanoTime() - drainStart) / 1_000_000;
|
||||
// Refill never pauses, so a slow drain earns extra tokens honestly - allow exactly the
|
||||
// number the elapsed time can have produced and no more.
|
||||
long earned = drainMs / refillIntervalMs;
|
||||
assertTrue(
|
||||
firstAllowed >= capacity && firstAllowed <= capacity + earned,
|
||||
"initial burst must be capacity ("
|
||||
+ capacity
|
||||
+ ") plus at most the "
|
||||
+ earned
|
||||
+ " token(s) refilled during a "
|
||||
+ drainMs
|
||||
+ "ms drain, got "
|
||||
+ firstAllowed);
|
||||
assertEquals(capacity, firstAllowed, "must allow exactly capacity tokens initially");
|
||||
|
||||
// A fixed-window limiter would hand back a whole fresh capacity at the boundary; a token
|
||||
// bucket hands back one token per refill interval.
|
||||
Thread.sleep(refillIntervalMs + 200);
|
||||
Thread.sleep(window.toMillis() + 50);
|
||||
int secondAllowed = 0;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
long start = System.nanoTime();
|
||||
for (int i = 0; i < 20 && (System.nanoTime() - start) < 20_000_000L; i++) {
|
||||
if (store.tryConsume(key, capacity, window).allowed()) secondAllowed++;
|
||||
}
|
||||
assertTrue(
|
||||
secondAllowed >= 1 && secondAllowed < capacity,
|
||||
"one refill interval must yield about one token, not a fresh full window of "
|
||||
+ capacity
|
||||
+ "; got "
|
||||
secondAllowed <= capacity,
|
||||
"token-bucket must not let a fresh full capacity be consumed instantly across"
|
||||
+ " the boundary; got "
|
||||
+ secondAllowed);
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -110,9 +110,9 @@ allprojects {
|
||||
}
|
||||
|
||||
def appVersionStr = project.version.toString()
|
||||
def tauriConfigPath = layout.projectDirectory.file('frontend/editor/src-tauri/tauri.conf.json').asFile.path
|
||||
def sim1Path = layout.projectDirectory.file('frontend/editor/src/core/testing/serverExperienceSimulations.ts').asFile.path
|
||||
def sim2Path = layout.projectDirectory.file('frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts').asFile.path
|
||||
def tauriConfigPath = layout.projectDirectory.file('frontend/src-tauri/tauri.conf.json').asFile.path
|
||||
def sim1Path = layout.projectDirectory.file('frontend/src/editor/core/testing/serverExperienceSimulations.ts').asFile.path
|
||||
def sim2Path = layout.projectDirectory.file('frontend/src/editor/proprietary/testing/serverExperienceSimulations.ts').asFile.path
|
||||
def aurDesktopPkgbuildPath = layout.projectDirectory.file('.github/aur/stirling-pdf-desktop/PKGBUILD').asFile.path
|
||||
def aurServerPkgbuildPath = layout.projectDirectory.file('.github/aur/stirling-pdf-server-bin/PKGBUILD').asFile.path
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@ Fork Stirling-PDF and create a new branch out of `main`.
|
||||
|
||||
### Add Language Directory and Translation File
|
||||
|
||||
1. Create a new language directory in `frontend/editor/public/locales/`
|
||||
1. Create a new language directory in `frontend/public/locales/`
|
||||
- Use hyphenated format: `pl-PL` (not underscore)
|
||||
|
||||
2. Copy the reference translation file:
|
||||
- Source: `frontend/editor/public/locales/en-US/translation.toml`
|
||||
- Destination: `frontend/editor/public/locales/pl-PL/translation.toml`
|
||||
- Source: `frontend/public/locales/en-US/translation.toml`
|
||||
- Destination: `frontend/public/locales/pl-PL/translation.toml`
|
||||
|
||||
3. Translate all entries in the TOML file
|
||||
- Keep the TOML structure intact
|
||||
@@ -49,7 +49,7 @@ ignore = [
|
||||
> [!IMPORTANT]
|
||||
> If you add any new translation tags, they must first be added to the `en-US/translation.toml` file. This ensures consistency across all language files.
|
||||
|
||||
- New translation tags **must be added** to `frontend/editor/public/locales/en-US/translation.toml` to maintain a reference for other languages.
|
||||
- New translation tags **must be added** to `frontend/public/locales/en-US/translation.toml` to maintain a reference for other languages.
|
||||
- After adding the new tags to `en-US/translation.toml`, add and translate them in the respective language file (e.g., `pl-PL/translation.toml`).
|
||||
- Use the scripts in `scripts/translations/` to validate and manage translations (see `scripts/translations/README.md`)
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -894,9 +894,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ RUN npm ci
|
||||
COPY frontend .
|
||||
|
||||
# Generate material-symbols icon subset (normally done by task prepare:icons).
|
||||
RUN node editor/scripts/generate-icons.js
|
||||
RUN node scripts/generate-icons.js
|
||||
|
||||
# Defaults match prior behaviour. Supabase values are client-publishable build args.
|
||||
ARG STIRLING_FLAVOR=proprietary
|
||||
@@ -21,7 +21,7 @@ ARG VITE_SUPABASE_URL=""
|
||||
# pragma: allowlist secret
|
||||
ARG VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=""
|
||||
|
||||
# Build vite from editor/, output lands in editor/dist/.
|
||||
# Build vite from editor/, output lands in dist/.
|
||||
RUN set -eu; \
|
||||
export STIRLING_FLAVOR="${STIRLING_FLAVOR}"; \
|
||||
if [ -n "${VITE_SUPABASE_URL}" ]; then export VITE_SUPABASE_URL="${VITE_SUPABASE_URL}"; fi; \
|
||||
@@ -31,7 +31,7 @@ RUN set -eu; \
|
||||
# Stage 2: nginx
|
||||
FROM nginx:alpine@sha256:b0f7830b6bfaa1258f45d94c240ab668ced1b3651c8a222aefe6683447c7bf55
|
||||
|
||||
COPY --from=build /app/editor/dist /usr/share/nginx/html
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY docker/frontend/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/frontend/entrypoint.sh /entrypoint.sh
|
||||
|
||||
|
||||
+4
-4
@@ -15,7 +15,7 @@
|
||||
/storybook-static
|
||||
|
||||
/editor/build
|
||||
/editor/dist
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -40,12 +40,12 @@ playwright-report
|
||||
test-results
|
||||
|
||||
# auto-generated files
|
||||
/editor/src/assets/material-symbols-icons.json
|
||||
/editor/src/assets/material-symbols-icons.d.ts
|
||||
/src/assets/material-symbols-icons.json
|
||||
/src/assets/material-symbols-icons.d.ts
|
||||
|
||||
# dev update testing - keys, built bundles, screenshots, and generated config override
|
||||
/scripts/dev-update-test/.keys/
|
||||
/scripts/dev-update-test/.update-dist/
|
||||
/scripts/dev-update-test/screenshots/
|
||||
/editor/src-tauri/tauri.conf.dev-update.json
|
||||
/src-tauri/tauri.conf.dev-update.json
|
||||
.a11y-scan/
|
||||
|
||||
+15
-15
@@ -1,30 +1,30 @@
|
||||
dist/
|
||||
editor/dist/
|
||||
dist/
|
||||
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier).
|
||||
# Match nested target/ dirs too - provisioner/ and thumbnail-handler/ each
|
||||
# have their own Cargo workspace under src-tauri/.
|
||||
editor/src-tauri/**/target/
|
||||
editor/src-tauri/gen/
|
||||
src-tauri/**/target/
|
||||
src-tauri/gen/
|
||||
node_modules/
|
||||
editor/public/vendor/
|
||||
public/vendor/
|
||||
# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
|
||||
editor/public/mockServiceWorker.js
|
||||
public/mockServiceWorker.js
|
||||
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
|
||||
editor/public/og-metadata.json
|
||||
editor/public/og-metadata.saas.json
|
||||
editor/src/core/data/ogImageMap.json
|
||||
public/og-metadata.json
|
||||
public/og-metadata.saas.json
|
||||
src/editor/core/data/ogImageMap.json
|
||||
# Auto-generated portal docs manifest (scripts/sync-portal-docs.mts); regenerated verbatim.
|
||||
editor/src/portal/generated/docsManifest.json
|
||||
editor/public/pdfjs*/
|
||||
editor/public/js/thirdParty/
|
||||
editor/public/css/cookieconsent.css
|
||||
src/processor/proprietary/generated/docsManifest.json
|
||||
public/pdfjs*/
|
||||
public/js/thirdParty/
|
||||
public/css/cookieconsent.css
|
||||
# Build / test artifacts that may exist locally even though they're gitignored
|
||||
storybook-static/
|
||||
playwright-report/
|
||||
editor/playwright-report/
|
||||
playwright-report/
|
||||
test-results/
|
||||
test-results/
|
||||
editor/test-results/
|
||||
*.min.*
|
||||
*.md
|
||||
*.wxs
|
||||
editor/src/output.css
|
||||
src/output.css
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,19 +59,14 @@ if (args.length > 0) {
|
||||
// checked before it is added to the index.
|
||||
files = [
|
||||
...new Set([
|
||||
...git(
|
||||
"ls-files",
|
||||
"--",
|
||||
"editor/src/**/*.stories.ts",
|
||||
"editor/src/**/*.stories.tsx",
|
||||
),
|
||||
...git("ls-files", "--", "src/**/*.stories.ts", "src/**/*.stories.tsx"),
|
||||
...git(
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
"--",
|
||||
"editor/src/**/*.stories.ts",
|
||||
"editor/src/**/*.stories.tsx",
|
||||
"src/**/*.stories.ts",
|
||||
"src/**/*.stories.tsx",
|
||||
),
|
||||
]),
|
||||
].sort();
|
||||
|
||||
+25
-61
@@ -1,4 +1,3 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
@@ -7,53 +6,14 @@ import tsconfigPaths from "vite-tsconfig-paths";
|
||||
* Storybook 9 ships essentials, interactions, and docs as built-ins, so the
|
||||
* addon list is just the extras we want: theme switching + a11y auditing.
|
||||
*
|
||||
* Story files live next to their components under editor/src/ (which includes
|
||||
* the portal layer at editor/src/portal/). MDX docs pages live in
|
||||
* editor/src/portal/docs/.
|
||||
* Story files live next to their components under src/editor/ (which includes
|
||||
* the portal layer at src/processor/proprietary/). MDX docs pages live in
|
||||
* src/processor/proprietary/docs/.
|
||||
*/
|
||||
|
||||
/** Layer search order for @app/*, mirroring each flavour's vite tsconfig. */
|
||||
const FLAVOUR_LAYERS: Record<string, string[]> = {
|
||||
desktop: ["desktop", "cloud", "proprietary", "core"],
|
||||
saas: ["saas", "cloud", "proprietary", "core"],
|
||||
cloud: ["cloud", "proprietary", "core"],
|
||||
prototypes: ["prototypes", "proprietary", "core"],
|
||||
};
|
||||
|
||||
const SUFFIXES = ["", ".tsx", ".ts", "/index.tsx", "/index.ts"];
|
||||
|
||||
function flavourAppAlias() {
|
||||
return {
|
||||
name: "storybook-app-alias-by-flavour",
|
||||
// Ahead of vite-tsconfig-paths, which would otherwise answer first with the
|
||||
// proprietary order.
|
||||
enforce: "pre" as const,
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (!importer || !source.startsWith("@app/")) return null;
|
||||
const layer = importer
|
||||
.split("\\")
|
||||
.join("/")
|
||||
.match(/\/editor\/src\/(desktop|saas|cloud|prototypes)\//)?.[1];
|
||||
if (!layer) return null;
|
||||
const rest = source.slice("@app/".length);
|
||||
for (const candidate of FLAVOUR_LAYERS[layer]) {
|
||||
for (const suffix of SUFFIXES) {
|
||||
const full = resolve(
|
||||
__dirname,
|
||||
`../editor/src/${candidate}/${rest}${suffix}`,
|
||||
);
|
||||
if (existsSync(full) && statSync(full).isFile()) return full;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
"../editor/src/portal/**/*.mdx",
|
||||
"../editor/src/**/*.stories.@(ts|tsx)",
|
||||
"../src/processor/proprietary/**/*.mdx",
|
||||
"../src/**/*.stories.@(ts|tsx)",
|
||||
],
|
||||
addons: [
|
||||
"@storybook/addon-themes",
|
||||
@@ -69,41 +29,45 @@ const config: StorybookConfig = {
|
||||
},
|
||||
// Serve the MSW worker file from the portal's public dir so Storybook can
|
||||
// intercept network calls the same way the dev portal does.
|
||||
staticDirs: ["../editor/public"],
|
||||
staticDirs: ["../public"],
|
||||
viteFinal: async (config) => {
|
||||
// Wire the @portal/* alias directly on the Storybook bundler so portal
|
||||
// Wire the @processor/* alias directly on the Storybook bundler so portal
|
||||
// story imports resolve without needing the portal's vite config.
|
||||
config.resolve = config.resolve ?? {};
|
||||
config.resolve.alias = {
|
||||
...(config.resolve.alias ?? {}),
|
||||
"@portal": resolve(__dirname, "../editor/src/portal"),
|
||||
"@processor": resolve(__dirname, "../src/processor/proprietary"),
|
||||
// Direct layer aliases so .storybook config files (preview.tsx), which sit
|
||||
// outside src/ and so aren't covered by tsconfigPaths, can import layer
|
||||
// modules (e.g. the auth supabase client that moved into proprietary).
|
||||
"@proprietary": resolve(__dirname, "../editor/src/proprietary"),
|
||||
"@core": resolve(__dirname, "../editor/src/core"),
|
||||
"@proprietary": resolve(__dirname, "../src/editor/proprietary"),
|
||||
"@core": resolve(__dirname, "../src/editor/core"),
|
||||
// Public assets (e.g. the en-US translation TOML loaded ?raw by preview.tsx).
|
||||
// No src alias covers public/, so this lets the config use an alias rather
|
||||
// than a relative path.
|
||||
"@public": resolve(__dirname, "../editor/public"),
|
||||
"@public": resolve(__dirname, "../public"),
|
||||
};
|
||||
// Editor stories import via @app/* (proprietary→core fallback), @core/* and
|
||||
// Editor stories import via @editor/* (proprietary→core fallback), @core/* and
|
||||
// @proprietary/*. Resolve them exactly the way the editor's own build does —
|
||||
// through vite-tsconfig-paths against the proprietary vite tsconfig — so the
|
||||
// shared Storybook can host editor components without duplicating the alias
|
||||
// map here.
|
||||
config.plugins = config.plugins ?? [];
|
||||
// Stories under desktop/, saas/, cloud/ and prototypes/ import @app/* too,
|
||||
// but each of those builds resolves it against its own layer first — an
|
||||
// order the single tsconfig below cannot express. Resolving by the
|
||||
// importer's layer lets those stories load without changing what @app/*
|
||||
// means for core, proprietary or portal stories, which never match here.
|
||||
config.plugins.push(flavourAppAlias());
|
||||
// Storybook's builder-vite auto-loads the app's vite.config.ts and merges in
|
||||
// its plugins, but a Storybook build is not an app build. Drop the app's
|
||||
// build-only plugins that emit deploy artifacts against a dist/ that a
|
||||
// Storybook build never produces (prerender-og would otherwise throw ENOENT).
|
||||
const APP_BUILD_ONLY = new Set(["prerender-og", "compress-static-copy"]);
|
||||
config.plugins = config.plugins.filter((p) => {
|
||||
const name =
|
||||
p && typeof p === "object" && "name" in p
|
||||
? (p as { name?: unknown }).name
|
||||
: undefined;
|
||||
return typeof name !== "string" || !APP_BUILD_ONLY.has(name);
|
||||
});
|
||||
config.plugins.push(
|
||||
tsconfigPaths({
|
||||
projects: [
|
||||
resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"),
|
||||
],
|
||||
projects: [resolve(__dirname, "../tsconfig.proprietary.vite.json")],
|
||||
}),
|
||||
);
|
||||
// Point apiClient.saas at a mock origin so the SaaS-backed billing stories
|
||||
|
||||
@@ -14,19 +14,12 @@ import { withThemeByDataAttribute } from "@storybook/addon-themes";
|
||||
void React;
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TierProvider, type Tier } from "@portal/contexts/TierContext";
|
||||
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
|
||||
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { UIProvider } from "@portal/contexts/UIContext";
|
||||
import { PreferencesProvider } from "@core/contexts/PreferencesContext";
|
||||
import { SidebarProvider } from "@core/contexts/SidebarContext";
|
||||
import { SuiProvider } from "@portal/theme/SuiProvider";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import {
|
||||
mantineTheme as editorMantineTheme,
|
||||
editorCssVariablesResolver,
|
||||
} from "@core/theme/mantineTheme";
|
||||
import { handlers } from "@portal/mocks/handlers";
|
||||
import { TierProvider, type Tier } from "@processor/contexts/TierContext";
|
||||
import { LinkProvider, type LinkState } from "@processor/contexts/LinkContext";
|
||||
import { ThemeProvider, useTheme } from "@processor/contexts/ThemeContext";
|
||||
import { UIProvider } from "@processor/contexts/UIContext";
|
||||
import { SuiProvider } from "@processor/theme/SuiProvider";
|
||||
import { handlers } from "@processor/mocks/handlers";
|
||||
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
|
||||
import i18next from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
@@ -36,11 +29,6 @@ import { rtlLanguages, supportedLanguages } from "@core/i18n/languages";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@core/tokens/tokens.css";
|
||||
import "@core/theme/index.css";
|
||||
// The editor's Mantine theme resolves its palette through the --color-* vocab
|
||||
// defined here. The app picks this up via styles/tailwind.css; Storybook has no
|
||||
// tailwind entry, so without it every var(--color-*) in the theme is undefined
|
||||
// and Mantine silently falls back to its stock palette.
|
||||
import "@core/styles/theme.css";
|
||||
import "@core/tokens/base.css";
|
||||
|
||||
// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
|
||||
@@ -48,7 +36,7 @@ import "@core/tokens/base.css";
|
||||
// async fetch (Storybook has no backend to serve /locales/). t(key) then renders
|
||||
// the shipped copy (e.g. "No sources connected yet") rather than the raw key.
|
||||
const localeModules = import.meta.glob<string>(
|
||||
"../editor/public/locales/*/translation.toml",
|
||||
"../public/locales/*/translation.toml",
|
||||
{ query: "?raw", import: "default", eager: true },
|
||||
);
|
||||
|
||||
@@ -202,37 +190,6 @@ const withLocale: Decorator = (Story, context) => {
|
||||
return <Story />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies the Mantine theme the story's component actually runs under in the
|
||||
* app: PortalApp wraps the Processor in SuiProvider, while the editor wraps
|
||||
* everything else in its own ThemeProvider. Getting this wrong is not just
|
||||
* cosmetic — the two themes carry different neutral ramps, so rendering an
|
||||
* editor component under the Processor's theme drops it onto Mantine's stock
|
||||
* greys and reports contrast failures the app doesn't have.
|
||||
*/
|
||||
function StoryTheme({
|
||||
isPortalStory,
|
||||
colorScheme,
|
||||
children,
|
||||
}: {
|
||||
isPortalStory: boolean;
|
||||
colorScheme: "light" | "dark";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (isPortalStory) {
|
||||
return <SuiProvider colorScheme={colorScheme}>{children}</SuiProvider>;
|
||||
}
|
||||
return (
|
||||
<MantineProvider
|
||||
theme={editorMantineTheme}
|
||||
cssVariablesResolver={editorCssVariablesResolver}
|
||||
forceColorScheme={colorScheme}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const withProviders: Decorator = (Story, context) => {
|
||||
const tier = (context.globals.tier as Tier) ?? "pro";
|
||||
const linkState =
|
||||
@@ -244,36 +201,25 @@ const withProviders: Decorator = (Story, context) => {
|
||||
// anything that isn't "dark" as light — matching the addon's own
|
||||
// `selected || defaultTheme` fallback where defaultTheme is light.
|
||||
const colorScheme = context.globals.theme === "dark" ? "dark" : "light";
|
||||
// Storybook titles are the routing key here: the Processor's stories are all
|
||||
// filed under "Portal/".
|
||||
const isPortalStory = (context.title ?? "").startsWith("Portal/");
|
||||
return (
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<SchemeSetup scheme={colorScheme} />
|
||||
<ThemeBridge theme={colorScheme}>
|
||||
<StoryTheme isPortalStory={isPortalStory} colorScheme={colorScheme}>
|
||||
<SuiProvider colorScheme={colorScheme}>
|
||||
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
|
||||
from useLink() (matches App.tsx's nesting). */}
|
||||
<LinkProvider key={linkState} initialState={linkState}>
|
||||
<TierKey tier={tier}>
|
||||
{/* Tooltip reads the user's logo preference and the sidebar
|
||||
geometry it positions against. It is used by ~100
|
||||
components, so without these a story that renders one
|
||||
throws. The real app always has both mounted. */}
|
||||
<PreferencesProvider>
|
||||
<SidebarProvider>
|
||||
<UIProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
</SidebarProvider>
|
||||
</PreferencesProvider>
|
||||
<UIProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
</TierKey>
|
||||
</LinkProvider>
|
||||
</StoryTheme>
|
||||
</SuiProvider>
|
||||
</ThemeBridge>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
@@ -300,14 +246,6 @@ const preview: Preview = {
|
||||
// any violation. Context is left at the addon default (the document root)
|
||||
// so it resolves under both the Storybook UI and the Vitest browser mount.
|
||||
test: "error",
|
||||
context: {
|
||||
// Nodes carrying this attribute render a facsimile of the user's own
|
||||
// document — their stamp text, their watermark, in the colour and
|
||||
// opacity they chose. WCAG contrast governs the interface, not the
|
||||
// content authored through it, and the controls that set those values
|
||||
// are checked normally.
|
||||
exclude: ["[data-user-content-preview]"],
|
||||
},
|
||||
},
|
||||
},
|
||||
globalTypes: {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
// Extends the same tsconfig main.ts feeds to vite-tsconfig-paths, so the
|
||||
// alias map can't drift from the one Storybook actually bundles with, and
|
||||
// stories are checked under that resolution (@app/* = proprietary -> core).
|
||||
// stories are checked under that resolution (@editor/* = proprietary -> core).
|
||||
// The per-layer typecheck projects also cover story files, but each resolves
|
||||
// @app/* its own way - not the way the single Storybook build renders them.
|
||||
"extends": "../editor/tsconfig.proprietary.vite.json",
|
||||
// @editor/* its own way - not the way the single Storybook build renders them.
|
||||
"extends": "../tsconfig.proprietary.vite.json",
|
||||
"exclude": [],
|
||||
"include": [
|
||||
"./**/*",
|
||||
"../editor/src/global.d.ts",
|
||||
"../editor/src/**/*.stories.ts",
|
||||
"../editor/src/**/*.stories.tsx"
|
||||
"../src/global.d.ts",
|
||||
"../src/**/*.stories.ts",
|
||||
"../src/**/*.stories.tsx"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
|
||||
* Dedicated Vitest config that turns every story into a browser test: it mounts
|
||||
* the story in real Chromium and runs axe against it, so a story fails if it
|
||||
* throws on mount or trips an accessibility rule. Kept separate from
|
||||
* editor/vitest.config.ts (the jsdom unit tests) so the two suites don't collide.
|
||||
* vitest.config.ts (the jsdom unit tests) so the two suites don't collide.
|
||||
*
|
||||
* The storybook test must live in a `test.projects[]` entry (not a flat config)
|
||||
* so Vitest wires up the browser test runner correctly.
|
||||
@@ -17,7 +17,7 @@ export default defineConfig({
|
||||
optimizeDeps: {
|
||||
// Pre-scan every story + the preview so Vite discovers the story set's large
|
||||
// dep surface (embedpdf plugins, @mui icons, …) in one pass up front.
|
||||
entries: ["editor/src/**/*.stories.@(ts|tsx)", ".storybook/preview.tsx"],
|
||||
entries: ["src/**/*.stories.@(ts|tsx)", ".storybook/preview.tsx"],
|
||||
// `entries` alone does not catch deps reached through a transformed JSX
|
||||
// runtime import, nor the preview's own dependency graph (the test plugin
|
||||
// injects the preview in a way the entry scanner doesn't crawl). Vite then
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeAll } from "vitest";
|
||||
import { setProjectAnnotations } from "@storybook/react-vite";
|
||||
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
|
||||
// oxlint-disable-next-line no-restricted-imports -- Storybook-only: the sibling preview config has no @-alias.
|
||||
// eslint-disable-next-line no-restricted-imports -- Storybook-only: the sibling preview config has no @-alias.
|
||||
import * as projectAnnotations from "./preview";
|
||||
|
||||
// Include addon-a11y's annotations so its axe checks run under Vitest, not only
|
||||
|
||||
@@ -10,7 +10,7 @@ Refer to the various `tsconfig.*.json` files to see the specific path alias orde
|
||||
|
||||
The vast majority of the code is in the `src/core` folder, which is the open-source app.
|
||||
Other builds, such as the desktop app, use `src/core` as the base layer, and then override various files to change behaviour.
|
||||
If an import is `from '@app/a/b'`, this will refer to `src/core/a/b.ts` in the core build of the app, but may refer to `src/desktop/a/b.ts` in the desktop app if that file exists.
|
||||
If an import is `from '@editor/a/b'`, this will refer to `src/editor/core/a/b.ts` in the core build of the app, but may refer to `src/editor/desktop/a/b.ts` in the desktop app if that file exists.
|
||||
|
||||
It is important to try to minimise the amount of overridden code in the app.
|
||||
Often, just one function needs to behave differently in a specific mode.
|
||||
@@ -27,7 +27,7 @@ In cases like this, instead of duplicating the entire file, create a new extensi
|
||||
|
||||
```ts
|
||||
// core/file1.ts
|
||||
import { f2 } from '@app/file1Extensions';
|
||||
import { f2 } from '@editor/file1Extensions';
|
||||
|
||||
function f1() { /* ... */ }
|
||||
function f3() { /* ... */ }
|
||||
@@ -102,7 +102,7 @@ export function getToolbarButtons(): ToolbarButton[] {
|
||||
|
||||
```tsx
|
||||
// core/Toolbar.tsx
|
||||
import { getToolbarButtons } from '@app/toolbarExtensions';
|
||||
import { getToolbarButtons } from '@editor/toolbarExtensions';
|
||||
|
||||
export function Toolbar() {
|
||||
return (
|
||||
@@ -123,7 +123,7 @@ This pattern works well for things like menu items or toolbar actions - anything
|
||||
|
||||
### Import aliases
|
||||
|
||||
In general, all imports for app code should come via `@app` because it allows for other builds of the app to override behaviour if necessary.
|
||||
In general, all imports for app code should come via `@editor` because it allows for other builds of the app to override behaviour if necessary.
|
||||
The only time that it is beneficial to import via a specific folder (e.g. `@core`) is when you want to reduce duplication **in the file you are overriding**. For example:
|
||||
|
||||
```ts
|
||||
+4
-4
@@ -6,7 +6,7 @@ All frontend commands are run from the repository root using [Task](https://task
|
||||
- `task frontend:build` — production build
|
||||
- `task frontend:test` — run tests
|
||||
- `task frontend:test:watch` — run tests in watch mode
|
||||
- `task frontend:lint` — run linting
|
||||
- `task frontend:lint` — run ESLint + cycle detection
|
||||
- `task frontend:typecheck` — run TypeScript type checking
|
||||
- `task frontend:check` — run typecheck + lint + test
|
||||
- `task frontend:install` — install npm dependencies
|
||||
@@ -16,15 +16,15 @@ For desktop app development, see the [Tauri](#tauri) section below.
|
||||
## Layout
|
||||
|
||||
`frontend/` is a workspace containing one or more apps. Today it holds the
|
||||
PDF editor under `frontend/editor/`; new apps (the developer portal, etc.)
|
||||
PDF editor under `frontend/`; new apps (the developer portal, etc.)
|
||||
will sit alongside it as siblings. Shared tooling — `package.json`, `node_modules`,
|
||||
`.storybook/`, oxlint, Prettier — lives at `frontend/` so every app installs
|
||||
`.storybook/`, ESLint, Prettier — lives at `frontend/` so every app installs
|
||||
once and lints with the same config.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The editor's environment variables live in committed `.env` files at
|
||||
`frontend/editor/`:
|
||||
`frontend/`:
|
||||
|
||||
- `.env` — used by all builds (core, proprietary, and as the base for desktop/SaaS)
|
||||
- `.env.desktop` — additional vars loaded in desktop (Tauri) mode
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Storybook coverage report — which rendered surfaces have a story, and what
|
||||
// stands between the ones that don't and having one. Modes:
|
||||
//
|
||||
// node storybook-coverage.mjs summary by area (report only)
|
||||
// node storybook-coverage.mjs --todo every uncovered surface, with the
|
||||
// work each needs
|
||||
// node storybook-coverage.mjs --area core/components/tools
|
||||
//
|
||||
// Coverage is counted by *import*, not by an adjacent .stories.tsx: several
|
||||
// components are covered by a shared story file (MantineForms covers Select,
|
||||
// MultiSelect, NumberInput and ColorInput between them), and counting siblings
|
||||
// reports those as gaps and invites duplicate stories.
|
||||
//
|
||||
// Each uncovered surface is classified by what a story would have to supply:
|
||||
//
|
||||
// props no context to supply. Note this means "no provider needed", not
|
||||
// "cheap" — a props-only component can still be expensive to
|
||||
// story if its props are heavy (ButtonAppearanceOverlay wants
|
||||
// real PDF bytes; AppConfigModalLazy lazy-loads the whole
|
||||
// settings tree). Read the props before assuming it is quick.
|
||||
// context it (or something it renders) reads a React context. The cheap
|
||||
// fix is usually to export the context and hand the story the
|
||||
// slice the component actually touches, rather than mounting the
|
||||
// provider and whatever chain sits behind it.
|
||||
// data it fetches, so the story needs MSW handlers
|
||||
// router it reads router state
|
||||
//
|
||||
// Not counted as surfaces at all: providers, contexts, gates, routers, test
|
||||
// helpers, and modules that return a config object rather than markup.
|
||||
//
|
||||
// Known blocker — four flavours cannot be storied as things stand.
|
||||
//
|
||||
// Storybook resolves @app/* through editor/tsconfig.proprietary.vite.json,
|
||||
// which maps it to src/proprietary/* then src/core/* and excludes src/desktop.
|
||||
// A file in another flavour that imports an @app/* asset living only in its own
|
||||
// tree therefore fails to resolve, and the story file does not load at all:
|
||||
//
|
||||
// desktop 80 of 152 @app/ imports unresolvable
|
||||
// saas 54 of 232
|
||||
// cloud 28 of 77
|
||||
// prototypes 4 of 40
|
||||
// portal-saas 0 of 7 (fine)
|
||||
//
|
||||
// That accounts for the 0% areas below — it is a build-config gap, not
|
||||
// neglect. Closing it means either per-flavour alias projects in
|
||||
// .storybook/main.ts or hoisting the shared assets, and it is a decision with
|
||||
// blast radius across every existing story, so it is deliberately not made
|
||||
// here.
|
||||
//
|
||||
// Run from frontend/.
|
||||
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
const SRC = resolve(process.cwd(), "editor/src");
|
||||
const args = process.argv.slice(2);
|
||||
const wantTodo = args.includes("--todo");
|
||||
const areaFilter = args.includes("--area")
|
||||
? args[args.indexOf("--area") + 1]
|
||||
: null;
|
||||
|
||||
/* ── file walk ────────────────────────────────────────────────────────────── */
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (entry.name.endsWith(".tsx")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const all = walk(SRC);
|
||||
const rel = (f) => relative(SRC, f).split("\\").join("/");
|
||||
const storyFiles = all.filter((f) => f.endsWith(".stories.tsx"));
|
||||
const sources = all.filter(
|
||||
(f) => !f.endsWith(".stories.tsx") && !f.endsWith(".test.tsx"),
|
||||
);
|
||||
|
||||
/* ── what the stories already reach ───────────────────────────────────────── */
|
||||
|
||||
const importedNames = new Set();
|
||||
const importedPaths = new Set();
|
||||
for (const f of storyFiles) {
|
||||
const src = readFileSync(f, "utf8");
|
||||
for (const m of src.matchAll(
|
||||
/import\s+(?:type\s+)?(?:\{([^}]*)\}|(\w+))\s*(?:,\s*\{([^}]*)\})?\s*from\s+["']([^"']+)/g,
|
||||
)) {
|
||||
for (const group of [m[1], m[3]]) {
|
||||
if (!group) continue;
|
||||
for (const name of group.split(","))
|
||||
importedNames.add(
|
||||
name.trim().split(" as ")[0].replace("type ", "").trim(),
|
||||
);
|
||||
}
|
||||
if (m[2]) importedNames.add(m[2]);
|
||||
importedPaths.add(m[4]);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── classification ───────────────────────────────────────────────────────── */
|
||||
|
||||
// Bridge: the viewer's *APIBridge components register an API into context and
|
||||
// render null — wiring, like the rest of these.
|
||||
const INFRA_NAME =
|
||||
/(Provider|Providers|Context|Gate|Boundary|Mount|Router|Guard|Bridge)\.tsx$/;
|
||||
const INFRA_DIR = /\/(contexts|test|tests|mocks|hooks|types|utils|api|data)\//;
|
||||
const RENDERS = /return\s*\(?\s*<|=>\s*\(?\s*</;
|
||||
// A module whose exported function returns a config object, not markup.
|
||||
const CONFIG_FACTORY = /:\s*(SlideConfig|ToolFlowConfig|\w+Config)\s*\{/;
|
||||
const DATA = /\buse(Query|Mutation|SWR|InfiniteQuery)\b|\bfetch\w*\(/;
|
||||
const ROUTER = /\buse(Navigate|Params|Location|SearchParams)\b/;
|
||||
const CONTEXT = /\buse[A-Z]\w*\(/g;
|
||||
// Hooks that are plainly not context reads.
|
||||
const LOCAL_HOOK =
|
||||
/^use(State|Effect|Memo|Callback|Ref|Id|Reducer|Context|Translation|LayoutEffect|ImperativeHandle|Transition|DeferredValue|SyncExternalStore|Debounced\w*|Media\w*|Disclosure|Form)$/;
|
||||
// Contexts .storybook/preview.tsx already mounts for every story. A component
|
||||
// that reads only these needs no fixture work, so it counts as props-level.
|
||||
const HARNESS_PROVIDED =
|
||||
/^use(Preferences|SidebarContext|Tier|Link|UI|Theme|QueryClient|Navigate|Location|Params|SearchParams)$/;
|
||||
|
||||
const byPath = new Map(sources.map((f) => [rel(f), f]));
|
||||
|
||||
function localImports(src, fromRel) {
|
||||
const out = [];
|
||||
for (const m of src.matchAll(
|
||||
/from\s+["'](@app\/|@core\/|\.\.?\/)([^"']+)/g,
|
||||
)) {
|
||||
const spec = m[1] + m[2];
|
||||
const guess = spec.replace(/^@app\//, "core/").replace(/^@core\//, "core/");
|
||||
for (const cand of [`${guess}.tsx`, `${guess}/index.tsx`]) {
|
||||
if (byPath.has(cand)) out.push(cand);
|
||||
}
|
||||
void fromRel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Does this file, or anything it renders, read a context? Depth-limited. */
|
||||
function needsContext(relPath, seen = new Set(), depth = 0) {
|
||||
if (depth > 3 || seen.has(relPath)) return false;
|
||||
seen.add(relPath);
|
||||
const file = byPath.get(relPath);
|
||||
if (!file) return false;
|
||||
const src = readFileSync(file, "utf8");
|
||||
for (const m of src.matchAll(CONTEXT)) {
|
||||
const name = m[0].slice(0, -1);
|
||||
if (!LOCAL_HOOK.test(name) && !HARNESS_PROVIDED.test(name)) return true;
|
||||
}
|
||||
return localImports(src, relPath).some((child) =>
|
||||
needsContext(child, seen, depth + 1),
|
||||
);
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const file of sources) {
|
||||
const r = rel(file);
|
||||
const base = r.split("/").pop();
|
||||
if (!/^[A-Z]/.test(base)) continue;
|
||||
const src = readFileSync(file, "utf8");
|
||||
if (!RENDERS.test(src)) continue;
|
||||
if (INFRA_NAME.test(base) || INFRA_DIR.test("/" + r)) continue;
|
||||
if (CONFIG_FACTORY.test(src)) continue;
|
||||
|
||||
const stem = base.replace(".tsx", "");
|
||||
const tail = r.replace(".tsx", "");
|
||||
const covered =
|
||||
importedNames.has(stem) ||
|
||||
[...importedPaths].some((p) => p.endsWith(tail) || p.endsWith("/" + stem));
|
||||
|
||||
let needs = "props";
|
||||
if (DATA.test(src)) needs = "data";
|
||||
else if (ROUTER.test(src)) needs = "router";
|
||||
else if (needsContext(r)) needs = "context";
|
||||
|
||||
const parts = r.split("/");
|
||||
rows.push({
|
||||
area: parts.slice(0, Math.min(3, parts.length - 1)).join("/"),
|
||||
file: r,
|
||||
covered,
|
||||
needs,
|
||||
loc: src.split("\n").length,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── report ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const shown = areaFilter
|
||||
? rows.filter((r) => r.file.startsWith(areaFilter))
|
||||
: rows;
|
||||
const todo = shown.filter((r) => !r.covered);
|
||||
|
||||
if (wantTodo || areaFilter) {
|
||||
const order = { props: 0, context: 1, router: 2, data: 3 };
|
||||
for (const r of todo.sort(
|
||||
(a, b) => order[a.needs] - order[b.needs] || a.loc - b.loc,
|
||||
)) {
|
||||
console.log(
|
||||
` ${r.needs.padEnd(8)} ${String(r.loc).padStart(5)} loc ${r.file}`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
const areas = new Map();
|
||||
for (const r of shown) {
|
||||
const a = areas.get(r.area) ?? { total: 0, covered: 0 };
|
||||
a.total += 1;
|
||||
if (r.covered) a.covered += 1;
|
||||
areas.set(r.area, a);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${"area".padEnd(38)}${"total".padStart(6)}${"covered".padStart(9)}${"%".padStart(6)}`,
|
||||
);
|
||||
for (const [area, a] of [...areas].sort(
|
||||
(x, y) => y[1].total - y[1].covered - (x[1].total - x[1].covered),
|
||||
)) {
|
||||
if (a.total === a.covered) continue;
|
||||
const pct = Math.round((100 * a.covered) / a.total);
|
||||
console.log(
|
||||
`${area.padEnd(38)}${String(a.total).padStart(6)}${String(a.covered).padStart(9)}${String(pct).padStart(5)}%`,
|
||||
);
|
||||
}
|
||||
|
||||
const covered = shown.filter((r) => r.covered).length;
|
||||
const byNeed = todo.reduce(
|
||||
(acc, r) => ((acc[r.needs] = (acc[r.needs] ?? 0) + 1), acc),
|
||||
{},
|
||||
);
|
||||
console.log(
|
||||
`\nsurfaces ${shown.length} covered ${covered} (${Math.round((100 * covered) / shown.length)}%) remaining ${todo.length}`,
|
||||
);
|
||||
console.log(
|
||||
`remaining by what a story needs: ` +
|
||||
Object.entries(byNeed)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `${k} ${v}`)
|
||||
.join(" "),
|
||||
);
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@app/*": [
|
||||
"../../src/cloud/*",
|
||||
"../../src/proprietary/*",
|
||||
"../../src/core/*"
|
||||
],
|
||||
"@portal/*": ["../../src/portal/*"],
|
||||
"@cloud/*": ["../../src/cloud/*"],
|
||||
"@proprietary/*": ["../../src/proprietary/*"],
|
||||
"@core/*": ["../../src/core/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"../global.d.ts",
|
||||
"../*.js",
|
||||
"../*.ts",
|
||||
"../*.tsx",
|
||||
"../core/setupTests.ts",
|
||||
"."
|
||||
]
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* The trailing card in the file editor's grid that invites the user to add more
|
||||
* files. Clicking the card (or its "Add Files" button) opens the files modal;
|
||||
* the smaller button beside it goes straight to the native file picker.
|
||||
*
|
||||
* The two buttons share one slot: hovering the upload button expands it to fill
|
||||
* the row and hides "Add Files". That is internal state, so it is not a story.
|
||||
* `accept` and `multiple` only reach the hidden input and change nothing on
|
||||
* screen, which leaves a single rendered state.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AddFileCard from "@app/components/fileEditor/AddFileCard";
|
||||
import {
|
||||
FilesModalContext,
|
||||
type FilesModalContextType,
|
||||
} from "@app/contexts/FilesModalContext";
|
||||
|
||||
const meta = {
|
||||
title: "FileEditor/AddFileCard",
|
||||
component: AddFileCard,
|
||||
parameters: { layout: "centered" },
|
||||
args: { onFileSelect: () => {} },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
// Only openFilesModal is read. The real provider reaches FileContext and
|
||||
// NavigationContext, so a slice is supplied instead.
|
||||
<FilesModalContext.Provider
|
||||
value={{ openFilesModal: () => {} } as unknown as FilesModalContextType}
|
||||
>
|
||||
{/* The card fills the cell the file editor's grid gives it. */}
|
||||
<div style={{ width: "16rem", height: "20rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
</FilesModalContext.Provider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof AddFileCard>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* The three-column arrangement of the file manager modal on a wide viewport:
|
||||
* sources on the left, the search/actions/list stack in the middle, and the
|
||||
* selected file's details on the right.
|
||||
*
|
||||
* The layout takes no props — everything comes from the provider. The search
|
||||
* bar and action bar above the list are tied to the Recent source, and the
|
||||
* list's scroll height is computed from the modal height and whether any files
|
||||
* exist, so the populated and empty cases are laid out differently.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import DesktopLayout from "@app/components/fileManager/DesktopLayout";
|
||||
import {
|
||||
makeStub,
|
||||
withFileManager,
|
||||
} from "@app/components/fileManager/storyFixtures";
|
||||
|
||||
const FILES = [
|
||||
makeStub("file-1", "quarterly-report.pdf"),
|
||||
makeStub("file-2", "invoice-2026-01.pdf"),
|
||||
makeStub("file-3", "scan-of-contract.pdf", { size: 18_400_000 }),
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/DesktopLayout",
|
||||
component: DesktopLayout,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "600px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof DesktopLayout>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Files present and one of them selected, so the details column is filled in. */
|
||||
export const Default: Story = {
|
||||
decorators: [
|
||||
withFileManager({ recentFiles: FILES, activeFileIds: [FILES[0].id] }),
|
||||
],
|
||||
};
|
||||
|
||||
/** First run: the middle column is the empty state and the details are blank. */
|
||||
export const Empty: Story = {
|
||||
decorators: [withFileManager({ recentFiles: [] })],
|
||||
};
|
||||
|
||||
/** With storage on, the middle column gains the filter and bulk cloud actions. */
|
||||
export const StorageEnabled: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
recentFiles: FILES,
|
||||
activeFileIds: [FILES[0].id],
|
||||
config: { storageEnabled: true, storageSharingEnabled: true },
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* What the file manager shows before anything has been added. The copy and
|
||||
* icons come from the file-action hooks, which desktop builds override — these
|
||||
* stories render the web wording.
|
||||
*
|
||||
* Only one context field is read (the upload click handler), so the fixture
|
||||
* supplies that alone rather than the whole provider.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import EmptyFilesState from "@app/components/fileManager/EmptyFilesState";
|
||||
import {
|
||||
FileManagerContext,
|
||||
type FileManagerContextValue,
|
||||
} from "@app/contexts/FileManagerContext";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
|
||||
const meta: Meta<typeof EmptyFilesState> = {
|
||||
title: "FileManager/EmptyFilesState",
|
||||
component: EmptyFilesState,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [
|
||||
// The wordmark resolves its logo variant through PreferencesContext, which
|
||||
// the Storybook preview does not mount, so this story supplies it.
|
||||
(Story) => (
|
||||
<PreferencesProvider>
|
||||
<FileManagerContext.Provider
|
||||
value={
|
||||
{ onLocalFileClick: () => {} } as unknown as FileManagerContextValue
|
||||
}
|
||||
>
|
||||
<div style={{ height: "32rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
</FileManagerContext.Provider>
|
||||
</PreferencesProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof EmptyFilesState>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The panel centres itself, so a short container crops rather than reflows. */
|
||||
export const ShortContainer: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "18rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** Narrow widths are the mobile case — the upload actions stack. */
|
||||
export const Narrow: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: 360 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* The action bar above the recent-files list: select-all, an optional storage
|
||||
* filter, the selection count, and the bulk delete/download/upload/share
|
||||
* buttons.
|
||||
*
|
||||
* What appears is decided by the storage config and by the current selection.
|
||||
* The upload button needs storage on; the share button additionally needs
|
||||
* sharing and share links on, which also widen the filter from All/Local to
|
||||
* include the two "shared" tabs. The delete and download buttons are always
|
||||
* present but disabled until something is selected, and the whole bar renders
|
||||
* nothing at all while there are no recent files.
|
||||
*
|
||||
* Selection is provider state seeded from `activeFileIds`, so the stories vary
|
||||
* that rather than a prop.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileActions from "@app/components/fileManager/FileActions";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import {
|
||||
makeStub,
|
||||
withFileManager,
|
||||
} from "@app/components/fileManager/storyFixtures";
|
||||
|
||||
const FILES = [
|
||||
makeStub("file-1", "quarterly-report.pdf"),
|
||||
makeStub("file-2", "invoice-2026-01.pdf"),
|
||||
makeStub("file-3", "scan.pdf"),
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/FileActions",
|
||||
component: FileActions,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof FileActions>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Local-only build, nothing selected: bulk actions present but inert. */
|
||||
export const Default: Story = {
|
||||
decorators: [withFileManager({ recentFiles: FILES })],
|
||||
};
|
||||
|
||||
/** With a selection the count appears and delete/download become live. */
|
||||
export const WithSelection: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
recentFiles: FILES,
|
||||
activeFileIds: [FILES[0].id, FILES[1].id],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/** Storage on adds the All/Local filter and the bulk upload button. */
|
||||
export const StorageEnabled: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
recentFiles: FILES,
|
||||
activeFileIds: [FILES[0].id],
|
||||
config: { storageEnabled: true },
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Sharing and share links on add the share button and the two "shared" filter
|
||||
* tabs.
|
||||
*/
|
||||
export const SharingEnabled: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
recentFiles: FILES,
|
||||
activeFileIds: [FILES[0].id],
|
||||
config: {
|
||||
storageEnabled: true,
|
||||
storageSharingEnabled: true,
|
||||
storageShareLinksEnabled: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* A selection that includes a file owned by someone else: bulk upload and share
|
||||
* stay disabled because they only apply to files the user owns.
|
||||
*/
|
||||
export const SelectionIncludesSharedFile: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
recentFiles: [
|
||||
FILES[0],
|
||||
makeStub("file-shared", "budget-from-alex.pdf", {
|
||||
remoteStorageId: 42,
|
||||
remoteOwnedByCurrentUser: false,
|
||||
remoteOwnerUsername: "alex",
|
||||
remoteAccessRole: "viewer",
|
||||
}),
|
||||
],
|
||||
activeFileIds: [FILES[0].id, "file-shared" as FileId],
|
||||
config: {
|
||||
storageEnabled: true,
|
||||
storageSharingEnabled: true,
|
||||
storageShareLinksEnabled: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/** With no recent files the bar renders nothing. */
|
||||
export const NoFiles: Story = {
|
||||
decorators: [withFileManager({ recentFiles: [] })],
|
||||
};
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* The details card on the right of the file manager: name, format, size, date
|
||||
* and version, followed by whatever the file's storage situation warrants.
|
||||
*
|
||||
* The trailing rows are all conditional and are driven by the stub's remote
|
||||
* fields rather than by props. A file with no `remoteStorageId` is local only;
|
||||
* one the user owns on the server shows a sync state that turns to "changes not
|
||||
* uploaded" once its local timestamp is newer than the remote one; one owned by
|
||||
* somebody else shows the owner and offers a copy. Sharing rows additionally
|
||||
* need the storage/sharing config on, and a tool chain appears only when the
|
||||
* file carries history.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileInfoCard from "@app/components/fileManager/FileInfoCard";
|
||||
import {
|
||||
makeStub,
|
||||
withFileManager,
|
||||
} from "@app/components/fileManager/storyFixtures";
|
||||
|
||||
const MODIFIED = Date.UTC(2026, 0, 14);
|
||||
|
||||
/** The default local-only build. */
|
||||
const local = withFileManager();
|
||||
|
||||
/** Storage plus sharing, for the stories about server-side state. */
|
||||
const cloud = withFileManager({
|
||||
config: {
|
||||
storageEnabled: true,
|
||||
storageSharingEnabled: true,
|
||||
storageShareLinksEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/FileInfoCard",
|
||||
component: FileInfoCard,
|
||||
args: { modalHeight: "600px" },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: "22rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof FileInfoCard>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** A file that has never left the browser: the "Local only" badge, nothing else. */
|
||||
export const LocalOnly: Story = {
|
||||
args: { currentFile: makeStub("file-1", "quarterly-report.pdf") },
|
||||
decorators: [local],
|
||||
};
|
||||
|
||||
/** No file selected — the labels stay, their values are blank. */
|
||||
export const NoFileSelected: Story = {
|
||||
args: { currentFile: null },
|
||||
decorators: [local],
|
||||
};
|
||||
|
||||
/** A file that has been through tools carries its chain as badges. */
|
||||
export const WithToolHistory: Story = {
|
||||
args: {
|
||||
currentFile: makeStub("file-1", "quarterly-report.pdf", {
|
||||
versionNumber: 3,
|
||||
toolHistory: [
|
||||
{ toolId: "split", timestamp: MODIFIED },
|
||||
{ toolId: "compress", timestamp: MODIFIED },
|
||||
],
|
||||
}),
|
||||
},
|
||||
decorators: [local],
|
||||
};
|
||||
|
||||
/** Uploaded and current: the cloud row reads "Synced" and shows the sync time. */
|
||||
export const SyncedToCloud: Story = {
|
||||
args: {
|
||||
currentFile: makeStub("file-1", "quarterly-report.pdf", {
|
||||
remoteStorageId: 7,
|
||||
remoteStorageUpdatedAt: MODIFIED + 60_000,
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
|
||||
/** Edited since the last upload, so the cloud row warns instead. */
|
||||
export const ChangesNotUploaded: Story = {
|
||||
args: {
|
||||
currentFile: makeStub("file-1", "quarterly-report.pdf", {
|
||||
lastModified: MODIFIED + 3_600_000,
|
||||
createdAt: MODIFIED + 3_600_000,
|
||||
remoteStorageId: 7,
|
||||
remoteStorageUpdatedAt: MODIFIED,
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
|
||||
/** Someone else's file: owner row, "Shared with you" badge, and a copy action. */
|
||||
export const SharedWithYou: Story = {
|
||||
args: {
|
||||
currentFile: makeStub("file-1", "budget-from-alex.pdf", {
|
||||
remoteStorageId: 11,
|
||||
remoteOwnedByCurrentUser: false,
|
||||
remoteOwnerUsername: "alex",
|
||||
remoteAccessRole: "viewer",
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
|
||||
/** The user's own file with links out: a sharing row and the management entry. */
|
||||
export const SharedByYou: Story = {
|
||||
args: {
|
||||
currentFile: makeStub("file-1", "quarterly-report.pdf", {
|
||||
remoteStorageId: 7,
|
||||
remoteStorageUpdatedAt: MODIFIED + 60_000,
|
||||
remoteHasShareLinks: true,
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* The scrolling list that fills the file manager. Which branch it renders is
|
||||
* decided entirely by context — the active source, whether files are loading,
|
||||
* and whether any survive the search filter — so the stories drive it through
|
||||
* the provider rather than through props.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileListArea from "@app/components/fileManager/FileListArea";
|
||||
import {
|
||||
makeStub,
|
||||
withFileManager,
|
||||
} from "@app/components/fileManager/storyFixtures";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const FILES = [
|
||||
makeStub("f1", "quarterly-report.pdf"),
|
||||
makeStub("f2", "signed-contract.pdf", { size: 840_000 }),
|
||||
makeStub("f3", "scan-2026-03-14.pdf", { size: 12_600_000 }),
|
||||
makeStub("f4", "minutes.pdf", { size: 96_000 }),
|
||||
];
|
||||
|
||||
const meta: Meta<typeof FileListArea> = {
|
||||
title: "FileManager/FileListArea",
|
||||
component: FileListArea,
|
||||
parameters: { layout: "padded" },
|
||||
args: { scrollAreaHeight: "26rem" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FileListArea>;
|
||||
|
||||
export const Default: Story = {
|
||||
decorators: [withFileManager({ recentFiles: FILES })],
|
||||
};
|
||||
|
||||
/** No files at all — the list gives way to the empty state. */
|
||||
export const Empty: Story = {
|
||||
decorators: [withFileManager({ recentFiles: [] })],
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
decorators: [withFileManager({ recentFiles: FILES, isLoading: true })],
|
||||
};
|
||||
|
||||
/** Files already open in the editor are marked as active in the list. */
|
||||
export const WithActiveFile: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
recentFiles: FILES,
|
||||
activeFileIds: ["f2" as FileId],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/** Enough rows to scroll, which is the normal state for a working library. */
|
||||
export const ManyFiles: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
recentFiles: Array.from({ length: 24 }, (_, i) =>
|
||||
makeStub(`many-${i}`, `document-${String(i + 1).padStart(3, "0")}.pdf`),
|
||||
),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/** A short frame proves the list scrolls inside its own box, not the page. */
|
||||
export const ShortFrame: Story = {
|
||||
args: { scrollAreaHeight: "12rem" },
|
||||
decorators: [withFileManager({ recentFiles: FILES })],
|
||||
};
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* A single row in the file manager's recent list: checkbox, name, a run of
|
||||
* status badges, size/date, and a hover-revealed overflow menu.
|
||||
*
|
||||
* The badges are the interesting part. Version always shows; "Active" comes
|
||||
* from the prop; and the storage badge is one of a mutually exclusive set
|
||||
* decided by the stub's remote fields — local only, synced, changes not
|
||||
* uploaded, or a shared-with-you pair of ownership and role — most of which
|
||||
* additionally require the storage/sharing config to be on. `isHistoryFile`
|
||||
* turns the row into an indented, non-selectable version entry instead.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileListItem from "@app/components/fileManager/FileListItem";
|
||||
import {
|
||||
makeStub,
|
||||
withFileManager,
|
||||
} from "@app/components/fileManager/storyFixtures";
|
||||
|
||||
const MODIFIED = Date.UTC(2026, 0, 14);
|
||||
const FILE = makeStub("file-1", "quarterly-report.pdf");
|
||||
|
||||
/** The default local-only build. */
|
||||
const local = withFileManager({ recentFiles: [FILE] });
|
||||
|
||||
/** Storage plus sharing, for the stories about server-side state. */
|
||||
const cloud = withFileManager({
|
||||
recentFiles: [FILE],
|
||||
config: {
|
||||
storageEnabled: true,
|
||||
storageSharingEnabled: true,
|
||||
storageShareLinksEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/FileListItem",
|
||||
component: FileListItem,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
file: FILE,
|
||||
isSelected: false,
|
||||
isLatestVersion: true,
|
||||
onSelect: () => {},
|
||||
onRemove: () => {},
|
||||
onDownload: () => {},
|
||||
onDoubleClick: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof FileListItem>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** A freshly uploaded file that has never been to the server. */
|
||||
export const Default: Story = { decorators: [local] };
|
||||
|
||||
/** Selected: the checkbox is ticked and the row takes the highlight fill. */
|
||||
export const Selected: Story = {
|
||||
args: { isSelected: true },
|
||||
decorators: [local],
|
||||
};
|
||||
|
||||
/** A file currently open in the workbench earns the "Active" badge. */
|
||||
export const Active: Story = {
|
||||
args: { isActive: true },
|
||||
decorators: [local],
|
||||
};
|
||||
|
||||
/** A processed file: a higher version number and the tool chain that produced it. */
|
||||
export const Processed: Story = {
|
||||
args: {
|
||||
file: makeStub("file-1", "quarterly-report.pdf", {
|
||||
versionNumber: 3,
|
||||
toolHistory: [
|
||||
{ toolId: "split", timestamp: MODIFIED },
|
||||
{ toolId: "compress", timestamp: MODIFIED },
|
||||
],
|
||||
}),
|
||||
},
|
||||
decorators: [local],
|
||||
};
|
||||
|
||||
/**
|
||||
* An older version listed under its leaf: indented behind a rule, with no
|
||||
* checkbox because history entries cannot be selected.
|
||||
*/
|
||||
export const HistoryEntry: Story = {
|
||||
args: {
|
||||
file: makeStub("file-1", "quarterly-report.pdf", { versionNumber: 2 }),
|
||||
isHistoryFile: true,
|
||||
isLatestVersion: false,
|
||||
},
|
||||
decorators: [local],
|
||||
};
|
||||
|
||||
/** Uploaded and current: "Synced" replaces the local-only badge. */
|
||||
export const Synced: Story = {
|
||||
args: {
|
||||
file: makeStub("file-1", "quarterly-report.pdf", {
|
||||
remoteStorageId: 7,
|
||||
remoteStorageUpdatedAt: MODIFIED + 60_000,
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
|
||||
/** Edited since the last upload, so the badge warns instead. */
|
||||
export const ChangesNotUploaded: Story = {
|
||||
args: {
|
||||
file: makeStub("file-1", "quarterly-report.pdf", {
|
||||
lastModified: MODIFIED + 3_600_000,
|
||||
createdAt: MODIFIED + 3_600_000,
|
||||
remoteStorageId: 7,
|
||||
remoteStorageUpdatedAt: MODIFIED,
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
|
||||
/** Someone else's file: ownership and the read-only role are both called out. */
|
||||
export const SharedWithYou: Story = {
|
||||
args: {
|
||||
file: makeStub("file-1", "budget-from-alex.pdf", {
|
||||
remoteStorageId: 11,
|
||||
remoteOwnedByCurrentUser: false,
|
||||
remoteOwnerUsername: "alex",
|
||||
remoteAccessRole: "viewer",
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
|
||||
/** The user's own file with a live link out. */
|
||||
export const SharedByYou: Story = {
|
||||
args: {
|
||||
file: makeStub("file-1", "quarterly-report.pdf", {
|
||||
remoteStorageId: 7,
|
||||
remoteStorageUpdatedAt: MODIFIED + 60_000,
|
||||
remoteHasShareLinks: true,
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* The source picker down the left of the file manager: Recent, local upload,
|
||||
* Google Drive and mobile scan.
|
||||
*
|
||||
* Recent and upload are always offered. The other two are each governed by a
|
||||
* pair of config flags — one that enables the integration and one that decides
|
||||
* whether an unavailable integration is shown greyed out or dropped from the
|
||||
* list entirely. `horizontal` reflows the same buttons into a centred row for
|
||||
* the mobile layout and shortens their labels ("Drive", "Mobile").
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileSourceButtons from "@app/components/fileManager/FileSourceButtons";
|
||||
import { withFileManager } from "@app/components/fileManager/storyFixtures";
|
||||
|
||||
/** The column the buttons occupy in the desktop layout. */
|
||||
const withColumn = (Story: () => ReactElement) => (
|
||||
<div style={{ width: "13.625rem", height: "20rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/FileSourceButtons",
|
||||
component: FileSourceButtons,
|
||||
decorators: [withColumn],
|
||||
} satisfies Meta<typeof FileSourceButtons>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/**
|
||||
* The plain build: Recent is the active source, and Drive and mobile scan are
|
||||
* both present but disabled because neither is configured.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
decorators: [withFileManager()],
|
||||
};
|
||||
|
||||
/** Hiding the unavailable integrations leaves only Recent and upload. */
|
||||
export const UnavailableSourcesHidden: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
config: {
|
||||
hideDisabledToolsGoogleDrive: true,
|
||||
hideDisabledToolsMobileQRScanner: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Fully configured: Drive takes its coloured icon and both integrations become
|
||||
* clickable. Drive needs the client/API/app ids as well as its enable flag.
|
||||
*/
|
||||
export const AllSourcesAvailable: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
config: {
|
||||
googleDriveEnabled: true,
|
||||
googleDriveClientId: "storybook-client-id",
|
||||
googleDriveApiKey: "storybook-api-key",
|
||||
googleDriveAppId: "storybook-app-id",
|
||||
enableMobileScanner: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/** The mobile layout's row: centred, no heading, and abbreviated labels. */
|
||||
export const Horizontal: Story = {
|
||||
args: { horizontal: true },
|
||||
decorators: [withFileManager()],
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* The stacked arrangement of the file manager modal on a narrow viewport: the
|
||||
* sources as a horizontal row, the compact file details, then the search bar,
|
||||
* action bar and list sharing one panel.
|
||||
*
|
||||
* As with the desktop layout there are no props — the provider supplies
|
||||
* everything. The search and action bars belong to the Recent source, and the
|
||||
* list's height is worked back from the modal height, allowing extra room for
|
||||
* the details block once a file is selected.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import MobileLayout from "@app/components/fileManager/MobileLayout";
|
||||
import {
|
||||
makeStub,
|
||||
withFileManager,
|
||||
} from "@app/components/fileManager/storyFixtures";
|
||||
|
||||
const FILES = [
|
||||
makeStub("file-1", "quarterly-report.pdf"),
|
||||
makeStub("file-2", "invoice-2026-01.pdf"),
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/MobileLayout",
|
||||
component: MobileLayout,
|
||||
parameters: { layout: "fullscreen" },
|
||||
globals: { viewport: { value: "mobile1", isRotated: false } },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "600px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof MobileLayout>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** A file selected, so the compact details block sits above the list. */
|
||||
export const Default: Story = {
|
||||
decorators: [
|
||||
withFileManager({ recentFiles: FILES, activeFileIds: [FILES[0].id] }),
|
||||
],
|
||||
};
|
||||
|
||||
/** Nothing selected: the details block shrinks and the list takes the room. */
|
||||
export const NoSelection: Story = {
|
||||
decorators: [withFileManager({ recentFiles: FILES })],
|
||||
};
|
||||
|
||||
/** First run, with the empty state filling the list panel. */
|
||||
export const Empty: Story = {
|
||||
decorators: [withFileManager({ recentFiles: [] })],
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* The file manager's search box. It reads the term and the change handler from
|
||||
* FileManagerContext rather than taking props, so the stories mount it against
|
||||
* a two-field slice of that context instead of the whole provider chain.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import SearchInput from "@app/components/fileManager/SearchInput";
|
||||
import {
|
||||
FileManagerContext,
|
||||
type FileManagerContextValue,
|
||||
} from "@app/contexts/FileManagerContext";
|
||||
|
||||
/**
|
||||
* SearchInput reads exactly two fields. Supplying only those keeps the fixture
|
||||
* honest about what the component depends on; the cast is what makes a partial
|
||||
* value acceptable in place of the full context.
|
||||
*/
|
||||
function withSearch(
|
||||
searchTerm: string,
|
||||
onSearchChange: (term: string) => void = () => {},
|
||||
) {
|
||||
return (children: React.ReactNode) => (
|
||||
<FileManagerContext.Provider
|
||||
value={
|
||||
{ searchTerm, onSearchChange } as unknown as FileManagerContextValue
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</FileManagerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof SearchInput> = {
|
||||
title: "FileManager/SearchInput",
|
||||
component: SearchInput,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof SearchInput>;
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => withSearch("")(<SearchInput />),
|
||||
};
|
||||
|
||||
export const WithTerm: Story = {
|
||||
render: () => withSearch("invoice")(<SearchInput />),
|
||||
};
|
||||
|
||||
/** Long terms are not truncated by the component — the field scrolls instead. */
|
||||
export const LongTerm: Story = {
|
||||
render: () =>
|
||||
withSearch("quarterly-report-2026-final-revised-approved")(<SearchInput />),
|
||||
};
|
||||
|
||||
/** The container controls width, so the box stretches to whatever it is given. */
|
||||
export const Narrow: Story = {
|
||||
render: () => (
|
||||
<div style={{ width: 220 }}>{withSearch("draft")(<SearchInput />)}</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Typing is driven by the context handler, so state lives outside the field. */
|
||||
export const Interactive: Story = {
|
||||
render: function Interactive() {
|
||||
const [term, setTerm] = useState("");
|
||||
return withSearch(term, setTerm)(<SearchInput />);
|
||||
},
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Shared fixtures for the file manager stories.
|
||||
*
|
||||
* These components sit deep in a provider chain — FileManagerContext needs
|
||||
* FileContext for useFileActions/useFileManagement, list rows additionally read
|
||||
* AppConfig — and none of it is part of the shared preview decorators. Rather
|
||||
* than each story rebuilding that tree, they mount the real providers here over
|
||||
* static data, so what a story exercises is the component and not a stub.
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import { FileManagerProvider } from "@app/contexts/FileManagerContext";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
/** A grey rectangle, so thumbnail lookups short-circuit instead of reading
|
||||
* file bytes out of IndexedDB. */
|
||||
const THUMBNAIL =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E";
|
||||
|
||||
/** Fixed so stories render identically on every run. */
|
||||
const LAST_MODIFIED = Date.parse("2026-03-14T09:30:00Z");
|
||||
|
||||
export function makeStub(
|
||||
id: string,
|
||||
name: string,
|
||||
overrides: Partial<StirlingFileStub> = {},
|
||||
): StirlingFileStub {
|
||||
return {
|
||||
id: id as FileId,
|
||||
name,
|
||||
type: "application/pdf",
|
||||
size: 2_400_000,
|
||||
lastModified: LAST_MODIFIED,
|
||||
isLeaf: true,
|
||||
originalFileId: id,
|
||||
versionNumber: 1,
|
||||
thumbnailUrl: THUMBNAIL,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockFile = makeStub("story-file-1", "quarterly-report.pdf");
|
||||
|
||||
/** Storage off keeps the row's upload and share affordances out of the way;
|
||||
* stories that want them pass their own config. */
|
||||
const BASE_CONFIG = {
|
||||
storageEnabled: false,
|
||||
storageSharingEnabled: false,
|
||||
storageShareLinksEnabled: false,
|
||||
frontendUrl: "https://stirling.example",
|
||||
};
|
||||
|
||||
interface FixtureOptions {
|
||||
recentFiles?: StirlingFileStub[];
|
||||
activeFileIds?: FileId[];
|
||||
isLoading?: boolean;
|
||||
config?: Partial<typeof BASE_CONFIG>;
|
||||
}
|
||||
|
||||
export function withFileManager({
|
||||
recentFiles = [mockFile],
|
||||
activeFileIds = [],
|
||||
isLoading = false,
|
||||
config,
|
||||
}: FixtureOptions = {}) {
|
||||
return (Story: () => ReactElement) => (
|
||||
<AppConfigProvider
|
||||
initialConfig={{ ...BASE_CONFIG, ...config } as never}
|
||||
bootstrapMode="non-blocking"
|
||||
autoFetch={false}
|
||||
>
|
||||
{/* The empty state's wordmark resolves a logo variant through
|
||||
PreferencesContext, which the preview does not mount. */}
|
||||
<PreferencesProvider>
|
||||
<FileContextProvider>
|
||||
<FileManagerProvider
|
||||
recentFiles={recentFiles}
|
||||
onRecentFilesSelected={() => {}}
|
||||
onNewFilesSelect={() => {}}
|
||||
onClose={() => {}}
|
||||
isFileSupported={() => true}
|
||||
isOpen
|
||||
onFileRemove={() => {}}
|
||||
modalHeight="600px"
|
||||
refreshRecentFiles={async () => {}}
|
||||
isLoading={isLoading}
|
||||
activeFileIds={activeFileIds}
|
||||
>
|
||||
<Story />
|
||||
</FileManagerProvider>
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* The resizable rail that holds the folder tree on the Files page. It supplies
|
||||
* the heading and the drag/keyboard resize handle around FolderTreeSidebar, and
|
||||
* wires the tree's folder actions through to the Files page context.
|
||||
*
|
||||
* `active` is the only prop: an inactive panel is collapsed away by CSS, hidden
|
||||
* from assistive technology, and drops its resize handle, so the tab it belongs
|
||||
* to can slide in and out. Its width is otherwise self-managed — auto-fitted to
|
||||
* the longest folder name until the user drags it, after which the chosen width
|
||||
* is persisted.
|
||||
*
|
||||
* No folders are seeded into IndexedDB, so the tree shows only its pinned rows.
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import { FolderProvider } from "@app/contexts/FolderContext";
|
||||
import { FilesPageProvider } from "@app/contexts/FilesPageContext";
|
||||
|
||||
/**
|
||||
* The panel reads the folder tree from FolderContext and the file counts and
|
||||
* folder dialogs from FilesPageContext; both sit above FileContext, which
|
||||
* brings in IndexedDBContext. None are part of the shared preview decorators.
|
||||
*/
|
||||
function withFolderContexts(Story: () => ReactElement) {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<FolderProvider>
|
||||
<FilesPageProvider>
|
||||
<div style={{ display: "flex", height: "24rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
</FilesPageProvider>
|
||||
</FolderProvider>
|
||||
</FileContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "FilesPage/FolderTreePanel",
|
||||
component: FolderTreePanel,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [withFolderContexts],
|
||||
} satisfies Meta<typeof FolderTreePanel>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Open: the heading, the tree, and the resize handle down the right edge. */
|
||||
export const Active: Story = {
|
||||
args: { active: true },
|
||||
};
|
||||
|
||||
/** Collapsed for a tab that is not showing folders. */
|
||||
export const Inactive: Story = {
|
||||
args: { active: false },
|
||||
};
|
||||
@@ -1,327 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge, Group, Menu, Text } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
|
||||
import { FileId, ToolOperation } from "@app/types/file";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
|
||||
import { downloadFileFromStorage } from "@app/utils/downloadUtils";
|
||||
|
||||
/** Small label/value row with crisp flex alignment and colon separation. */
|
||||
export function DetailField({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="files-page-details-field"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
alignItems: "baseline",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="files-page-details-field-label"
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: "var(--c-text-subtle)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{label}:
|
||||
</span>
|
||||
<span
|
||||
className="files-page-details-field-value"
|
||||
style={{ fontWeight: 600, color: "var(--c-text)" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tool that produced `version` from `prior`; null for v1. */
|
||||
function deltaToolFor(
|
||||
version: StirlingFileStub,
|
||||
prior: StirlingFileStub | null,
|
||||
): ToolOperation | null {
|
||||
if (!prior) return null;
|
||||
const priorLen = prior.toolHistory?.length ?? 0;
|
||||
const curr = version.toolHistory ?? [];
|
||||
return curr[priorLen] ?? null;
|
||||
}
|
||||
|
||||
/** Translated tool name via `home.{toolId}.title`. */
|
||||
function ToolLabel({ toolId }: { toolId: ToolId }) {
|
||||
const { t } = useTranslation();
|
||||
return <span>{t(`home.${toolId}.title`, toolId)}</span>;
|
||||
}
|
||||
|
||||
export interface VersionTimelineProps {
|
||||
/** Chain sorted oldest-first. */
|
||||
chain: StirlingFileStub[];
|
||||
/** Currently selected version. */
|
||||
currentId: FileId;
|
||||
onAddToWorkspace: (fileIds: FileId[]) => void;
|
||||
onRemove: (fileIds: FileId[]) => void;
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
/** Clean, spacious version timeline with minimal clutter. */
|
||||
export function VersionTimeline({
|
||||
chain,
|
||||
currentId,
|
||||
onAddToWorkspace,
|
||||
onRemove,
|
||||
hideHeader = false,
|
||||
}: VersionTimelineProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showAllCollapsed, setShowAllCollapsed] = useState(false);
|
||||
|
||||
// Newest-first ordering.
|
||||
const ordered = useMemo(
|
||||
() =>
|
||||
[...chain].sort(
|
||||
(a, b) => (b.versionNumber ?? 1) - (a.versionNumber ?? 1),
|
||||
),
|
||||
[chain],
|
||||
);
|
||||
|
||||
// Index by versionNumber for prior-version lookup.
|
||||
const byVersionNumber = useMemo(() => {
|
||||
const map = new Map<number, StirlingFileStub>();
|
||||
for (const v of chain) {
|
||||
map.set(v.versionNumber ?? 1, v);
|
||||
}
|
||||
return map;
|
||||
}, [chain]);
|
||||
|
||||
// Collapse middle when long: 3 newest + ellipsis + 2 oldest.
|
||||
const COLLAPSE_THRESHOLD = 6;
|
||||
const collapsible = ordered.length > COLLAPSE_THRESHOLD;
|
||||
type Row =
|
||||
| { kind: "version"; version: StirlingFileStub }
|
||||
| {
|
||||
kind: "ellipsis";
|
||||
hidden: number;
|
||||
};
|
||||
const rows: Row[] = useMemo(() => {
|
||||
if (!collapsible || showAllCollapsed) {
|
||||
return ordered.map((v) => ({ kind: "version", version: v }) as Row);
|
||||
}
|
||||
const head = ordered
|
||||
.slice(0, 3)
|
||||
.map((v) => ({ kind: "version", version: v }) as Row);
|
||||
const tail = ordered
|
||||
.slice(-2)
|
||||
.map((v) => ({ kind: "version", version: v }) as Row);
|
||||
const hidden = ordered.length - 5;
|
||||
return [...head, { kind: "ellipsis", hidden }, ...tail];
|
||||
}, [collapsible, showAllCollapsed, ordered]);
|
||||
|
||||
return (
|
||||
<div className="files-page-details-version-timeline">
|
||||
{!hideHeader && (
|
||||
<div className="files-page-details-version-timeline-label">
|
||||
<HistoryIcon fontSize="small" />
|
||||
<span>{t("filesPage.field.versionHistory", "Version journey")}</span>
|
||||
<span className="files-page-details-version-timeline-count">
|
||||
{t("filesPage.versionsCount", "{{count}} versions", {
|
||||
count: ordered.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ul
|
||||
className="files-page-details-version-timeline-list"
|
||||
style={{ listStyle: "none", padding: 0, margin: 0 }}
|
||||
>
|
||||
{rows.map((row, idx) => {
|
||||
const isLast = idx === rows.length - 1;
|
||||
if (row.kind === "ellipsis") {
|
||||
return (
|
||||
<li
|
||||
key="ellipsis"
|
||||
className="files-page-details-version-timeline-ellipsis"
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
<div className="files-page-details-version-timeline-rail">
|
||||
<span className="files-page-details-version-timeline-rail-dot is-ellipsis" />
|
||||
{!isLast && (
|
||||
<span className="files-page-details-version-timeline-rail-line" />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => setShowAllCollapsed(true)}
|
||||
>
|
||||
{t(
|
||||
"filesPage.versionShowHidden",
|
||||
"Show {{count}} earlier versions",
|
||||
{ count: row.hidden },
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const v = row.version;
|
||||
const isActive = v.id === currentId;
|
||||
const prior = byVersionNumber.get((v.versionNumber ?? 1) - 1) ?? null;
|
||||
const delta = deltaToolFor(v, prior);
|
||||
const isOriginal = (v.versionNumber ?? 1) === 1;
|
||||
const nameChanged = prior ? prior.name !== v.name : false;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={v.id}
|
||||
className={`files-page-details-version-timeline-row${
|
||||
isActive ? " is-active" : ""
|
||||
}`}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
<div className="files-page-details-version-timeline-rail">
|
||||
<span
|
||||
className={`files-page-details-version-timeline-rail-dot${
|
||||
isActive ? " is-active" : ""
|
||||
}`}
|
||||
/>
|
||||
{!isLast && (
|
||||
<span className="files-page-details-version-timeline-rail-line" />
|
||||
)}
|
||||
</div>
|
||||
<div className="files-page-details-version-timeline-body">
|
||||
{/* Header row: Version Badge, Action Title + Menu */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
align="center"
|
||||
style={{ flex: 1, minWidth: 0, flexWrap: "nowrap" }}
|
||||
>
|
||||
<Badge size="xs" variant={isActive ? "filled" : "light"}>
|
||||
v{v.versionNumber ?? 1}
|
||||
</Badge>
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
truncate
|
||||
style={{ color: "var(--c-text)" }}
|
||||
>
|
||||
{delta ? (
|
||||
<ToolLabel toolId={delta.toolId} />
|
||||
) : (
|
||||
t("filesPage.versionOrigin", "Original upload")
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{!isActive && (
|
||||
<Menu position="bottom-end" withinPortal shadow="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
aria-label={t(
|
||||
"filesPage.versionActions",
|
||||
"Version actions",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={() => onAddToWorkspace([v.id])}
|
||||
>
|
||||
{t(
|
||||
"filesPage.openVersionInWorkspace",
|
||||
"Open in workspace",
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<DownloadIcon fontSize="small" />}
|
||||
onClick={() => {
|
||||
void downloadFileFromStorage(v);
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"filesPage.downloadVersion",
|
||||
"Download this version",
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={() => onRemove([v.id])}
|
||||
>
|
||||
{t("filesPage.removeVersion", "Remove this version")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quiet Meta Line: File Size · Date */}
|
||||
<Text size="xs" c="dimmed" style={{ marginTop: "0.15rem" }}>
|
||||
{formatFileSize(v.size)}
|
||||
{v.lastModified && (
|
||||
<> · {getFileDate({ lastModified: v.lastModified })}</>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Show filename ONLY if original upload or if name changed */}
|
||||
{(isOriginal || nameChanged) && (
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ wordBreak: "break-all", marginTop: "0.2rem" }}
|
||||
>
|
||||
{nameChanged
|
||||
? `${t("filesPage.renamed", "Renamed")}: `
|
||||
: `${t("filesPage.file", "File")}: `}
|
||||
<span style={{ color: "var(--c-text)", fontWeight: 500 }}>
|
||||
{v.name}
|
||||
</span>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{collapsible && showAllCollapsed && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => setShowAllCollapsed(false)}
|
||||
>
|
||||
{t("filesPage.versionCollapse", "Collapse middle versions")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
HotkeyContext,
|
||||
type HotkeyContextValue,
|
||||
} from "@app/contexts/HotkeyContext";
|
||||
import { HotkeyDisplay } from "@app/components/hotkeys/HotkeyDisplay";
|
||||
import { getDisplayParts } from "@app/utils/hotkeys";
|
||||
|
||||
/** A keyboard shortcut rendered as key caps.
|
||||
*
|
||||
* HotkeyProvider pulls in the whole tool-workflow chain, but the display only
|
||||
* needs one function off the context — so the stories supply that slice, using
|
||||
* the real formatter so the caps render exactly as they do in the app. Pinned
|
||||
* to the non-mac glyphs to keep the stories stable across machines. */
|
||||
const withHotkeys = (Story: React.ComponentType) => (
|
||||
<HotkeyContext.Provider
|
||||
value={
|
||||
{
|
||||
getDisplayParts: (binding) => getDisplayParts(binding, false),
|
||||
} as HotkeyContextValue
|
||||
}
|
||||
>
|
||||
<Story />
|
||||
</HotkeyContext.Provider>
|
||||
);
|
||||
|
||||
const meta: Meta<typeof HotkeyDisplay> = {
|
||||
title: "Hotkeys/HotkeyDisplay",
|
||||
component: HotkeyDisplay,
|
||||
parameters: { layout: "centered" },
|
||||
decorators: [withHotkeys],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof HotkeyDisplay>;
|
||||
|
||||
/** A single key. */
|
||||
export const SingleKey: Story = { args: { binding: { code: "KeyS" } } };
|
||||
|
||||
/** The common save shortcut. */
|
||||
export const WithModifier: Story = {
|
||||
args: { binding: { code: "KeyS", ctrl: true } },
|
||||
};
|
||||
|
||||
/** Several modifiers at once. */
|
||||
export const MultipleModifiers: Story = {
|
||||
args: { binding: { code: "KeyP", ctrl: true, shift: true, alt: true } },
|
||||
};
|
||||
|
||||
/** The macOS command modifier. */
|
||||
export const MetaModifier: Story = {
|
||||
args: { binding: { code: "KeyK", meta: true } },
|
||||
};
|
||||
|
||||
/** A non-letter key, which renders its own glyph rather than a letter. */
|
||||
export const ArrowKey: Story = { args: { binding: { code: "ArrowRight" } } };
|
||||
|
||||
/** Both sizes side by side. */
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||
<HotkeyDisplay binding={{ code: "KeyS", ctrl: true }} size="sm" />
|
||||
<HotkeyDisplay binding={{ code: "KeyS", ctrl: true }} size="md" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Muted, for a shortcut shown beside a disabled action. */
|
||||
export const Muted: Story = {
|
||||
args: { binding: { code: "KeyS", ctrl: true }, muted: true },
|
||||
};
|
||||
|
||||
/** No binding assigned — the component renders nothing rather than an empty
|
||||
* cap, so an unbound action shows no stray chrome. */
|
||||
export const Unbound: Story = { args: { binding: null } };
|
||||
@@ -1,244 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Modal } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { Button, type ButtonAccent } from "@app/ui/Button";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||
import stirlingMark from "@app/assets/brand/modern-logo/logo512.png";
|
||||
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
|
||||
|
||||
/** A footer button. `action` is an opaque string handled by the caller. */
|
||||
export interface ShellButton {
|
||||
key: string;
|
||||
/** Chevron-left icon button (a back control) instead of a labelled button. */
|
||||
back?: boolean;
|
||||
label?: string;
|
||||
/** Filled primary (blue) vs. quiet text button. */
|
||||
primary?: boolean;
|
||||
/** Accent override for a primary button (e.g. "premium"). */
|
||||
accent?: ButtonAccent;
|
||||
action: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingSlideShellProps {
|
||||
opened?: boolean;
|
||||
/** Hero art node — use {@link ShellHero} to render the app mark or a glyph. */
|
||||
hero: ReactNode;
|
||||
slideKey: string;
|
||||
title: ReactNode;
|
||||
body: ReactNode;
|
||||
stepIndex: number;
|
||||
stepCount: number;
|
||||
buttons: ShellButton[];
|
||||
onAction: (action: string) => void;
|
||||
onClose: () => void;
|
||||
allowDismiss?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero art for the inset panel. `appIcon` renders the Stirling app mark
|
||||
* directly; otherwise the children glyph sits inside a soft white tile.
|
||||
*/
|
||||
export function ShellHero({
|
||||
appIcon = false,
|
||||
children,
|
||||
}: {
|
||||
appIcon?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
if (appIcon) {
|
||||
return (
|
||||
<img src={stirlingMark} alt="Stirling" className={styles.heroAppIcon} />
|
||||
);
|
||||
}
|
||||
return <div className={styles.heroTile}>{children}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared onboarding slide chrome: branded header + step progress, an inset
|
||||
* hero panel, left-aligned title/body, and a right-aligned action footer.
|
||||
* Generic over button actions so every flow (editor, SaaS, portal) renders
|
||||
* the same card.
|
||||
*/
|
||||
export default function OnboardingSlideShell({
|
||||
opened = true,
|
||||
hero,
|
||||
slideKey,
|
||||
title,
|
||||
body,
|
||||
stepIndex,
|
||||
stepCount,
|
||||
buttons,
|
||||
onAction,
|
||||
onClose,
|
||||
allowDismiss = true,
|
||||
}: OnboardingSlideShellProps) {
|
||||
const { t } = useTranslation();
|
||||
const showProgress = stepCount > 1;
|
||||
|
||||
// Back/icon buttons anchor the left; text actions cluster on the right.
|
||||
// A back control can't do anything on the first slide, so hide it there.
|
||||
const backButtons = stepIndex === 0 ? [] : buttons.filter((b) => b.back);
|
||||
const actionButtons = buttons.filter((b) => !b.back);
|
||||
|
||||
const renderButton = (button: ShellButton) => (
|
||||
<Button
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
disabled={button.disabled}
|
||||
variant={button.primary ? "primary" : "quiet"}
|
||||
accent={button.accent ?? (button.primary ? "default" : "neutral")}
|
||||
>
|
||||
{button.label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const actions = (
|
||||
<div className={styles.footerGroup}>{actionButtons.map(renderButton)}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
// Composed rather than the plain <Modal>, because only Modal.Content lands
|
||||
// props on the role="dialog" element — the slide draws its own title, so the
|
||||
// dialog needs an aria-label to have an accessible name.
|
||||
<Modal.Root
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={allowDismiss}
|
||||
centered
|
||||
size="lg"
|
||||
radius={20}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
|
||||
content: {
|
||||
overflow: "hidden",
|
||||
border: "none",
|
||||
background: "var(--c-surface)",
|
||||
maxHeight: "90vh",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content
|
||||
radius={20}
|
||||
aria-label={t("onboarding.dialogLabel", "Onboarding")}
|
||||
>
|
||||
<Modal.Body>
|
||||
<div className={styles.card}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.brand}>
|
||||
<img
|
||||
src={stirlingMark}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={styles.brandLogo}
|
||||
/>
|
||||
<span className={styles.wordmark}>Stirling</span>
|
||||
</div>
|
||||
<div className={styles.headerRight}>
|
||||
{showProgress && (
|
||||
<span className={styles.stepPill}>
|
||||
{t("onboarding.stepOf", "Step {{current}} of {{total}}", {
|
||||
current: stepIndex + 1,
|
||||
total: stepCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{allowDismiss && (
|
||||
<ActionIcon
|
||||
onClick={onClose}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
aria-label={t("common.close", "Close")}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="close-rounded"
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showProgress && (
|
||||
<div
|
||||
className={styles.progressTrack}
|
||||
role="progressbar"
|
||||
aria-valuenow={stepIndex + 1}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={stepCount}
|
||||
aria-label={t(
|
||||
"onboarding.stepOf",
|
||||
"Step {{current}} of {{total}}",
|
||||
{
|
||||
current: stepIndex + 1,
|
||||
total: stepCount,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: stepCount }, (_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`${styles.progressSeg} ${
|
||||
index <= stepIndex ? styles.progressSegDone : ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.heroPanel}>
|
||||
<div className={styles.heroArt} key={`hero-${slideKey}`}>
|
||||
{hero}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key={`title-${slideKey}`} className={styles.titleNew}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div key={`body-${slideKey}`} className={styles.bodyNew}>
|
||||
{body}
|
||||
<style>{`.${styles.bodyNew} strong{color: var(--c-text); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
{backButtons.length === 0 ? (
|
||||
<div className={styles.footerEnd}>{actions}</div>
|
||||
) : (
|
||||
<div className={styles.footerBetween}>
|
||||
<div className={styles.footerGroup}>
|
||||
{backButtons.map((button) => (
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
disabled={button.disabled}
|
||||
aria-label={t("onboarding.buttons.back", "Back")}
|
||||
>
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
))}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import BulkSelectionPanel from "@app/components/pageEditor/BulkSelectionPanel";
|
||||
|
||||
/** A document of `n` pages in the shape the panel reads. */
|
||||
const doc = (n: number) => ({
|
||||
pages: Array.from({ length: n }, (_, i) => ({
|
||||
id: `page-${i + 1}`,
|
||||
pageNumber: i + 1,
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* Selecting pages by typing a range rather than clicking thumbnails. The CSV
|
||||
* field is the whole point of the panel, so the stories drive it with real
|
||||
* state — typing into a static snapshot would prove nothing.
|
||||
*/
|
||||
const meta: Meta<typeof BulkSelectionPanel> = {
|
||||
title: "PageEditor/BulkSelectionPanel",
|
||||
component: BulkSelectionPanel,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof BulkSelectionPanel>;
|
||||
|
||||
function Demo({
|
||||
initialCsv = "",
|
||||
selected = [],
|
||||
pages = 24,
|
||||
}: {
|
||||
initialCsv?: string;
|
||||
selected?: string[];
|
||||
pages?: number;
|
||||
}) {
|
||||
const [csvInput, setCsvInput] = useState(initialCsv);
|
||||
return (
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={selected}
|
||||
displayDocument={doc(pages)}
|
||||
onUpdatePagesFromCSV={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nothing selected yet. */
|
||||
export const Empty: Story = { render: () => <Demo /> };
|
||||
|
||||
/** A typed range, with the matching pages selected. */
|
||||
export const WithRange: Story = {
|
||||
render: () => (
|
||||
<Demo
|
||||
initialCsv="1-5"
|
||||
selected={["page-1", "page-2", "page-3", "page-4", "page-5"]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** A mixed expression — individual pages and ranges together. */
|
||||
export const MixedExpression: Story = {
|
||||
render: () => (
|
||||
<Demo initialCsv="1,4-6,12" selected={["page-1", "page-4", "page-12"]} />
|
||||
),
|
||||
};
|
||||
|
||||
/** Every page selected. */
|
||||
export const AllSelected: Story = {
|
||||
render: () => (
|
||||
<Demo
|
||||
pages={8}
|
||||
initialCsv="1-8"
|
||||
selected={Array.from({ length: 8 }, (_, i) => `page-${i + 1}`)}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** A single-page document, where ranges have little to do. */
|
||||
export const SinglePage: Story = {
|
||||
render: () => <Demo pages={1} />,
|
||||
};
|
||||
|
||||
/** A long document, to check the summary stays readable as counts grow. */
|
||||
export const LongDocument: Story = {
|
||||
render: () => (
|
||||
<Demo
|
||||
pages={480}
|
||||
initialCsv="1-200"
|
||||
selected={Array.from({ length: 200 }, (_, i) => `page-${i + 1}`)}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton";
|
||||
|
||||
const doc = (n: number) => ({
|
||||
pages: Array.from({ length: n }, (_, i) => ({
|
||||
id: `page-${i + 1}`,
|
||||
pageNumber: i + 1,
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* The toolbar affordance that opens bulk page selection. It disables itself
|
||||
* when there are no pages to select, so an empty document offers no dead
|
||||
* control.
|
||||
*/
|
||||
const meta: Meta<typeof PageSelectByNumberButton> = {
|
||||
title: "PageEditor/PageSelectByNumberButton",
|
||||
component: PageSelectByNumberButton,
|
||||
parameters: { layout: "centered" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof PageSelectByNumberButton>;
|
||||
|
||||
function Demo({
|
||||
totalPages = 24,
|
||||
disabled = false,
|
||||
initialCsv = "",
|
||||
selected = [],
|
||||
}: {
|
||||
totalPages?: number;
|
||||
disabled?: boolean;
|
||||
initialCsv?: string;
|
||||
selected?: string[];
|
||||
}) {
|
||||
const [csvInput, setCsvInput] = useState(initialCsv);
|
||||
return (
|
||||
<PageSelectByNumberButton
|
||||
disabled={disabled}
|
||||
totalPages={totalPages}
|
||||
label="Select pages by number"
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={selected}
|
||||
displayDocument={doc(totalPages)}
|
||||
updatePagesFromCSV={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Available — click to open the selection popover. */
|
||||
export const Default: Story = { render: () => <Demo /> };
|
||||
|
||||
/** A selection already in place. */
|
||||
export const WithSelection: Story = {
|
||||
render: () => (
|
||||
<Demo initialCsv="2,5-9" selected={["page-2", "page-5", "page-9"]} />
|
||||
),
|
||||
};
|
||||
|
||||
/** Explicitly disabled, e.g. while the document is still loading. */
|
||||
export const Disabled: Story = { render: () => <Demo disabled /> };
|
||||
|
||||
/** No pages: the control disables itself regardless of the `disabled` prop. */
|
||||
export const NoPages: Story = { render: () => <Demo totalPages={0} /> };
|
||||
|
||||
/** A single page — selectable, but ranges have little to do. */
|
||||
export const SinglePage: Story = { render: () => <Demo totalPages={1} /> };
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* The page-selection expression field at the top of the bulk selection panel —
|
||||
* a title with a syntax-guide tooltip, an optional "Advanced" switch, and the
|
||||
* comma/range input itself.
|
||||
*
|
||||
* Two things decide what renders. The clear button in the input's right section
|
||||
* appears only while the expression is non-empty, and the Advanced switch is
|
||||
* present only when the caller passes an `advancedOpened` boolean at all —
|
||||
* panels that have no advanced mode omit the prop and get no switch.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import PageSelectionInput from "@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput";
|
||||
|
||||
const meta = {
|
||||
title: "PageEditor/BulkSelectionPanel/PageSelectionInput",
|
||||
component: PageSelectionInput,
|
||||
args: {
|
||||
csvInput: "",
|
||||
setCsvInput: () => {},
|
||||
onUpdatePagesFromCSV: () => {},
|
||||
onClear: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof PageSelectionInput>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Empty: placeholder syntax only, and no clear affordance to offer yet. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A non-empty expression reveals the clear button beside the input. */
|
||||
export const WithExpression: Story = {
|
||||
args: { csvInput: "1,3,5-10" },
|
||||
};
|
||||
|
||||
/** Passing `advancedOpened` adds the Advanced switch to the header row. */
|
||||
export const WithAdvancedToggle: Story = {
|
||||
args: { advancedOpened: false, onToggleAdvanced: () => {} },
|
||||
};
|
||||
|
||||
/** The switch on, as it sits while the advanced panel below is expanded. */
|
||||
export const AdvancedOpened: Story = {
|
||||
args: {
|
||||
csvInput: "odd & 1-50",
|
||||
advancedOpened: true,
|
||||
onToggleAdvanced: () => {},
|
||||
},
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Lazy wrapper around the settings modal: it renders nothing until opened, so
|
||||
* the heavy config bundle is only fetched when someone asks for settings.
|
||||
*
|
||||
* Sections come from the build's registry, and hosts can add their own or hide
|
||||
* ones they cannot run.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AppConfigModalLazy from "@app/components/shared/AppConfigModalLazy";
|
||||
|
||||
const meta: Meta<typeof AppConfigModalLazy> = {
|
||||
title: "Shared/AppConfigModalLazy",
|
||||
component: AppConfigModalLazy,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { opened: false, onClose: () => {}, urlSync: false },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AppConfigModalLazy>;
|
||||
|
||||
/** Closed, which is the state it spends nearly all its life in. */
|
||||
export const Closed: Story = {};
|
||||
|
||||
export const Opened: Story = { args: { opened: true } };
|
||||
|
||||
/** A host that cannot run a registry section drops it by key. */
|
||||
export const WithHiddenSection: Story = {
|
||||
args: { opened: true, hiddenSectionKeys: ["about"] as never },
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
|
||||
|
||||
/**
|
||||
* The sidebar brand header. Core has no admin portal to switch to, so this is
|
||||
* just the logo — builds that bundle the portal shadow this file with a version
|
||||
* whose logo doubles as the editor⇄processor switcher. Both states matter here
|
||||
* because the rail collapses.
|
||||
*/
|
||||
const meta: Meta<typeof AppSwitcher> = {
|
||||
title: "Shared/AppSwitcher",
|
||||
component: AppSwitcher,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AppSwitcher>;
|
||||
|
||||
/** Expanded rail — mark and wordmark. */
|
||||
export const Expanded: Story = {};
|
||||
|
||||
/** Collapsed rail — icon only. */
|
||||
export const Collapsed: Story = { args: { collapsed: true } };
|
||||
|
||||
/** Both, to compare the mark's optical size between the two rail widths. */
|
||||
export const BothStates: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "3rem", alignItems: "center" }}>
|
||||
<AppSwitcher />
|
||||
<AppSwitcher collapsed />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import Badge from "@app/components/shared/Badge";
|
||||
|
||||
/** Small inline label. `colored` takes an explicit palette so callers can tint
|
||||
* a badge to whatever the surrounding feature already uses. */
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: "Shared/Badge",
|
||||
component: Badge,
|
||||
parameters: { layout: "centered" },
|
||||
args: { children: "Beta" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Badge>;
|
||||
|
||||
/** Default tone. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The three sizes together, so their baselines can be compared. */
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
|
||||
<Badge size="sm">Small</Badge>
|
||||
<Badge size="md">Medium</Badge>
|
||||
<Badge size="lg">Large</Badge>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Explicitly tinted. The pairing is the caller's to get right — these use the
|
||||
* semantic tokens rather than raw hues so they hold up in both themes. */
|
||||
export const Colored: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
|
||||
<Badge
|
||||
variant="colored"
|
||||
backgroundColor="var(--c-success-solid)"
|
||||
textColor="var(--c-text-on-primary)"
|
||||
>
|
||||
Active
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="colored"
|
||||
backgroundColor="var(--c-warning-solid)"
|
||||
textColor="var(--c-text-on-primary)"
|
||||
>
|
||||
Pending
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="colored"
|
||||
backgroundColor="var(--c-danger-solid)"
|
||||
textColor="var(--c-text-on-primary)"
|
||||
>
|
||||
Failed
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** A long label, to check it stays on one line rather than breaking the row. */
|
||||
export const LongLabel: Story = {
|
||||
args: { children: "Requires the AI engine" },
|
||||
};
|
||||
|
||||
/** A numeral, the other common use. */
|
||||
export const Count: Story = { args: { children: "12", size: "sm" } };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user