Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e5267162c | ||
|
|
0692058602 | ||
|
|
8150d16b6f | ||
|
|
c8f238ae60 | ||
|
|
8df49ac053 | ||
|
|
328cd8c664 | ||
|
|
f703a67817 | ||
|
|
105af51100 | ||
|
|
57bf17d348 | ||
|
|
11df30b914 | ||
|
|
43162c40ad | ||
|
|
be57f11747 | ||
|
|
8ba8f69252 | ||
|
|
be97268a7c |
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: feature-walkthrough
|
||||
description: >-
|
||||
Explain the full logic and process of the current branch end-to-end so someone
|
||||
with no prior knowledge of the task can understand, review, and reproduce it.
|
||||
Scopes the change from the branch diff, traces the flow across every layer it
|
||||
touches (frontend tool/hook/component, Java controller/service/endpoint, Python
|
||||
engine, config, i18n, tests), and produces a self-contained walkthrough document
|
||||
with Mermaid diagrams (sequence/flow/architecture), annotated file map with
|
||||
clickable references, before/after behavior, screenshots where a UI is involved,
|
||||
a "try it locally" section, and edge cases/risks. Use when asked for a feature or
|
||||
branch walkthrough, "explain what this branch does", a design/logic writeup, PR
|
||||
reviewer onboarding, or a hand-off doc. Pass --html to also emit a rendered HTML
|
||||
version; --no-screens to skip screenshots.
|
||||
argument-hint: "[branch-or-area] [--html] [--no-screens]"
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
# Feature / Branch Walkthrough
|
||||
|
||||
Turn the current branch into a walkthrough a newcomer can follow. Audience:
|
||||
**someone who has never seen this task**. Explain the *why*, the *flow*, and *how to
|
||||
try it* - not just a diff summary.
|
||||
|
||||
`$ARGUMENTS` may name a branch or area to focus on; default is the current branch
|
||||
vs `main`. Flags: `--html` (also emit a rendered HTML twin), `--no-screens`.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Scope the change
|
||||
- `git log --oneline main..HEAD` and `git diff --stat main...HEAD` for the shape.
|
||||
- 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/*`),
|
||||
hooks (`core/hooks/tools/*`, `useToolOperation`), contexts, routes, i18n
|
||||
(`public/locales/en-US`).
|
||||
- **Java backend**: controllers (`.../controller/api/...`), services, models, config.
|
||||
- **Engine**: `engine/src/stirling/{agents,contracts,api,services}`.
|
||||
- **Config / build / docker / tests.**
|
||||
|
||||
### 2. Trace the flow end-to-end
|
||||
Follow one real path from user action to result. For a typical PDF tool that's:
|
||||
UI control → `useToolOperation` hook → `POST /api/v1/...` → Spring controller →
|
||||
service (PDFBox / LibreOffice / engine call) → response → review panel → download.
|
||||
Read the actual files so the narrative is true to the code, and collect the exact
|
||||
file:line anchors you'll cite.
|
||||
|
||||
### 3. Draw the diagrams (Mermaid)
|
||||
Pick what fits; usually 2-3 of:
|
||||
- **Sequence diagram** - request/response across frontend → backend → engine.
|
||||
- **Flowchart** - the core decision/branching logic of the feature.
|
||||
- **Architecture/component** - new pieces and how they wire to existing ones.
|
||||
- **State** - if the feature has modes/steps.
|
||||
Keep nodes labeled in plain language. Validate the Mermaid parses before shipping.
|
||||
|
||||
### 4. Screenshots (unless --no-screens)
|
||||
If a UI is involved, capture key states with the stubbed Playwright harness
|
||||
(see the **ui-walkthrough** skill and `files-page-screenshots.spec.ts` for the
|
||||
pattern) or, for before/after, capture `main` then the branch. Drop PNGs in
|
||||
`walkthrough/<feature>/` and reference them from the doc. For backend-only
|
||||
changes, show request/response examples (curl + JSON) instead.
|
||||
|
||||
### 5. Write the walkthrough
|
||||
Create `walkthrough/<feature>/FEATURE-WALKTHROUGH.md` with:
|
||||
1. **TL;DR** - what the branch does and who it's for, in 3-4 sentences.
|
||||
2. **Problem & approach** - what wasn't possible before; the chosen solution.
|
||||
3. **Architecture diagram** + 1-paragraph orientation.
|
||||
4. **End-to-end flow** - the sequence diagram + a numbered walk of each step,
|
||||
each citing the real file (clickable `path:line`).
|
||||
5. **Key files** - annotated map (path → one line on its role).
|
||||
6. **Logic deep-dive** - the flowchart + prose for the non-obvious decisions.
|
||||
7. **Behavior** - before vs after; screenshots or request/response examples.
|
||||
8. **Try it locally** - exact steps (`task dev` / `task dev:all`, the route to
|
||||
open or the curl to run, any env like `DOCKER_ENABLE_SECURITY` or a test
|
||||
license key). Make it copy-pasteable.
|
||||
9. **Edge cases, risks, follow-ups** - what's untested, known limits, gotchas.
|
||||
|
||||
Markdown is the primary deliverable - it renders with diagrams in GitHub PRs and
|
||||
IDEs, no build step, ideal for review.
|
||||
|
||||
### 6. If `--html`
|
||||
Also emit `walkthrough/<feature>/walkthrough.html`: the same content with Mermaid
|
||||
rendered via `mermaid.initialize({startOnLoad:true})` (script from CDN; note in
|
||||
the file that rendering diagrams needs network, the `.md` is the offline copy) and
|
||||
screenshots inline. Keep it self-contained otherwise.
|
||||
|
||||
### 7. Deliver
|
||||
Give the doc path and a short chat summary. Offer to `SendUserFile` it.
|
||||
|
||||
## Principles
|
||||
- **True to the code.** Every claim traces to a file you read; cite `path:line`.
|
||||
No fabricated migration/version history.
|
||||
- **Newcomer-first.** Define repo-specific terms (FileContext, `useToolOperation`,
|
||||
the `@app/*` layer cascade, stubbed vs live tests) on first use.
|
||||
- **Show, don't assert.** Prefer a diagram + a real example over adjectives.
|
||||
- Don't commit the `walkthrough/` output unless asked.
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
name: ui-before-after
|
||||
description: >-
|
||||
Analyse a branch or PR and automatically capture before/after screenshots of
|
||||
every UI surface its changes touch, then pixel-diff the pairs to surface what
|
||||
actually changed and assemble PR-ready before/after montage images. Generic and
|
||||
diff-driven: it derives the capture targets from the diff (changed tools/routes →
|
||||
URLs) instead of hand-listing screens, captures "before" from the base branch and
|
||||
"after" from the head, then keeps only the views that visually differ. Each
|
||||
comparison is auto-cropped to the region that actually changed (the bounding box of
|
||||
differing pixels), falling back to the full page only when the change spans most of
|
||||
it. Use for before/after shots, a visual diff of a branch/PR, "screenshots for the
|
||||
PR description", "show what changed in the UI", or a side-by-side of UI changes.
|
||||
Takes a PR number/URL (resolved via gh) or a branch; defaults to the current branch
|
||||
vs its base. Flags: --scope <selector>, --base <ref|merge-base>, --theme
|
||||
light|dark|both, --all (capture every route, not just changed), --no-autocrop,
|
||||
--pagewide <n>, --threshold <n>.
|
||||
argument-hint: "[PR# | PR-url | branch] [--scope <sel>] [--base <ref>] [--theme both] [--all] [--no-autocrop]"
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
# UI Before / After (generic visual diff)
|
||||
|
||||
Point it at a branch or PR; it figures out which UI changed, screenshots every
|
||||
affected surface **before** (base) and **after** (head), pixel-diffs the pairs, and
|
||||
montages the ones that actually changed into images for the PR description.
|
||||
|
||||
`$ARGUMENTS`: a PR number/URL, a branch, or nothing (current branch vs base).
|
||||
By default it captures the full viewport and auto-crops each comparison to the region
|
||||
that changed. Flags: `--scope <css>` (narrow the *capture* to a container, e.g.
|
||||
`[data-sidebar="tool-panel"]`, when you already know where the change is),
|
||||
`--no-autocrop` (keep full frames), `--pagewide <fraction>` (above this share of the
|
||||
page, skip cropping; default 0.6), `--base <ref|merge-base>`,
|
||||
`--theme light|dark|both`, `--all` (walk every route, not just changed),
|
||||
`--threshold <fraction>` (diff sensitivity, default 0.001).
|
||||
|
||||
Shares the capture harness with **ui-walkthrough** - read its SKILL.md for the
|
||||
stubbed-Playwright setup, worktree node_modules + `generate-icons`, the
|
||||
stale-`:5173` gotcha, and the dark-mode init-script. Bundled helpers:
|
||||
[capture-spec.template.ts](capture-spec.template.ts), [diff-shots.mjs](diff-shots.mjs),
|
||||
[montage-template.html](montage-template.html), [shoot-sections.mjs](shoot-sections.mjs).
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Resolve target + base
|
||||
```
|
||||
gh pr view <pr> --json number,title,headRefName,baseRefName,url,files # PR
|
||||
# or branch: base = merge-base(main, HEAD); head = HEAD
|
||||
gh pr diff <pr> --name-only # or: git diff --name-only <base>...HEAD
|
||||
```
|
||||
|
||||
### 2. Derive capture targets from the diff (the "analyse" step - no hand-listing)
|
||||
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
|
||||
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` =
|
||||
`[{ "id":"compress", "url":"/compress", "name":"Compress" }]`. This is what makes
|
||||
it generic - the spec never names a tool.
|
||||
|
||||
### 3. Capture AFTER (head) then BEFORE (base)
|
||||
Copy [capture-spec.template.ts](capture-spec.template.ts) →
|
||||
`src/core/tests/stubbed/ui-before-after.spec.ts` (it loops `targets.json`, seeds a
|
||||
sample PDF so file-dependent panels render, navigates to each URL, and screenshots
|
||||
the full viewport - or the `--scope` container if given). Ensure the harness is ready
|
||||
(node_modules + icons).
|
||||
```
|
||||
# after = current head
|
||||
cd frontend/editor && 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 \
|
||||
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.
|
||||
```
|
||||
|
||||
### 4. Auto-diff (surface what changed)
|
||||
```
|
||||
cd frontend/editor && 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 |
|
||||
removed`. For each changed view it computes the bounding box of differing pixels and
|
||||
writes cropped `__before_crop.png` / `__after_crop.png` / `__diff.png` to that region
|
||||
(+ padding) - **unless** the change covers more than `--pagewide` of the frame, where
|
||||
it keeps the full frame (`pageWide:true`). Drop `unchanged` - that's the noise the
|
||||
user doesn't want.
|
||||
|
||||
### 5. Montage the changes
|
||||
Build the manifest from the non-unchanged entries (group by tab/tool; each becomes a
|
||||
state row with before/after). For changed views use the cropped `cropBefore` /
|
||||
`cropAfter` from `diff-report.json` (tight on the affected region; full frame when
|
||||
`pageWide`); `added`/`removed` render the "not present" placeholder. Fill
|
||||
[montage-template.html](montage-template.html) (replace the `window.__BA__` data
|
||||
block; base64-inline the PNGs for portability), then render one PNG per section with
|
||||
[shoot-sections.mjs](shoot-sections.mjs). Optionally include the `__diff.png` overlay
|
||||
as a third column.
|
||||
|
||||
### 6. Deliver
|
||||
Output the `montage_<tab>.png` files + a short summary (N changed / added / removed,
|
||||
M unchanged skipped) and a paste-ready Markdown block. GitHub has no PR-body image
|
||||
API, so tell the user to drag the PNGs into the description. Do **not** post to the
|
||||
PR.
|
||||
|
||||
## Gotchas
|
||||
- Two installs (base worktree + head); junction main's node_modules only if its deps
|
||||
match that ref, else `npm ci` (see ui-walkthrough's stale-dep note).
|
||||
- A view that errors on one side (refactored/removed) → that side is missing; the
|
||||
diff marks it added/removed rather than failing the run.
|
||||
- Pixel diff needs equal dimensions, so capture at a fixed viewport (the template
|
||||
does); a view whose size changed is reported as "changed (dimensions differ)",
|
||||
uncropped.
|
||||
- Auto-crop uses a single bounding box, so two far-apart changes give one large crop
|
||||
(or trip `--pagewide`); narrow with `--scope` if that happens.
|
||||
- `getToolUrlPath` is the source of truth for tool URLs - use it, don't guess slugs.
|
||||
- Don't commit `screenshots/`, the throwaway spec, or the base worktree.
|
||||
@@ -0,0 +1,67 @@
|
||||
// Generic before/after capturer. NOT app-specific: it walks a targets.json that
|
||||
// the ui-before-after skill generates from the branch/PR diff, so nothing here is
|
||||
// hand-listed. Copy to src/core/tests/stubbed/ui-before-after.spec.ts, then run
|
||||
// once per (side, theme):
|
||||
// PR_SHOT_SIDE=after PR_SHOT_THEME=light \
|
||||
// npx playwright test --project=stubbed ui-before-after.spec.ts
|
||||
//
|
||||
// targets.json shape: [{ "id":"compress", "url":"/compress", "name":"Compress",
|
||||
// "needsFile": true }]
|
||||
import { test } from "@app/tests/helpers/stub-test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const SIDE = process.env.PR_SHOT_SIDE ?? "after";
|
||||
const THEME = process.env.PR_SHOT_THEME ?? "light";
|
||||
// Capture the full viewport by default so the affected region is in frame
|
||||
// wherever it is; diff-shots.mjs crops each comparison to what actually changed.
|
||||
// Set PR_SHOT_SCOPE to a selector to narrow the capture to one container.
|
||||
const SCOPE = process.env.PR_SHOT_SCOPE ?? "";
|
||||
const ROOT = path.resolve(process.cwd(), "screenshots", "ui-diff");
|
||||
const OUT = path.join(ROOT, SIDE);
|
||||
// A tiny sample PDF so file-dependent tool panels render. Point at a real fixture.
|
||||
const SAMPLE_PDF = process.env.PR_SHOT_SAMPLE ?? "src/core/tests/test-fixtures/sample.pdf";
|
||||
|
||||
type Target = { id: string; url: string; name?: string; needsFile?: boolean };
|
||||
const targets: Target[] = JSON.parse(fs.readFileSync(path.join(ROOT, "targets.json"), "utf-8"));
|
||||
|
||||
test.use({ autoGoto: false, viewport: { width: 1600, height: 900 }, seedJwt: true });
|
||||
|
||||
async function applyTheme(page: Page): Promise<void> {
|
||||
if (THEME !== "dark") return;
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("mantine-color-scheme", "dark");
|
||||
localStorage.setItem("mantine-color-scheme-value", "dark");
|
||||
});
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
}
|
||||
|
||||
async function seedFile(page: Page): Promise<void> {
|
||||
if (!fs.existsSync(SAMPLE_PDF)) return;
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("files-button").click().catch(() => {});
|
||||
await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF).catch(() => {});
|
||||
await page.locator(".file-sidebar-file-item").first().isVisible({ timeout: 8_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
for (const t of targets) {
|
||||
// One test per target so a single failure doesn't drop the rest.
|
||||
test(`${SIDE}/${THEME} ${t.id}`, async ({ page }) => {
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
await applyTheme(page);
|
||||
if (t.needsFile !== false) await seedFile(page);
|
||||
await page.goto(t.url, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(400); // settle Mantine portals/transitions
|
||||
const shot = path.join(OUT, `${t.id}__${THEME}.png`);
|
||||
if (SCOPE) {
|
||||
const scope = page.locator(SCOPE).first();
|
||||
if (await scope.isVisible({ timeout: 8_000 }).catch(() => false)) {
|
||||
await scope.screenshot({ path: shot });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Full viewport (fixed size → stable dimensions for pixel diffing).
|
||||
await page.screenshot({ path: shot });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Auto-diff before/ vs after/ screenshots, classify each as
|
||||
// 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):
|
||||
// node <skill>/diff-shots.mjs <beforeDir> <afterDir> [outDir]
|
||||
// Env:
|
||||
// DIFF_THRESHOLD min fraction of differing pixels to count as changed (default 0.001)
|
||||
// DIFF_PAD padding px around the affected region (default 24)
|
||||
// DIFF_PAGEWIDE if affected bbox area / image area exceeds this, keep full frame (default 0.6)
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(path.join(process.cwd(), "noop.js"));
|
||||
const pm = require("pixelmatch");
|
||||
const pixelmatch = pm.default || pm;
|
||||
const { PNG } = require("pngjs");
|
||||
|
||||
const beforeDir = path.resolve(process.argv[2]);
|
||||
const afterDir = path.resolve(process.argv[3]);
|
||||
const outDir = path.resolve(process.argv[4] || afterDir);
|
||||
const THRESHOLD = Number(process.env.DIFF_THRESHOLD ?? "0.001");
|
||||
const PAD = Number(process.env.DIFF_PAD ?? "24");
|
||||
const PAGEWIDE = Number(process.env.DIFF_PAGEWIDE ?? "0.6");
|
||||
|
||||
const read = (p) => PNG.sync.read(fs.readFileSync(p));
|
||||
const isShot = (f) => f.endsWith(".png") && !/__(diff|before_crop|after_crop)\.png$/.test(f);
|
||||
const list = (d) => (fs.existsSync(d) ? fs.readdirSync(d).filter(isShot) : []);
|
||||
const names = [...new Set([...list(beforeDir), ...list(afterDir)])].sort();
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
function cropPNG(src, x, y, w, h) {
|
||||
const out = new PNG({ width: w, height: h });
|
||||
PNG.bitblt(src, out, x, y, w, h, 0, 0);
|
||||
return out;
|
||||
}
|
||||
const writePNG = (p, png) => fs.writeFileSync(p, PNG.sync.write(png));
|
||||
|
||||
// Bounding box of differing pixels using a diff mask (alpha>0 where changed).
|
||||
function changedBBox(before, after, w, h) {
|
||||
const mask = new PNG({ width: w, height: h });
|
||||
pixelmatch(before.data, after.data, mask.data, w, h, { threshold: 0.1, diffMask: true });
|
||||
let minX = w, minY = h, maxX = -1, maxY = -1, count = 0;
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (mask.data[(y * w + x) * 4 + 3] > 0) {
|
||||
count++;
|
||||
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxX < 0 ? null : { minX, minY, maxX, maxY, count };
|
||||
}
|
||||
|
||||
const report = [];
|
||||
for (const name of names) {
|
||||
const id = name.replace(/\.png$/, "");
|
||||
const bp = path.join(beforeDir, name), ap = path.join(afterDir, name);
|
||||
const hasB = fs.existsSync(bp), hasA = fs.existsSync(ap);
|
||||
if (hasB && !hasA) { report.push({ id, status: "removed", before: bp }); continue; }
|
||||
if (!hasB && hasA) { report.push({ id, status: "added", after: ap }); continue; }
|
||||
|
||||
const before = read(bp), after = read(ap);
|
||||
if (before.width !== after.width || before.height !== after.height) {
|
||||
report.push({ id, status: "changed", note: "dimensions differ", before: bp, after: ap });
|
||||
continue;
|
||||
}
|
||||
const w = after.width, h = after.height;
|
||||
const overlay = new PNG({ width: w, height: h });
|
||||
const px = pixelmatch(before.data, after.data, overlay.data, w, h, { threshold: 0.1 });
|
||||
const ratio = px / (w * h);
|
||||
if (ratio <= THRESHOLD) { report.push({ id, status: "unchanged", ratio: Number(ratio.toFixed(5)), before: bp, after: ap }); continue; }
|
||||
|
||||
const box = changedBBox(before, after, w, h);
|
||||
// Pad + clamp the affected region.
|
||||
const x = Math.max(0, box.minX - PAD), y = Math.max(0, box.minY - PAD);
|
||||
const x2 = Math.min(w, box.maxX + 1 + PAD), y2 = Math.min(h, box.maxY + 1 + PAD);
|
||||
const bw = x2 - x, bh = y2 - y;
|
||||
const pageWide = (bw * bh) / (w * h) > PAGEWIDE;
|
||||
|
||||
const entry = { id, status: "changed", ratio: Number(ratio.toFixed(5)), before: bp, after: ap, pageWide };
|
||||
if (pageWide) {
|
||||
// Change spans most of the page - keep the full frame, full overlay.
|
||||
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, overlay);
|
||||
entry.diff = dp;
|
||||
} else {
|
||||
entry.bbox = { x, y, w: bw, h: bh };
|
||||
const cb = path.join(outDir, `${id}__before_crop.png`); writePNG(cb, cropPNG(before, x, y, bw, bh));
|
||||
const ca = path.join(outDir, `${id}__after_crop.png`); writePNG(ca, cropPNG(after, x, y, bw, bh));
|
||||
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, cropPNG(overlay, x, y, bw, bh));
|
||||
entry.cropBefore = cb; entry.cropAfter = ca; entry.diff = dp;
|
||||
}
|
||||
report.push(entry);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(outDir, "diff-report.json"), JSON.stringify(report, null, 2));
|
||||
const changed = report.filter((r) => r.status !== "unchanged");
|
||||
console.log(`diffed ${report.length} view(s): ${changed.length} changed/added/removed, ${report.length - changed.length} unchanged`);
|
||||
for (const r of changed) {
|
||||
const tail = r.status !== "changed" ? ""
|
||||
: r.pageWide ? " (page-wide → full frame)"
|
||||
: ` (${(r.ratio * 100).toFixed(2)}%, cropped to ${r.bbox.w}×${r.bbox.h})`;
|
||||
console.log(` ${r.status.padEnd(9)} ${r.id}${tail}${r.note ? " - " + r.note : ""}`);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Build EXAMPLE.html from montage-template.html using REAL files-page shots as
|
||||
stand-in before/after pairs (layout demo, not an actual PR diff). Inlines PNGs as
|
||||
data URIs so the HTML is portable. Run: python make_example.py"""
|
||||
import base64
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
SHOTS = pathlib.Path(
|
||||
r"C:\Users\systo\git\Stirling-PDFNew\.claude\worktrees\kind-faraday-522a30"
|
||||
r"\frontend\editor\screenshots\files-page"
|
||||
)
|
||||
|
||||
|
||||
def uri(fname):
|
||||
p = SHOTS / fname
|
||||
return "data:image/png;base64," + base64.b64encode(p.read_bytes()).decode() if p.exists() else None
|
||||
|
||||
|
||||
data = {
|
||||
"pr": "DEMO",
|
||||
"title": "EXAMPLE — before/after montage (layout demo, real Files-page shots; not a real PR diff)",
|
||||
"base": "main", "head": "demo-branch",
|
||||
"cropSelector": "[data-sidebar=\"tool-panel\"] (real runs crop to the side; these demo shots are full-page)",
|
||||
"tabs": [
|
||||
{"id": "files", "title": "Files page", "ctx": "Each row = one flow state; left = base branch, right = this PR.",
|
||||
"states": [
|
||||
{"name": "Empty folder", "before": uri("01_empty_state_ctas.png"), "after": uri("02_empty_state_storage_off.png")},
|
||||
{"name": "Files + details panel", "before": uri("03_subtoolbar_with_files.png"), "after": uri("06_details_panel_save_to_server.png")},
|
||||
{"name": "Delete folder confirm", "before": None, "after": uri("19_delete_folder_dialog.png"), "note": "New in this PR"},
|
||||
]},
|
||||
{"id": "move", "title": "Move-to-folder dialog",
|
||||
"states": [
|
||||
{"name": "Dialog opened", "before": uri("07_move_dialog_collapsed.png"), "after": uri("08_move_dialog_create_folder_expanded.png")},
|
||||
{"name": "After folder created", "before": None, "after": uri("08b_move_dialog_after_create_folder.png"), "note": "New flow"},
|
||||
]},
|
||||
],
|
||||
}
|
||||
|
||||
tpl = (HERE / "montage-template.html").read_text(encoding="utf-8")
|
||||
out = re.sub(
|
||||
r"/\*__DATA__\*/.*?/\*__END__\*/",
|
||||
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
|
||||
tpl, count=1, flags=re.S,
|
||||
)
|
||||
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
|
||||
print("wrote", HERE / "EXAMPLE.html", "(", (HERE / "EXAMPLE.html").stat().st_size // 1024, "KB )")
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Before/After montage for a PR description. The ui-before-after skill replaces
|
||||
the JSON in the window.__BA__ data block below with the captured manifest, then
|
||||
screenshots each .tab-section (id="section-<tabId>") into a PNG to drag into the
|
||||
PR description. Self-contained; images may be relative paths or data URIs.
|
||||
|
||||
Data shape:
|
||||
{
|
||||
"pr":"6552","title":"...","base":"main","head":"feat/x",
|
||||
"cropSelector":"[data-sidebar=\"tool-panel\"]",
|
||||
"tabs":[
|
||||
{ "id":"sign","title":"Sign tool","states":[
|
||||
{"name":"Initial","before":"before/sign__initial.png","after":"after/sign__initial.png"},
|
||||
{"name":"Cert selected","before":null,"after":"after/sign__cert.png","note":"New in this PR"}
|
||||
]}
|
||||
]
|
||||
}
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Before / After</title>
|
||||
<style>
|
||||
:root { --bg:#ffffff; --ink:#0b0c0e; --muted:#6b7280; --line:#e5e7eb;
|
||||
--before:#6b7280; --after:#1f883d; --frame:#f3f4f6; --note:#b45309; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--ink);
|
||||
font:14px/1.5 -apple-system,"Segoe UI",Roboto,system-ui,sans-serif; }
|
||||
.wrap { max-width:1100px; margin:0 auto; padding:24px; }
|
||||
.doc-head { margin-bottom:8px; }
|
||||
.doc-head h1 { font-size:18px; margin:0 0 2px; }
|
||||
.doc-head .sub { color:var(--muted); font-size:12.5px; }
|
||||
.legend { display:flex; gap:14px; align-items:center; margin:10px 0 4px; font-size:12px; color:var(--muted); }
|
||||
.chip { font-size:10px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
|
||||
padding:2px 8px; border-radius:999px; color:#fff; }
|
||||
.chip.before { background:var(--before); } .chip.after { background:var(--after); }
|
||||
|
||||
.tab-section { border:1px solid var(--line); border-radius:14px; padding:18px 18px 8px;
|
||||
margin:18px 0; background:var(--bg); }
|
||||
.tab-section > h2 { font-size:16px; margin:0 0 2px; }
|
||||
.tab-section > .ctx { color:var(--muted); font-size:12px; margin-bottom:14px; }
|
||||
.state { margin-bottom:18px; }
|
||||
.state .name { font-weight:600; font-size:13.5px; margin-bottom:8px; display:flex; gap:8px; align-items:center; }
|
||||
.state .name .note { font-weight:500; color:var(--note); font-size:12px; }
|
||||
.pair { display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:start; }
|
||||
.cell { border:1px solid var(--line); border-radius:10px; overflow:hidden; background:var(--frame); }
|
||||
.cell .cap { display:flex; align-items:center; gap:8px; padding:7px 10px; border-bottom:1px solid var(--line);
|
||||
background:var(--bg); }
|
||||
.cell .cap .meta { color:var(--muted); font-size:11px; }
|
||||
.cell img { display:block; width:100%; height:auto; background:#fff; }
|
||||
.cell.empty .ph { display:flex; align-items:center; justify-content:center; height:160px; color:var(--muted);
|
||||
font-size:12.5px; text-align:center; padding:0 16px; }
|
||||
.single .pair { grid-template-columns:1fr; }
|
||||
.empty-doc { color:var(--muted); padding:40px; text-align:center; }
|
||||
@media (max-width:760px){ .pair{ grid-template-columns:1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap" id="root"></div>
|
||||
|
||||
<script id="data">
|
||||
window.__BA__ = /*__DATA__*/{"pr":"","title":"No data","base":"","head":"","cropSelector":"","tabs":[]}/*__END__*/;
|
||||
</script>
|
||||
<script>
|
||||
(function(){
|
||||
var D = window.__BA__ || { tabs: [] };
|
||||
var root = document.getElementById("root");
|
||||
function el(html){ var t=document.createElement("template"); t.innerHTML=html.trim(); return t.content.firstChild; }
|
||||
function esc(s){ return (s==null?"":String(s)).replace(/[&<>]/g, function(c){return {"&":"&","<":"<",">":">"}[c];}); }
|
||||
|
||||
function cell(kind, src){
|
||||
if (src) {
|
||||
return '<div class="cell"><div class="cap"><span class="chip '+kind+'">'+kind+'</span></div>'+
|
||||
'<img src="'+esc(src)+'" alt="'+kind+'"/></div>';
|
||||
}
|
||||
return '<div class="cell empty"><div class="cap"><span class="chip '+kind+'">'+kind+'</span>'+
|
||||
'<span class="meta">not present</span></div><div class="ph">No '+kind+' screenshot for this state</div></div>';
|
||||
}
|
||||
|
||||
var head = '<div class="doc-head"><h1>'+esc(D.title || ("PR #"+D.pr))+'</h1>'+
|
||||
'<div class="sub">Before / after · base <code>'+esc(D.base)+'</code> → head <code>'+esc(D.head)+'</code>'+
|
||||
(D.cropSelector ? ' · cropped to <code>'+esc(D.cropSelector)+'</code>' : '')+'</div></div>'+
|
||||
'<div class="legend"><span class="chip before">Before</span> base branch'+
|
||||
'<span class="chip after">After</span> this PR</div>';
|
||||
root.appendChild(el('<div>'+head+'</div>'));
|
||||
|
||||
if (!D.tabs || !D.tabs.length){ root.appendChild(el('<div class="empty-doc">No tabs captured yet.</div>')); return; }
|
||||
|
||||
D.tabs.forEach(function(tab){
|
||||
var states = (tab.states||[]).map(function(s){
|
||||
var onlyOne = (!s.before || !s.after);
|
||||
return '<div class="state'+(onlyOne?' ':'')+'">'+
|
||||
'<div class="name">'+esc(s.name)+(s.note?'<span class="note">'+esc(s.note)+'</span>':'')+'</div>'+
|
||||
'<div class="pair">'+cell("before", s.before)+cell("after", s.after)+'</div></div>';
|
||||
}).join("");
|
||||
var sec = '<section class="tab-section" id="section-'+esc(tab.id)+'">'+
|
||||
'<h2>'+esc(tab.title)+'</h2>'+
|
||||
(tab.ctx?'<div class="ctx">'+esc(tab.ctx)+'</div>':'')+
|
||||
states+'</section>';
|
||||
root.appendChild(el(sec));
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
// Render each .tab-section of a montage HTML into its own PNG (the PR-ready image).
|
||||
// Run from frontend/editor (so @playwright/test resolves):
|
||||
// node <skill>/shoot-sections.mjs <montage.html> <outDir>
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(path.join(process.cwd(), "noop.js"));
|
||||
const { chromium } = require("@playwright/test");
|
||||
|
||||
const htmlPath = path.resolve(process.argv[2]);
|
||||
const outDir = path.resolve(process.argv[3] || path.dirname(htmlPath));
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 1200 }, deviceScaleFactor: 2 });
|
||||
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" });
|
||||
await page.waitForTimeout(250); // let images/fonts paint
|
||||
const ids = await page.$$eval(".tab-section", (els) => els.map((e) => e.id));
|
||||
if (!ids.length) { console.error("no .tab-section found"); process.exit(1); }
|
||||
for (const id of ids) {
|
||||
const name = id.replace(/^section-/, "");
|
||||
await page.locator("#" + id).screenshot({ path: path.join(outDir, `montage_${name}.png`) });
|
||||
console.log("wrote montage_" + name + ".png");
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: ui-walkthrough
|
||||
description: >-
|
||||
Full UI investigation of the current branch's feature. Enumerates every view
|
||||
and state (empty, populated, loading, error, each dialog/menu/panel, responsive
|
||||
breakpoints, light + dark + RTL), captures them with the stubbed Playwright
|
||||
harness, assembles a single-image HTML walkthrough with a global light/dark
|
||||
toggle slider, then runs two review passes: visual/consistency (alignment,
|
||||
spacing, professionalism, dark/light parity, contrast, truncation) and
|
||||
UX/ease-of-use (flow, discoverability, affordances, empty/error states,
|
||||
expectations). Use when asked for a UI walkthrough, screenshot review, design
|
||||
or QA pass, "find anywhere to make it easier/better for users", or before
|
||||
merging frontend work. Pass --fix to auto-apply safe frontend fixes and
|
||||
re-capture; --theme to limit themes; --no-rtl to skip RTL.
|
||||
argument-hint: "[feature/area] [--fix] [--theme light|dark|both] [--no-rtl] [--breakpoints]"
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
# UI Walkthrough
|
||||
|
||||
Produce a reviewable HTML walkthrough of a feature's UI in every state and theme,
|
||||
then critique it. Optionally auto-fix and re-capture.
|
||||
|
||||
`$ARGUMENTS` may name the feature/area to focus on. If empty, scope from the
|
||||
current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both),
|
||||
`--no-rtl`, `--breakpoints` (also capture phone/narrow widths).
|
||||
|
||||
## 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`.
|
||||
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`
|
||||
(`uploadFiles`, `openSettings`, `waitForModalOpen`, `dismissTourTooltip`, …)
|
||||
and the `stub-test-base` fixtures (`autoGoto`, `seedJwt`, `viewport`).
|
||||
- Config: `frontend/editor/playwright.config.ts` (run from `frontend/editor/`).
|
||||
- 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.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Scope the feature
|
||||
- If `$ARGUMENTS` is empty: `git diff --name-only main...HEAD` and read the PR/commits.
|
||||
Identify changed pages, tools (`core/components/tools/<tool>` or `core/tools/<tool>`),
|
||||
dialogs, panels, and routes.
|
||||
- Enumerate **every view and state** to capture, e.g.:
|
||||
empty / populated / loading / error / disabled; each dialog, menu, popover, tooltip;
|
||||
each tab or step; selection + multi-select; success/result panel; and (if relevant)
|
||||
permission/role variants. Write the list down before capturing - it's the report's spine.
|
||||
|
||||
### 2. Prepare the harness (worktree-safe)
|
||||
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
|
||||
```
|
||||
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`,
|
||||
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
|
||||
`page.screenshot({ path: shotPath("NN_name_<theme>") })`.
|
||||
- Capture each view in **light and dark** (and RTL unless `--no-rtl`). Reuse the
|
||||
`enableDarkMode` / `enableRtl` init-script pattern from the reference spec
|
||||
(`localStorage["mantine-color-scheme"]="dark"` + `emulateMedia({colorScheme:"dark"})`).
|
||||
- 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`.
|
||||
Add `--project=stubbed-firefox`/`-webkit` only if cross-browser layout matters.
|
||||
|
||||
### 4. Build the report
|
||||
- Copy `report-template.html` to `screenshots/<feature>/walkthrough.html` (so the
|
||||
relative `screenshots/...` image paths resolve, or rewrite paths to sit beside it).
|
||||
- Build the manifest and inject it: replace the JSON between the
|
||||
`/*__DATA__*/` … `/*__END__*/` markers with one `views[]` entry per view
|
||||
(`{id,title,light,dark,viewport,notes}`) and an empty `findings` object you'll
|
||||
fill in step 5. Keep `light`/`dark` as relative paths.
|
||||
- The toggle slider answers the "one big image + flip light/dark for all" request:
|
||||
it shows a single large screenshot, and switching the slider re-themes every view.
|
||||
|
||||
### 5. Review pass 1 - visual & consistency
|
||||
Open each screenshot (Read the PNG) and judge against the others:
|
||||
alignment & spacing rhythm, control placement, button hierarchy, typography,
|
||||
**light/dark parity** (contrast, invisible borders, washed-out text, wrong tokens),
|
||||
truncation/overflow, RTL mirroring, focus states, icon consistency, professional polish.
|
||||
Record each issue as a finding `{severity:high|med|low, view, title, detail, fix}`.
|
||||
|
||||
### 6. Review pass 2 - UX & ease of use
|
||||
Walk the flow as a first-time user: discoverability, number of steps, affordance
|
||||
clarity, empty-state guidance, error recovery, destructive-action confirmation,
|
||||
defaults, loading feedback, mobile reachability, accessible names, and whether the
|
||||
UI matches user expectations for this kind of tool. Record findings the same way.
|
||||
|
||||
Write both finding lists into the report's `findings.visual` / `findings.ux`,
|
||||
and add short per-view `notes`. Re-inject the manifest.
|
||||
|
||||
### 7. If `--fix`
|
||||
Only safe, self-contained frontend fixes (spacing, alignment, tokens, missing
|
||||
dark-mode colors, labels, aria, obvious copy). For each: edit the component/CSS,
|
||||
mark the finding `fixed:true` with what changed, then **re-run the spec** to
|
||||
re-capture the affected shots and regenerate the report. Run `task frontend:check`.
|
||||
Leave anything risky or ambiguous as a finding, not a change.
|
||||
|
||||
### 8. Deliver
|
||||
Tell the user the report path and give a tight chat summary: N views ×
|
||||
themes captured, top findings by severity, and (if `--fix`) what changed.
|
||||
Optionally `SendUserFile` the `walkthrough.html`.
|
||||
|
||||
## Gotchas
|
||||
- Stale `:5173` server serves old bundles - kill it before capturing (see step 2).
|
||||
- Missing `material-symbols-icons.json` → blank app → every shot times out. Run
|
||||
`generate-icons.js` first.
|
||||
- `await settle(page)` before shots or portals/transitions tear mid-capture.
|
||||
- Don't commit the generated `screenshots/` or the throwaway spec unless asked.
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Build a self-contained EXAMPLE.html from report-template.html with mock
|
||||
light/dark screenshots, so the viewer + global theme slider can be demoed
|
||||
without a real capture run. Run: python make_example.py"""
|
||||
import base64
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
|
||||
|
||||
def svg(bg, fg, panel, accent, muted, label, kind):
|
||||
"""A simple fake 'screen' SVG: title bar, sidebar, content varies by kind."""
|
||||
parts = [
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">',
|
||||
f'<rect width="1600" height="900" fill="{bg}"/>',
|
||||
# top bar
|
||||
f'<rect width="1600" height="64" fill="{panel}"/>',
|
||||
f'<circle cx="40" cy="32" r="12" fill="{accent}"/>',
|
||||
f'<rect x="64" y="24" width="160" height="16" rx="6" fill="{muted}"/>',
|
||||
f'<rect x="1430" y="20" width="130" height="24" rx="12" fill="{accent}"/>',
|
||||
# left sidebar
|
||||
f'<rect x="0" y="64" width="220" height="836" fill="{panel}"/>',
|
||||
]
|
||||
for i in range(6):
|
||||
y = 100 + i * 56
|
||||
parts.append(f'<rect x="24" y="{y}" width="172" height="32" rx="8" fill="{bg}"/>')
|
||||
if kind == "empty":
|
||||
parts += [
|
||||
f'<rect x="700" y="360" width="200" height="120" rx="16" fill="none" stroke="{muted}" stroke-width="3" stroke-dasharray="10 8"/>',
|
||||
f'<rect x="690" y="510" width="220" height="44" rx="10" fill="{accent}"/>',
|
||||
f'<text x="800" y="600" fill="{muted}" font-family="sans-serif" font-size="26" text-anchor="middle">{label}</text>',
|
||||
]
|
||||
elif kind == "form":
|
||||
for i in range(4):
|
||||
y = 140 + i * 90
|
||||
parts.append(f'<rect x="280" y="{y}" width="160" height="16" rx="6" fill="{muted}"/>')
|
||||
parts.append(f'<rect x="280" y="{y+26}" width="900" height="44" rx="8" fill="{panel}" stroke="{muted}" stroke-width="1"/>')
|
||||
parts.append(f'<rect x="280" y="560" width="200" height="50" rx="10" fill="{accent}"/>')
|
||||
parts.append(f'<text x="800" y="850" fill="{muted}" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>')
|
||||
else: # dialog
|
||||
parts += [
|
||||
f'<rect width="1600" height="900" fill="{fg}" opacity="0.45"/>',
|
||||
f'<rect x="520" y="280" width="560" height="360" rx="18" fill="{panel}"/>',
|
||||
f'<rect x="556" y="320" width="280" height="22" rx="8" fill="{fg}"/>',
|
||||
f'<rect x="556" y="372" width="488" height="14" rx="6" fill="{muted}"/>',
|
||||
f'<rect x="556" y="398" width="420" height="14" rx="6" fill="{muted}"/>',
|
||||
f'<rect x="820" y="560" width="110" height="44" rx="9" fill="{bg}" stroke="{muted}"/>',
|
||||
f'<rect x="946" y="560" width="98" height="44" rx="9" fill="{accent}"/>',
|
||||
f'<text x="800" y="700" fill="#fff" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>',
|
||||
]
|
||||
parts.append("</svg>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def data_uri(s):
|
||||
return "data:image/svg+xml;base64," + base64.b64encode(s.encode()).decode()
|
||||
|
||||
|
||||
LIGHT = dict(bg="#ffffff", fg="#111418", panel="#f1f3f6", accent="#2f6fed", muted="#c2c8d0")
|
||||
DARK = dict(bg="#16181c", fg="#000000", panel="#1f232a", accent="#5b8cff", muted="#3a414b")
|
||||
|
||||
|
||||
def pair(kind, label):
|
||||
return (
|
||||
data_uri(svg(LIGHT["bg"], LIGHT["fg"], LIGHT["panel"], LIGHT["accent"], LIGHT["muted"], label, kind)),
|
||||
data_uri(svg(DARK["bg"], DARK["fg"], DARK["panel"], DARK["accent"], DARK["muted"], label, kind)),
|
||||
)
|
||||
|
||||
|
||||
views = []
|
||||
for idx, (kind, title, label) in enumerate([
|
||||
("empty", "Empty state", "Drop a PDF to start"),
|
||||
("form", "Tool options panel", "Compress options"),
|
||||
("dialog", "Confirm dialog", "Replace original file?"),
|
||||
], start=1):
|
||||
light, dark = pair(kind, label)
|
||||
views.append({
|
||||
"id": f"{idx:02d}_{kind}",
|
||||
"title": title,
|
||||
"light": light,
|
||||
"dark": dark,
|
||||
"viewport": "1600x900",
|
||||
"notes": ["This is mock data to demo the viewer."],
|
||||
})
|
||||
|
||||
data = {
|
||||
"feature": "EXAMPLE - Compress PDF (mock data)",
|
||||
"branch": "demo",
|
||||
"generated": "example",
|
||||
"views": views,
|
||||
"findings": {
|
||||
"visual": [
|
||||
{"severity": "high", "view": "03_dialog", "title": "Dialog buttons too close",
|
||||
"detail": "Cancel/Confirm have only 8px gap; easy to misclick.",
|
||||
"fix": "Increase gap to var(--mantine-spacing-md)."},
|
||||
{"severity": "low", "view": "02_form", "title": "Field labels low contrast in dark mode",
|
||||
"detail": "Muted token fails WCAG AA on the dark panel.",
|
||||
"fix": "Use --mantine-color-dimmed instead of a hard-coded grey."},
|
||||
],
|
||||
"ux": [
|
||||
{"severity": "med", "view": "01_empty", "title": "Primary CTA below the dropzone",
|
||||
"detail": "Users expect the action button adjacent to the dropzone.",
|
||||
"fix": "Move the button directly under the dashed zone."},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
tpl = (HERE / "report-template.html").read_text(encoding="utf-8")
|
||||
out = re.sub(
|
||||
r"/\*__DATA__\*/.*?/\*__END__\*/",
|
||||
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
|
||||
tpl, count=1, flags=re.S,
|
||||
)
|
||||
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
|
||||
print("wrote", (HERE / "EXAMPLE.html"))
|
||||
@@ -0,0 +1,298 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
UI Walkthrough report template (self-contained, works from file://).
|
||||
The ui-walkthrough skill replaces the JSON in the window.__WALKTHROUGH__ data
|
||||
block below with the captured manifest. Do not add external CDN deps - it must open offline.
|
||||
|
||||
Data shape:
|
||||
{
|
||||
"feature": "Compress PDF tool",
|
||||
"branch": "claude/...",
|
||||
"generated": "2026-06-21",
|
||||
"views": [
|
||||
{ "id": "01_empty", "title": "Empty state",
|
||||
"light": "screenshots/compress/01_empty_light.png",
|
||||
"dark": "screenshots/compress/01_empty_dark.png",
|
||||
"viewport": "1600x900",
|
||||
"notes": ["Heading is centered", "Primary CTA below the fold on mobile"] }
|
||||
],
|
||||
"findings": {
|
||||
"visual": [ { "severity":"high", "view":"01_empty", "title":"...", "detail":"...", "fix":"..." } ],
|
||||
"ux": [ { "severity":"med", "view":"03_dialog", "title":"...", "detail":"...", "fix":"..." } ]
|
||||
}
|
||||
}
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>UI Walkthrough</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f7f9; --panel: #ffffff; --panel-2: #f0f2f5; --text: #1a1b1e;
|
||||
--muted: #6b7280; --border: #e2e5ea; --accent: #2f6fed; --accent-weak: #e8f0fe;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);
|
||||
--hi: #d92d20; --med: #d98e00; --low: #2f6fed; --stage: #0b0c0e;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
--bg: #0d0e10; --panel: #16181c; --panel-2: #1d2024; --text: #e6e8eb;
|
||||
--muted: #9aa3ad; --border: #2a2e35; --accent: #5b8cff; --accent-weak: #1a2336;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.5), 0 8px 24px rgba(0,0,0,.4); --stage: #000;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.5 -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
background: var(--bg); color: var(--text); }
|
||||
header { display: flex; align-items: center; gap: 16px; padding: 12px 20px;
|
||||
background: var(--panel); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 5; }
|
||||
header h1 { font-size: 15px; margin: 0; font-weight: 650; }
|
||||
header .sub { color: var(--muted); font-size: 12px; }
|
||||
.spacer { flex: 1; }
|
||||
.counter { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
|
||||
.tabs { display: flex; gap: 4px; }
|
||||
.tab { border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
|
||||
padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; }
|
||||
.tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
|
||||
/* Light/Dark slider */
|
||||
.theme-toggle { display: flex; align-items: center; gap: 9px; user-select: none; }
|
||||
.theme-toggle .lbl { font-size: 12px; color: var(--muted); }
|
||||
.theme-toggle .lbl.on { color: var(--text); font-weight: 600; }
|
||||
.switch { position: relative; width: 52px; height: 28px; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; inset: 0; cursor: pointer; background: var(--panel-2);
|
||||
border: 1px solid var(--border); border-radius: 999px; transition: .2s; }
|
||||
.slider:before { content: ""; position: absolute; height: 20px; width: 20px; left: 3px; top: 3px;
|
||||
background: #fbbf24; border-radius: 50%; transition: .2s; box-shadow: 0 1px 2px rgba(0,0,0,.3); }
|
||||
.switch input:checked + .slider { background: var(--accent); }
|
||||
.switch input:checked + .slider:before { transform: translateX(24px); background: #c7d2fe; }
|
||||
|
||||
main { display: grid; grid-template-columns: 240px 1fr; height: calc(100vh - 53px); }
|
||||
.rail { border-right: 1px solid var(--border); overflow-y: auto; background: var(--panel); padding: 8px; }
|
||||
.rail .group-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em;
|
||||
color: var(--muted); padding: 10px 8px 4px; }
|
||||
.thumb { display: flex; gap: 9px; align-items: center; padding: 7px; border-radius: 8px;
|
||||
cursor: pointer; border: 1px solid transparent; }
|
||||
.thumb:hover { background: var(--panel-2); }
|
||||
.thumb.active { background: var(--accent-weak); border-color: var(--accent); }
|
||||
.thumb img { width: 64px; height: 40px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); background: var(--stage); }
|
||||
.thumb .t { font-size: 12.5px; line-height: 1.3; }
|
||||
.thumb .badge { font-size: 10px; color: var(--muted); }
|
||||
.thumb .dot { width: 7px; height: 7px; border-radius: 50%; margin-left: auto; flex: none; }
|
||||
|
||||
.stagewrap { display: flex; flex-direction: column; min-width: 0; }
|
||||
.stage { flex: 1; display: flex; align-items: center; justify-content: center; padding: 22px;
|
||||
background: var(--stage); position: relative; min-height: 0; }
|
||||
.stage img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px;
|
||||
box-shadow: 0 4px 30px rgba(0,0,0,.4); background: #fff; }
|
||||
html[data-theme="dark"] .stage img { background: #16181c; }
|
||||
.nav-btn { position: absolute; top: 50%; transform: translateY(-50%); width: 42px; height: 42px;
|
||||
border-radius: 50%; border: 1px solid var(--border); background: var(--panel);
|
||||
color: var(--text); cursor: pointer; font-size: 18px; opacity: .85; }
|
||||
.nav-btn:hover { opacity: 1; } .nav-btn.prev { left: 16px; } .nav-btn.next { right: 16px; }
|
||||
.nav-btn:disabled { opacity: .25; cursor: default; }
|
||||
.missing { color: var(--muted); font-size: 13px; text-align: center; }
|
||||
|
||||
.detail { border-top: 1px solid var(--border); background: var(--panel); padding: 14px 20px;
|
||||
max-height: 38vh; overflow-y: auto; }
|
||||
.detail h2 { margin: 0 0 4px; font-size: 15px; }
|
||||
.detail .meta { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
|
||||
.notes { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
|
||||
.notes li { display: flex; gap: 8px; align-items: flex-start; }
|
||||
.sev { font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 2px 7px; border-radius: 999px;
|
||||
color: #fff; flex: none; margin-top: 1px; }
|
||||
.sev.high { background: var(--hi); } .sev.med { background: var(--med); } .sev.low { background: var(--low); }
|
||||
.finding .fix { color: var(--muted); font-size: 12.5px; }
|
||||
.finding .fix b { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* Summary tab */
|
||||
.summary { padding: 20px 28px; overflow-y: auto; }
|
||||
.summary h2 { font-size: 16px; margin: 22px 0 8px; }
|
||||
.summary .empty { color: var(--muted); }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 12px 14px; margin-bottom: 8px; box-shadow: var(--shadow); }
|
||||
.card .head { display: flex; gap: 8px; align-items: center; }
|
||||
.card a { color: var(--accent); text-decoration: none; cursor: pointer; }
|
||||
.hide { display: none !important; }
|
||||
kbd { font: 11px ui-monospace, monospace; background: var(--panel-2); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 1px 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1 id="feature-title">UI Walkthrough</h1>
|
||||
<div class="sub" id="feature-sub"></div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="viewer">Walkthrough</button>
|
||||
<button class="tab" data-tab="summary">Findings</button>
|
||||
</div>
|
||||
<div class="counter" id="counter"></div>
|
||||
<label class="theme-toggle" title="Toggle light / dark for every screenshot">
|
||||
<span class="lbl" id="lbl-light">Light</span>
|
||||
<span class="switch"><input type="checkbox" id="theme-switch" /><span class="slider"></span></span>
|
||||
<span class="lbl" id="lbl-dark">Dark</span>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<main id="viewer-pane">
|
||||
<aside class="rail" id="rail"></aside>
|
||||
<section class="stagewrap">
|
||||
<div class="stage">
|
||||
<button class="nav-btn prev" id="prev" aria-label="Previous">‹</button>
|
||||
<img id="stage-img" alt="" />
|
||||
<div class="missing hide" id="missing"></div>
|
||||
<button class="nav-btn next" id="next" aria-label="Next">›</button>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<h2 id="view-title"></h2>
|
||||
<div class="meta" id="view-meta"></div>
|
||||
<ul class="notes" id="view-notes"></ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section class="summary hide" id="summary-pane"></section>
|
||||
|
||||
<script id="data">
|
||||
window.__WALKTHROUGH__ = /*__DATA__*/{"feature":"No data","branch":"","generated":"","views":[],"findings":{"visual":[],"ux":[]}}/*__END__*/;
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
var D = window.__WALKTHROUGH__ || { views: [], findings: { visual: [], ux: [] } };
|
||||
var views = D.views || [];
|
||||
var state = { i: 0, theme: localStorage.getItem("ui-wt-theme") || "light", tab: "viewer" };
|
||||
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
function sevClass(s) { return s === "high" ? "high" : s === "med" || s === "medium" ? "med" : "low"; }
|
||||
|
||||
function applyChrome() {
|
||||
document.documentElement.setAttribute("data-theme", state.theme);
|
||||
$("theme-switch").checked = state.theme === "dark";
|
||||
$("lbl-light").classList.toggle("on", state.theme === "light");
|
||||
$("lbl-dark").classList.toggle("on", state.theme === "dark");
|
||||
}
|
||||
|
||||
function srcFor(v) { return state.theme === "dark" ? (v.dark || v.light) : (v.light || v.dark); }
|
||||
|
||||
function findingsForView(id) {
|
||||
var all = (D.findings && D.findings.visual || []).concat(D.findings && D.findings.ux || []);
|
||||
return all.filter(function (f) { return f.view === id; });
|
||||
}
|
||||
|
||||
function renderRail() {
|
||||
var rail = $("rail");
|
||||
rail.innerHTML = "";
|
||||
if (!views.length) { rail.innerHTML = '<div class="group-label">No views captured</div>'; return; }
|
||||
views.forEach(function (v, idx) {
|
||||
var fs = findingsForView(v.id);
|
||||
var worst = fs.some(function (f){return sevClass(f.severity)==="high";}) ? "var(--hi)"
|
||||
: fs.some(function (f){return sevClass(f.severity)==="med";}) ? "var(--med)"
|
||||
: fs.length ? "var(--low)" : "transparent";
|
||||
var el = document.createElement("div");
|
||||
el.className = "thumb" + (idx === state.i ? " active" : "");
|
||||
el.innerHTML = '<img src="' + srcFor(v) + '" alt="" />' +
|
||||
'<div><div class="t">' + (v.title || v.id) + '</div>' +
|
||||
'<div class="badge">' + (v.viewport || "") + '</div></div>' +
|
||||
'<span class="dot" style="background:' + worst + '"></span>';
|
||||
el.onclick = function () { state.i = idx; render(); };
|
||||
rail.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
applyChrome();
|
||||
if (!views.length) {
|
||||
$("missing").classList.remove("hide"); $("stage-img").classList.add("hide");
|
||||
$("missing").textContent = "No screenshots in this report yet.";
|
||||
$("counter").textContent = ""; return;
|
||||
}
|
||||
var v = views[state.i];
|
||||
var src = srcFor(v);
|
||||
var img = $("stage-img");
|
||||
if (src) {
|
||||
img.classList.remove("hide"); $("missing").classList.add("hide");
|
||||
img.src = src; img.alt = v.title || v.id;
|
||||
} else {
|
||||
img.classList.add("hide"); $("missing").classList.remove("hide");
|
||||
$("missing").textContent = "No " + state.theme + " screenshot for this view.";
|
||||
}
|
||||
$("counter").textContent = (state.i + 1) + " / " + views.length;
|
||||
$("view-title").textContent = v.title || v.id;
|
||||
$("view-meta").textContent = [v.viewport, state.theme + " mode"].filter(Boolean).join(" · ");
|
||||
var notes = $("view-notes"); notes.innerHTML = "";
|
||||
var fs = findingsForView(v.id);
|
||||
(v.notes || []).forEach(function (n) {
|
||||
var li = document.createElement("li"); li.textContent = "· " + n; notes.appendChild(li);
|
||||
});
|
||||
fs.forEach(function (f) {
|
||||
var li = document.createElement("li"); li.className = "finding";
|
||||
li.innerHTML = '<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
|
||||
'<span><b>' + (f.title || "") + '</b> — ' + (f.detail || "") +
|
||||
(f.fix ? ' <span class="fix"><b>Fix:</b> ' + f.fix + '</span>' : '') + '</span>';
|
||||
notes.appendChild(li);
|
||||
});
|
||||
$("prev").disabled = state.i === 0;
|
||||
$("next").disabled = state.i === views.length - 1;
|
||||
renderRail();
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
var pane = $("summary-pane");
|
||||
function block(title, arr) {
|
||||
var h = '<h2>' + title + ' (' + arr.length + ')</h2>';
|
||||
if (!arr.length) return h + '<div class="empty">None found.</div>';
|
||||
return h + arr.map(function (f) {
|
||||
return '<div class="card"><div class="head">' +
|
||||
'<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
|
||||
'<b>' + (f.title || "") + '</b>' +
|
||||
(f.view ? ' <a data-jump="' + f.view + '">' + f.view + '</a>' : '') + '</div>' +
|
||||
'<div style="margin-top:6px">' + (f.detail || "") + '</div>' +
|
||||
(f.fix ? '<div class="finding" style="margin-top:6px"><span class="fix"><b>Fix:</b> ' + f.fix + '</span></div>' : '') +
|
||||
'</div>';
|
||||
}).join("");
|
||||
}
|
||||
pane.innerHTML = block("Visual & consistency", (D.findings && D.findings.visual) || []) +
|
||||
block("UX & ease of use", (D.findings && D.findings.ux) || []);
|
||||
pane.querySelectorAll("[data-jump]").forEach(function (a) {
|
||||
a.onclick = function () {
|
||||
var id = a.getAttribute("data-jump");
|
||||
var idx = views.findIndex(function (v) { return v.id === id; });
|
||||
if (idx >= 0) { state.i = idx; setTab("viewer"); }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function setTab(t) {
|
||||
state.tab = t;
|
||||
document.querySelectorAll(".tab").forEach(function (b) { b.classList.toggle("active", b.dataset.tab === t); });
|
||||
$("viewer-pane").classList.toggle("hide", t !== "viewer");
|
||||
$("summary-pane").classList.toggle("hide", t !== "summary");
|
||||
if (t === "viewer") $("viewer-pane").style.display = "grid";
|
||||
if (t === "summary") renderSummary();
|
||||
}
|
||||
|
||||
// wiring
|
||||
$("feature-title").textContent = D.feature || "UI Walkthrough";
|
||||
$("feature-sub").textContent = [D.branch, D.generated].filter(Boolean).join(" · ");
|
||||
$("theme-switch").onchange = function () {
|
||||
state.theme = this.checked ? "dark" : "light";
|
||||
localStorage.setItem("ui-wt-theme", state.theme);
|
||||
render();
|
||||
};
|
||||
$("prev").onclick = function () { if (state.i > 0) { state.i--; render(); } };
|
||||
$("next").onclick = function () { if (state.i < views.length - 1) { state.i++; render(); } };
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (state.tab !== "viewer") return;
|
||||
if (e.key === "ArrowLeft") $("prev").click();
|
||||
if (e.key === "ArrowRight") $("next").click();
|
||||
if (e.key.toLowerCase() === "t") $("theme-switch").click();
|
||||
});
|
||||
document.querySelectorAll(".tab").forEach(function (b) { b.onclick = function () { setTab(b.dataset.tab); }; });
|
||||
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -116,6 +116,9 @@ jobs:
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
# Single source of truth for whether this preview embeds the admin portal:
|
||||
# drives the image build-arg and the deployment comment.
|
||||
BUILD_PORTAL: "true"
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -246,7 +249,9 @@ jobs:
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push V2 image (Docker fork fallback)
|
||||
@@ -259,7 +264,9 @@ jobs:
|
||||
cache-from: type=gha,scope=stirling-pdf-latest
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
@@ -290,6 +297,8 @@ jobs:
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
POLICIES_ENABLED: "true"
|
||||
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
|
||||
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
|
||||
@@ -359,12 +368,19 @@ jobs:
|
||||
}
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
|
||||
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
|
||||
|
||||
// Only mention the portal when this image actually embeds it.
|
||||
// Use the direct IP URL - the SSL hostname isn't supported yet.
|
||||
const withPortal = "${{ env.BUILD_PORTAL }}" === "true";
|
||||
const portalNote = withPortal
|
||||
? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n`
|
||||
: ``;
|
||||
|
||||
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
|
||||
`Your V2 PR with embedded architecture has been deployed!\n\n` +
|
||||
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
|
||||
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
|
||||
`🔐 **Secure HTTPS URL**: unsupported currently\n\n` +
|
||||
portalNote +
|
||||
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
|
||||
`🔄 **Auto-deployed** for approved V2 contributors.`;
|
||||
|
||||
|
||||
@@ -57,6 +57,15 @@ public class RequestUriUtils {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Admin portal SPA shell. Served publicly like the editor root so a direct
|
||||
// nav / refresh to /portal loads the app (the JWT lives in localStorage, not
|
||||
// a cookie, so the server can't authenticate the navigation itself). The
|
||||
// portal gates access via its own auth gate + RequirePortalAccess, and its
|
||||
// data APIs stay protected, so serving the shell pre-auth is safe.
|
||||
if (normalizedUri.equals("/portal") || normalizedUri.startsWith("/portal/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Treat common static file extensions as static resources
|
||||
return normalizedUri.endsWith(".svg")
|
||||
|| normalizedUri.endsWith(".png")
|
||||
|
||||
@@ -73,6 +73,14 @@ class RequestUriUtilsTest {
|
||||
assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResource_portalShell() {
|
||||
// The admin portal SPA shell is served pre-auth so it's directly navigable.
|
||||
assertTrue(RequestUriUtils.isStaticResource("/portal"));
|
||||
assertTrue(RequestUriUtils.isStaticResource("/portal/users"));
|
||||
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/portal"));
|
||||
}
|
||||
|
||||
// --- isFrontendRoute tests ---
|
||||
|
||||
@Test
|
||||
|
||||
@@ -175,6 +175,14 @@ springBoot {
|
||||
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
|
||||
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
|
||||
def buildPrototypes = project.hasProperty('prototypesMode') && project.property('prototypesMode') == 'true'
|
||||
// The admin portal ships as a lazy route inside the editor bundle (see
|
||||
// proprietary/routes/adminRouteExtensions). -PbuildWithPortal=true includes that
|
||||
// chunk via VITE_INCLUDE_PORTAL on the editor build; the deploy GHA sets it when
|
||||
// the portal or AI layers change. Building the portal implies building the editor.
|
||||
def buildWithPortal = project.hasProperty('buildWithPortal') && project.property('buildWithPortal') == 'true'
|
||||
if (buildWithPortal) {
|
||||
buildWithFrontend = true
|
||||
}
|
||||
// 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/.
|
||||
@@ -297,9 +305,11 @@ tasks.register('npmBuild', Exec) {
|
||||
// Override VITE_API_BASE_URL to use relative paths for production builds
|
||||
// This ensures JARs work regardless of how they're deployed (direct, proxied, etc.)
|
||||
environment 'VITE_API_BASE_URL', '/'
|
||||
// Include the admin portal's lazy route/chunk in the editor build when requested.
|
||||
environment 'VITE_INCLUDE_PORTAL', (buildWithPortal ? 'true' : 'false')
|
||||
|
||||
doFirst {
|
||||
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)"
|
||||
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/, portal=${buildWithPortal})"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ COPY . .
|
||||
ARG PROTOTYPES_BUILD=false
|
||||
ARG STIRLING_FLAVOR=proprietary
|
||||
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
|
||||
# Embed the admin portal app at /portal. Set true by the deploy workflow when the
|
||||
# portal or AI layers change; defaults false so normal builds skip the extra app.
|
||||
ARG BUILD_PORTAL=false
|
||||
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
@@ -49,6 +52,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
|
||||
STIRLING_FLAVOR=${STIRLING_FLAVOR} \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PbuildWithPortal=${BUILD_PORTAL} \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-PprototypesMode=${PROTOTYPES_BUILD} \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
|
||||
@@ -40,12 +40,15 @@ RUN gradle dependencies --no-daemon || true
|
||||
|
||||
COPY . .
|
||||
|
||||
# Embed the admin portal app at /portal when the deploy workflow flags it.
|
||||
ARG BUILD_PORTAL=false
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
|
||||
DISABLE_ADDITIONAL_FEATURES=false \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PbuildWithPortal=${BUILD_PORTAL} \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
@@ -40,12 +40,15 @@ RUN ./gradlew dependencies --no-daemon || true
|
||||
COPY . .
|
||||
|
||||
# Build ultra-lite JAR with embedded frontend (minimal features).
|
||||
# Embed the admin portal app at /portal when the deploy workflow flags it.
|
||||
ARG BUILD_PORTAL=false
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
|
||||
DISABLE_ADDITIONAL_FEATURES=true \
|
||||
./gradlew clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PbuildWithPortal=${BUILD_PORTAL} \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
declare module "*.css" {}
|
||||
@@ -61,6 +61,11 @@ const config: StorybookConfig = {
|
||||
config.define = {
|
||||
...(config.define ?? {}),
|
||||
"import.meta.env.VITE_SAAS_API_URL": JSON.stringify("http://saas.mock"),
|
||||
// Keep the Supabase auth env empty so ensureSaasSupabase() is a no-op and
|
||||
// never replaces the mock SaaS client stubbed in preview.tsx.
|
||||
"import.meta.env.VITE_SUPABASE_URL": JSON.stringify(""),
|
||||
"import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY":
|
||||
JSON.stringify(""),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { Decorator, Preview } from "@storybook/react-vite";
|
||||
import { initialize, mswLoader } from "msw-storybook-addon";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { withThemeByDataAttribute } from "@storybook/addon-themes";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Reference React so the import isn't dropped as unused by the bundler — the
|
||||
// classic runtime needs it present even though it's not named in the JSX.
|
||||
@@ -15,9 +14,9 @@ void React;
|
||||
|
||||
import { TierProvider, type Tier } from "@portal/contexts/TierContext";
|
||||
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
|
||||
import { ThemeProvider } from "@portal/contexts/ThemeContext";
|
||||
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { UIProvider } from "@portal/contexts/UIContext";
|
||||
import { mantineTheme } from "@portal/theme/mantineTheme";
|
||||
import { SuiProvider } from "@portal/theme/SuiProvider";
|
||||
import { handlers } from "@portal/mocks/handlers";
|
||||
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
|
||||
|
||||
@@ -30,10 +29,10 @@ initialize({ onUnhandledRequest: "bypass" }, handlers);
|
||||
|
||||
// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment
|
||||
// method, wallet) clear the session check and reach the MSW handlers instead of
|
||||
// failing with "No SaaS session". VITE_SAAS_SUPABASE_URL/KEY are intentionally
|
||||
// unset, so ensureSaasSupabase() is a no-op and never replaces this client; only
|
||||
// VITE_SAAS_API_URL (a mock origin MSW matches) is configured — injected via
|
||||
// .storybook/main.ts's viteFinal define, not a frontend/.env file.
|
||||
// failing with "No SaaS session". VITE_SUPABASE_URL/KEY are defined empty (see
|
||||
// .storybook/main.ts), so ensureSaasSupabase() is a no-op and never replaces this
|
||||
// client; only VITE_SAAS_API_URL (a mock origin MSW matches) is configured —
|
||||
// injected via .storybook/main.ts's viteFinal define, not a frontend/.env file.
|
||||
const saasStub = configureSupabase({
|
||||
url: "http://saas.mock",
|
||||
key: "storybook-anon-key",
|
||||
@@ -79,13 +78,21 @@ function TierKey({
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps useTheme() and the data-theme attribute in sync. */
|
||||
function ThemeWatcher() {
|
||||
/**
|
||||
* Makes the Storybook toolbar the SINGLE source of truth for the theme.
|
||||
*/
|
||||
function ThemeBridge({
|
||||
theme,
|
||||
children,
|
||||
}: {
|
||||
theme: "light" | "dark";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { setTheme } = useTheme();
|
||||
useEffect(() => {
|
||||
// The addon-themes decorator already sets data-theme on <html>.
|
||||
// We just read it on mount so ThemeProvider picks it up.
|
||||
}, []);
|
||||
return null;
|
||||
setTheme(theme);
|
||||
}, [theme, setTheme]);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const withProviders: Decorator = (Story, context) => {
|
||||
@@ -102,20 +109,21 @@ const withProviders: Decorator = (Story, context) => {
|
||||
return (
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<ThemeProvider>
|
||||
<MantineProvider theme={mantineTheme} forceColorScheme={colorScheme}>
|
||||
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
|
||||
from useLink() (matches App.tsx's nesting). */}
|
||||
<LinkProvider key={linkState} initialState={linkState}>
|
||||
<TierKey tier={tier}>
|
||||
<UIProvider>
|
||||
<ThemeWatcher />
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
</TierKey>
|
||||
</LinkProvider>
|
||||
</MantineProvider>
|
||||
<ThemeBridge theme={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}>
|
||||
<UIProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
</TierKey>
|
||||
</LinkProvider>
|
||||
</SuiProvider>
|
||||
</ThemeBridge>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"jsx": "react-jsx",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {
|
||||
"@app/*": [
|
||||
"../editor/src/desktop/*",
|
||||
"../editor/src/proprietary/*",
|
||||
"../editor/src/core/*"
|
||||
],
|
||||
"@core/*": ["../editor/src/core/*"],
|
||||
"@proprietary/*": ["../editor/src/proprietary/*"],
|
||||
"@portal/*": ["../editor/src/portal/*"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["./**/*"]
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
# API base URL — use / for same-origin (default for web builds)
|
||||
VITE_API_BASE_URL=/
|
||||
|
||||
# Include the admin portal's lazy route/chunk in the build (set true by
|
||||
# -PbuildWithPortal in the JAR). Off by default; always on in dev.
|
||||
VITE_INCLUDE_PORTAL=false
|
||||
|
||||
# Google Drive integration
|
||||
VITE_GOOGLE_DRIVE_CLIENT_ID=
|
||||
VITE_GOOGLE_DRIVE_API_KEY=
|
||||
|
||||
@@ -18,13 +18,6 @@ VITE_EDITOR_URL=/
|
||||
# backend.
|
||||
VITE_PORTAL_MOCKS=
|
||||
|
||||
# Hosted SaaS Supabase project for the self-hosted portal's IN-APP account
|
||||
# linking (both values are public). Set per deploy; absent -> the account-link
|
||||
# UI shows a "configure" state. For local e2e, point these at the SaaS Supabase
|
||||
# project the local backend links against.
|
||||
VITE_SAAS_SUPABASE_URL=
|
||||
VITE_SAAS_SUPABASE_ANON_KEY=
|
||||
|
||||
# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used for
|
||||
# ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the
|
||||
# admin's Supabase JWT. Distinct from the local backend (reached same-origin via
|
||||
|
||||
@@ -7,6 +7,7 @@ black = "Black"
|
||||
blue = "Blue"
|
||||
cancel = "Cancel"
|
||||
chooseFile = "Choose File"
|
||||
clear = "Clear"
|
||||
close = "Close"
|
||||
comingSoon = "Coming soon"
|
||||
confirm = "Confirm"
|
||||
@@ -87,6 +88,7 @@ processingCompleteMultiple = "{{count}} files are ready."
|
||||
property = "Property"
|
||||
quickPosition = "Quick Position"
|
||||
red = "Red"
|
||||
remove = "Remove"
|
||||
reset = "Reset"
|
||||
review = "Review"
|
||||
save = "Save"
|
||||
@@ -180,6 +182,7 @@ addMoreFiles = "Add more files..."
|
||||
attachments = "Select Attachments"
|
||||
info = "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel."
|
||||
placeholder = "Choose files..."
|
||||
removeFile = "Remove file"
|
||||
selectedFiles = "Selected Files"
|
||||
submit = "Add Attachments"
|
||||
|
||||
@@ -1628,6 +1631,9 @@ title = "Do you want to help make Stirling PDF better?"
|
||||
tags = "annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand"
|
||||
|
||||
[annotation]
|
||||
alignCenter = "Align center"
|
||||
alignLeft = "Align left"
|
||||
alignRight = "Align right"
|
||||
annotationStyle = "Annotation style"
|
||||
backgroundColor = "Background color"
|
||||
borderOff = "Border: Off"
|
||||
@@ -2531,6 +2537,7 @@ title = "Rule of thumb"
|
||||
|
||||
[certSign.source]
|
||||
device = "This device"
|
||||
noOtherSources = "No other certificate sources are available."
|
||||
server = "Server"
|
||||
stepTitle = "Certificate source"
|
||||
upload = "Upload"
|
||||
@@ -3595,7 +3602,9 @@ makeCopy = "Make a copy"
|
||||
mobileShort = "Mobile"
|
||||
mobileUpload = "Mobile Upload"
|
||||
mobileUploadNotAvailable = "Mobile upload not enabled"
|
||||
moreOptions = "More options"
|
||||
myFiles = "My Files"
|
||||
nextFile = "Next file"
|
||||
noFiles = "No files available"
|
||||
noFilesFound = "No files found matching your search"
|
||||
noRecentFiles = "No recent files found"
|
||||
@@ -3605,6 +3614,7 @@ openInFileEditor = "Open in File Editor"
|
||||
openInPageEditor = "Open in Page Editor"
|
||||
owner = "Owner"
|
||||
ownerUnknown = "Unknown"
|
||||
previousFile = "Previous file"
|
||||
recent = "Recent"
|
||||
removeBoth = "Remove from both"
|
||||
removeFilePrompt = "This file is saved on this device and on your server. Where would you like to remove it from?"
|
||||
@@ -4614,10 +4624,10 @@ welcomeTitle = "You've been invited!"
|
||||
[landing]
|
||||
addFiles = "Add Files"
|
||||
heroSubtitle = "Drop in or add an existing PDF to get started."
|
||||
heroTitle = "Stirling PDF"
|
||||
mobileUpload = "Upload from Mobile"
|
||||
openFromComputer = "Open from computer"
|
||||
uploadFromComputer = "Upload from computer"
|
||||
workbenchEmptyStateHero = "Drop a PDF anywhere"
|
||||
|
||||
[language]
|
||||
direction = "ltr"
|
||||
@@ -4930,6 +4940,7 @@ title = "Output"
|
||||
[onboarding]
|
||||
activeFiles = "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."
|
||||
allTools = "This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools."
|
||||
close = "Close"
|
||||
cropSettings = "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to."
|
||||
fileCheckbox = "Clicking one of the files selects it for processing. You can select multiple files for batch operations."
|
||||
fileReplacement = "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."
|
||||
@@ -4955,6 +4966,7 @@ skipTheTour = "Skip the tour"
|
||||
|
||||
[onboarding.desktopInstall]
|
||||
body = "Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer."
|
||||
selectOs = "Select operating system"
|
||||
title = "Download"
|
||||
titleWithOs = "Download for {{osLabel}}"
|
||||
|
||||
@@ -5566,7 +5578,9 @@ viewLabel = "PDF Editor"
|
||||
|
||||
[pdfTextEditor.actions]
|
||||
applyChanges = "Apply Changes"
|
||||
clearText = "Clear text"
|
||||
downloadCopy = "Download Copy"
|
||||
moreOptions = "More options"
|
||||
reset = "Reset Changes"
|
||||
|
||||
[pdfTextEditor.badges]
|
||||
@@ -8918,6 +8932,21 @@ manage = "Manage"
|
||||
description = "Policies and legal information for this service."
|
||||
title = "Legal Documents"
|
||||
|
||||
[settings.licenses]
|
||||
backendDescription = "Licenses for backend dependencies bundled with this server."
|
||||
backendLabel = "Backend Licenses"
|
||||
backendTitle = "Backend 3rd Party Licenses"
|
||||
empty = "No dependencies found."
|
||||
frontendDescription = "Licenses for frontend dependencies bundled into the release build."
|
||||
frontendLabel = "Frontend Licenses"
|
||||
frontendTitle = "Frontend 3rd Party Licenses"
|
||||
license = "License"
|
||||
listDescription = "The list is shown directly in the UI from the release bundle or backend endpoint."
|
||||
listTitle = "Bundled dependencies"
|
||||
loadError = "Failed to load third-party licenses"
|
||||
module = "Module"
|
||||
version = "Version"
|
||||
|
||||
[settings.licensingAnalytics]
|
||||
audit = "Audit"
|
||||
plan = "Plan"
|
||||
@@ -9670,7 +9699,9 @@ memberRemoved = "Member removed successfully"
|
||||
namePlaceholder = "Enter team name"
|
||||
personal = "Personal"
|
||||
removeError = "Failed to remove member"
|
||||
renameCancel = "Cancel rename"
|
||||
renameError = "Failed to rename team"
|
||||
renameSubmit = "Save team name"
|
||||
renameSuccess = "Team renamed successfully"
|
||||
|
||||
[team.invitationBanner]
|
||||
@@ -9686,6 +9717,7 @@ sendButton = "Send Invitation"
|
||||
title = "Invite Team Member"
|
||||
|
||||
[team.members]
|
||||
actions = "Member actions"
|
||||
emailColumn = "Email"
|
||||
empty = "No team members yet"
|
||||
nameColumn = "Name"
|
||||
@@ -10041,14 +10073,23 @@ zoomOut = "Zoom Out"
|
||||
[viewer.attachments]
|
||||
addAttachment = "Add attachment"
|
||||
close = "Close attachments"
|
||||
closeSidebar = "Close attachments sidebar"
|
||||
download = "Download attachment"
|
||||
empty = "No attachments in this document"
|
||||
loading = "Loading attachments..."
|
||||
noDocument = "Open a PDF to view its attachments."
|
||||
noMatch = "No attachments match your search"
|
||||
noSupport = "Attachment support is unavailable for this viewer."
|
||||
retry = "Retry"
|
||||
searchPlaceholder = "Search attachments"
|
||||
title = "Attachments"
|
||||
|
||||
[viewer.bookmarks]
|
||||
bookmarkTitle = "Bookmark title"
|
||||
closeSidebar = "Close bookmarks sidebar"
|
||||
collapseAll = "Collapse all bookmarks"
|
||||
expandAll = "Expand all bookmarks"
|
||||
|
||||
[viewer.comments]
|
||||
addComment = "Add comment"
|
||||
addCommentPlaceholder = "Add comment..."
|
||||
@@ -10059,6 +10100,7 @@ clearAll = "Clear all comments"
|
||||
clearAllDescription = "This removes comments and replies from the sidebar while keeping any attached annotations in the document."
|
||||
clearAllTitle = "Clear all comments?"
|
||||
close = "Close comments"
|
||||
closeSidebar = "Close comments sidebar"
|
||||
deleteAnnotationAndComment = "Delete annotation & comment"
|
||||
deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything."
|
||||
deleteTitle = "Remove annotation from comments?"
|
||||
@@ -10090,6 +10132,11 @@ title = "Form Fields"
|
||||
unsavedBadge = "Unsaved"
|
||||
unsavedDesc = "You have unsaved changes"
|
||||
|
||||
[viewer.layers]
|
||||
closeSidebar = "Close layers sidebar"
|
||||
hideAll = "Hide all layers"
|
||||
showAll = "Show all layers"
|
||||
|
||||
[viewer.link]
|
||||
delete = "Delete link"
|
||||
|
||||
@@ -10120,6 +10167,9 @@ resultsOf = "of {{total}}"
|
||||
[viewer.signature]
|
||||
delete = "Delete signature"
|
||||
|
||||
[viewer.thumbnails]
|
||||
closeSidebar = "Close thumbnails sidebar"
|
||||
|
||||
[viewPdf]
|
||||
tags = "view,read,annotate,text,image,highlight,edit"
|
||||
title = "View/Edit PDF"
|
||||
@@ -10546,6 +10596,7 @@ loading = "Loading people..."
|
||||
locked = "locked"
|
||||
lockedBadge = "Locked"
|
||||
loginRequired = "Enable login mode first"
|
||||
memberActions = "Member actions"
|
||||
noMembersFound = "No members found"
|
||||
role = "Role"
|
||||
searchMembers = "Search members..."
|
||||
@@ -10555,6 +10606,7 @@ unlockAccount = "Unlock Account"
|
||||
unlockUserError = "Failed to unlock user account"
|
||||
unlockUserSuccess = "User account unlocked successfully"
|
||||
user = "User"
|
||||
userInfo = "User info"
|
||||
|
||||
[workspace.people.actions]
|
||||
upgrade = "Upgrade"
|
||||
@@ -10700,6 +10752,7 @@ removeMemberError = "Failed to remove user from team"
|
||||
removeMemberSuccess = "User removed from team"
|
||||
renameTeamLabel = "Rename Team"
|
||||
system = "System"
|
||||
teamActions = "Team actions"
|
||||
teamName = "Team Name"
|
||||
teamNotFound = "Team not found"
|
||||
title = "Teams"
|
||||
|
||||
@@ -485,6 +485,16 @@
|
||||
"title": "Legal Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/backendThirdPartyLicenses": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Backend Third Party Licenses Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/frontendThirdPartyLicenses": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Frontend Third Party Licenses Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/payg": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Payg Settings - Stirling PDF",
|
||||
@@ -641,6 +651,8 @@
|
||||
"/settings/adminMcp": "/settings/adminMcp",
|
||||
"/settings/help": "/settings/help",
|
||||
"/settings/legal": "/settings/legal",
|
||||
"/settings/backendThirdPartyLicenses": "/settings/backendThirdPartyLicenses",
|
||||
"/settings/frontendThirdPartyLicenses": "/settings/frontendThirdPartyLicenses",
|
||||
"/settings/payg": "/settings/payg"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 51 KiB |
@@ -10,7 +10,8 @@ Terminal=false
|
||||
MimeType=application/pdf;
|
||||
Categories=Office;Graphics;Utility;
|
||||
Actions=open-file;
|
||||
StartupWMClass=Stirling-PDF
|
||||
|
||||
[Desktop Action open-file]
|
||||
Name=Open PDF File
|
||||
Exec={{exec}} %F
|
||||
Exec={{exec}} %F
|
||||
|
||||
@@ -3,133 +3,182 @@
|
||||
{
|
||||
"moduleName": "@atlaskit/pragmatic-drag-and-drop",
|
||||
"moduleUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git",
|
||||
"moduleVersion": "1.7.7",
|
||||
"moduleVersion": "1.7.9",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/core",
|
||||
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleName": "@cantoo/pdf-lib",
|
||||
"moduleUrl": "git+https://github.com/cantoo-scribe/pdf-lib.git",
|
||||
"moduleVersion": "2.6.5",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz"
|
||||
"moduleLicenseUrl": "git+https://github.com/cantoo-scribe/pdf-lib.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@dnd-kit/core",
|
||||
"moduleUrl": "git+https://github.com/clauderic/dnd-kit.git",
|
||||
"moduleVersion": "6.3.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/clauderic/dnd-kit.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/core",
|
||||
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-2.14.4.tgz",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-2.14.4.tgz"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/engines",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/models",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-annotation",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-attachment",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-bookmark",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-document-manager",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-export",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-history",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-interaction-manager",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-loader",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-pan",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-print",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-redaction",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-render",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-rotate",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-scroll",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-search",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-selection",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-spread",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-thumbnail",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-tiling",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-viewport",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-zoom",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleVersion": "2.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
@@ -157,94 +206,185 @@
|
||||
{
|
||||
"moduleName": "@mantine/core",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
"moduleVersion": "8.3.1",
|
||||
"moduleVersion": "8.3.18",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/dates",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
"moduleVersion": "8.3.1",
|
||||
"moduleVersion": "8.3.18",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/dropzone",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
"moduleVersion": "8.3.1",
|
||||
"moduleVersion": "8.3.18",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/hooks",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
"moduleVersion": "8.3.1",
|
||||
"moduleVersion": "8.3.18",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mui/icons-material",
|
||||
"moduleUrl": "git+https://github.com/mui/material-ui.git",
|
||||
"moduleVersion": "7.3.2",
|
||||
"moduleVersion": "9.0.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mui/material",
|
||||
"moduleUrl": "git+https://github.com/mui/material-ui.git",
|
||||
"moduleVersion": "7.3.2",
|
||||
"moduleVersion": "9.0.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tailwindcss/postcss",
|
||||
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
|
||||
"moduleVersion": "4.1.13",
|
||||
"moduleName": "@posthog/react",
|
||||
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
|
||||
"moduleVersion": "1.8.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
|
||||
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@reactour/tour",
|
||||
"moduleUrl": "git+https://github.com/elrumordelaluz/reactour.git",
|
||||
"moduleVersion": "3.8.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/elrumordelaluz/reactour.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@stripe/react-stripe-js",
|
||||
"moduleUrl": "https://github.com/stripe/react-stripe-js.git",
|
||||
"moduleVersion": "4.0.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://github.com/stripe/react-stripe-js.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@stripe/stripe-js",
|
||||
"moduleUrl": "https://github.com/stripe/stripe-js.git",
|
||||
"moduleVersion": "7.9.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://github.com/stripe/stripe-js.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@supabase/supabase-js",
|
||||
"moduleUrl": "https://github.com/supabase/supabase-js.git",
|
||||
"moduleVersion": "2.100.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://github.com/supabase/supabase-js.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tailwindcss/postcss",
|
||||
"moduleUrl": "https://github.com/tailwindlabs/tailwindcss.git",
|
||||
"moduleVersion": "4.2.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://github.com/tailwindlabs/tailwindcss.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tanstack/react-virtual",
|
||||
"moduleUrl": "git+https://github.com/TanStack/virtual.git",
|
||||
"moduleVersion": "3.13.12",
|
||||
"moduleVersion": "3.13.23",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/TanStack/virtual.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tauri-apps/api",
|
||||
"moduleUrl": "git+https://github.com/tauri-apps/tauri.git",
|
||||
"moduleVersion": "2.10.1",
|
||||
"moduleLicense": "Apache-2.0 OR MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/tauri-apps/tauri.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tauri-apps/plugin-dialog",
|
||||
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
|
||||
"moduleVersion": "2.7.0",
|
||||
"moduleLicense": "MIT OR Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tauri-apps/plugin-fs",
|
||||
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
|
||||
"moduleVersion": "2.5.0",
|
||||
"moduleLicense": "MIT OR Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tauri-apps/plugin-http",
|
||||
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
|
||||
"moduleVersion": "2.5.7",
|
||||
"moduleLicense": "MIT OR Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tauri-apps/plugin-notification",
|
||||
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
|
||||
"moduleVersion": "2.3.3",
|
||||
"moduleLicense": "MIT OR Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tauri-apps/plugin-shell",
|
||||
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
|
||||
"moduleVersion": "2.3.5",
|
||||
"moduleLicense": "MIT OR Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@userback/widget",
|
||||
"moduleUrl": "git+https://github.com/userback/widget-js.git",
|
||||
"moduleVersion": "0.3.12",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/userback/widget-js.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "autoprefixer",
|
||||
"moduleUrl": "git+https://github.com/postcss/autoprefixer.git",
|
||||
"moduleVersion": "10.4.21",
|
||||
"moduleVersion": "10.4.27",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/postcss/autoprefixer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "axios",
|
||||
"moduleUrl": "git+https://github.com/axios/axios.git",
|
||||
"moduleVersion": "1.12.2",
|
||||
"moduleVersion": "1.15.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/axios/axios.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "d3",
|
||||
"moduleUrl": "git+https://github.com/d3/d3.git",
|
||||
"moduleVersion": "7.9.0",
|
||||
"moduleLicense": "ISC",
|
||||
"moduleLicenseUrl": "git+https://github.com/d3/d3.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "globals",
|
||||
"moduleUrl": "git+https://github.com/sindresorhus/globals.git",
|
||||
"moduleVersion": "17.5.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/sindresorhus/globals.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "i18next",
|
||||
"moduleUrl": "git+https://github.com/i18next/i18next.git",
|
||||
"moduleVersion": "25.5.2",
|
||||
"moduleVersion": "25.10.10",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/i18next/i18next.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "i18next-browser-languagedetector",
|
||||
"moduleUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git",
|
||||
"moduleVersion": "8.2.0",
|
||||
"moduleVersion": "8.2.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "i18next-http-backend",
|
||||
"moduleUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git",
|
||||
"moduleVersion": "3.0.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "jszip",
|
||||
"moduleUrl": "git+https://github.com/Stuk/jszip.git",
|
||||
@@ -254,66 +394,129 @@
|
||||
},
|
||||
{
|
||||
"moduleName": "license-report",
|
||||
"moduleUrl": "git+https://github.com/kessler/license-report.git",
|
||||
"moduleVersion": "6.8.0",
|
||||
"moduleUrl": "git+https://github.com/bepo65/license-report.git",
|
||||
"moduleVersion": "6.8.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/kessler/license-report.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "pdf-lib",
|
||||
"moduleUrl": "git+https://github.com/Hopding/pdf-lib.git",
|
||||
"moduleVersion": "1.17.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/Hopding/pdf-lib.git"
|
||||
"moduleLicenseUrl": "git+https://github.com/bepo65/license-report.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "pdfjs-dist",
|
||||
"moduleUrl": "git+https://github.com/mozilla/pdf.js.git",
|
||||
"moduleVersion": "5.4.149",
|
||||
"moduleVersion": "5.5.207",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/mozilla/pdf.js.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "peerjs",
|
||||
"moduleUrl": "git+https://github.com/peers/peerjs.git",
|
||||
"moduleVersion": "1.5.5",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/peers/peerjs.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "pixelmatch",
|
||||
"moduleUrl": "git+https://github.com/mapbox/pixelmatch.git",
|
||||
"moduleVersion": "7.1.0",
|
||||
"moduleLicense": "ISC",
|
||||
"moduleLicenseUrl": "git+https://github.com/mapbox/pixelmatch.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "posthog-js",
|
||||
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
|
||||
"moduleVersion": "1.268.0",
|
||||
"moduleUrl": "https://github.com/PostHog/posthog-js",
|
||||
"moduleVersion": "1.363.3",
|
||||
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
|
||||
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
|
||||
"moduleLicenseUrl": "https://github.com/PostHog/posthog-js"
|
||||
},
|
||||
{
|
||||
"moduleName": "qrcode.react",
|
||||
"moduleUrl": "git+https://github.com/zpao/qrcode.react.git",
|
||||
"moduleVersion": "4.2.0",
|
||||
"moduleLicense": "ISC",
|
||||
"moduleLicenseUrl": "git+https://github.com/zpao/qrcode.react.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react",
|
||||
"moduleUrl": "git+https://github.com/facebook/react.git",
|
||||
"moduleVersion": "19.1.1",
|
||||
"moduleVersion": "19.2.4",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react-dom",
|
||||
"moduleUrl": "git+https://github.com/facebook/react.git",
|
||||
"moduleVersion": "19.1.1",
|
||||
"moduleVersion": "19.2.4",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react-easy-crop",
|
||||
"moduleUrl": "git+https://github.com/ValentinH/react-easy-crop.git",
|
||||
"moduleVersion": "5.5.6",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/ValentinH/react-easy-crop.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react-i18next",
|
||||
"moduleUrl": "git+https://github.com/i18next/react-i18next.git",
|
||||
"moduleVersion": "15.7.3",
|
||||
"moduleVersion": "16.6.6",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/i18next/react-i18next.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react-markdown",
|
||||
"moduleUrl": "git+https://github.com/remarkjs/react-markdown.git",
|
||||
"moduleVersion": "9.1.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/remarkjs/react-markdown.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react-rnd",
|
||||
"moduleUrl": "git+https://github.com/bokuweb/react-rnd.git",
|
||||
"moduleVersion": "10.5.3",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/bokuweb/react-rnd.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react-router-dom",
|
||||
"moduleUrl": "git+https://github.com/remix-run/react-router.git",
|
||||
"moduleVersion": "7.9.1",
|
||||
"moduleVersion": "7.13.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/remix-run/react-router.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "tailwindcss",
|
||||
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
|
||||
"moduleVersion": "4.1.13",
|
||||
"moduleName": "recharts",
|
||||
"moduleUrl": "git+https://github.com/recharts/recharts.git",
|
||||
"moduleVersion": "3.8.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
|
||||
"moduleLicenseUrl": "git+https://github.com/recharts/recharts.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "remark-gfm",
|
||||
"moduleUrl": "git+https://github.com/remarkjs/remark-gfm.git",
|
||||
"moduleVersion": "4.0.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/remarkjs/remark-gfm.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "signature_pad",
|
||||
"moduleUrl": "git+https://github.com/szimek/signature_pad.git",
|
||||
"moduleVersion": "5.1.3",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/szimek/signature_pad.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "smol-toml",
|
||||
"moduleUrl": "github:squirrelchat/smol-toml",
|
||||
"moduleVersion": "1.6.1",
|
||||
"moduleLicense": "BSD-3-Clause",
|
||||
"moduleLicenseUrl": "github:squirrelchat/smol-toml"
|
||||
},
|
||||
{
|
||||
"moduleName": "tailwindcss",
|
||||
"moduleUrl": "https://github.com/tailwindlabs/tailwindcss.git",
|
||||
"moduleVersion": "4.2.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://github.com/tailwindlabs/tailwindcss.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "web-vitals",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import { Button, Group, ActionIcon } from "@mantine/core";
|
||||
import { Group } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
@@ -31,22 +33,6 @@ export function renderButtons({
|
||||
(btn) => btn.group === "right",
|
||||
);
|
||||
|
||||
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
|
||||
variant === "primary"
|
||||
? {
|
||||
root: {
|
||||
background: "var(--onboarding-primary-button-bg)",
|
||||
color: "var(--onboarding-primary-button-text)",
|
||||
},
|
||||
}
|
||||
: {
|
||||
root: {
|
||||
background: "var(--onboarding-secondary-button-bg)",
|
||||
border: "1px solid var(--onboarding-secondary-button-border)",
|
||||
color: "var(--onboarding-secondary-button-text)",
|
||||
},
|
||||
};
|
||||
|
||||
const resolveButtonLabel = (button: ButtonDefinition) => {
|
||||
// Translate the label (it's a translation key)
|
||||
const label = button.label ?? "";
|
||||
@@ -65,20 +51,15 @@ export function renderButtons({
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
radius="md"
|
||||
size={40}
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
background: "var(--onboarding-secondary-button-bg)",
|
||||
border: "1px solid var(--onboarding-secondary-button-border)",
|
||||
color: "var(--onboarding-secondary-button-text)",
|
||||
},
|
||||
}}
|
||||
aria-label={t("onboarding.buttons.back", "Back")}
|
||||
>
|
||||
{button.icon === "chevron-left" && (
|
||||
{button.icon === "chevron-left" ? (
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
)}
|
||||
) : null}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
@@ -91,7 +72,8 @@ export function renderButtons({
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
disabled={disabled}
|
||||
styles={buttonStyles(variant)}
|
||||
variant={variant === "primary" ? "primary" : "secondary"}
|
||||
accent="neutral"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Badge, Button, TextInput } from "@mantine/core";
|
||||
import { Badge, TextInput } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { createLightSlideBackground } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { Modal, Stack, Button } from "@mantine/core";
|
||||
import { Modal, Stack } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CelebrationIcon from "@mui/icons-material/CelebrationOutlined";
|
||||
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
||||
@@ -169,7 +170,7 @@ export function FreeLimitReachedModal({ onClose }: FreeLimitReachedModalProps) {
|
||||
>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="free-limit-modal-button"
|
||||
style={{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { Modal, Stack, Button } from "@mantine/core";
|
||||
import { Modal, Stack } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TrendingUpIcon from "@mui/icons-material/TrendingUpOutlined";
|
||||
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
||||
@@ -167,7 +168,7 @@ export function SpendCapReachedModal({ onClose }: SpendCapReachedModalProps) {
|
||||
>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="spend-cap-modal-button"
|
||||
style={{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Group, Text } from "@mantine/core";
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
@@ -75,9 +76,9 @@ export function TeamInvitationBanner() {
|
||||
const actionButtons = (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
variant="white"
|
||||
color="gray"
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={handleAccept}
|
||||
loading={processing}
|
||||
leftSection={
|
||||
@@ -88,17 +89,12 @@ export function TeamInvitationBanner() {
|
||||
style={{ color: "var(--mantine-color-dark-9)" }}
|
||||
/>
|
||||
}
|
||||
styles={{
|
||||
label: {
|
||||
color: "var(--mantine-color-dark-9)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("team.invitationBanner.acceptButton", "Accept")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={handleReject}
|
||||
loading={processing}
|
||||
style={{ color: "rgba(255, 255, 255, 0.7)" }}
|
||||
|
||||
@@ -14,14 +14,13 @@
|
||||
--payg-divider: var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Dark mode: the modal content bg is #2a2f36 and so is --bg-surface, so plain
|
||||
cards vanish. Lift cards a shade above the modal and strengthen borders. */
|
||||
/* Dark mode: modal content bg is #131729 (--bg-surface), so lift cards above it */
|
||||
[data-mantine-color-scheme="dark"] .payg {
|
||||
--payg-card-bg: #313842;
|
||||
--payg-card-border: #3d444e;
|
||||
--payg-inset-bg: #272c33;
|
||||
--payg-accent-soft: rgba(10, 139, 255, 0.16);
|
||||
--payg-divider: #3d444e;
|
||||
--payg-card-bg: #1c2340;
|
||||
--payg-card-border: #2d3560;
|
||||
--payg-inset-bg: #0d1020;
|
||||
--payg-accent-soft: rgba(79, 142, 245, 0.16);
|
||||
--payg-divider: #2d3560;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────── */
|
||||
@@ -258,16 +257,16 @@
|
||||
|
||||
/* Dark mode: the stock default button is a flat slab that disappears into the
|
||||
card. Lift it with a soft top-down gradient + brighter border, and warm the
|
||||
hover so the buttons feel tactile against --payg-card-bg (#313842). Driven
|
||||
hover so the buttons feel tactile against --payg-card-bg (#1c2340). Driven
|
||||
through Mantine's --button-* vars. The same rule covers disabled buttons
|
||||
(Cancel/Update cap before the form is dirty) so they read identically to the
|
||||
always-enabled ones — Mantine only fades them via opacity. */
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.payg
|
||||
.mantine-Button-root[data-variant="default"] {
|
||||
--button-bg: linear-gradient(180deg, #3d4651 0%, #353d48 100%);
|
||||
--button-hover: linear-gradient(180deg, #475160 0%, #3d4654 100%);
|
||||
--button-bd: 1px solid #4b5563;
|
||||
--button-bg: linear-gradient(180deg, #232a50 0%, #1c2340 100%);
|
||||
--button-hover: linear-gradient(180deg, #2d3560 0%, #232a50 100%);
|
||||
--button-bd: 1px solid #2d3560;
|
||||
--button-color: var(--mantine-color-white);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.04) inset,
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
* V1 — so it shows a real empty state, not fabricated rows
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useRenderCount } from "@app/hooks/useRenderCount";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import LockIcon from "@mui/icons-material/LockOutlined";
|
||||
@@ -110,16 +111,16 @@ export function DocHelp() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="payg-help">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="payg-help__toggle"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
leftSection={<HelpOutlineIcon sx={{ fontSize: 15 }} />}
|
||||
>
|
||||
<HelpOutlineIcon sx={{ fontSize: 15 }} />
|
||||
{t("payg.docHelp.toggle", "What counts as a PDF?")}
|
||||
<ExpandMoreIcon className="payg-help__chevron" sx={{ fontSize: 16 }} />
|
||||
</button>
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="payg-help__panel">
|
||||
<ul>
|
||||
@@ -446,16 +447,16 @@ function CapReachedHelp() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="payg-help">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="payg-help__toggle"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
leftSection={<HelpOutlineIcon sx={{ fontSize: 15 }} />}
|
||||
>
|
||||
<HelpOutlineIcon sx={{ fontSize: 15 }} />
|
||||
{t("payg.gates.title", "What happens when the cap is reached")}
|
||||
<ExpandMoreIcon className="payg-help__chevron" sx={{ fontSize: 16 }} />
|
||||
</button>
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="payg-help__panel">
|
||||
<div className="payg-gates">
|
||||
@@ -656,7 +657,7 @@ function StripePortalLink({
|
||||
onClick={handleClick}
|
||||
loading={loading}
|
||||
rightSection={<OpenInNewIcon sx={{ fontSize: 16 }} />}
|
||||
variant="light"
|
||||
variant="secondary"
|
||||
>
|
||||
{t("payg.stripe.open", "Open billing portal")}
|
||||
</Button>
|
||||
|
||||
@@ -217,25 +217,6 @@
|
||||
border-top: 1px solid var(--payg-divider);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.paygf-cta__button {
|
||||
padding: 13px 22px;
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.paygf-cta__button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 22px -6px rgba(10, 139, 255, 0.55);
|
||||
}
|
||||
.paygf-cta__reassurance {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import BoltIcon from "@mui/icons-material/BoltRounded";
|
||||
import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded";
|
||||
import CheckIcon from "@mui/icons-material/CheckRounded";
|
||||
@@ -171,14 +172,14 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {
|
||||
<FreeMeterPanel snap={snap} />
|
||||
{isLeader ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="paygf-cta__button paygf-proc__cta"
|
||||
<Button
|
||||
fullWidth
|
||||
className="paygf-proc__cta"
|
||||
onClick={onTurnOn}
|
||||
data-testid="turn-on-processor"
|
||||
>
|
||||
{t("payg.free.cta.button", "Turn on Processor →")}
|
||||
</button>
|
||||
</Button>
|
||||
<span className="paygf-cta__reassurance paygf-proc__reassure">
|
||||
{t(
|
||||
"payg.free.cta.reassurance",
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
gap: 14px;
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .scc {
|
||||
--scc-accent-text: #66b8ff;
|
||||
--scc-accent-soft: rgba(10, 139, 255, 0.16);
|
||||
--scc-chip-bg: #272d35;
|
||||
--scc-chip-border: #3d444e;
|
||||
--scc-accent-text: #7ab4ff;
|
||||
--scc-accent-soft: rgba(79, 142, 245, 0.16);
|
||||
--scc-chip-bg: #1c2340;
|
||||
--scc-chip-border: #2d3560;
|
||||
}
|
||||
|
||||
/* ── Inline row: presets · custom · no-cap · (save) ──────────────────── */
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import {
|
||||
createCheckoutSession,
|
||||
@@ -267,17 +268,12 @@ const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="upm-btn"
|
||||
data-variant="primary"
|
||||
onClick={onComplete}
|
||||
>
|
||||
<Button onClick={onComplete}>
|
||||
{t(
|
||||
"payg.checkout.mock.continue",
|
||||
"Continue with mock subscription",
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Button,
|
||||
TextInput,
|
||||
Group,
|
||||
Text,
|
||||
@@ -8,9 +7,10 @@ import {
|
||||
Alert,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
} from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
@@ -233,19 +233,18 @@ const TeamSection: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={handleRenameSubmit}
|
||||
loading={renamingTeam}
|
||||
disabled={!newTeamName.trim()}
|
||||
aria-label={t("team.renameSubmit", "Save team name")}
|
||||
>
|
||||
<LocalIcon icon="check" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
variant="tertiary"
|
||||
onClick={handleCancelRename}
|
||||
disabled={renamingTeam}
|
||||
aria-label={t("team.renameCancel", "Cancel rename")}
|
||||
>
|
||||
<LocalIcon icon="close" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
@@ -257,7 +256,7 @@ const TeamSection: React.FC = () => {
|
||||
</Text>
|
||||
{isTeamLeader && !isPersonalTeam && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={handleStartRename}
|
||||
aria-label={t("team.editName", "Edit team name")}
|
||||
@@ -285,9 +284,9 @@ const TeamSection: React.FC = () => {
|
||||
</div>
|
||||
{!isPersonalTeam && !isTeamLeader && !isEditingName && (
|
||||
<Button
|
||||
color="red"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
accent="danger"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleLeaveTeam}
|
||||
leftSection={
|
||||
<LocalIcon icon="logout" width="1rem" height="1rem" />
|
||||
@@ -449,7 +448,13 @@ const TeamSection: React.FC = () => {
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle">
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
aria-label={t(
|
||||
"team.members.actions",
|
||||
"Member actions",
|
||||
)}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="more-vert"
|
||||
width="1rem"
|
||||
@@ -504,8 +509,8 @@ const TeamSection: React.FC = () => {
|
||||
{isTeamLeader && !isPersonalTeam && (
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
variant="tertiary"
|
||||
accent="danger"
|
||||
onClick={() =>
|
||||
handleCancelInvitation(
|
||||
invitation.invitationId,
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
--upm-muted: var(--text-muted);
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .upm {
|
||||
--upm-card-bg: #313842;
|
||||
--upm-border: #3d444e;
|
||||
--upm-divider: #3d444e;
|
||||
--upm-accent-soft: rgba(10, 139, 255, 0.14);
|
||||
--upm-card-bg: #1c2340;
|
||||
--upm-border: #2d3560;
|
||||
--upm-divider: #2d3560;
|
||||
--upm-accent-soft: rgba(79, 142, 245, 0.14);
|
||||
}
|
||||
|
||||
/* Backdrop locks the page and centres the modal. z-index matches
|
||||
@@ -101,26 +101,6 @@
|
||||
color: var(--upm-text);
|
||||
margin: 0;
|
||||
}
|
||||
.upm-header__close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--upm-muted);
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
.upm-header__close:hover {
|
||||
background: var(--upm-divider);
|
||||
color: var(--upm-text);
|
||||
}
|
||||
|
||||
/* Left cluster: optional back arrow + title. The back arrow only renders on the
|
||||
checkout step (cap/confirm have no parent step to return to) — it replaces the
|
||||
old footer "← Back" button. */
|
||||
@@ -130,28 +110,6 @@
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.upm-header__back {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--upm-muted);
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: -6px;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
.upm-header__back:hover {
|
||||
background: var(--upm-divider);
|
||||
color: var(--upm-text);
|
||||
}
|
||||
|
||||
.upm-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -357,41 +315,6 @@
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.upm-btn {
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid transparent;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.upm-btn[data-variant="ghost"] {
|
||||
background: transparent;
|
||||
border-color: var(--upm-divider);
|
||||
color: var(--upm-text);
|
||||
}
|
||||
.upm-btn[data-variant="ghost"]:hover {
|
||||
border-color: var(--upm-accent);
|
||||
color: var(--upm-accent);
|
||||
}
|
||||
.upm-btn[data-variant="primary"] {
|
||||
background: linear-gradient(135deg, var(--upm-accent), var(--upm-accent-2));
|
||||
color: white;
|
||||
}
|
||||
.upm-btn[data-variant="primary"]:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px -6px rgba(10, 139, 255, 0.5);
|
||||
}
|
||||
.upm-btn[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* ── Step 3: confirmation ─────────────────────────────────────────────── */
|
||||
|
||||
.upm-confirm {
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
import React, { Suspense, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import CloseIcon from "@mui/icons-material/CloseRounded";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBackRounded";
|
||||
import ShieldIcon from "@mui/icons-material/ShieldOutlined";
|
||||
@@ -152,14 +154,14 @@ export default function UpgradeModal({
|
||||
<header className="upm-header">
|
||||
<div className="upm-header__left">
|
||||
{step === "checkout" && (
|
||||
<button
|
||||
type="button"
|
||||
className="upm-header__back"
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
aria-label={t("payg.upgrade.backAria", "Back")}
|
||||
onClick={goBackToCap}
|
||||
style={{ marginLeft: -6 }}
|
||||
>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</button>
|
||||
</ActionIcon>
|
||||
)}
|
||||
<h2 className="upm-header__title">
|
||||
{step === "confirm"
|
||||
@@ -170,14 +172,13 @@ export default function UpgradeModal({
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="upm-header__close"
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
aria-label={t("payg.upgrade.closeAria", "Close")}
|
||||
onClick={closeAndReset}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</button>
|
||||
</ActionIcon>
|
||||
</header>
|
||||
|
||||
{/* Step indicator. Hidden on the confirmation panel since the
|
||||
@@ -198,13 +199,13 @@ export default function UpgradeModal({
|
||||
"{{symbol}}{{amount}} / month",
|
||||
{ symbol: sym, amount: effectiveCap },
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="upm-step__edit"
|
||||
onClick={goBackToCap}
|
||||
>
|
||||
{t("payg.upgrade.checkout.edit", "Edit")}
|
||||
</button>
|
||||
</Button>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
@@ -264,36 +265,23 @@ export default function UpgradeModal({
|
||||
<div className="upm-footer__actions">
|
||||
{step === "cap" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="upm-btn"
|
||||
data-variant="ghost"
|
||||
onClick={closeAndReset}
|
||||
>
|
||||
<Button variant="secondary" onClick={closeAndReset}>
|
||||
{t("payg.upgrade.button.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="upm-btn"
|
||||
data-variant="primary"
|
||||
onClick={goToCheckout}
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={goToCheckout}>
|
||||
{t("payg.upgrade.button.continue", "Continue →")}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{step === "confirm" && (
|
||||
<button
|
||||
type="button"
|
||||
className="upm-btn"
|
||||
data-variant="primary"
|
||||
<Button
|
||||
onClick={() => {
|
||||
setStep("cap");
|
||||
onComplete({ capUsd: effectiveCap });
|
||||
}}
|
||||
>
|
||||
{t("payg.upgrade.button.finish", "Finish")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg width="37" height="36" viewBox="0 0 37 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.5 3.3125C16.5712 3.3125 14.6613 3.6924 12.8793 4.43052C11.0974 5.16864 9.47823 6.25051 8.11437 7.61437C5.35993 10.3688 3.8125 14.1046 3.8125 18C3.8125 24.4919 8.02781 29.9997 13.8588 31.9531C14.5931 32.0706 14.8281 31.6153 14.8281 31.2187V28.7366C10.7597 29.6178 9.89312 26.7684 9.89312 26.7684C9.2175 25.0647 8.26281 24.6094 8.26281 24.6094C6.92625 23.6987 8.36562 23.7281 8.36562 23.7281C9.83437 23.8309 10.6128 25.2409 10.6128 25.2409C11.8906 27.4734 14.0497 26.8125 14.8869 26.46C15.0191 25.5053 15.4009 24.8591 15.8122 24.4919C12.5516 24.1247 9.12937 22.8616 9.12937 17.2656C9.12937 15.6353 9.6875 14.3281 10.6422 13.2853C10.4953 12.9181 9.98125 11.3906 10.7891 9.40781C10.7891 9.40781 12.0228 9.01125 14.8281 10.9059C15.9884 10.5828 17.2516 10.4212 18.5 10.4212C19.7484 10.4212 21.0116 10.5828 22.1719 10.9059C24.9772 9.01125 26.2109 9.40781 26.2109 9.40781C27.0188 11.3906 26.5047 12.9181 26.3578 13.2853C27.3125 14.3281 27.8706 15.6353 27.8706 17.2656C27.8706 22.8762 24.4338 24.11 21.1584 24.4772C21.6872 24.9325 22.1719 25.8284 22.1719 27.1944V31.2187C22.1719 31.6153 22.4069 32.0853 23.1559 31.9531C28.9869 29.985 33.1875 24.4919 33.1875 18C33.1875 16.0712 32.8076 14.1613 32.0695 12.3793C31.3314 10.5974 30.2495 8.97823 28.8856 7.61437C27.5218 6.25051 25.9026 5.16864 24.1207 4.43052C22.3387 3.6924 20.4288 3.3125 18.5 3.3125Z" fill="black"/>
|
||||
<path d="M18.5 3.3125C16.5712 3.3125 14.6613 3.6924 12.8793 4.43052C11.0974 5.16864 9.47823 6.25051 8.11437 7.61437C5.35993 10.3688 3.8125 14.1046 3.8125 18C3.8125 24.4919 8.02781 29.9997 13.8588 31.9531C14.5931 32.0706 14.8281 31.6153 14.8281 31.2187V28.7366C10.7597 29.6178 9.89312 26.7684 9.89312 26.7684C9.2175 25.0647 8.26281 24.6094 8.26281 24.6094C6.92625 23.6987 8.36562 23.7281 8.36562 23.7281C9.83437 23.8309 10.6128 25.2409 10.6128 25.2409C11.8906 27.4734 14.0497 26.8125 14.8869 26.46C15.0191 25.5053 15.4009 24.8591 15.8122 24.4919C12.5516 24.1247 9.12937 22.8616 9.12937 17.2656C9.12937 15.6353 9.6875 14.3281 10.6422 13.2853C10.4953 12.9181 9.98125 11.3906 10.7891 9.40781C10.7891 9.40781 12.0228 9.01125 14.8281 10.9059C15.9884 10.5828 17.2516 10.4212 18.5 10.4212C19.7484 10.4212 21.0116 10.5828 22.1719 10.9059C24.9772 9.01125 26.2109 9.40781 26.2109 9.40781C27.0188 11.3906 26.5047 12.9181 26.3578 13.2853C27.3125 14.3281 27.8706 15.6353 27.8706 17.2656C27.8706 22.8762 24.4338 24.11 21.1584 24.4772C21.6872 24.9325 22.1719 25.8284 22.1719 27.1944V31.2187C22.1719 31.6153 22.4069 32.0853 23.1559 31.9531C28.9869 29.985 33.1875 24.4919 33.1875 18C33.1875 16.0712 32.8076 14.1613 32.0695 12.3793C31.3314 10.5974 30.2495 8.97823 28.8856 7.61437C27.5218 6.25051 25.9026 5.16864 24.1207 4.43052C22.3387 3.6924 20.4288 3.3125 18.5 3.3125Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { Card, Group, Text, Button, Progress } from "@mantine/core";
|
||||
import { Card, Group, Text, Progress } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
@@ -58,21 +59,16 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
|
||||
<Group gap="xs">
|
||||
{filesCount > 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
onClick={onClearAll}
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t("fileManager.clearAll", "Clear All")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="xs"
|
||||
onClick={onReloadFiles}
|
||||
>
|
||||
<Button variant="secondary" size="sm" onClick={onReloadFiles}>
|
||||
Reload Files
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Popover,
|
||||
Stack,
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ColorizeIcon from "@mui/icons-material/Colorize";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
|
||||
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
|
||||
// the button is hidden in the UI if the API is not supported.
|
||||
@@ -66,24 +66,12 @@ export function ColorControl({
|
||||
<Popover.Target>
|
||||
<Tooltip label={label}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={label}
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
onClick={() => setOpened(!opened)}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
flexShrink: 0,
|
||||
backgroundColor: "var(--bg-raised)",
|
||||
border: "1px solid var(--border-default)",
|
||||
color: "var(--text-secondary)",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--hover-bg)",
|
||||
borderColor: "var(--border-strong)",
|
||||
color: "var(--text-primary)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ColorSwatch color={localColor} size={18} />
|
||||
</ActionIcon>
|
||||
@@ -117,11 +105,14 @@ export function ColorControl({
|
||||
label={t("color.eyeDropper.tooltip", "Pick colour from screen")}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t(
|
||||
"color.eyeDropper.tooltip",
|
||||
"Pick colour from screen",
|
||||
)}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={handleEyeDropper}
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
<ColorizeIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -4,13 +4,12 @@ import {
|
||||
Stack,
|
||||
ColorPicker as MantineColorPicker,
|
||||
Group,
|
||||
Button,
|
||||
ColorSwatch,
|
||||
Slider,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@app/ui/Button";
|
||||
interface ColorPickerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Paper, Button, Modal, Stack, Text, Group } from "@mantine/core";
|
||||
import { Paper, Modal, Stack, Text, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ColorSwatchButton } from "@app/components/annotation/shared/ColorPicker";
|
||||
import PenSizeSelector from "@app/components/tools/sign/PenSizeSelector";
|
||||
import SignaturePad from "signature_pad";
|
||||
@@ -331,7 +332,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
</PrivateContent>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Button variant="subtle" color="red" onClick={clear}>
|
||||
<Button variant="tertiary" accent="danger" onClick={clear}>
|
||||
{t("sign.canvas.clear", "Clear canvas")}
|
||||
</Button>
|
||||
<Button onClick={closeModal}>{t("common.done", "Done")}</Button>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from "react";
|
||||
import { Group, Button, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { Group, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
|
||||
interface DrawingControlsProps {
|
||||
onUndo?: () => void;
|
||||
@@ -37,12 +39,11 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
{onUndo && (
|
||||
<Tooltip label={t("sign.undo", "Undo")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
aria-label={t("sign.undo", "Undo")}
|
||||
onClick={onUndo}
|
||||
disabled={undoDisabled}
|
||||
color={undoDisabled ? "gray" : "blue"}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="undo"
|
||||
@@ -56,12 +57,11 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
{onRedo && (
|
||||
<Tooltip label={t("sign.redo", "Redo")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
aria-label={t("sign.redo", "Redo")}
|
||||
onClick={onRedo}
|
||||
disabled={redoDisabled}
|
||||
color={redoDisabled ? "gray" : "blue"}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="redo"
|
||||
@@ -78,11 +78,9 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
{/* Place Signature Button */}
|
||||
{showPlaceButton && onPlaceSignature && (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={onPlaceSignature}
|
||||
disabled={disabled || !hasSignatureData}
|
||||
ml="auto"
|
||||
style={{ marginLeft: "auto" }}
|
||||
>
|
||||
{placeButtonText}
|
||||
</Button>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Popover,
|
||||
Stack,
|
||||
Slider,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import OpacityIcon from "@mui/icons-material/Opacity";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
|
||||
interface OpacityControlProps {
|
||||
value: number; // 0-100
|
||||
@@ -29,24 +23,12 @@ export function OpacityControl({
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("annotation.opacity", "Opacity")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("annotation.opacity", "Opacity")}
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
onClick={() => setOpened(!opened)}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
flexShrink: 0,
|
||||
backgroundColor: "var(--bg-raised)",
|
||||
border: "1px solid var(--border-default)",
|
||||
color: "var(--text-secondary)",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--hover-bg)",
|
||||
borderColor: "var(--border-strong)",
|
||||
color: "var(--text-primary)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<OpacityIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Popover,
|
||||
Stack,
|
||||
Slider,
|
||||
Text,
|
||||
Group,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Tooltip, Popover, Stack, Slider, Text, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { useState } from "react";
|
||||
import type { TrackedAnnotation } from "@embedpdf/plugin-annotation";
|
||||
import type { PdfAnnotationObject } from "@embedpdf/models";
|
||||
@@ -106,21 +99,24 @@ export function PropertiesPopover({
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant={currentAlign === "left" ? "filled" : "default"}
|
||||
aria-label={t("annotation.alignLeft", "Align left")}
|
||||
variant={currentAlign === "left" ? "primary" : "secondary"}
|
||||
onClick={() => onUpdate({ textAlign: 0 })}
|
||||
size="md"
|
||||
>
|
||||
<FormatAlignLeftIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={currentAlign === "center" ? "filled" : "default"}
|
||||
aria-label={t("annotation.alignCenter", "Align center")}
|
||||
variant={currentAlign === "center" ? "primary" : "secondary"}
|
||||
onClick={() => onUpdate({ textAlign: 1 })}
|
||||
size="md"
|
||||
>
|
||||
<FormatAlignCenterIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={currentAlign === "right" ? "filled" : "default"}
|
||||
aria-label={t("annotation.alignRight", "Align right")}
|
||||
variant={currentAlign === "right" ? "primary" : "secondary"}
|
||||
onClick={() => onUpdate({ textAlign: 2 })}
|
||||
size="md"
|
||||
>
|
||||
@@ -176,8 +172,8 @@ export function PropertiesPopover({
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={!borderVisible ? "filled" : "light"}
|
||||
size="sm"
|
||||
variant={!borderVisible ? "primary" : "secondary"}
|
||||
onClick={() => {
|
||||
const newValue = borderVisible ? 0 : 1;
|
||||
onUpdate({
|
||||
@@ -201,24 +197,12 @@ export function PropertiesPopover({
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("annotation.properties", "Properties")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("annotation.properties", "Properties")}
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
onClick={() => setOpened(!opened)}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
flexShrink: 0,
|
||||
backgroundColor: "var(--bg-raised)",
|
||||
border: "1px solid var(--border-default)",
|
||||
color: "var(--text-secondary)",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--hover-bg)",
|
||||
borderColor: "var(--border-strong)",
|
||||
color: "var(--text-primary)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TuneIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
useCombobox,
|
||||
Group,
|
||||
Box,
|
||||
SegmentedControl,
|
||||
} from "@mantine/core";
|
||||
import { SegmentedControl } from "@app/ui/SegmentedControl";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
||||
|
||||
@@ -253,14 +253,14 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
|
||||
{/* Text Alignment */}
|
||||
{onTextAlignChange && (
|
||||
<SegmentedControl
|
||||
<SegmentedControl<"left" | "center" | "right">
|
||||
value={textAlign}
|
||||
onChange={(value: string) => {
|
||||
onTextAlignChange(value as "left" | "center" | "right");
|
||||
onChange={(value) => {
|
||||
onTextAlignChange(value);
|
||||
onAnyChange?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
data={[
|
||||
loading={disabled}
|
||||
options={[
|
||||
{ label: t("textAlign.left", "Left"), value: "left" },
|
||||
{ label: t("textAlign.center", "Center"), value: "center" },
|
||||
{ label: t("textAlign.right", "Right"), value: "right" },
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Popover,
|
||||
Stack,
|
||||
Slider,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import LineWeightIcon from "@mui/icons-material/LineWeight";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
|
||||
interface WidthControlProps {
|
||||
value: number;
|
||||
@@ -33,24 +27,12 @@ export function WidthControl({
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("annotation.width", "Width")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("annotation.width", "Width")}
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
onClick={() => setOpened(!opened)}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
flexShrink: 0,
|
||||
backgroundColor: "var(--bg-raised)",
|
||||
border: "1px solid var(--border-default)",
|
||||
color: "var(--text-secondary)",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--hover-bg)",
|
||||
borderColor: "var(--border-strong)",
|
||||
color: "var(--text-primary)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LineWeightIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { Group } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
@@ -106,6 +107,7 @@ const AddFileCard = ({
|
||||
>
|
||||
{!isUploadHover && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
style={{
|
||||
backgroundColor: "var(--landing-button-bg)",
|
||||
color: "var(--landing-button-color)",
|
||||
@@ -133,6 +135,7 @@ const AddFileCard = ({
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
aria-label={t("addFileCard.upload", "Upload")}
|
||||
title={terminology.uploadFromComputer}
|
||||
style={{
|
||||
|
||||
@@ -291,15 +291,15 @@
|
||||
DARK MODE OVERRIDES
|
||||
========================= */
|
||||
:global([data-mantine-color-scheme="dark"]) .card {
|
||||
outline-color: #3a4047; /* deselected stroke */
|
||||
outline-color: #1c2340; /* deselected stroke */
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] {
|
||||
outline-color: #4b525a; /* selected stroke (subtle grey) */
|
||||
outline-color: #2d3560; /* selected stroke (subtle navy) */
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .headerResting {
|
||||
background: #1f2329; /* requested default unselected color */
|
||||
background: #0d1020; /* requested default unselected color */
|
||||
color: var(--tool-header-text); /* #D0D6DC */
|
||||
border-bottom-color: var(--tool-header-border); /* #3A4047 */
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from "react";
|
||||
import {
|
||||
Text,
|
||||
Modal,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Text, Modal, Group, Stack, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -545,9 +539,12 @@ const FileEditorThumbnail = ({
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="yellow"
|
||||
size="sm"
|
||||
accent="warning"
|
||||
aria-label={t(
|
||||
"encryptedPdfUnlock.unlockPrompt",
|
||||
"Unlock PDF to continue",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEncryptedUnlockPrompt(file.id);
|
||||
@@ -606,17 +603,13 @@ const FileEditorThumbnail = ({
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
<Button variant="secondary" onClick={handleCancelClose}>
|
||||
{t("confirmCloseCancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="red"
|
||||
onClick={handleConfirmClose}
|
||||
>
|
||||
<Button accent="danger" onClick={handleConfirmClose}>
|
||||
{t("confirmCloseDiscard", "Discard changes and close")}
|
||||
</Button>
|
||||
<Button variant="filled" onClick={handleSaveAndClose}>
|
||||
<Button onClick={handleSaveAndClose}>
|
||||
{t("confirmCloseSave", "Save and close")}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -633,14 +626,10 @@ const FileEditorThumbnail = ({
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
<Button variant="secondary" onClick={handleCancelClose}>
|
||||
{t("confirmCloseCancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="red"
|
||||
onClick={handleConfirmClose}
|
||||
>
|
||||
<Button accent="danger" onClick={handleConfirmClose}>
|
||||
{t("confirmCloseConfirm", "Close File")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Text, Button, ActionIcon, Center } from "@mantine/core";
|
||||
import { Stack, Box, Text, Center } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
@@ -128,18 +130,20 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
{hasMultipleFiles && (
|
||||
<Box style={{ display: "flex", gap: "0.25rem" }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={onPrevious}
|
||||
disabled={isAnimating}
|
||||
aria-label={t("fileManager.previousFile", "Previous file")}
|
||||
>
|
||||
<ChevronLeftIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
disabled={isAnimating}
|
||||
aria-label={t("fileManager.nextFile", "Next file")}
|
||||
>
|
||||
<ChevronRightIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
@@ -150,16 +154,10 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
size="sm"
|
||||
accent="neutral"
|
||||
onClick={onOpenFiles}
|
||||
disabled={!hasSelection && !canCloseAll}
|
||||
fullWidth
|
||||
style={{
|
||||
backgroundColor:
|
||||
hasSelection || canCloseAll
|
||||
? "var(--btn-open-file)"
|
||||
: "var(--mantine-color-gray-4)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{canCloseAll
|
||||
? t("fileManager.closeAllFiles", "Close all files")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Group, Text, Stack } from "@mantine/core";
|
||||
import { Group, Text, Stack } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||
@@ -77,6 +78,7 @@ const EmptyFilesState: React.FC = () => {
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
aria-label={t("emptyFilesState.upload", "Upload")}
|
||||
style={{
|
||||
backgroundColor: "var(--bg-file-manager)",
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
Group,
|
||||
Text,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
SegmentedControl,
|
||||
} from "@mantine/core";
|
||||
import { Group, Text, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { SegmentedControl } from "@app/ui/SegmentedControl";
|
||||
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
@@ -131,26 +127,29 @@ const FileActions: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
color="dimmed"
|
||||
onClick={handleSelectAll}
|
||||
disabled={filteredFiles.length === 0}
|
||||
radius="sm"
|
||||
aria-label={
|
||||
allFilesSelected
|
||||
? t("fileManager.deselectAll", "Deselect All")
|
||||
: t("fileManager.selectAll", "Select All")
|
||||
}
|
||||
>
|
||||
<SelectAllIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{showStorageFilter && (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
size="sm"
|
||||
value={storageFilter}
|
||||
onChange={(value) =>
|
||||
onStorageFilterChange(
|
||||
value as "all" | "local" | "sharedWithMe" | "sharedByMe",
|
||||
)
|
||||
}
|
||||
data={storageFilterOptions}
|
||||
options={storageFilterOptions}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
@@ -177,12 +176,11 @@ const FileActions: React.FC = () => {
|
||||
{uploadEnabled && (
|
||||
<Tooltip label={t("fileManager.uploadSelected", "Upload Selected")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
color="dimmed"
|
||||
onClick={() => setShowBulkUploadModal(true)}
|
||||
disabled={!canBulkUpload}
|
||||
radius="sm"
|
||||
aria-label={t("fileManager.uploadSelected", "Upload Selected")}
|
||||
>
|
||||
<CloudUploadIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
@@ -191,12 +189,11 @@ const FileActions: React.FC = () => {
|
||||
{shareLinksEnabled && (
|
||||
<Tooltip label={t("fileManager.shareSelected", "Share Selected")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
color="dimmed"
|
||||
onClick={() => setShowBulkShareModal(true)}
|
||||
disabled={!canBulkShare}
|
||||
radius="sm"
|
||||
aria-label={t("fileManager.shareSelected", "Share Selected")}
|
||||
>
|
||||
<LinkIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
@@ -204,12 +201,12 @@ const FileActions: React.FC = () => {
|
||||
)}
|
||||
<Tooltip label={t("fileManager.deleteSelected", "Delete Selected")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
color="dimmed"
|
||||
accent="danger"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={!hasSelection}
|
||||
radius="sm"
|
||||
aria-label={t("fileManager.deleteSelected", "Delete Selected")}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
@@ -217,12 +214,11 @@ const FileActions: React.FC = () => {
|
||||
|
||||
<Tooltip label={terminology.downloadSelected}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
color="dimmed"
|
||||
onClick={handleDownloadSelected}
|
||||
disabled={!hasSelection || !hasDownloadAccess}
|
||||
radius="sm"
|
||||
aria-label={terminology.downloadSelected}
|
||||
>
|
||||
<DownloadIcon style={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Stack, Button, Box } from "@mantine/core";
|
||||
import { Stack, Box } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
|
||||
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||
@@ -108,16 +109,10 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
accent="neutral"
|
||||
onClick={onOpenFiles}
|
||||
disabled={!hasSelection && !canCloseAll}
|
||||
fullWidth
|
||||
style={{
|
||||
backgroundColor:
|
||||
hasSelection || canCloseAll
|
||||
? "var(--btn-open-file)"
|
||||
: "var(--mantine-color-gray-4)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{canCloseAll
|
||||
? t("fileManager.closeAllFiles", "Close all files")
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
Group,
|
||||
Divider,
|
||||
ScrollArea,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { detectFileExtension, getFileSize } from "@app/utils/fileUtils";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
@@ -208,7 +208,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
<Divider />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
variant="secondary"
|
||||
onClick={() => onMakeCopy(currentFile)}
|
||||
fullWidth
|
||||
>
|
||||
@@ -260,7 +260,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
</Group>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
variant="secondary"
|
||||
onClick={() => setShowShareManageModal(true)}
|
||||
fullWidth
|
||||
>
|
||||
|
||||
@@ -3,12 +3,12 @@ import {
|
||||
Group,
|
||||
Box,
|
||||
Text,
|
||||
ActionIcon,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Menu,
|
||||
Badge,
|
||||
} from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
@@ -323,10 +323,11 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("fileManager.moreOptions", "More options")}
|
||||
style={{
|
||||
opacity: shouldShowHovered ? 1 : 0,
|
||||
transform: shouldShowHovered ? "scale(1)" : "scale(0.8)",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { Stack, Text, Button, Group } from "@mantine/core";
|
||||
import { Stack, Text, Group } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import PhonelinkIcon from "@mui/icons-material/Phonelink";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -65,86 +66,45 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
// Determine visibility of Mobile QR Scanner button
|
||||
const shouldHideMobileQR =
|
||||
!isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner;
|
||||
|
||||
const buttonProps = {
|
||||
variant: (source: string) =>
|
||||
activeSource === source ? "filled" : "subtle",
|
||||
getColor: (source: string) =>
|
||||
activeSource === source ? "var(--mantine-color-gray-2)" : undefined,
|
||||
getStyles: (source: string) => ({
|
||||
root: {
|
||||
backgroundColor: activeSource === source ? undefined : "transparent",
|
||||
color:
|
||||
activeSource === source
|
||||
? "var(--mantine-color-gray-9)"
|
||||
: "var(--mantine-color-gray-6)",
|
||||
border: "none",
|
||||
"&:hover": {
|
||||
backgroundColor:
|
||||
activeSource === source ? undefined : "var(--mantine-color-gray-0)",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
// Shared Button has no `xs`; map the old horizontal `xs` to `sm`.
|
||||
const buttonSize = "sm" as const;
|
||||
const buttonJustify = horizontal ? "center" : "start";
|
||||
const buttons = (
|
||||
<>
|
||||
<Button
|
||||
variant={activeSource === "recent" ? "primary" : "tertiary"}
|
||||
accent="neutral"
|
||||
leftSection={<HistoryIcon />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
justify={buttonJustify}
|
||||
onClick={() => onSourceChange("recent")}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
color={buttonProps.getColor("recent")}
|
||||
styles={buttonProps.getStyles("recent")}
|
||||
size={buttonSize}
|
||||
>
|
||||
{horizontal
|
||||
? t("fileManager.recent", "Recent")
|
||||
: t("fileManager.recent", "Recent")}
|
||||
{t("fileManager.recent", "Recent")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="var(--mantine-color-gray-6)"
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
leftSection={<UploadIcon />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
justify={buttonJustify}
|
||||
onClick={onLocalFileClick}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--mantine-color-gray-0)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
size={buttonSize}
|
||||
>
|
||||
{horizontal ? terminology.upload : terminology.uploadFiles}
|
||||
</Button>
|
||||
|
||||
{!shouldHideGoogleDrive && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="var(--mantine-color-gray-6)"
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
leftSection={<GoogleDriveIcon colored={isGoogleDriveEnabled} />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
justify={buttonJustify}
|
||||
onClick={handleGoogleDriveClick}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
size={buttonSize}
|
||||
disabled={!isGoogleDriveEnabled}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
"&:hover": {
|
||||
backgroundColor: isGoogleDriveEnabled
|
||||
? "var(--mantine-color-gray-0)"
|
||||
: "transparent",
|
||||
},
|
||||
},
|
||||
}}
|
||||
title={
|
||||
!isGoogleDriveEnabled
|
||||
? t(
|
||||
@@ -162,25 +122,14 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
|
||||
{!shouldHideMobileQR && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="var(--mantine-color-gray-6)"
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
leftSection={<PhonelinkIcon />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
justify={buttonJustify}
|
||||
onClick={handleMobileUploadClick}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
size={buttonSize}
|
||||
disabled={!isMobileUploadEnabled}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
"&:hover": {
|
||||
backgroundColor: isMobileUploadEnabled
|
||||
? "var(--mantine-color-gray-0)"
|
||||
: "transparent",
|
||||
},
|
||||
},
|
||||
}}
|
||||
title={
|
||||
!isMobileUploadEnabled
|
||||
? t(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Button, Group, Modal, Radio, Stack, Text } from "@mantine/core";
|
||||
import { Alert, Group, Modal, Radio, Stack, Text } from "@mantine/core";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
|
||||
|
||||
import { Button } from "@app/ui/Button";
|
||||
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { DeleteScope } from "@app/services/serverStorageDelete";
|
||||
|
||||
@@ -164,11 +166,11 @@ export function DeleteFilesDialog({
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||
<Button variant="secondary" onClick={onClose} disabled={submitting}>
|
||||
{t("filesPage.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
accent="danger"
|
||||
loading={submitting}
|
||||
onClick={() => runConfirm(showChoice ? scope : fixedScope)}
|
||||
>
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Alert, Checkbox, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
|
||||
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { FolderRecord } from "@app/types/folder";
|
||||
|
||||
interface DeleteFolderDialogProps {
|
||||
@@ -95,11 +88,11 @@ export function DeleteFolderDialog({
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||
<Button variant="secondary" onClick={onClose} disabled={submitting}>
|
||||
{t("filesPage.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
accent="danger"
|
||||
loading={submitting}
|
||||
onClick={async () => {
|
||||
setSubmitting(true);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, Badge, Button, Tooltip } from "@mantine/core";
|
||||
import { Badge, Tooltip } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove";
|
||||
@@ -143,7 +145,12 @@ export function FileDetailsPanel({
|
||||
label={t("filesPage.closeDetails", "Close details")}
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon variant="subtle" size="sm" onClick={onClose}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
aria-label={t("filesPage.closeDetails", "Close details")}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -181,25 +188,27 @@ export function FileDetailsPanel({
|
||||
<span className="files-page-details-ext-tag">{ext}</span>
|
||||
)}
|
||||
{(single.versionNumber ?? 1) > 1 && (
|
||||
<Badge size="sm" variant="filled" color="blue">
|
||||
<Badge size="sm" color="blue">
|
||||
v{single.versionNumber}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="files-page-details-collapse-toggle"
|
||||
onClick={() => setFieldsOpen((o) => !o)}
|
||||
aria-expanded={fieldsOpen}
|
||||
rightSection={
|
||||
<KeyboardArrowDownIcon
|
||||
className={`files-page-details-collapse-chevron${
|
||||
fieldsOpen ? " is-open" : ""
|
||||
}`}
|
||||
fontSize="small"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>{t("filesPage.fileInfo", "File info")}</span>
|
||||
<KeyboardArrowDownIcon
|
||||
className={`files-page-details-collapse-chevron${
|
||||
fieldsOpen ? " is-open" : ""
|
||||
}`}
|
||||
fontSize="small"
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
{fieldsOpen && (
|
||||
<div className="files-page-details-fieldlist">
|
||||
<DetailField
|
||||
@@ -256,7 +265,7 @@ export function FileDetailsPanel({
|
||||
(compactVersions && onOpenVersionHistory ? (
|
||||
<Button
|
||||
leftSection={<HistoryIcon fontSize="small" />}
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
onClick={onOpenVersionHistory}
|
||||
>
|
||||
{t(
|
||||
@@ -291,7 +300,6 @@ export function FileDetailsPanel({
|
||||
<div className="files-page-details-actions">
|
||||
<Button
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
variant="filled"
|
||||
onClick={() => onAddToWorkspace(selectedFileIds)}
|
||||
>
|
||||
{files.length === 1
|
||||
@@ -302,7 +310,7 @@ export function FileDetailsPanel({
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<DownloadIcon fontSize="small" />}
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
onClick={handleDownload}
|
||||
loading={downloading}
|
||||
>
|
||||
@@ -329,14 +337,12 @@ export function FileDetailsPanel({
|
||||
>
|
||||
<Button
|
||||
leftSection={<LinkIcon fontSize="small" />}
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
disabled={!sharingEnabled}
|
||||
onClick={() => setShareModalOpen(true)}
|
||||
styles={{
|
||||
root: {
|
||||
// Keep tooltip hoverable while button is disabled.
|
||||
pointerEvents: sharingEnabled ? undefined : "auto",
|
||||
},
|
||||
style={{
|
||||
// Keep tooltip hoverable while button is disabled.
|
||||
pointerEvents: sharingEnabled ? undefined : "auto",
|
||||
}}
|
||||
>
|
||||
{t("filesPage.shareManage", "Manage sharing")}
|
||||
@@ -345,7 +351,7 @@ export function FileDetailsPanel({
|
||||
)}
|
||||
<Button
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
onClick={() => onMove(selectedFileIds)}
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
@@ -363,16 +369,12 @@ export function FileDetailsPanel({
|
||||
>
|
||||
<Button
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={() => onSaveToServer(localOnlyFiles)}
|
||||
styles={{
|
||||
root: {
|
||||
// Keep tooltip hoverable while button is disabled.
|
||||
pointerEvents: saveToServerDisabledReason
|
||||
? "auto"
|
||||
: undefined,
|
||||
},
|
||||
style={{
|
||||
// Keep tooltip hoverable while button is disabled.
|
||||
pointerEvents: saveToServerDisabledReason ? "auto" : undefined,
|
||||
}}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
@@ -381,8 +383,7 @@ export function FileDetailsPanel({
|
||||
)}
|
||||
<Button
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
color="red"
|
||||
variant="light"
|
||||
accent="danger"
|
||||
onClick={() => onRemove(selectedFileIds)}
|
||||
>
|
||||
{t("filesPage.remove", "Delete")}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core";
|
||||
import { Checkbox, Menu, Tooltip } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
@@ -297,10 +299,10 @@ function EmptyState({
|
||||
<span style={{ display: "inline-flex" }}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
leftSection={<CreateNewFolderIcon fontSize="small" />}
|
||||
disabled
|
||||
styles={{ root: { pointerEvents: "auto" } }}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
{t("filesPage.empty.newFolderCta", "Create folder")}
|
||||
</Button>
|
||||
@@ -309,7 +311,7 @@ function EmptyState({
|
||||
) : (
|
||||
<Button
|
||||
size="md"
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
leftSection={<CreateNewFolderIcon fontSize="small" />}
|
||||
onClick={onCreateFolder}
|
||||
>
|
||||
@@ -528,8 +530,6 @@ function FolderCard({
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="filled"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.folderMenu", "Folder actions")}
|
||||
@@ -771,8 +771,6 @@ function FileCard({
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="filled"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.fileMenu", "File actions")}
|
||||
@@ -1151,7 +1149,7 @@ function FolderRow({
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="subtle"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.folderMenu", "Folder actions")}
|
||||
@@ -1370,7 +1368,7 @@ function FileRow({
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="subtle"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.fileMenu", "File actions")}
|
||||
|
||||
@@ -7,19 +7,12 @@ import React, {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Drawer,
|
||||
Group,
|
||||
MultiSelect,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Drawer, Group, MultiSelect, Select, Tooltip } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { SegmentedControl } from "@app/ui/SegmentedControl";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import QrCode2Icon from "@mui/icons-material/QrCode2";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
@@ -910,8 +903,8 @@ export default function FileManagerView() {
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="md"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={refreshing}
|
||||
disabled={refreshing || Boolean(signInRequiredReason)}
|
||||
aria-busy={refreshing}
|
||||
@@ -930,11 +923,11 @@ export default function FileManagerView() {
|
||||
>
|
||||
<span style={{ display: "inline-flex" }}>
|
||||
<Button
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<CreateNewFolderIcon fontSize="small" />}
|
||||
disabled
|
||||
styles={{ root: { pointerEvents: "auto" } }}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
{t("filesPage.newFolder", "New folder")}
|
||||
</Button>
|
||||
@@ -942,7 +935,7 @@ export default function FileManagerView() {
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<CreateNewFolderIcon fontSize="small" />}
|
||||
onClick={() => openNewFolderDialog()}
|
||||
@@ -966,9 +959,8 @@ export default function FileManagerView() {
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setMobileUploadModalOpen(true)}
|
||||
aria-label={t(
|
||||
"filesPage.uploadFromMobile",
|
||||
@@ -1012,11 +1004,11 @@ export default function FileManagerView() {
|
||||
<span>{folders.error}</span>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
variant="tertiary"
|
||||
aria-label={t("filesPage.dismissError", "Dismiss")}
|
||||
onClick={() => folders.setError(null)}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
×
|
||||
</ActionIcon>
|
||||
</div>
|
||||
)}
|
||||
@@ -1147,8 +1139,8 @@ export default function FileManagerView() {
|
||||
w={280}
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelectedFileIds(new Set());
|
||||
@@ -1210,19 +1202,17 @@ export default function FileManagerView() {
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={() =>
|
||||
setSaveToServerTarget(localOnlySelectedStubs)
|
||||
}
|
||||
styles={{
|
||||
root: {
|
||||
// Keep the tooltip hoverable while disabled.
|
||||
pointerEvents: saveToServerDisabledReason
|
||||
? "auto"
|
||||
: undefined,
|
||||
},
|
||||
style={{
|
||||
// Keep the tooltip hoverable while disabled.
|
||||
pointerEvents: saveToServerDisabledReason
|
||||
? "auto"
|
||||
: undefined,
|
||||
}}
|
||||
aria-label={t(
|
||||
"filesPage.saveToServer",
|
||||
@@ -1242,7 +1232,7 @@ export default function FileManagerView() {
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
leftSection={
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
}
|
||||
@@ -1259,7 +1249,7 @@ export default function FileManagerView() {
|
||||
<Tooltip label={moveLabel} withinPortal>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
onClick={() => promptMoveFiles(selectedFiles)}
|
||||
aria-label={moveLabel}
|
||||
@@ -1270,8 +1260,8 @@ export default function FileManagerView() {
|
||||
<Tooltip label={removeLabel} withinPortal>
|
||||
<Button
|
||||
size="sm"
|
||||
color="red"
|
||||
variant="light"
|
||||
accent="danger"
|
||||
variant="secondary"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={() => handleRemoveFiles(selectedFiles)}
|
||||
aria-label={removeLabel}
|
||||
@@ -1284,7 +1274,7 @@ export default function FileManagerView() {
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
variant="tertiary"
|
||||
size="md"
|
||||
onClick={() => clearSelection()}
|
||||
aria-label={t(
|
||||
@@ -1292,7 +1282,7 @@ export default function FileManagerView() {
|
||||
"Clear selection",
|
||||
)}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
×
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
@@ -1388,7 +1378,7 @@ export default function FileManagerView() {
|
||||
/>
|
||||
<span className="files-page-toolbar-divider" aria-hidden="true" />
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
size="sm"
|
||||
value={viewMode}
|
||||
onChange={(v) => {
|
||||
// Mantine only emits values declared in `data[].value`, but
|
||||
@@ -1401,7 +1391,7 @@ export default function FileManagerView() {
|
||||
setViewMode(v as (typeof FILES_PAGE_VIEW_MODES)[number]);
|
||||
}}
|
||||
aria-label={t("filesPage.viewMode.label", "View mode")}
|
||||
data={[
|
||||
options={[
|
||||
{
|
||||
value: "grid",
|
||||
label: (
|
||||
@@ -1686,12 +1676,12 @@ const SearchField = React.forwardRef<
|
||||
/>
|
||||
{value && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => onChange("")}
|
||||
aria-label={t("filesPage.clearSearch", "Clear search")}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
×
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
@@ -1712,8 +1702,8 @@ function Breadcrumbs() {
|
||||
const isLast = idx === trail.length - 1;
|
||||
return (
|
||||
<React.Fragment key={entry.id ?? "root"}>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className={`files-page-breadcrumb${isLast ? " is-current" : ""}`}
|
||||
onClick={() => folders.setCurrentFolderId(entry.id)}
|
||||
onDragOver={(e) => {
|
||||
@@ -1772,7 +1762,7 @@ function Breadcrumbs() {
|
||||
}}
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
</Button>
|
||||
{!isLast && (
|
||||
<KeyboardArrowRightIcon
|
||||
className="files-page-breadcrumb-sep"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
FOLDER_ICONS,
|
||||
FolderIconOption,
|
||||
} from "@app/components/filesPage/folderIcons";
|
||||
import { Button } from "@app/ui/Button";
|
||||
|
||||
interface FolderAppearancePickerProps {
|
||||
folder: FolderRecord;
|
||||
@@ -50,9 +51,9 @@ export function FolderAppearancePicker({
|
||||
}}
|
||||
>
|
||||
{FOLDER_COLOR_PALETTE.map((c) => (
|
||||
<button
|
||||
<Button
|
||||
key={c}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={disabled}
|
||||
aria-label={t(
|
||||
"filesPage.appearance.useColour",
|
||||
@@ -146,8 +147,8 @@ function IconButton({
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={icon.label} withinPortal>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="tertiary"
|
||||
disabled={disabled}
|
||||
aria-label={icon.label}
|
||||
onClick={(e) => {
|
||||
@@ -172,7 +173,7 @@ function IconButton({
|
||||
}}
|
||||
>
|
||||
{icon.glyph || "-"}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Button, Group, Modal, Stack, TextInput } from "@mantine/core";
|
||||
import { Alert, Group, Modal, Stack, TextInput } from "@mantine/core";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
|
||||
|
||||
import { Button } from "@app/ui/Button";
|
||||
|
||||
interface FolderNameDialogProps {
|
||||
opened: boolean;
|
||||
title: string;
|
||||
@@ -94,7 +96,7 @@ export function FolderNameDialog({
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t("filesPage.folderName.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { Menu } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import HomeIcon from "@mui/icons-material/Home";
|
||||
@@ -409,8 +410,8 @@ function TreeNodeRow({
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
className="files-page-tree-kebab"
|
||||
aria-label={t(
|
||||
"filesPage.treeMenu.actions",
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
@@ -15,9 +13,10 @@ import HomeIcon from "@mui/icons-material/Home";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
|
||||
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
|
||||
|
||||
interface MoveToFolderDialogProps {
|
||||
@@ -228,8 +227,7 @@ export function MoveToFolderDialog({
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
onClick={handleCancel}
|
||||
disabled={creating}
|
||||
@@ -238,20 +236,20 @@ export function MoveToFolderDialog({
|
||||
"Discard",
|
||||
)}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
×
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
) : (
|
||||
<Button
|
||||
variant="subtle"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
leftSection={<CreateNewFolderIcon fontSize="small" />}
|
||||
onClick={() => {
|
||||
setCreatingFolder(true);
|
||||
setNewFolderName("");
|
||||
}}
|
||||
styles={{ root: { alignSelf: "flex-start" } }}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
data-testid="move-dialog-create-folder-toggle"
|
||||
>
|
||||
{t(
|
||||
@@ -272,7 +270,7 @@ export function MoveToFolderDialog({
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||
<Button variant="secondary" onClick={onClose} disabled={submitting}>
|
||||
{t("filesPage.moveDialog.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -325,35 +323,35 @@ function FolderPick({
|
||||
onPick,
|
||||
}: FolderPickProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="tertiary"
|
||||
justify="start"
|
||||
fullWidth
|
||||
onClick={onPick}
|
||||
disabled={disabled}
|
||||
leftSection={
|
||||
isRoot ? (
|
||||
<HomeIcon fontSize="small" />
|
||||
) : isActive ? (
|
||||
<FolderOpenIcon fontSize="small" style={{ color }} />
|
||||
) : (
|
||||
<FolderIcon fontSize="small" style={{ color }} />
|
||||
)
|
||||
}
|
||||
style={{
|
||||
all: "unset",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: `0.4rem 0.75rem 0.4rem ${0.75 + depth * 0.85}rem`,
|
||||
width: "100%",
|
||||
background: isActive ? "var(--hover-bg)" : "transparent",
|
||||
borderBottom: "1px solid var(--border-subtle)",
|
||||
boxSizing: "border-box",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{isRoot ? (
|
||||
<HomeIcon fontSize="small" />
|
||||
) : isActive ? (
|
||||
<FolderOpenIcon fontSize="small" style={{ color }} />
|
||||
) : (
|
||||
<FolderIcon fontSize="small" style={{ color }} />
|
||||
)}
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, Badge, Menu } from "@mantine/core";
|
||||
import { Badge, Menu } 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";
|
||||
@@ -144,8 +146,8 @@ export function VersionTimeline({
|
||||
<span className="files-page-details-version-timeline-rail-line" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="files-page-details-version-timeline-ellipsis-btn"
|
||||
onClick={() => setShowAllCollapsed(true)}
|
||||
>
|
||||
@@ -154,7 +156,7 @@ export function VersionTimeline({
|
||||
"Show {{count}} earlier versions",
|
||||
{ count: row.hidden },
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -181,24 +183,31 @@ export function VersionTimeline({
|
||||
)}
|
||||
</div>
|
||||
<div className="files-page-details-version-timeline-body">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="files-page-details-version-timeline-summary"
|
||||
onClick={() => toggleExpand(v.id)}
|
||||
aria-expanded={isExpanded}
|
||||
leftSection={
|
||||
<Badge
|
||||
size="xs"
|
||||
variant={isActive ? "filled" : "outline"}
|
||||
color="blue"
|
||||
>
|
||||
v{v.versionNumber ?? 1}
|
||||
</Badge>
|
||||
}
|
||||
rightSection={
|
||||
<KeyboardArrowDownIcon
|
||||
className={`files-page-details-version-timeline-chevron${
|
||||
isExpanded ? " is-expanded" : ""
|
||||
}`}
|
||||
fontSize="small"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant={isActive ? "filled" : "outline"}
|
||||
color="blue"
|
||||
>
|
||||
v{v.versionNumber ?? 1}
|
||||
</Badge>
|
||||
{delta ? (
|
||||
<span className="files-page-details-version-timeline-delta">
|
||||
<span className="files-page-details-version-timeline-delta-plus">
|
||||
+
|
||||
</span>
|
||||
<ToolLabel toolId={delta.toolId} />
|
||||
</span>
|
||||
) : (
|
||||
@@ -206,14 +215,7 @@ export function VersionTimeline({
|
||||
{t("filesPage.versionOrigin", "Original upload")}
|
||||
</span>
|
||||
)}
|
||||
<span className="files-page-details-version-timeline-spacer" />
|
||||
<KeyboardArrowDownIcon
|
||||
className={`files-page-details-version-timeline-chevron${
|
||||
isExpanded ? " is-expanded" : ""
|
||||
}`}
|
||||
fontSize="small"
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
<div className="files-page-details-version-timeline-meta-line">
|
||||
<span>{formatFileSize(v.size)}</span>
|
||||
{v.lastModified ? (
|
||||
@@ -230,7 +232,7 @@ export function VersionTimeline({
|
||||
<Menu position="bottom-end" withinPortal shadow="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
aria-label={t(
|
||||
"filesPage.versionActions",
|
||||
@@ -303,13 +305,13 @@ export function VersionTimeline({
|
||||
})}
|
||||
</ol>
|
||||
{collapsible && showAllCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="files-page-details-version-timeline-collapse-btn"
|
||||
onClick={() => setShowAllCollapsed(false)}
|
||||
>
|
||||
{t("filesPage.versionCollapse", "Collapse middle versions")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import { Button, Group, ActionIcon } from "@mantine/core";
|
||||
import { Group } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -33,22 +35,6 @@ export function SlideButtons({
|
||||
(btn) => btn.group === "right",
|
||||
);
|
||||
|
||||
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
|
||||
variant === "primary"
|
||||
? {
|
||||
root: {
|
||||
background: "var(--onboarding-primary-button-bg)",
|
||||
color: "var(--onboarding-primary-button-text)",
|
||||
},
|
||||
}
|
||||
: {
|
||||
root: {
|
||||
background: "var(--onboarding-secondary-button-bg)",
|
||||
border: "1px solid var(--onboarding-secondary-button-border)",
|
||||
color: "var(--onboarding-secondary-button-text)",
|
||||
},
|
||||
};
|
||||
|
||||
const resolveButtonLabel = (button: ButtonDefinition) => {
|
||||
// Special case: override "See Plans" with "Upgrade now" when over limit
|
||||
if (
|
||||
@@ -77,20 +63,14 @@ export function SlideButtons({
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
radius="md"
|
||||
size={40}
|
||||
variant="secondary"
|
||||
accent="neutral"
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
background: "var(--onboarding-secondary-button-bg)",
|
||||
border: "1px solid var(--onboarding-secondary-button-border)",
|
||||
color: "var(--onboarding-secondary-button-text)",
|
||||
},
|
||||
}}
|
||||
aria-label={t("onboarding.buttons.back", "Back")}
|
||||
>
|
||||
{button.icon === "chevron-left" && (
|
||||
{button.icon === "chevron-left" ? (
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
)}
|
||||
) : null}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
@@ -103,7 +83,10 @@ export function SlideButtons({
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
disabled={disabled}
|
||||
styles={buttonStyles(variant)}
|
||||
variant={variant === "primary" ? "primary" : "secondary"}
|
||||
accent={
|
||||
button.accent ?? (variant === "primary" ? "default" : "neutral")
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Modal, Stack, ActionIcon } from "@mantine/core";
|
||||
import { Modal, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
import type {
|
||||
SlideDefinition,
|
||||
@@ -45,6 +46,7 @@ export default function OnboardingModalSlide({
|
||||
onAction,
|
||||
allowDismiss = true,
|
||||
}: OnboardingModalSlideProps) {
|
||||
const { t } = useTranslation();
|
||||
const renderHero = () => {
|
||||
if (slideDefinition.hero.type === "dual-icon") {
|
||||
return (
|
||||
@@ -139,8 +141,9 @@ export default function OnboardingModalSlide({
|
||||
{allowDismiss && (
|
||||
<ActionIcon
|
||||
onClick={onSkip}
|
||||
radius="md"
|
||||
size={36}
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
aria-label={t("common.close", "Close")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 16,
|
||||
@@ -150,15 +153,12 @@ export default function OnboardingModalSlide({
|
||||
backdropFilter: "blur(4px)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
<LocalIcon
|
||||
icon="close-rounded"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { TourProvider, useTour, type StepType } from "@reactour/tour";
|
||||
import { CloseButton, ActionIcon } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
@@ -137,7 +137,7 @@ export default function OnboardingTour({
|
||||
setIsOpen,
|
||||
})
|
||||
}
|
||||
variant="subtle"
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
aria-label={
|
||||
isLast
|
||||
@@ -151,11 +151,15 @@ export default function OnboardingTour({
|
||||
}}
|
||||
components={{
|
||||
Close: ({ onClick }) => (
|
||||
<CloseButton
|
||||
<ActionIcon
|
||||
onClick={onClick}
|
||||
variant="tertiary"
|
||||
size="md"
|
||||
aria-label={t("onboarding.close", "Close")}
|
||||
style={{ position: "absolute", top: "8px", right: "8px" }}
|
||||
/>
|
||||
>
|
||||
×
|
||||
</ActionIcon>
|
||||
),
|
||||
Content: ({ content }: { content: string }) => (
|
||||
<div
|
||||
|
||||
@@ -8,6 +8,7 @@ import TourOverviewSlide from "@app/components/onboarding/slides/TourOverviewSli
|
||||
import AnalyticsChoiceSlide from "@app/components/onboarding/slides/AnalyticsChoiceSlide";
|
||||
import MFASetupSlide from "@app/components/onboarding/slides/MFASetupSlide";
|
||||
import { SlideConfig, LicenseNotice } from "@app/types/types";
|
||||
import type { ButtonAccent } from "@app/ui/Button";
|
||||
|
||||
export type SlideId =
|
||||
| "first-login"
|
||||
@@ -83,6 +84,8 @@ export interface ButtonDefinition {
|
||||
label?: string;
|
||||
icon?: "chevron-left";
|
||||
variant?: "primary" | "secondary" | "default";
|
||||
/** Accent for the shared Button; defaults to neutral. */
|
||||
accent?: ButtonAccent;
|
||||
group: "left" | "right";
|
||||
action: ButtonAction;
|
||||
disabledWhen?: (state: FlowState) => boolean;
|
||||
@@ -238,6 +241,7 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
type: "button",
|
||||
label: "onboarding.serverLicense.seePlans",
|
||||
variant: "primary",
|
||||
accent: "premium",
|
||||
group: "right",
|
||||
action: "see-plans",
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { Button } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import i18n from "@app/i18n";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
@@ -36,7 +36,7 @@ export default function AnalyticsChoiceSlide({
|
||||
<br />
|
||||
<div style={{ textAlign: "right", marginTop: 0 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, ActionIcon } from "@mantine/core";
|
||||
import { Menu } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
|
||||
export interface OSOption {
|
||||
@@ -67,8 +68,12 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
||||
<Menu position="bottom" offset={5} zIndex={10000}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
aria-label={t(
|
||||
"onboarding.desktopInstall.selectOs",
|
||||
"Select operating system",
|
||||
)}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { Stack, PasswordInput, Button, Alert, Text } from "@mantine/core";
|
||||
import { Stack, PasswordInput, Alert, Text } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
@@ -199,8 +200,8 @@ function FirstLoginForm({
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={
|
||||
!newPassword ||
|
||||
@@ -209,7 +210,7 @@ function FirstLoginForm({
|
||||
confirmPassword.length < 8
|
||||
}
|
||||
size="md"
|
||||
mt="xs"
|
||||
style={{ marginTop: "var(--mantine-spacing-xs)" }}
|
||||
>
|
||||
{t("firstLogin.changePassword", "Change Password")}
|
||||
</Button>
|
||||
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
@@ -210,8 +210,8 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
variant="light"
|
||||
onClick={fetchMfaSetup}
|
||||
disabled={mfaLoading || submitting || setupComplete}
|
||||
>
|
||||
@@ -226,7 +226,7 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
>
|
||||
Enable MFA
|
||||
</Button>
|
||||
<Button type="button" variant="light" onClick={onLogout}>
|
||||
<Button variant="secondary" type="button" onClick={onLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -5,7 +5,9 @@ import React, {
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { ActionIcon, CheckboxIndicator } from "@mantine/core";
|
||||
import { CheckboxIndicator } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
@@ -259,7 +261,7 @@ const FileThumbnail = ({
|
||||
{/* Kebab menu */}
|
||||
<ActionIcon
|
||||
aria-label={t("moreOptions", "More options")}
|
||||
variant="subtle"
|
||||
variant="tertiary"
|
||||
className={styles.kebab}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -277,8 +279,18 @@ const FileThumbnail = ({
|
||||
style={{ width: actionsWidth }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant="tertiary"
|
||||
justify="start"
|
||||
fullWidth
|
||||
className={styles.actionRow}
|
||||
leftSection={
|
||||
isPinned ? (
|
||||
<PushPinIcon fontSize="small" />
|
||||
) : (
|
||||
<PushPinOutlinedIcon fontSize="small" />
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
@@ -292,38 +304,36 @@ const FileThumbnail = ({
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
{isPinned ? (
|
||||
<PushPinIcon fontSize="small" />
|
||||
) : (
|
||||
<PushPinOutlinedIcon fontSize="small" />
|
||||
)}
|
||||
<span>{isPinned ? t("unpin", "Unpin") : t("pin", "Pin")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
{isPinned ? t("unpin", "Unpin") : t("pin", "Pin")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
justify="start"
|
||||
fullWidth
|
||||
className={styles.actionRow}
|
||||
leftSection={<DownloadOutlinedIcon fontSize="small" />}
|
||||
onClick={() => {
|
||||
downloadSelectedFile();
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
<span>{terminology.download}</span>
|
||||
</button>
|
||||
|
||||
{terminology.download}
|
||||
</Button>
|
||||
<div className={styles.actionsDivider} />
|
||||
|
||||
<button
|
||||
<Button
|
||||
variant="tertiary"
|
||||
justify="start"
|
||||
fullWidth
|
||||
className={`${styles.actionRow} ${styles.actionDanger}`}
|
||||
leftSection={<DeleteOutlineIcon fontSize="small" />}
|
||||
onClick={() => {
|
||||
onDeleteFile(file.id);
|
||||
onSetStatus(`Deleted ${file.name}`);
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
<span>{t("delete", "Delete")}</span>
|
||||
</button>
|
||||
{t("delete", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Tooltip, ActionIcon } from "@mantine/core";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import RedoIcon from "@mui/icons-material/Redo";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
@@ -135,28 +136,22 @@ const PageEditorControls = ({
|
||||
{/* Undo/Redo */}
|
||||
<Tooltip label={t("pageEditor.toolbar.undo", "Undo")}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
variant="subtle"
|
||||
style={{
|
||||
color: canUndo ? "var(--text-secondary)" : "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
aria-label={t("pageEditor.toolbar.undo", "Undo")}
|
||||
>
|
||||
<UndoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("pageEditor.toolbar.redo", "Redo")}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
variant="subtle"
|
||||
style={{
|
||||
color: canRedo ? "var(--text-secondary)" : "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
aria-label={t("pageEditor.toolbar.redo", "Redo")}
|
||||
>
|
||||
<RedoIcon />
|
||||
</ActionIcon>
|
||||
@@ -176,17 +171,14 @@ const PageEditorControls = ({
|
||||
label={t("pageEditor.toolbar.rotateLeft", "Rotate Selected Left")}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
onClick={() => onRotate("left")}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
aria-label={t(
|
||||
"pageEditor.toolbar.rotateLeft",
|
||||
"Rotate Selected Left",
|
||||
)}
|
||||
>
|
||||
<RotateLeftIcon />
|
||||
</ActionIcon>
|
||||
@@ -195,68 +187,47 @@ const PageEditorControls = ({
|
||||
label={t("pageEditor.toolbar.rotateRight", "Rotate Selected Right")}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
onClick={() => onRotate("right")}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
aria-label={t(
|
||||
"pageEditor.toolbar.rotateRight",
|
||||
"Rotate Selected Right",
|
||||
)}
|
||||
>
|
||||
<RotateRightIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("pageEditor.toolbar.delete", "Delete Selected")}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
onClick={onDelete}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
aria-label={t("pageEditor.toolbar.delete", "Delete Selected")}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={getSplitTooltip()}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
onClick={onSplit}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
aria-label={getSplitTooltip()}
|
||||
>
|
||||
<ContentCutIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={getPageBreakTooltip()}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
onClick={onPageBreak}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
aria-label={getPageBreakTooltip()}
|
||||
>
|
||||
<InsertPageBreakIcon />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ActionIcon, Popover } from "@mantine/core";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import BulkSelectionPanel from "@app/components/pageEditor/BulkSelectionPanel";
|
||||
@@ -37,8 +38,7 @@ export default function PageSelectByNumberButton({
|
||||
<Popover.Target>
|
||||
<div style={{ display: "inline-flex" }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
variant="tertiary"
|
||||
disabled={disabled || totalPages === 0}
|
||||
aria-label={label}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button, Text, Group, Divider } from "@mantine/core";
|
||||
import { Text, Group, Divider } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
|
||||
import { LogicalOperator } from "@app/utils/bulkselection/selectionBuilders";
|
||||
@@ -22,7 +23,7 @@ const OperatorsSection = ({
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator("and")}
|
||||
disabled={!csvInput.trim()}
|
||||
@@ -37,7 +38,7 @@ const OperatorsSection = ({
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator("or")}
|
||||
disabled={!csvInput.trim()}
|
||||
@@ -52,7 +53,7 @@ const OperatorsSection = ({
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator("not")}
|
||||
disabled={!csvInput.trim()}
|
||||
@@ -70,7 +71,7 @@ const OperatorsSection = ({
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator("even")}
|
||||
title={t(
|
||||
@@ -84,7 +85,7 @@ const OperatorsSection = ({
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
className={classes.operatorChip}
|
||||
onClick={() => onInsertOperator("odd")}
|
||||
title={t(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TextInput, Button, Text, Flex, Switch } from "@mantine/core";
|
||||
import { TextInput, Text, Flex, Switch } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
@@ -77,20 +78,15 @@ const PageSelectionInput = ({
|
||||
placeholder="1,3,5-10"
|
||||
rightSection={
|
||||
csvInput && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
minWidth: "auto",
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
padding: 0,
|
||||
}}
|
||||
aria-label={t("clear", "Clear")}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
×
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) => e.key === "Enter" && onUpdatePagesFromCSV()}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Text, NumberInput, Group } from "@mantine/core";
|
||||
import { Text, NumberInput, Group } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
|
||||
|
||||
interface SelectPagesProps {
|
||||
|
||||
@@ -5,7 +5,8 @@ import React, {
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core";
|
||||
import { Badge, Modal, Text, Tooltip, Group } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
@@ -365,7 +366,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
/>
|
||||
<ActionIcon
|
||||
ref={closeButtonRef}
|
||||
variant="subtle"
|
||||
variant="tertiary"
|
||||
onClick={handleClose}
|
||||
aria-label={t("settings.close", "Close")}
|
||||
data-autofocus
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Trigger: bare square icon button that blends into either sidebar's header. */
|
||||
.app-switch-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-4);
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.app-switch-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.app-switch-icon {
|
||||
width: 1rem;
|
||||
height: 1.0625rem;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Dropdown } from "@app/ui";
|
||||
import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
|
||||
import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
|
||||
import "@app/components/shared/AppSwitch.css";
|
||||
|
||||
export type AppSwitchTarget = "editor" | "processor";
|
||||
|
||||
function ChevronDownIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppSwitchProps {
|
||||
/** The app this switcher is rendered in (shown as active in the menu). */
|
||||
current: AppSwitchTarget;
|
||||
/** Resolved color scheme; picks the brand mark for the menu items. */
|
||||
theme: "light" | "dark";
|
||||
/** Invoked with the selected app; only called for apps other than `current`. */
|
||||
onSwitch: (app: AppSwitchTarget) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor ⇄ processor app switcher (chevron button → app menu). The editor
|
||||
* and portal sidebars render this same element so the two apps present one
|
||||
* identical switcher; each host supplies its own theme source and navigation.
|
||||
*/
|
||||
export function AppSwitch({
|
||||
current,
|
||||
theme,
|
||||
onSwitch,
|
||||
className,
|
||||
}: AppSwitchProps) {
|
||||
const { t } = useTranslation();
|
||||
const mark = theme === "dark" ? markDark : markLight;
|
||||
const apps: Array<{ id: AppSwitchTarget; label: string }> = [
|
||||
{
|
||||
id: "processor",
|
||||
label: t("portal.shell.sidebar.appProcessor", "Processor"),
|
||||
},
|
||||
{ id: "editor", label: t("portal.shell.sidebar.appEditor", "Editor") },
|
||||
];
|
||||
return (
|
||||
<Dropdown.Root align="end" className={className}>
|
||||
<Dropdown.Trigger>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="app-switch-btn"
|
||||
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width="11rem">
|
||||
{apps.map((app) => (
|
||||
<Dropdown.Item
|
||||
key={app.id}
|
||||
active={current === app.id}
|
||||
onSelect={app.id === current ? undefined : () => onSwitch(app.id)}
|
||||
leading={<img className="app-switch-icon" src={mark} alt="" />}
|
||||
>
|
||||
{app.label}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Core stub for the sidebar app switcher. Builds that bundle the admin portal
|
||||
* (proprietary/saas) shadow this with a real switcher; core has no portal, so
|
||||
* there is nothing to switch to.
|
||||
*/
|
||||
export function AppSwitcher() {
|
||||
return null;
|
||||
}
|
||||
@@ -3,13 +3,13 @@ import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Alert,
|
||||
TextInput,
|
||||
Paper,
|
||||
Select,
|
||||
} from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -243,8 +243,8 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
|
||||
label={t("storageShare.linkLabel", "Share link")}
|
||||
rightSection={
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<ContentCopyRoundedIcon style={{ fontSize: 16 }} />
|
||||
}
|
||||
@@ -297,7 +297,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
|
||||
</Paper>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={isWorking}>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isWorking}>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Modal, Stack, Text, Button, Group, Alert } from "@mantine/core";
|
||||
import { Modal, Stack, Text, Group, Alert } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -151,7 +152,7 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={isUploading}>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isUploading}>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -8,6 +8,14 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// The shared SegmentedControl renders each option as a radio <input> (inside a
|
||||
// <label>) whose `value` attribute matches the option value. Select by value
|
||||
// since it is stable regardless of how the label is wrapped (e.g. FitText).
|
||||
const getRadioByValue = (container: HTMLElement, value: string) =>
|
||||
container.querySelector<HTMLInputElement>(
|
||||
`input[type="radio"][value="${value}"]`,
|
||||
);
|
||||
|
||||
describe("ButtonSelector", () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
@@ -15,7 +23,7 @@ describe("ButtonSelector", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should render all options as buttons", () => {
|
||||
test("should render all options as segments", () => {
|
||||
const options = [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
@@ -37,13 +45,13 @@ describe("ButtonSelector", () => {
|
||||
expect(screen.getByText("Option 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should highlight selected button with filled variant", () => {
|
||||
test("should mark selected option as checked", () => {
|
||||
const options = [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
];
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
@@ -54,22 +62,22 @@ describe("ButtonSelector", () => {
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const selectedButton = screen.getByRole("button", { name: "Option 1" });
|
||||
const unselectedButton = screen.getByRole("button", { name: "Option 2" });
|
||||
const selectedRadio = getRadioByValue(container, "option1");
|
||||
const unselectedRadio = getRadioByValue(container, "option2");
|
||||
|
||||
// Check data-variant attribute for filled/outline
|
||||
expect(selectedButton).toHaveAttribute("data-variant", "filled");
|
||||
expect(unselectedButton).toHaveAttribute("data-variant", "outline");
|
||||
// Selected option is marked via the radio's checked state.
|
||||
expect(selectedRadio).toBeChecked();
|
||||
expect(unselectedRadio).not.toBeChecked();
|
||||
expect(screen.getByText("Selection Label")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should call onChange when button is clicked", () => {
|
||||
test("should call onChange when an option is clicked", () => {
|
||||
const options = [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
];
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
@@ -79,7 +87,7 @@ describe("ButtonSelector", () => {
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Option 2" }));
|
||||
fireEvent.click(getRadioByValue(container, "option2")!);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith("option2");
|
||||
});
|
||||
@@ -90,7 +98,7 @@ describe("ButtonSelector", () => {
|
||||
{ value: "option2", label: "Option 2" },
|
||||
];
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value={undefined}
|
||||
@@ -100,17 +108,17 @@ describe("ButtonSelector", () => {
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
// Both buttons should be outlined when no value is selected
|
||||
const button1 = screen.getByRole("button", { name: "Option 1" });
|
||||
const button2 = screen.getByRole("button", { name: "Option 2" });
|
||||
// No option should be checked when no value is selected
|
||||
const radio1 = getRadioByValue(container, "option1");
|
||||
const radio2 = getRadioByValue(container, "option2");
|
||||
|
||||
expect(button1).toHaveAttribute("data-variant", "outline");
|
||||
expect(button2).toHaveAttribute("data-variant", "outline");
|
||||
expect(radio1).not.toBeChecked();
|
||||
expect(radio2).not.toBeChecked();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
description: "disable buttons when disabled prop is true",
|
||||
description: "disable options when disabled prop is true",
|
||||
options: [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
@@ -128,7 +136,7 @@ describe("ButtonSelector", () => {
|
||||
expectedStates: [false, true],
|
||||
},
|
||||
])("should $description", ({ options, globalDisabled, expectedStates }) => {
|
||||
render(
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
@@ -140,18 +148,18 @@ describe("ButtonSelector", () => {
|
||||
);
|
||||
|
||||
options.forEach((option, index) => {
|
||||
const button = screen.getByRole("button", { name: option.label });
|
||||
expect(button).toHaveProperty("disabled", expectedStates[index]);
|
||||
const radio = getRadioByValue(container, String(option.value));
|
||||
expect(radio).toHaveProperty("disabled", expectedStates[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test("should not call onChange when disabled button is clicked", () => {
|
||||
test("should not allow selecting a disabled option", () => {
|
||||
const options = [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2", disabled: true },
|
||||
];
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
@@ -161,12 +169,16 @@ describe("ButtonSelector", () => {
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Option 2" }));
|
||||
|
||||
// The disabled option's radio is disabled, so a real user cannot select it
|
||||
// and onChange will not fire from genuine interaction. (jsdom does not
|
||||
// replicate the browser's disabled-click blocking, so assert the disabled
|
||||
// state — that is what prevents selection for real users.)
|
||||
const disabledRadio = getRadioByValue(container, "option2");
|
||||
expect(disabledRadio).toBeDisabled();
|
||||
expect(mockOnChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not apply fullWidth styling when fullWidth is false", () => {
|
||||
test("should render options when fullWidth is false", () => {
|
||||
const options = [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
@@ -184,8 +196,7 @@ describe("ButtonSelector", () => {
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Option 1" });
|
||||
expect(button).not.toHaveStyle({ flex: "1" });
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Layout Label")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -205,14 +216,14 @@ describe("ButtonSelector", () => {
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
// Should render buttons
|
||||
// Should render the options
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 2")).toBeInTheDocument();
|
||||
|
||||
// Stack should only contain the Group (buttons), no Text element for label
|
||||
// Stack should only contain the SegmentedControl, no label Text element
|
||||
const stackElement = container.querySelector(
|
||||
'[class*="mantine-Stack-root"]',
|
||||
);
|
||||
expect(stackElement?.children).toHaveLength(1); // Only the Group, no label Text
|
||||
expect(stackElement?.children).toHaveLength(1); // Only the SegmentedControl, no label Text
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { Stack, Text, Tooltip } from "@mantine/core";
|
||||
import FitText from "@app/components/shared/FitText";
|
||||
import { SegmentedControl } from "@app/ui/SegmentedControl";
|
||||
|
||||
export interface ButtonOption<T> {
|
||||
value: T;
|
||||
@@ -15,7 +16,6 @@ interface ButtonSelectorProps<T> {
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
buttonClassName?: string;
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
@@ -26,9 +26,43 @@ const ButtonSelector = <T extends string | number>({
|
||||
label = undefined,
|
||||
disabled = false,
|
||||
fullWidth = true,
|
||||
buttonClassName,
|
||||
textClassName,
|
||||
}: ButtonSelectorProps<T>) => {
|
||||
const selectedValue = value === undefined ? "" : String(value);
|
||||
|
||||
const segmentedOptions = options.map((option) => {
|
||||
const isDisabled = disabled || option.disabled;
|
||||
const fitText = (
|
||||
<FitText
|
||||
text={option.label}
|
||||
lines={1}
|
||||
minimumFontScale={0.5}
|
||||
fontSize={10}
|
||||
className={textClassName}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
value: String(option.value),
|
||||
disabled: isDisabled,
|
||||
label:
|
||||
option.tooltip && isDisabled ? (
|
||||
<Tooltip label={option.tooltip} position="top" withArrow>
|
||||
<span>{fitText}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
fitText
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
const matched = options.find((option) => String(option.value) === next);
|
||||
if (matched) {
|
||||
onChange(matched.value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="var(--mantine-spacing-sm)">
|
||||
{/* Label (if it exists) */}
|
||||
@@ -44,69 +78,12 @@ const ButtonSelector = <T extends string | number>({
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<Group gap="4px">
|
||||
{options.map((option) => {
|
||||
const isDisabled = disabled || option.disabled;
|
||||
const button = (
|
||||
<Button
|
||||
variant={value === option.value ? "filled" : "outline"}
|
||||
color={
|
||||
value === option.value
|
||||
? "var(--color-primary-500)"
|
||||
: "var(--text-muted)"
|
||||
}
|
||||
onClick={() => onChange(option.value)}
|
||||
disabled={isDisabled}
|
||||
className={buttonClassName}
|
||||
style={{
|
||||
flex: fullWidth ? 1 : undefined,
|
||||
height: "auto",
|
||||
minHeight: "2.5rem",
|
||||
fontSize: "var(--mantine-font-size-sm)",
|
||||
lineHeight: "1.4",
|
||||
paddingTop: "0.5rem",
|
||||
paddingBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<FitText
|
||||
text={option.label}
|
||||
lines={1}
|
||||
minimumFontScale={0.5}
|
||||
fontSize={10}
|
||||
className={textClassName}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
|
||||
// Wrap with tooltip if provided (useful for disabled state explanations)
|
||||
if (option.tooltip && isDisabled) {
|
||||
return (
|
||||
<Tooltip
|
||||
key={option.value}
|
||||
label={option.tooltip}
|
||||
position="top"
|
||||
withArrow
|
||||
>
|
||||
<span
|
||||
style={{ flex: fullWidth ? 1 : undefined, display: "flex" }}
|
||||
>
|
||||
{button}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={option.value}
|
||||
style={{ flex: fullWidth ? 1 : undefined, display: "flex" }}
|
||||
>
|
||||
{button}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
<SegmentedControl
|
||||
options={segmentedOptions}
|
||||
value={selectedValue}
|
||||
onChange={handleChange}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||