mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c1bf918af | ||
|
|
dc2cbbd2c0 | ||
|
|
0b2a1d4a4f | ||
|
|
c879b56fac | ||
|
|
1f27655c1b | ||
|
|
2a403d3210 | ||
|
|
d98a4875db | ||
|
|
8ec80eb9fc | ||
|
|
e97c16db92 | ||
|
|
7058a41af9 | ||
|
|
366516dcdf | ||
|
|
422df509f1 | ||
|
|
9562c1622a |
@@ -1,3 +1,4 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
@@ -10,6 +11,45 @@ import tsconfigPaths from "vite-tsconfig-paths";
|
||||
* the portal layer at editor/src/portal/). MDX docs pages live in
|
||||
* editor/src/portal/docs/.
|
||||
*/
|
||||
|
||||
/** Layer search order for @app/*, mirroring each flavour's vite tsconfig. */
|
||||
const FLAVOUR_LAYERS: Record<string, string[]> = {
|
||||
desktop: ["desktop", "cloud", "proprietary", "core"],
|
||||
saas: ["saas", "cloud", "proprietary", "core"],
|
||||
cloud: ["cloud", "proprietary", "core"],
|
||||
prototypes: ["prototypes", "proprietary", "core"],
|
||||
};
|
||||
|
||||
const SUFFIXES = ["", ".tsx", ".ts", "/index.tsx", "/index.ts"];
|
||||
|
||||
function flavourAppAlias() {
|
||||
return {
|
||||
name: "storybook-app-alias-by-flavour",
|
||||
// Ahead of vite-tsconfig-paths, which would otherwise answer first with the
|
||||
// proprietary order.
|
||||
enforce: "pre" as const,
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (!importer || !source.startsWith("@app/")) return null;
|
||||
const layer = importer
|
||||
.split("\\")
|
||||
.join("/")
|
||||
.match(/\/editor\/src\/(desktop|saas|cloud|prototypes)\//)?.[1];
|
||||
if (!layer) return null;
|
||||
const rest = source.slice("@app/".length);
|
||||
for (const candidate of FLAVOUR_LAYERS[layer]) {
|
||||
for (const suffix of SUFFIXES) {
|
||||
const full = resolve(
|
||||
__dirname,
|
||||
`../editor/src/${candidate}/${rest}${suffix}`,
|
||||
);
|
||||
if (existsSync(full) && statSync(full).isFile()) return full;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
"../editor/src/portal/**/*.mdx",
|
||||
@@ -53,6 +93,12 @@ const config: StorybookConfig = {
|
||||
// shared Storybook can host editor components without duplicating the alias
|
||||
// map here.
|
||||
config.plugins = config.plugins ?? [];
|
||||
// Stories under desktop/, saas/, cloud/ and prototypes/ import @app/* too,
|
||||
// but each of those builds resolves it against its own layer first — an
|
||||
// order the single tsconfig below cannot express. Resolving by the
|
||||
// importer's layer lets those stories load without changing what @app/*
|
||||
// means for core, proprietary or portal stories, which never match here.
|
||||
config.plugins.push(flavourAppAlias());
|
||||
config.plugins.push(
|
||||
tsconfigPaths({
|
||||
projects: [
|
||||
|
||||
@@ -18,7 +18,14 @@ import { TierProvider, type Tier } from "@portal/contexts/TierContext";
|
||||
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
|
||||
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { UIProvider } from "@portal/contexts/UIContext";
|
||||
import { PreferencesProvider } from "@core/contexts/PreferencesContext";
|
||||
import { SidebarProvider } from "@core/contexts/SidebarContext";
|
||||
import { SuiProvider } from "@portal/theme/SuiProvider";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import {
|
||||
mantineTheme as editorMantineTheme,
|
||||
editorCssVariablesResolver,
|
||||
} from "@core/theme/mantineTheme";
|
||||
import { handlers } from "@portal/mocks/handlers";
|
||||
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
|
||||
import i18next from "i18next";
|
||||
@@ -29,6 +36,11 @@ import { rtlLanguages, supportedLanguages } from "@core/i18n/languages";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@core/tokens/tokens.css";
|
||||
import "@core/theme/index.css";
|
||||
// The editor's Mantine theme resolves its palette through the --color-* vocab
|
||||
// defined here. The app picks this up via styles/tailwind.css; Storybook has no
|
||||
// tailwind entry, so without it every var(--color-*) in the theme is undefined
|
||||
// and Mantine silently falls back to its stock palette.
|
||||
import "@core/styles/theme.css";
|
||||
import "@core/tokens/base.css";
|
||||
|
||||
// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
|
||||
@@ -190,6 +202,37 @@ const withLocale: Decorator = (Story, context) => {
|
||||
return <Story />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies the Mantine theme the story's component actually runs under in the
|
||||
* app: PortalApp wraps the Processor in SuiProvider, while the editor wraps
|
||||
* everything else in its own ThemeProvider. Getting this wrong is not just
|
||||
* cosmetic — the two themes carry different neutral ramps, so rendering an
|
||||
* editor component under the Processor's theme drops it onto Mantine's stock
|
||||
* greys and reports contrast failures the app doesn't have.
|
||||
*/
|
||||
function StoryTheme({
|
||||
isPortalStory,
|
||||
colorScheme,
|
||||
children,
|
||||
}: {
|
||||
isPortalStory: boolean;
|
||||
colorScheme: "light" | "dark";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (isPortalStory) {
|
||||
return <SuiProvider colorScheme={colorScheme}>{children}</SuiProvider>;
|
||||
}
|
||||
return (
|
||||
<MantineProvider
|
||||
theme={editorMantineTheme}
|
||||
cssVariablesResolver={editorCssVariablesResolver}
|
||||
forceColorScheme={colorScheme}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const withProviders: Decorator = (Story, context) => {
|
||||
const tier = (context.globals.tier as Tier) ?? "pro";
|
||||
const linkState =
|
||||
@@ -201,25 +244,36 @@ const withProviders: Decorator = (Story, context) => {
|
||||
// anything that isn't "dark" as light — matching the addon's own
|
||||
// `selected || defaultTheme` fallback where defaultTheme is light.
|
||||
const colorScheme = context.globals.theme === "dark" ? "dark" : "light";
|
||||
// Storybook titles are the routing key here: the Processor's stories are all
|
||||
// filed under "Portal/".
|
||||
const isPortalStory = (context.title ?? "").startsWith("Portal/");
|
||||
return (
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<SchemeSetup scheme={colorScheme} />
|
||||
<ThemeBridge theme={colorScheme}>
|
||||
<SuiProvider colorScheme={colorScheme}>
|
||||
<StoryTheme isPortalStory={isPortalStory} 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>
|
||||
{/* Tooltip reads the user's logo preference and the sidebar
|
||||
geometry it positions against. It is used by ~100
|
||||
components, so without these a story that renders one
|
||||
throws. The real app always has both mounted. */}
|
||||
<PreferencesProvider>
|
||||
<SidebarProvider>
|
||||
<UIProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
</SidebarProvider>
|
||||
</PreferencesProvider>
|
||||
</TierKey>
|
||||
</LinkProvider>
|
||||
</SuiProvider>
|
||||
</StoryTheme>
|
||||
</ThemeBridge>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
@@ -246,6 +300,14 @@ const preview: Preview = {
|
||||
// any violation. Context is left at the addon default (the document root)
|
||||
// so it resolves under both the Storybook UI and the Vitest browser mount.
|
||||
test: "error",
|
||||
context: {
|
||||
// Nodes carrying this attribute render a facsimile of the user's own
|
||||
// document — their stamp text, their watermark, in the colour and
|
||||
// opacity they chose. WCAG contrast governs the interface, not the
|
||||
// content authored through it, and the controls that set those values
|
||||
// are checked normally.
|
||||
exclude: ["[data-user-content-preview]"],
|
||||
},
|
||||
},
|
||||
},
|
||||
globalTypes: {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
// Storybook coverage report — which rendered surfaces have a story, and what
|
||||
// stands between the ones that don't and having one. Modes:
|
||||
//
|
||||
// node storybook-coverage.mjs summary by area (report only)
|
||||
// node storybook-coverage.mjs --todo every uncovered surface, with the
|
||||
// work each needs
|
||||
// node storybook-coverage.mjs --area core/components/tools
|
||||
//
|
||||
// Coverage is counted by *import*, not by an adjacent .stories.tsx: several
|
||||
// components are covered by a shared story file (MantineForms covers Select,
|
||||
// MultiSelect, NumberInput and ColorInput between them), and counting siblings
|
||||
// reports those as gaps and invites duplicate stories.
|
||||
//
|
||||
// Each uncovered surface is classified by what a story would have to supply:
|
||||
//
|
||||
// props no context to supply. Note this means "no provider needed", not
|
||||
// "cheap" — a props-only component can still be expensive to
|
||||
// story if its props are heavy (ButtonAppearanceOverlay wants
|
||||
// real PDF bytes; AppConfigModalLazy lazy-loads the whole
|
||||
// settings tree). Read the props before assuming it is quick.
|
||||
// context it (or something it renders) reads a React context. The cheap
|
||||
// fix is usually to export the context and hand the story the
|
||||
// slice the component actually touches, rather than mounting the
|
||||
// provider and whatever chain sits behind it.
|
||||
// data it fetches, so the story needs MSW handlers
|
||||
// router it reads router state
|
||||
//
|
||||
// Not counted as surfaces at all: providers, contexts, gates, routers, test
|
||||
// helpers, and modules that return a config object rather than markup.
|
||||
//
|
||||
// Known blocker — four flavours cannot be storied as things stand.
|
||||
//
|
||||
// Storybook resolves @app/* through editor/tsconfig.proprietary.vite.json,
|
||||
// which maps it to src/proprietary/* then src/core/* and excludes src/desktop.
|
||||
// A file in another flavour that imports an @app/* asset living only in its own
|
||||
// tree therefore fails to resolve, and the story file does not load at all:
|
||||
//
|
||||
// desktop 80 of 152 @app/ imports unresolvable
|
||||
// saas 54 of 232
|
||||
// cloud 28 of 77
|
||||
// prototypes 4 of 40
|
||||
// portal-saas 0 of 7 (fine)
|
||||
//
|
||||
// That accounts for the 0% areas below — it is a build-config gap, not
|
||||
// neglect. Closing it means either per-flavour alias projects in
|
||||
// .storybook/main.ts or hoisting the shared assets, and it is a decision with
|
||||
// blast radius across every existing story, so it is deliberately not made
|
||||
// here.
|
||||
//
|
||||
// Run from frontend/.
|
||||
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
const SRC = resolve(process.cwd(), "editor/src");
|
||||
const args = process.argv.slice(2);
|
||||
const wantTodo = args.includes("--todo");
|
||||
const areaFilter = args.includes("--area")
|
||||
? args[args.indexOf("--area") + 1]
|
||||
: null;
|
||||
|
||||
/* ── file walk ────────────────────────────────────────────────────────────── */
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (entry.name.endsWith(".tsx")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const all = walk(SRC);
|
||||
const rel = (f) => relative(SRC, f).split("\\").join("/");
|
||||
const storyFiles = all.filter((f) => f.endsWith(".stories.tsx"));
|
||||
const sources = all.filter(
|
||||
(f) => !f.endsWith(".stories.tsx") && !f.endsWith(".test.tsx"),
|
||||
);
|
||||
|
||||
/* ── what the stories already reach ───────────────────────────────────────── */
|
||||
|
||||
const importedNames = new Set();
|
||||
const importedPaths = new Set();
|
||||
for (const f of storyFiles) {
|
||||
const src = readFileSync(f, "utf8");
|
||||
for (const m of src.matchAll(
|
||||
/import\s+(?:type\s+)?(?:\{([^}]*)\}|(\w+))\s*(?:,\s*\{([^}]*)\})?\s*from\s+["']([^"']+)/g,
|
||||
)) {
|
||||
for (const group of [m[1], m[3]]) {
|
||||
if (!group) continue;
|
||||
for (const name of group.split(","))
|
||||
importedNames.add(
|
||||
name.trim().split(" as ")[0].replace("type ", "").trim(),
|
||||
);
|
||||
}
|
||||
if (m[2]) importedNames.add(m[2]);
|
||||
importedPaths.add(m[4]);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── classification ───────────────────────────────────────────────────────── */
|
||||
|
||||
// Bridge: the viewer's *APIBridge components register an API into context and
|
||||
// render null — wiring, like the rest of these.
|
||||
const INFRA_NAME =
|
||||
/(Provider|Providers|Context|Gate|Boundary|Mount|Router|Guard|Bridge)\.tsx$/;
|
||||
const INFRA_DIR = /\/(contexts|test|tests|mocks|hooks|types|utils|api|data)\//;
|
||||
const RENDERS = /return\s*\(?\s*<|=>\s*\(?\s*</;
|
||||
// A module whose exported function returns a config object, not markup.
|
||||
const CONFIG_FACTORY = /:\s*(SlideConfig|ToolFlowConfig|\w+Config)\s*\{/;
|
||||
const DATA = /\buse(Query|Mutation|SWR|InfiniteQuery)\b|\bfetch\w*\(/;
|
||||
const ROUTER = /\buse(Navigate|Params|Location|SearchParams)\b/;
|
||||
const CONTEXT = /\buse[A-Z]\w*\(/g;
|
||||
// Hooks that are plainly not context reads.
|
||||
const LOCAL_HOOK =
|
||||
/^use(State|Effect|Memo|Callback|Ref|Id|Reducer|Context|Translation|LayoutEffect|ImperativeHandle|Transition|DeferredValue|SyncExternalStore|Debounced\w*|Media\w*|Disclosure|Form)$/;
|
||||
// Contexts .storybook/preview.tsx already mounts for every story. A component
|
||||
// that reads only these needs no fixture work, so it counts as props-level.
|
||||
const HARNESS_PROVIDED =
|
||||
/^use(Preferences|SidebarContext|Tier|Link|UI|Theme|QueryClient|Navigate|Location|Params|SearchParams)$/;
|
||||
|
||||
const byPath = new Map(sources.map((f) => [rel(f), f]));
|
||||
|
||||
function localImports(src, fromRel) {
|
||||
const out = [];
|
||||
for (const m of src.matchAll(
|
||||
/from\s+["'](@app\/|@core\/|\.\.?\/)([^"']+)/g,
|
||||
)) {
|
||||
const spec = m[1] + m[2];
|
||||
const guess = spec.replace(/^@app\//, "core/").replace(/^@core\//, "core/");
|
||||
for (const cand of [`${guess}.tsx`, `${guess}/index.tsx`]) {
|
||||
if (byPath.has(cand)) out.push(cand);
|
||||
}
|
||||
void fromRel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Does this file, or anything it renders, read a context? Depth-limited. */
|
||||
function needsContext(relPath, seen = new Set(), depth = 0) {
|
||||
if (depth > 3 || seen.has(relPath)) return false;
|
||||
seen.add(relPath);
|
||||
const file = byPath.get(relPath);
|
||||
if (!file) return false;
|
||||
const src = readFileSync(file, "utf8");
|
||||
for (const m of src.matchAll(CONTEXT)) {
|
||||
const name = m[0].slice(0, -1);
|
||||
if (!LOCAL_HOOK.test(name) && !HARNESS_PROVIDED.test(name)) return true;
|
||||
}
|
||||
return localImports(src, relPath).some((child) =>
|
||||
needsContext(child, seen, depth + 1),
|
||||
);
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const file of sources) {
|
||||
const r = rel(file);
|
||||
const base = r.split("/").pop();
|
||||
if (!/^[A-Z]/.test(base)) continue;
|
||||
const src = readFileSync(file, "utf8");
|
||||
if (!RENDERS.test(src)) continue;
|
||||
if (INFRA_NAME.test(base) || INFRA_DIR.test("/" + r)) continue;
|
||||
if (CONFIG_FACTORY.test(src)) continue;
|
||||
|
||||
const stem = base.replace(".tsx", "");
|
||||
const tail = r.replace(".tsx", "");
|
||||
const covered =
|
||||
importedNames.has(stem) ||
|
||||
[...importedPaths].some((p) => p.endsWith(tail) || p.endsWith("/" + stem));
|
||||
|
||||
let needs = "props";
|
||||
if (DATA.test(src)) needs = "data";
|
||||
else if (ROUTER.test(src)) needs = "router";
|
||||
else if (needsContext(r)) needs = "context";
|
||||
|
||||
const parts = r.split("/");
|
||||
rows.push({
|
||||
area: parts.slice(0, Math.min(3, parts.length - 1)).join("/"),
|
||||
file: r,
|
||||
covered,
|
||||
needs,
|
||||
loc: src.split("\n").length,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── report ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const shown = areaFilter
|
||||
? rows.filter((r) => r.file.startsWith(areaFilter))
|
||||
: rows;
|
||||
const todo = shown.filter((r) => !r.covered);
|
||||
|
||||
if (wantTodo || areaFilter) {
|
||||
const order = { props: 0, context: 1, router: 2, data: 3 };
|
||||
for (const r of todo.sort(
|
||||
(a, b) => order[a.needs] - order[b.needs] || a.loc - b.loc,
|
||||
)) {
|
||||
console.log(
|
||||
` ${r.needs.padEnd(8)} ${String(r.loc).padStart(5)} loc ${r.file}`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
const areas = new Map();
|
||||
for (const r of shown) {
|
||||
const a = areas.get(r.area) ?? { total: 0, covered: 0 };
|
||||
a.total += 1;
|
||||
if (r.covered) a.covered += 1;
|
||||
areas.set(r.area, a);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${"area".padEnd(38)}${"total".padStart(6)}${"covered".padStart(9)}${"%".padStart(6)}`,
|
||||
);
|
||||
for (const [area, a] of [...areas].sort(
|
||||
(x, y) => y[1].total - y[1].covered - (x[1].total - x[1].covered),
|
||||
)) {
|
||||
if (a.total === a.covered) continue;
|
||||
const pct = Math.round((100 * a.covered) / a.total);
|
||||
console.log(
|
||||
`${area.padEnd(38)}${String(a.total).padStart(6)}${String(a.covered).padStart(9)}${String(pct).padStart(5)}%`,
|
||||
);
|
||||
}
|
||||
|
||||
const covered = shown.filter((r) => r.covered).length;
|
||||
const byNeed = todo.reduce(
|
||||
(acc, r) => ((acc[r.needs] = (acc[r.needs] ?? 0) + 1), acc),
|
||||
{},
|
||||
);
|
||||
console.log(
|
||||
`\nsurfaces ${shown.length} covered ${covered} (${Math.round((100 * covered) / shown.length)}%) remaining ${todo.length}`,
|
||||
);
|
||||
console.log(
|
||||
`remaining by what a story needs: ` +
|
||||
Object.entries(byNeed)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `${k} ${v}`)
|
||||
.join(" "),
|
||||
);
|
||||
@@ -39,6 +39,7 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
|
||||
</Text>
|
||||
{storageStats.quota && (
|
||||
<Progress
|
||||
aria-label={t("fileManager.storageUsed", "Storage used")}
|
||||
value={storageUsagePercent}
|
||||
color={
|
||||
storageUsagePercent > 80
|
||||
|
||||
@@ -51,6 +51,13 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
format="hex"
|
||||
value={selectedColor}
|
||||
onChange={onColorChange}
|
||||
// The saturation area and hue bar are role="slider" divs; these are
|
||||
// their only accessible names.
|
||||
saturationLabel={t(
|
||||
"colorPicker.saturation",
|
||||
"Saturation and brightness",
|
||||
)}
|
||||
hueLabel={t("colorPicker.hue", "Hue")}
|
||||
swatches={[
|
||||
"#000000",
|
||||
"#0066cc",
|
||||
@@ -73,6 +80,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
max={100}
|
||||
value={opacity}
|
||||
onChange={onOpacityChange}
|
||||
thumbLabel={resolvedOpacityLabel}
|
||||
marks={[
|
||||
{ value: 25, label: "25%" },
|
||||
{ value: 50, label: "50%" },
|
||||
|
||||
@@ -45,6 +45,7 @@ export function OpacityControl({
|
||||
min={10}
|
||||
max={100}
|
||||
label={(val) => `${val}%`}
|
||||
thumbLabel={t("annotation.opacity", "Opacity")}
|
||||
/>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
|
||||
@@ -75,6 +75,7 @@ export function PropertiesPopover({
|
||||
min={8}
|
||||
max={32}
|
||||
label={(val) => `${val}pt`}
|
||||
thumbLabel={t("annotation.fontSize", "Font size")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -86,6 +87,7 @@ export function PropertiesPopover({
|
||||
<Slider
|
||||
value={Math.round((obj?.opacity ?? 1) * 100)}
|
||||
onChange={(val) => onUpdate({ opacity: val / 100 })}
|
||||
thumbLabel={t("annotation.opacity", "Opacity")}
|
||||
min={10}
|
||||
max={100}
|
||||
label={(val) => `${val}%`}
|
||||
@@ -144,6 +146,7 @@ export function PropertiesPopover({
|
||||
fillOpacity: newOpacity,
|
||||
});
|
||||
}}
|
||||
thumbLabel={t("annotation.opacity", "Opacity")}
|
||||
min={10}
|
||||
max={100}
|
||||
label={(val) => `${val}%`}
|
||||
@@ -169,6 +172,7 @@ export function PropertiesPopover({
|
||||
min={0}
|
||||
max={12}
|
||||
label={(val) => `${val}pt`}
|
||||
thumbLabel={t("annotation.strokeWidth", "Stroke")}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -49,6 +49,7 @@ export function WidthControl({
|
||||
min={min}
|
||||
max={max}
|
||||
label={(val) => `${val}pt`}
|
||||
thumbLabel={t("annotation.width", "Width")}
|
||||
/>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The trailing card in the file editor's grid that invites the user to add more
|
||||
* files. Clicking the card (or its "Add Files" button) opens the files modal;
|
||||
* the smaller button beside it goes straight to the native file picker.
|
||||
*
|
||||
* The two buttons share one slot: hovering the upload button expands it to fill
|
||||
* the row and hides "Add Files". That is internal state, so it is not a story.
|
||||
* `accept` and `multiple` only reach the hidden input and change nothing on
|
||||
* screen, which leaves a single rendered state.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AddFileCard from "@app/components/fileEditor/AddFileCard";
|
||||
import {
|
||||
FilesModalContext,
|
||||
type FilesModalContextType,
|
||||
} from "@app/contexts/FilesModalContext";
|
||||
|
||||
const meta = {
|
||||
title: "FileEditor/AddFileCard",
|
||||
component: AddFileCard,
|
||||
parameters: { layout: "centered" },
|
||||
args: { onFileSelect: () => {} },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
// Only openFilesModal is read. The real provider reaches FileContext and
|
||||
// NavigationContext, so a slice is supplied instead.
|
||||
<FilesModalContext.Provider
|
||||
value={{ openFilesModal: () => {} } as unknown as FilesModalContextType}
|
||||
>
|
||||
{/* The card fills the cell the file editor's grid gives it. */}
|
||||
<div style={{ width: "16rem", height: "20rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
</FilesModalContext.Provider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof AddFileCard>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -70,16 +70,10 @@ const AddFileCard = ({
|
||||
|
||||
<div
|
||||
className={`${styles.addFileCard} select-none flex flex-col transition-all relative cursor-pointer`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={t("fileEditor.addFiles", "Add files")}
|
||||
/* The card holds its own Add-files and upload buttons, so making the
|
||||
card a button as well nests one control inside another. The click is
|
||||
a mouse convenience; the buttons carry the keyboard path. */
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleCardClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Main content area */}
|
||||
<div className={styles.addFileContent}>
|
||||
@@ -161,7 +155,7 @@ const AddFileCard = ({
|
||||
icon={icons.uploadIconName}
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--c-primary)", flexShrink: 0 }}
|
||||
style={{ color: "var(--c-accent-text)", flexShrink: 0 }}
|
||||
/>
|
||||
{isUploadHover && (
|
||||
<span
|
||||
|
||||
@@ -107,7 +107,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
{currentFile && ` • v${currentFile.versionNumber || 1}`}
|
||||
</Text>
|
||||
{hasMultipleFiles && (
|
||||
<Text size="xs" c="blue">
|
||||
<Text size="xs" c="var(--c-accent-text)">
|
||||
{currentFileIndex + 1} of {selectedFiles.length}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* The three-column arrangement of the file manager modal on a wide viewport:
|
||||
* sources on the left, the search/actions/list stack in the middle, and the
|
||||
* selected file's details on the right.
|
||||
*
|
||||
* The layout takes no props — everything comes from the provider. The search
|
||||
* bar and action bar above the list are tied to the Recent source, and the
|
||||
* list's scroll height is computed from the modal height and whether any files
|
||||
* exist, so the populated and empty cases are laid out differently.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import DesktopLayout from "@app/components/fileManager/DesktopLayout";
|
||||
import {
|
||||
makeStub,
|
||||
withFileManager,
|
||||
} from "@app/components/fileManager/storyFixtures";
|
||||
|
||||
const FILES = [
|
||||
makeStub("file-1", "quarterly-report.pdf"),
|
||||
makeStub("file-2", "invoice-2026-01.pdf"),
|
||||
makeStub("file-3", "scan-of-contract.pdf", { size: 18_400_000 }),
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/DesktopLayout",
|
||||
component: DesktopLayout,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "600px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof DesktopLayout>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Files present and one of them selected, so the details column is filled in. */
|
||||
export const Default: Story = {
|
||||
decorators: [
|
||||
withFileManager({ recentFiles: FILES, activeFileIds: [FILES[0].id] }),
|
||||
],
|
||||
};
|
||||
|
||||
/** First run: the middle column is the empty state and the details are blank. */
|
||||
export const Empty: Story = {
|
||||
decorators: [withFileManager({ recentFiles: [] })],
|
||||
};
|
||||
|
||||
/** With storage on, the middle column gains the filter and bulk cloud actions. */
|
||||
export const StorageEnabled: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
recentFiles: FILES,
|
||||
activeFileIds: [FILES[0].id],
|
||||
config: { storageEnabled: true, storageSharingEnabled: true },
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* What the file manager shows before anything has been added. The copy and
|
||||
* icons come from the file-action hooks, which desktop builds override — these
|
||||
* stories render the web wording.
|
||||
*
|
||||
* Only one context field is read (the upload click handler), so the fixture
|
||||
* supplies that alone rather than the whole provider.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import EmptyFilesState from "@app/components/fileManager/EmptyFilesState";
|
||||
import {
|
||||
FileManagerContext,
|
||||
type FileManagerContextValue,
|
||||
} from "@app/contexts/FileManagerContext";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
|
||||
const meta: Meta<typeof EmptyFilesState> = {
|
||||
title: "FileManager/EmptyFilesState",
|
||||
component: EmptyFilesState,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [
|
||||
// The wordmark resolves its logo variant through PreferencesContext, which
|
||||
// the Storybook preview does not mount, so this story supplies it.
|
||||
(Story) => (
|
||||
<PreferencesProvider>
|
||||
<FileManagerContext.Provider
|
||||
value={
|
||||
{ onLocalFileClick: () => {} } as unknown as FileManagerContextValue
|
||||
}
|
||||
>
|
||||
<div style={{ height: "32rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
</FileManagerContext.Provider>
|
||||
</PreferencesProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof EmptyFilesState>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The panel centres itself, so a short container crops rather than reflows. */
|
||||
export const ShortContainer: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "18rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** Narrow widths are the mobile case — the upload actions stack. */
|
||||
export const Narrow: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: 360 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -103,7 +103,7 @@ const EmptyFilesState: React.FC = () => {
|
||||
icon={icons.uploadIconName}
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--c-primary)" }}
|
||||
style={{ color: "var(--c-accent-text)" }}
|
||||
/>
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: ".5rem" }}>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* The action bar above the recent-files list: select-all, an optional storage
|
||||
* filter, the selection count, and the bulk delete/download/upload/share
|
||||
* buttons.
|
||||
*
|
||||
* What appears is decided by the storage config and by the current selection.
|
||||
* The upload button needs storage on; the share button additionally needs
|
||||
* sharing and share links on, which also widen the filter from All/Local to
|
||||
* include the two "shared" tabs. The delete and download buttons are always
|
||||
* present but disabled until something is selected, and the whole bar renders
|
||||
* nothing at all while there are no recent files.
|
||||
*
|
||||
* Selection is provider state seeded from `activeFileIds`, so the stories vary
|
||||
* that rather than a prop.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileActions from "@app/components/fileManager/FileActions";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import {
|
||||
makeStub,
|
||||
withFileManager,
|
||||
} from "@app/components/fileManager/storyFixtures";
|
||||
|
||||
const FILES = [
|
||||
makeStub("file-1", "quarterly-report.pdf"),
|
||||
makeStub("file-2", "invoice-2026-01.pdf"),
|
||||
makeStub("file-3", "scan.pdf"),
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/FileActions",
|
||||
component: FileActions,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof FileActions>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Local-only build, nothing selected: bulk actions present but inert. */
|
||||
export const Default: Story = {
|
||||
decorators: [withFileManager({ recentFiles: FILES })],
|
||||
};
|
||||
|
||||
/** With a selection the count appears and delete/download become live. */
|
||||
export const WithSelection: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
recentFiles: FILES,
|
||||
activeFileIds: [FILES[0].id, FILES[1].id],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/** Storage on adds the All/Local filter and the bulk upload button. */
|
||||
export const StorageEnabled: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
recentFiles: FILES,
|
||||
activeFileIds: [FILES[0].id],
|
||||
config: { storageEnabled: true },
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Sharing and share links on add the share button and the two "shared" filter
|
||||
* tabs.
|
||||
*/
|
||||
export const SharingEnabled: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
recentFiles: FILES,
|
||||
activeFileIds: [FILES[0].id],
|
||||
config: {
|
||||
storageEnabled: true,
|
||||
storageSharingEnabled: true,
|
||||
storageShareLinksEnabled: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* A selection that includes a file owned by someone else: bulk upload and share
|
||||
* stay disabled because they only apply to files the user owns.
|
||||
*/
|
||||
export const SelectionIncludesSharedFile: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
recentFiles: [
|
||||
FILES[0],
|
||||
makeStub("file-shared", "budget-from-alex.pdf", {
|
||||
remoteStorageId: 42,
|
||||
remoteOwnedByCurrentUser: false,
|
||||
remoteOwnerUsername: "alex",
|
||||
remoteAccessRole: "viewer",
|
||||
}),
|
||||
],
|
||||
activeFileIds: [FILES[0].id, "file-shared" as FileId],
|
||||
config: {
|
||||
storageEnabled: true,
|
||||
storageSharingEnabled: true,
|
||||
storageShareLinksEnabled: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/** With no recent files the bar renders nothing. */
|
||||
export const NoFiles: Story = {
|
||||
decorators: [withFileManager({ recentFiles: [] })],
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* The details card on the right of the file manager: name, format, size, date
|
||||
* and version, followed by whatever the file's storage situation warrants.
|
||||
*
|
||||
* The trailing rows are all conditional and are driven by the stub's remote
|
||||
* fields rather than by props. A file with no `remoteStorageId` is local only;
|
||||
* one the user owns on the server shows a sync state that turns to "changes not
|
||||
* uploaded" once its local timestamp is newer than the remote one; one owned by
|
||||
* somebody else shows the owner and offers a copy. Sharing rows additionally
|
||||
* need the storage/sharing config on, and a tool chain appears only when the
|
||||
* file carries history.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileInfoCard from "@app/components/fileManager/FileInfoCard";
|
||||
import {
|
||||
makeStub,
|
||||
withFileManager,
|
||||
} from "@app/components/fileManager/storyFixtures";
|
||||
|
||||
const MODIFIED = Date.UTC(2026, 0, 14);
|
||||
|
||||
/** The default local-only build. */
|
||||
const local = withFileManager();
|
||||
|
||||
/** Storage plus sharing, for the stories about server-side state. */
|
||||
const cloud = withFileManager({
|
||||
config: {
|
||||
storageEnabled: true,
|
||||
storageSharingEnabled: true,
|
||||
storageShareLinksEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/FileInfoCard",
|
||||
component: FileInfoCard,
|
||||
args: { modalHeight: "600px" },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: "22rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof FileInfoCard>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** A file that has never left the browser: the "Local only" badge, nothing else. */
|
||||
export const LocalOnly: Story = {
|
||||
args: { currentFile: makeStub("file-1", "quarterly-report.pdf") },
|
||||
decorators: [local],
|
||||
};
|
||||
|
||||
/** No file selected — the labels stay, their values are blank. */
|
||||
export const NoFileSelected: Story = {
|
||||
args: { currentFile: null },
|
||||
decorators: [local],
|
||||
};
|
||||
|
||||
/** A file that has been through tools carries its chain as badges. */
|
||||
export const WithToolHistory: Story = {
|
||||
args: {
|
||||
currentFile: makeStub("file-1", "quarterly-report.pdf", {
|
||||
versionNumber: 3,
|
||||
toolHistory: [
|
||||
{ toolId: "split", timestamp: MODIFIED },
|
||||
{ toolId: "compress", timestamp: MODIFIED },
|
||||
],
|
||||
}),
|
||||
},
|
||||
decorators: [local],
|
||||
};
|
||||
|
||||
/** Uploaded and current: the cloud row reads "Synced" and shows the sync time. */
|
||||
export const SyncedToCloud: Story = {
|
||||
args: {
|
||||
currentFile: makeStub("file-1", "quarterly-report.pdf", {
|
||||
remoteStorageId: 7,
|
||||
remoteStorageUpdatedAt: MODIFIED + 60_000,
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
|
||||
/** Edited since the last upload, so the cloud row warns instead. */
|
||||
export const ChangesNotUploaded: Story = {
|
||||
args: {
|
||||
currentFile: makeStub("file-1", "quarterly-report.pdf", {
|
||||
lastModified: MODIFIED + 3_600_000,
|
||||
createdAt: MODIFIED + 3_600_000,
|
||||
remoteStorageId: 7,
|
||||
remoteStorageUpdatedAt: MODIFIED,
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
|
||||
/** Someone else's file: owner row, "Shared with you" badge, and a copy action. */
|
||||
export const SharedWithYou: Story = {
|
||||
args: {
|
||||
currentFile: makeStub("file-1", "budget-from-alex.pdf", {
|
||||
remoteStorageId: 11,
|
||||
remoteOwnedByCurrentUser: false,
|
||||
remoteOwnerUsername: "alex",
|
||||
remoteAccessRole: "viewer",
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
|
||||
/** The user's own file with links out: a sharing row and the management entry. */
|
||||
export const SharedByYou: Story = {
|
||||
args: {
|
||||
currentFile: makeStub("file-1", "quarterly-report.pdf", {
|
||||
remoteStorageId: 7,
|
||||
remoteStorageUpdatedAt: MODIFIED + 60_000,
|
||||
remoteHasShareLinks: true,
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
@@ -86,19 +86,29 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
bg="gray.4"
|
||||
p="sm"
|
||||
style={{
|
||||
background: "var(--c-surface-raised)",
|
||||
borderTopLeftRadius: "var(--mantine-radius-md)",
|
||||
borderTopRightRadius: "var(--mantine-radius-md)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={500} ta="center" c="white">
|
||||
<Text size="sm" fw={500} ta="center">
|
||||
{t("fileManager.details", "File Details")}
|
||||
</Text>
|
||||
</Box>
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }} p="md">
|
||||
{/* The viewport is focusable and named so keyboard users can scroll the
|
||||
detail list once it overflows. */}
|
||||
<ScrollArea
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
p="md"
|
||||
viewportProps={{
|
||||
tabIndex: 0,
|
||||
role: "group",
|
||||
"aria-label": t("fileManager.details", "File Details"),
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* The scrolling list that fills the file manager. Which branch it renders is
|
||||
* decided entirely by context — the active source, whether files are loading,
|
||||
* and whether any survive the search filter — so the stories drive it through
|
||||
* the provider rather than through props.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileListArea from "@app/components/fileManager/FileListArea";
|
||||
import {
|
||||
makeStub,
|
||||
withFileManager,
|
||||
} from "@app/components/fileManager/storyFixtures";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const FILES = [
|
||||
makeStub("f1", "quarterly-report.pdf"),
|
||||
makeStub("f2", "signed-contract.pdf", { size: 840_000 }),
|
||||
makeStub("f3", "scan-2026-03-14.pdf", { size: 12_600_000 }),
|
||||
makeStub("f4", "minutes.pdf", { size: 96_000 }),
|
||||
];
|
||||
|
||||
const meta: Meta<typeof FileListArea> = {
|
||||
title: "FileManager/FileListArea",
|
||||
component: FileListArea,
|
||||
parameters: { layout: "padded" },
|
||||
args: { scrollAreaHeight: "26rem" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FileListArea>;
|
||||
|
||||
export const Default: Story = {
|
||||
decorators: [withFileManager({ recentFiles: FILES })],
|
||||
};
|
||||
|
||||
/** No files at all — the list gives way to the empty state. */
|
||||
export const Empty: Story = {
|
||||
decorators: [withFileManager({ recentFiles: [] })],
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
decorators: [withFileManager({ recentFiles: FILES, isLoading: true })],
|
||||
};
|
||||
|
||||
/** Files already open in the editor are marked as active in the list. */
|
||||
export const WithActiveFile: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
recentFiles: FILES,
|
||||
activeFileIds: ["f2" as FileId],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/** Enough rows to scroll, which is the normal state for a working library. */
|
||||
export const ManyFiles: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
recentFiles: Array.from({ length: 24 }, (_, i) =>
|
||||
makeStub(`many-${i}`, `document-${String(i + 1).padStart(3, "0")}.pdf`),
|
||||
),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/** A short frame proves the list scrolls inside its own box, not the page. */
|
||||
export const ShortFrame: Story = {
|
||||
args: { scrollAreaHeight: "12rem" },
|
||||
decorators: [withFileManager({ recentFiles: FILES })],
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* A single row in the file manager's recent list: checkbox, name, a run of
|
||||
* status badges, size/date, and a hover-revealed overflow menu.
|
||||
*
|
||||
* The badges are the interesting part. Version always shows; "Active" comes
|
||||
* from the prop; and the storage badge is one of a mutually exclusive set
|
||||
* decided by the stub's remote fields — local only, synced, changes not
|
||||
* uploaded, or a shared-with-you pair of ownership and role — most of which
|
||||
* additionally require the storage/sharing config to be on. `isHistoryFile`
|
||||
* turns the row into an indented, non-selectable version entry instead.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileListItem from "@app/components/fileManager/FileListItem";
|
||||
import {
|
||||
makeStub,
|
||||
withFileManager,
|
||||
} from "@app/components/fileManager/storyFixtures";
|
||||
|
||||
const MODIFIED = Date.UTC(2026, 0, 14);
|
||||
const FILE = makeStub("file-1", "quarterly-report.pdf");
|
||||
|
||||
/** The default local-only build. */
|
||||
const local = withFileManager({ recentFiles: [FILE] });
|
||||
|
||||
/** Storage plus sharing, for the stories about server-side state. */
|
||||
const cloud = withFileManager({
|
||||
recentFiles: [FILE],
|
||||
config: {
|
||||
storageEnabled: true,
|
||||
storageSharingEnabled: true,
|
||||
storageShareLinksEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/FileListItem",
|
||||
component: FileListItem,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
file: FILE,
|
||||
isSelected: false,
|
||||
isLatestVersion: true,
|
||||
onSelect: () => {},
|
||||
onRemove: () => {},
|
||||
onDownload: () => {},
|
||||
onDoubleClick: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof FileListItem>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** A freshly uploaded file that has never been to the server. */
|
||||
export const Default: Story = { decorators: [local] };
|
||||
|
||||
/** Selected: the checkbox is ticked and the row takes the highlight fill. */
|
||||
export const Selected: Story = {
|
||||
args: { isSelected: true },
|
||||
decorators: [local],
|
||||
};
|
||||
|
||||
/** A file currently open in the workbench earns the "Active" badge. */
|
||||
export const Active: Story = {
|
||||
args: { isActive: true },
|
||||
decorators: [local],
|
||||
};
|
||||
|
||||
/** A processed file: a higher version number and the tool chain that produced it. */
|
||||
export const Processed: Story = {
|
||||
args: {
|
||||
file: makeStub("file-1", "quarterly-report.pdf", {
|
||||
versionNumber: 3,
|
||||
toolHistory: [
|
||||
{ toolId: "split", timestamp: MODIFIED },
|
||||
{ toolId: "compress", timestamp: MODIFIED },
|
||||
],
|
||||
}),
|
||||
},
|
||||
decorators: [local],
|
||||
};
|
||||
|
||||
/**
|
||||
* An older version listed under its leaf: indented behind a rule, with no
|
||||
* checkbox because history entries cannot be selected.
|
||||
*/
|
||||
export const HistoryEntry: Story = {
|
||||
args: {
|
||||
file: makeStub("file-1", "quarterly-report.pdf", { versionNumber: 2 }),
|
||||
isHistoryFile: true,
|
||||
isLatestVersion: false,
|
||||
},
|
||||
decorators: [local],
|
||||
};
|
||||
|
||||
/** Uploaded and current: "Synced" replaces the local-only badge. */
|
||||
export const Synced: Story = {
|
||||
args: {
|
||||
file: makeStub("file-1", "quarterly-report.pdf", {
|
||||
remoteStorageId: 7,
|
||||
remoteStorageUpdatedAt: MODIFIED + 60_000,
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
|
||||
/** Edited since the last upload, so the badge warns instead. */
|
||||
export const ChangesNotUploaded: Story = {
|
||||
args: {
|
||||
file: makeStub("file-1", "quarterly-report.pdf", {
|
||||
lastModified: MODIFIED + 3_600_000,
|
||||
createdAt: MODIFIED + 3_600_000,
|
||||
remoteStorageId: 7,
|
||||
remoteStorageUpdatedAt: MODIFIED,
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
|
||||
/** Someone else's file: ownership and the read-only role are both called out. */
|
||||
export const SharedWithYou: Story = {
|
||||
args: {
|
||||
file: makeStub("file-1", "budget-from-alex.pdf", {
|
||||
remoteStorageId: 11,
|
||||
remoteOwnedByCurrentUser: false,
|
||||
remoteOwnerUsername: "alex",
|
||||
remoteAccessRole: "viewer",
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
|
||||
/** The user's own file with a live link out. */
|
||||
export const SharedByYou: Story = {
|
||||
args: {
|
||||
file: makeStub("file-1", "quarterly-report.pdf", {
|
||||
remoteStorageId: 7,
|
||||
remoteStorageUpdatedAt: MODIFIED + 60_000,
|
||||
remoteHasShareLinks: true,
|
||||
}),
|
||||
},
|
||||
decorators: [cloud],
|
||||
};
|
||||
@@ -213,6 +213,9 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => {}} // Handled by parent onClick
|
||||
aria-label={t("fileManager.selectFile", "Select {{name}}", {
|
||||
name: file.name,
|
||||
})}
|
||||
size="sm"
|
||||
pl="sm"
|
||||
pr="xs"
|
||||
@@ -261,12 +264,9 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
: t("storageShare.roleViewer", "Viewer")}
|
||||
</Badge>
|
||||
) : isLocalOnly ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="default"
|
||||
c="dimmed"
|
||||
style={{ opacity: 0.75 }}
|
||||
>
|
||||
// No extra opacity: at this size the muted colour is already at
|
||||
// the edge of 4.5:1, and fading it drops below.
|
||||
<Badge size="xs" variant="default" c="dimmed">
|
||||
{t("fileManager.localOnly", "Local only")}
|
||||
</Badge>
|
||||
) : uploadEnabled && isOutOfSync ? (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The source picker down the left of the file manager: Recent, local upload,
|
||||
* Google Drive and mobile scan.
|
||||
*
|
||||
* Recent and upload are always offered. The other two are each governed by a
|
||||
* pair of config flags — one that enables the integration and one that decides
|
||||
* whether an unavailable integration is shown greyed out or dropped from the
|
||||
* list entirely. `horizontal` reflows the same buttons into a centred row for
|
||||
* the mobile layout and shortens their labels ("Drive", "Mobile").
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileSourceButtons from "@app/components/fileManager/FileSourceButtons";
|
||||
import { withFileManager } from "@app/components/fileManager/storyFixtures";
|
||||
|
||||
/** The column the buttons occupy in the desktop layout. */
|
||||
const withColumn = (Story: () => ReactElement) => (
|
||||
<div style={{ width: "13.625rem", height: "20rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/FileSourceButtons",
|
||||
component: FileSourceButtons,
|
||||
decorators: [withColumn],
|
||||
} satisfies Meta<typeof FileSourceButtons>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/**
|
||||
* The plain build: Recent is the active source, and Drive and mobile scan are
|
||||
* both present but disabled because neither is configured.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
decorators: [withFileManager()],
|
||||
};
|
||||
|
||||
/** Hiding the unavailable integrations leaves only Recent and upload. */
|
||||
export const UnavailableSourcesHidden: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
config: {
|
||||
hideDisabledToolsGoogleDrive: true,
|
||||
hideDisabledToolsMobileQRScanner: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Fully configured: Drive takes its coloured icon and both integrations become
|
||||
* clickable. Drive needs the client/API/app ids as well as its enable flag.
|
||||
*/
|
||||
export const AllSourcesAvailable: Story = {
|
||||
decorators: [
|
||||
withFileManager({
|
||||
config: {
|
||||
googleDriveEnabled: true,
|
||||
googleDriveClientId: "storybook-client-id",
|
||||
googleDriveApiKey: "storybook-api-key",
|
||||
googleDriveAppId: "storybook-app-id",
|
||||
enableMobileScanner: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/** The mobile layout's row: centred, no heading, and abbreviated labels. */
|
||||
export const Horizontal: Story = {
|
||||
args: { horizontal: true },
|
||||
decorators: [withFileManager()],
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* The stacked arrangement of the file manager modal on a narrow viewport: the
|
||||
* sources as a horizontal row, the compact file details, then the search bar,
|
||||
* action bar and list sharing one panel.
|
||||
*
|
||||
* As with the desktop layout there are no props — the provider supplies
|
||||
* everything. The search and action bars belong to the Recent source, and the
|
||||
* list's height is worked back from the modal height, allowing extra room for
|
||||
* the details block once a file is selected.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import MobileLayout from "@app/components/fileManager/MobileLayout";
|
||||
import {
|
||||
makeStub,
|
||||
withFileManager,
|
||||
} from "@app/components/fileManager/storyFixtures";
|
||||
|
||||
const FILES = [
|
||||
makeStub("file-1", "quarterly-report.pdf"),
|
||||
makeStub("file-2", "invoice-2026-01.pdf"),
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/MobileLayout",
|
||||
component: MobileLayout,
|
||||
parameters: { layout: "fullscreen" },
|
||||
globals: { viewport: { value: "mobile1", isRotated: false } },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "600px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof MobileLayout>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** A file selected, so the compact details block sits above the list. */
|
||||
export const Default: Story = {
|
||||
decorators: [
|
||||
withFileManager({ recentFiles: FILES, activeFileIds: [FILES[0].id] }),
|
||||
],
|
||||
};
|
||||
|
||||
/** Nothing selected: the details block shrinks and the list takes the room. */
|
||||
export const NoSelection: Story = {
|
||||
decorators: [withFileManager({ recentFiles: FILES })],
|
||||
};
|
||||
|
||||
/** First run, with the empty state filling the list panel. */
|
||||
export const Empty: Story = {
|
||||
decorators: [withFileManager({ recentFiles: [] })],
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* The file manager's search box. It reads the term and the change handler from
|
||||
* FileManagerContext rather than taking props, so the stories mount it against
|
||||
* a two-field slice of that context instead of the whole provider chain.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import SearchInput from "@app/components/fileManager/SearchInput";
|
||||
import {
|
||||
FileManagerContext,
|
||||
type FileManagerContextValue,
|
||||
} from "@app/contexts/FileManagerContext";
|
||||
|
||||
/**
|
||||
* SearchInput reads exactly two fields. Supplying only those keeps the fixture
|
||||
* honest about what the component depends on; the cast is what makes a partial
|
||||
* value acceptable in place of the full context.
|
||||
*/
|
||||
function withSearch(
|
||||
searchTerm: string,
|
||||
onSearchChange: (term: string) => void = () => {},
|
||||
) {
|
||||
return (children: React.ReactNode) => (
|
||||
<FileManagerContext.Provider
|
||||
value={
|
||||
{ searchTerm, onSearchChange } as unknown as FileManagerContextValue
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</FileManagerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof SearchInput> = {
|
||||
title: "FileManager/SearchInput",
|
||||
component: SearchInput,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof SearchInput>;
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => withSearch("")(<SearchInput />),
|
||||
};
|
||||
|
||||
export const WithTerm: Story = {
|
||||
render: () => withSearch("invoice")(<SearchInput />),
|
||||
};
|
||||
|
||||
/** Long terms are not truncated by the component — the field scrolls instead. */
|
||||
export const LongTerm: Story = {
|
||||
render: () =>
|
||||
withSearch("quarterly-report-2026-final-revised-approved")(<SearchInput />),
|
||||
};
|
||||
|
||||
/** The container controls width, so the box stretches to whatever it is given. */
|
||||
export const Narrow: Story = {
|
||||
render: () => (
|
||||
<div style={{ width: 220 }}>{withSearch("draft")(<SearchInput />)}</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Typing is driven by the context handler, so state lives outside the field. */
|
||||
export const Interactive: Story = {
|
||||
render: function Interactive() {
|
||||
const [term, setTerm] = useState("");
|
||||
return withSearch(term, setTerm)(<SearchInput />);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Shared fixtures for the file manager stories.
|
||||
*
|
||||
* These components sit deep in a provider chain — FileManagerContext needs
|
||||
* FileContext for useFileActions/useFileManagement, list rows additionally read
|
||||
* AppConfig — and none of it is part of the shared preview decorators. Rather
|
||||
* than each story rebuilding that tree, they mount the real providers here over
|
||||
* static data, so what a story exercises is the component and not a stub.
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import { FileManagerProvider } from "@app/contexts/FileManagerContext";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
/** A grey rectangle, so thumbnail lookups short-circuit instead of reading
|
||||
* file bytes out of IndexedDB. */
|
||||
const THUMBNAIL =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E";
|
||||
|
||||
/** Fixed so stories render identically on every run. */
|
||||
const LAST_MODIFIED = Date.parse("2026-03-14T09:30:00Z");
|
||||
|
||||
export function makeStub(
|
||||
id: string,
|
||||
name: string,
|
||||
overrides: Partial<StirlingFileStub> = {},
|
||||
): StirlingFileStub {
|
||||
return {
|
||||
id: id as FileId,
|
||||
name,
|
||||
type: "application/pdf",
|
||||
size: 2_400_000,
|
||||
lastModified: LAST_MODIFIED,
|
||||
isLeaf: true,
|
||||
originalFileId: id,
|
||||
versionNumber: 1,
|
||||
thumbnailUrl: THUMBNAIL,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockFile = makeStub("story-file-1", "quarterly-report.pdf");
|
||||
|
||||
/** Storage off keeps the row's upload and share affordances out of the way;
|
||||
* stories that want them pass their own config. */
|
||||
const BASE_CONFIG = {
|
||||
storageEnabled: false,
|
||||
storageSharingEnabled: false,
|
||||
storageShareLinksEnabled: false,
|
||||
frontendUrl: "https://stirling.example",
|
||||
};
|
||||
|
||||
interface FixtureOptions {
|
||||
recentFiles?: StirlingFileStub[];
|
||||
activeFileIds?: FileId[];
|
||||
isLoading?: boolean;
|
||||
config?: Partial<typeof BASE_CONFIG>;
|
||||
}
|
||||
|
||||
export function withFileManager({
|
||||
recentFiles = [mockFile],
|
||||
activeFileIds = [],
|
||||
isLoading = false,
|
||||
config,
|
||||
}: FixtureOptions = {}) {
|
||||
return (Story: () => ReactElement) => (
|
||||
<AppConfigProvider
|
||||
initialConfig={{ ...BASE_CONFIG, ...config } as never}
|
||||
bootstrapMode="non-blocking"
|
||||
autoFetch={false}
|
||||
>
|
||||
{/* The empty state's wordmark resolves a logo variant through
|
||||
PreferencesContext, which the preview does not mount. */}
|
||||
<PreferencesProvider>
|
||||
<FileContextProvider>
|
||||
<FileManagerProvider
|
||||
recentFiles={recentFiles}
|
||||
onRecentFilesSelected={() => {}}
|
||||
onNewFilesSelect={() => {}}
|
||||
onClose={() => {}}
|
||||
isFileSupported={() => true}
|
||||
isOpen
|
||||
onFileRemove={() => {}}
|
||||
modalHeight="600px"
|
||||
refreshRecentFiles={async () => {}}
|
||||
isLoading={isLoading}
|
||||
activeFileIds={activeFileIds}
|
||||
>
|
||||
<Story />
|
||||
</FileManagerProvider>
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -906,35 +906,47 @@ function ListView({
|
||||
|
||||
return (
|
||||
<div className="files-page-list" role="grid">
|
||||
{/* Each direct child is a columnheader: a role="row" may only own cells, so
|
||||
the sort controls and the select-all box have to sit inside one. */}
|
||||
<div className="files-page-list-row is-header" role="row">
|
||||
{onSetSelection && visibleFileIds.length > 0 ? (
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={() => {
|
||||
onSetSelection(allSelected ? new Set() : new Set(visibleFileIds));
|
||||
}}
|
||||
aria-label={
|
||||
allSelected
|
||||
? t("filesPage.deselectAll", "Clear selection")
|
||||
: t("filesPage.selectAll", "Select all")
|
||||
}
|
||||
/>
|
||||
<span role="columnheader">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={() => {
|
||||
onSetSelection(
|
||||
allSelected ? new Set() : new Set(visibleFileIds),
|
||||
);
|
||||
}}
|
||||
aria-label={
|
||||
allSelected
|
||||
? t("filesPage.deselectAll", "Clear selection")
|
||||
: t("filesPage.selectAll", "Select all")
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
<span {...headerProps("name-asc", "name-desc")}>
|
||||
{t("filesPage.column.name", "Name")}
|
||||
{sortIndicator("name-asc", "name-desc")}
|
||||
<span role="columnheader">
|
||||
<span {...headerProps("name-asc", "name-desc")}>
|
||||
{t("filesPage.column.name", "Name")}
|
||||
{sortIndicator("name-asc", "name-desc")}
|
||||
</span>
|
||||
</span>
|
||||
<span>{t("filesPage.column.type", "Type")}</span>
|
||||
<span {...headerProps("size-asc", "size-desc")}>
|
||||
{t("filesPage.column.size", "Size")}
|
||||
{sortIndicator("size-asc", "size-desc")}
|
||||
<span role="columnheader">{t("filesPage.column.type", "Type")}</span>
|
||||
<span role="columnheader">
|
||||
<span {...headerProps("size-asc", "size-desc")}>
|
||||
{t("filesPage.column.size", "Size")}
|
||||
{sortIndicator("size-asc", "size-desc")}
|
||||
</span>
|
||||
</span>
|
||||
<span {...headerProps("modified-asc", "modified-desc")}>
|
||||
{t("filesPage.column.modified", "Modified")}
|
||||
{sortIndicator("modified-asc", "modified-desc")}
|
||||
<span role="columnheader">
|
||||
<span {...headerProps("modified-asc", "modified-desc")}>
|
||||
{t("filesPage.column.modified", "Modified")}
|
||||
{sortIndicator("modified-asc", "modified-desc")}
|
||||
</span>
|
||||
</span>
|
||||
<span aria-hidden="true" />
|
||||
</div>
|
||||
@@ -1091,7 +1103,12 @@ function FolderRow({
|
||||
className={`files-page-list-row${isDropTarget ? " is-drop-target" : ""}`}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
{/* Each direct child is a gridcell: a role="row" may only own cells, so the
|
||||
actions menu has to sit inside one. */}
|
||||
<span
|
||||
role="gridcell"
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||
>
|
||||
<FolderThumbnail
|
||||
color={folder.color}
|
||||
size="row"
|
||||
@@ -1119,61 +1136,65 @@ function FolderRow({
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span>{t("filesPage.folder", "Folder")}</span>
|
||||
<span>
|
||||
<span role="gridcell">{t("filesPage.folder", "Folder")}</span>
|
||||
<span role="gridcell">
|
||||
{fileCount === 0
|
||||
? "-"
|
||||
: t("filesPage.folderItems", "{{count}} items", { count: fileCount })}
|
||||
</span>
|
||||
<span>{getFileDate({ lastModified: folder.updatedAt })}</span>
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.folderMenu", "Folder actions")}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{t("filesPage.open", "Open")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
|
||||
onClick={onRename}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.rename", "Rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label>
|
||||
{t("filesPage.appearance.title", "Appearance")}
|
||||
</Menu.Label>
|
||||
<FolderAppearancePicker
|
||||
folder={folder}
|
||||
onChange={onChangeAppearance}
|
||||
disabled={!serverReachable}
|
||||
/>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={onDelete}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.deleteFolder", "Delete folder")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<span role="gridcell">
|
||||
{getFileDate({ lastModified: folder.updatedAt })}
|
||||
</span>
|
||||
<span role="gridcell">
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.folderMenu", "Folder actions")}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{t("filesPage.open", "Open")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
|
||||
onClick={onRename}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.rename", "Rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label>
|
||||
{t("filesPage.appearance.title", "Appearance")}
|
||||
</Menu.Label>
|
||||
<FolderAppearancePicker
|
||||
folder={folder}
|
||||
onChange={onChangeAppearance}
|
||||
disabled={!serverReachable}
|
||||
/>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={onDelete}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.deleteFolder", "Delete folder")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1254,35 +1275,40 @@ function FileRow({
|
||||
isInWorkspace ? " is-in-workspace" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox only shows in multi-select mode (see FileCard). When the
|
||||
checkbox is hidden the first grid column collapses, but the row's
|
||||
CSS grid keeps the columns aligned via the named template, so no
|
||||
empty cell shows. */}
|
||||
{/* Each direct child is a gridcell: a role="row" may only own cells, so the
|
||||
checkbox and the actions menu have to sit inside one.
|
||||
|
||||
The checkbox only shows in multi-select mode (see FileCard). When it is
|
||||
hidden the first grid column collapses, but the row's CSS grid keeps the
|
||||
columns aligned via the named template, so no empty cell shows. */}
|
||||
{multiSelectActive ? (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onClick={(e) => {
|
||||
// Toggle this file in/out of the selection without modifier keys.
|
||||
e.stopPropagation();
|
||||
onClick({
|
||||
...e,
|
||||
shiftKey: false,
|
||||
ctrlKey: true,
|
||||
metaKey: true,
|
||||
} as unknown as React.MouseEvent);
|
||||
}}
|
||||
onChange={() => {
|
||||
/* handled by onClick */
|
||||
}}
|
||||
aria-label={t("filesPage.selectFile", "Select file {{name}}", {
|
||||
name: file.name,
|
||||
})}
|
||||
/>
|
||||
<span role="gridcell">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onClick={(e) => {
|
||||
// Toggle this file in/out of the selection without modifier keys.
|
||||
e.stopPropagation();
|
||||
onClick({
|
||||
...e,
|
||||
shiftKey: false,
|
||||
ctrlKey: true,
|
||||
metaKey: true,
|
||||
} as unknown as React.MouseEvent);
|
||||
}}
|
||||
onChange={() => {
|
||||
/* handled by onClick */
|
||||
}}
|
||||
aria-label={t("filesPage.selectFile", "Select file {{name}}", {
|
||||
name: file.name,
|
||||
})}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
// Empty cell preserves grid column alignment.
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
<span
|
||||
role="gridcell"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -1342,94 +1368,96 @@ function FileRow({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{ext || t("filesPage.file", "File")}</span>
|
||||
<span>{fileSize}</span>
|
||||
<span>{fileDate}</span>
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.fileMenu", "File actions")}
|
||||
data-testid="file-card-actions"
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.addToWorkspace", "Add to workspace")}
|
||||
</Menu.Item>
|
||||
<OpenInNewWindowMenuItem file={file} />
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMove();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Menu.Item>
|
||||
{/* Per-file Save to server; shown for local-only files. When
|
||||
storage is off it stays visible but disabled with a tooltip. */}
|
||||
{onSaveToServer && file.remoteStorageId == null && (
|
||||
<Tooltip
|
||||
label={saveToServerDisabledReason}
|
||||
disabled={!saveToServerDisabledReason}
|
||||
withinPortal
|
||||
position="left"
|
||||
multiline
|
||||
w={240}
|
||||
<span role="gridcell">{ext || t("filesPage.file", "File")}</span>
|
||||
<span role="gridcell">{fileSize}</span>
|
||||
<span role="gridcell">{fileDate}</span>
|
||||
<span role="gridcell">
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.fileMenu", "File actions")}
|
||||
data-testid="file-card-actions"
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToServer();
|
||||
}}
|
||||
style={
|
||||
saveToServerDisabledReason
|
||||
? { pointerEvents: "auto" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onVersionHistory && (file.versionNumber ?? 1) > 1 && (
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<HistoryIcon fontSize="small" />}
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onVersionHistory();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.versionHistory", "Version history")}
|
||||
{t("filesPage.addToWorkspace", "Add to workspace")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.remove", "Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<OpenInNewWindowMenuItem file={file} />
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMove();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Menu.Item>
|
||||
{/* Per-file Save to server; shown for local-only files. When
|
||||
storage is off it stays visible but disabled with a tooltip. */}
|
||||
{onSaveToServer && file.remoteStorageId == null && (
|
||||
<Tooltip
|
||||
label={saveToServerDisabledReason}
|
||||
disabled={!saveToServerDisabledReason}
|
||||
withinPortal
|
||||
position="left"
|
||||
multiline
|
||||
w={240}
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToServer();
|
||||
}}
|
||||
style={
|
||||
saveToServerDisabledReason
|
||||
? { pointerEvents: "auto" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onVersionHistory && (file.versionNumber ?? 1) > 1 && (
|
||||
<Menu.Item
|
||||
leftSection={<HistoryIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onVersionHistory();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.versionHistory", "Version history")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.remove", "Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,12 +32,12 @@ const styles = {
|
||||
},
|
||||
cloud: {
|
||||
background: "color-mix(in srgb, var(--c-primary) 16%, transparent)",
|
||||
color: "var(--c-primary)",
|
||||
color: "var(--c-accent-text)",
|
||||
},
|
||||
shared: {
|
||||
background:
|
||||
"color-mix(in srgb, var(--mantine-color-orange-6) 16%, transparent)",
|
||||
color: "var(--mantine-color-orange-6)",
|
||||
color: "var(--color-amber-dark)",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -339,6 +339,9 @@
|
||||
}
|
||||
|
||||
.files-page-list-row.is-header [data-sortable="true"] {
|
||||
/* Block so the hit area and hover tint fill the columnheader cell that wraps
|
||||
it, rather than hugging the label text. */
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin: -0.2rem -0.4rem;
|
||||
@@ -741,7 +744,7 @@
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--c-primary) 12%, transparent);
|
||||
color: var(--c-primary);
|
||||
color: var(--c-accent-text);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -980,7 +983,7 @@
|
||||
.files-page-details-version-timeline-count {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
color: var(--c-primary);
|
||||
color: var(--c-accent-text);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@@ -1112,7 +1115,29 @@
|
||||
.files-page-details-version-timeline-expand-btn:hover span {
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.files-page-details-version-timeline-delta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--c-text);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.files-page-details-version-timeline-delta.is-origin {
|
||||
font-weight: 400;
|
||||
color: var(--c-text-subtle);
|
||||
font-style: italic;
|
||||
}
|
||||
.files-page-details-version-timeline-delta-plus {
|
||||
color: var(--c-accent-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.files-page-details-version-timeline-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.files-page-details-version-timeline-chevron {
|
||||
color: var(--c-text-subtle);
|
||||
transition: transform 0.15s ease;
|
||||
@@ -1120,7 +1145,7 @@
|
||||
|
||||
.files-page-details-version-timeline-chevron.is-expanded {
|
||||
transform: rotate(180deg);
|
||||
color: var(--c-primary);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.files-page-details-version-timeline-expanded {
|
||||
@@ -1185,7 +1210,7 @@
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
pointer-events: none;
|
||||
color: var(--c-primary);
|
||||
color: var(--c-accent-text);
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
z-index: 10;
|
||||
|
||||
@@ -151,7 +151,10 @@ export function FolderThumbnail({
|
||||
borderRadius: "999px",
|
||||
background: "var(--c-surface, #fff)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: accent,
|
||||
// The ring carries the folder's accent; the numeral does not.
|
||||
// Folder colours are user-chosen and many are too light to read
|
||||
// as text on the white pill.
|
||||
color: "var(--c-text)",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
display: "inline-flex",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The resizable rail that holds the folder tree on the Files page. It supplies
|
||||
* the heading and the drag/keyboard resize handle around FolderTreeSidebar, and
|
||||
* wires the tree's folder actions through to the Files page context.
|
||||
*
|
||||
* `active` is the only prop: an inactive panel is collapsed away by CSS, hidden
|
||||
* from assistive technology, and drops its resize handle, so the tab it belongs
|
||||
* to can slide in and out. Its width is otherwise self-managed — auto-fitted to
|
||||
* the longest folder name until the user drags it, after which the chosen width
|
||||
* is persisted.
|
||||
*
|
||||
* No folders are seeded into IndexedDB, so the tree shows only its pinned rows.
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import { FolderProvider } from "@app/contexts/FolderContext";
|
||||
import { FilesPageProvider } from "@app/contexts/FilesPageContext";
|
||||
|
||||
/**
|
||||
* The panel reads the folder tree from FolderContext and the file counts and
|
||||
* folder dialogs from FilesPageContext; both sit above FileContext, which
|
||||
* brings in IndexedDBContext. None are part of the shared preview decorators.
|
||||
*/
|
||||
function withFolderContexts(Story: () => ReactElement) {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<FolderProvider>
|
||||
<FilesPageProvider>
|
||||
<div style={{ display: "flex", height: "24rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
</FilesPageProvider>
|
||||
</FolderProvider>
|
||||
</FileContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "FilesPage/FolderTreePanel",
|
||||
component: FolderTreePanel,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [withFolderContexts],
|
||||
} satisfies Meta<typeof FolderTreePanel>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Open: the heading, the tree, and the resize handle down the right edge. */
|
||||
export const Active: Story = {
|
||||
args: { active: true },
|
||||
};
|
||||
|
||||
/** Collapsed for a tab that is not showing folders. */
|
||||
export const Inactive: Story = {
|
||||
args: { active: false },
|
||||
};
|
||||
@@ -109,7 +109,11 @@ export function FolderTreePanel({ active }: FolderTreePanelProps) {
|
||||
<div
|
||||
className="folder-tree-panel"
|
||||
data-active={String(active)}
|
||||
/* Collapsed, the panel keeps its DOM but must not be reachable: hiding it
|
||||
from assistive tech alone would leave its controls in the tab order,
|
||||
focusable but invisible. inert removes both. */
|
||||
aria-hidden={!active}
|
||||
inert={!active}
|
||||
style={
|
||||
active
|
||||
? ({
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
HotkeyContext,
|
||||
type HotkeyContextValue,
|
||||
} from "@app/contexts/HotkeyContext";
|
||||
import { HotkeyDisplay } from "@app/components/hotkeys/HotkeyDisplay";
|
||||
import { getDisplayParts } from "@app/utils/hotkeys";
|
||||
|
||||
/** A keyboard shortcut rendered as key caps.
|
||||
*
|
||||
* HotkeyProvider pulls in the whole tool-workflow chain, but the display only
|
||||
* needs one function off the context — so the stories supply that slice, using
|
||||
* the real formatter so the caps render exactly as they do in the app. Pinned
|
||||
* to the non-mac glyphs to keep the stories stable across machines. */
|
||||
const withHotkeys = (Story: React.ComponentType) => (
|
||||
<HotkeyContext.Provider
|
||||
value={
|
||||
{
|
||||
getDisplayParts: (binding) => getDisplayParts(binding, false),
|
||||
} as HotkeyContextValue
|
||||
}
|
||||
>
|
||||
<Story />
|
||||
</HotkeyContext.Provider>
|
||||
);
|
||||
|
||||
const meta: Meta<typeof HotkeyDisplay> = {
|
||||
title: "Hotkeys/HotkeyDisplay",
|
||||
component: HotkeyDisplay,
|
||||
parameters: { layout: "centered" },
|
||||
decorators: [withHotkeys],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof HotkeyDisplay>;
|
||||
|
||||
/** A single key. */
|
||||
export const SingleKey: Story = { args: { binding: { code: "KeyS" } } };
|
||||
|
||||
/** The common save shortcut. */
|
||||
export const WithModifier: Story = {
|
||||
args: { binding: { code: "KeyS", ctrl: true } },
|
||||
};
|
||||
|
||||
/** Several modifiers at once. */
|
||||
export const MultipleModifiers: Story = {
|
||||
args: { binding: { code: "KeyP", ctrl: true, shift: true, alt: true } },
|
||||
};
|
||||
|
||||
/** The macOS command modifier. */
|
||||
export const MetaModifier: Story = {
|
||||
args: { binding: { code: "KeyK", meta: true } },
|
||||
};
|
||||
|
||||
/** A non-letter key, which renders its own glyph rather than a letter. */
|
||||
export const ArrowKey: Story = { args: { binding: { code: "ArrowRight" } } };
|
||||
|
||||
/** Both sizes side by side. */
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||
<HotkeyDisplay binding={{ code: "KeyS", ctrl: true }} size="sm" />
|
||||
<HotkeyDisplay binding={{ code: "KeyS", ctrl: true }} size="md" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Muted, for a shortcut shown beside a disabled action. */
|
||||
export const Muted: Story = {
|
||||
args: { binding: { code: "KeyS", ctrl: true }, muted: true },
|
||||
};
|
||||
|
||||
/** No binding assigned — the component renders nothing rather than an empty
|
||||
* cap, so an unbound action shows no stray chrome. */
|
||||
export const Unbound: Story = { args: { binding: null } };
|
||||
+1
-1
@@ -325,7 +325,7 @@
|
||||
|
||||
.v2Badge {
|
||||
background: var(--c-primary-tint);
|
||||
color: var(--c-accent-fg);
|
||||
color: var(--c-accent-text);
|
||||
padding: 3px 9px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -77,3 +77,72 @@ export const NotDismissible: Story = {
|
||||
allowDismiss: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** The final step — the bar is fully filled and the primary action closes the
|
||||
* tour rather than advancing it. */
|
||||
export const LastStep: Story = {
|
||||
args: {
|
||||
hero: <ShellHero appIcon />,
|
||||
slideKey: "done",
|
||||
title: "You're all set",
|
||||
body: "You can reopen this tour any time from the help menu.",
|
||||
stepIndex: 4,
|
||||
stepCount: 5,
|
||||
buttons: [
|
||||
{ key: "back", back: true, action: "back" },
|
||||
{ key: "finish", label: "Finish", primary: true, action: "finish" },
|
||||
],
|
||||
onAction: () => {},
|
||||
onClose: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
/** An action that is not yet available — shown rather than hidden, so the path
|
||||
* forward stays visible. */
|
||||
export const DisabledAction: Story = {
|
||||
args: {
|
||||
hero: <ShellHero>1</ShellHero>,
|
||||
slideKey: "choose",
|
||||
title: "Choose your install",
|
||||
body: "Pick a platform to continue.",
|
||||
stepIndex: 1,
|
||||
stepCount: 4,
|
||||
buttons: [
|
||||
{ key: "back", back: true, action: "back" },
|
||||
{
|
||||
key: "next",
|
||||
label: "Download",
|
||||
primary: true,
|
||||
action: "next",
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
onAction: () => {},
|
||||
onClose: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
/** A body longer than the viewport must scroll inside the card rather than
|
||||
* pushing the actions off-screen. */
|
||||
export const LongBody: Story = {
|
||||
args: {
|
||||
hero: <ShellHero appIcon />,
|
||||
slideKey: "release-notes",
|
||||
title: "What changed in this release",
|
||||
body: (
|
||||
<div style={{ display: "grid", gap: "0.75rem", textAlign: "left" }}>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<p key={i} style={{ margin: 0 }}>
|
||||
{i + 1}. Batch processing now runs pipelined rather than serialised,
|
||||
so a queue finishes in roughly the time the slowest document takes.
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
stepIndex: 0,
|
||||
stepCount: 1,
|
||||
buttons: [{ key: "ok", label: "Got it", primary: true, action: "close" }],
|
||||
onAction: () => {},
|
||||
onClose: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -101,7 +101,10 @@ export default function OnboardingSlideShell({
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
// Composed rather than the plain <Modal>, because only Modal.Content lands
|
||||
// props on the role="dialog" element — the slide draws its own title, so the
|
||||
// dialog needs an aria-label to have an accessible name.
|
||||
<Modal.Root
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
closeOnClickOutside={false}
|
||||
@@ -109,7 +112,6 @@ export default function OnboardingSlideShell({
|
||||
centered
|
||||
size="lg"
|
||||
radius={20}
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
|
||||
@@ -121,106 +123,122 @@ export default function OnboardingSlideShell({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className={styles.card}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.brand}>
|
||||
<img
|
||||
src={stirlingMark}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={styles.brandLogo}
|
||||
/>
|
||||
<span className={styles.wordmark}>Stirling</span>
|
||||
</div>
|
||||
<div className={styles.headerRight}>
|
||||
{showProgress && (
|
||||
<span className={styles.stepPill}>
|
||||
{t("onboarding.stepOf", "Step {{current}} of {{total}}", {
|
||||
current: stepIndex + 1,
|
||||
total: stepCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{allowDismiss && (
|
||||
<ActionIcon
|
||||
onClick={onClose}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
aria-label={t("common.close", "Close")}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="close-rounded"
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
<Modal.Overlay />
|
||||
<Modal.Content
|
||||
radius={20}
|
||||
aria-label={t("onboarding.dialogLabel", "Onboarding")}
|
||||
>
|
||||
<Modal.Body>
|
||||
<div className={styles.card}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.brand}>
|
||||
<img
|
||||
src={stirlingMark}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={styles.brandLogo}
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<span className={styles.wordmark}>Stirling</span>
|
||||
</div>
|
||||
<div className={styles.headerRight}>
|
||||
{showProgress && (
|
||||
<span className={styles.stepPill}>
|
||||
{t("onboarding.stepOf", "Step {{current}} of {{total}}", {
|
||||
current: stepIndex + 1,
|
||||
total: stepCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{allowDismiss && (
|
||||
<ActionIcon
|
||||
onClick={onClose}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
aria-label={t("common.close", "Close")}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="close-rounded"
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showProgress && (
|
||||
<div
|
||||
className={styles.progressTrack}
|
||||
role="progressbar"
|
||||
aria-valuenow={stepIndex + 1}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={stepCount}
|
||||
>
|
||||
{Array.from({ length: stepCount }, (_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`${styles.progressSeg} ${
|
||||
index <= stepIndex ? styles.progressSegDone : ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.heroPanel}>
|
||||
<div className={styles.heroArt} key={`hero-${slideKey}`}>
|
||||
{hero}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key={`title-${slideKey}`} className={styles.titleNew}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div key={`body-${slideKey}`} className={styles.bodyNew}>
|
||||
{body}
|
||||
<style>{`.${styles.bodyNew} strong{color: var(--c-text); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
{backButtons.length === 0 ? (
|
||||
<div className={styles.footerEnd}>{actions}</div>
|
||||
) : (
|
||||
<div className={styles.footerBetween}>
|
||||
<div className={styles.footerGroup}>
|
||||
{backButtons.map((button) => (
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
disabled={button.disabled}
|
||||
aria-label={t("onboarding.buttons.back", "Back")}
|
||||
>
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
))}
|
||||
</div>
|
||||
{actions}
|
||||
{showProgress && (
|
||||
<div
|
||||
className={styles.progressTrack}
|
||||
role="progressbar"
|
||||
aria-valuenow={stepIndex + 1}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={stepCount}
|
||||
aria-label={t(
|
||||
"onboarding.stepOf",
|
||||
"Step {{current}} of {{total}}",
|
||||
{
|
||||
current: stepIndex + 1,
|
||||
total: stepCount,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: stepCount }, (_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`${styles.progressSeg} ${
|
||||
index <= stepIndex ? styles.progressSegDone : ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.heroPanel}>
|
||||
<div className={styles.heroArt} key={`hero-${slideKey}`}>
|
||||
{hero}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key={`title-${slideKey}`} className={styles.titleNew}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div key={`body-${slideKey}`} className={styles.bodyNew}>
|
||||
{body}
|
||||
<style>{`.${styles.bodyNew} strong{color: var(--c-text); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
{backButtons.length === 0 ? (
|
||||
<div className={styles.footerEnd}>{actions}</div>
|
||||
) : (
|
||||
<div className={styles.footerBetween}>
|
||||
<div className={styles.footerGroup}>
|
||||
{backButtons.map((button) => (
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
disabled={button.disabled}
|
||||
aria-label={t("onboarding.buttons.back", "Back")}
|
||||
>
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
))}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ function FirstLoginForm({
|
||||
icon="info-rounded"
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ color: "var(--c-primary)", flexShrink: 0 }}
|
||||
style={{ color: "var(--c-accent-text)", flexShrink: 0 }}
|
||||
/>
|
||||
<span>
|
||||
{t(
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function SecurityCheckSlide({
|
||||
icon="error"
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ color: "var(--c-danger)", flexShrink: 0 }}
|
||||
style={{ color: "var(--color-red-dark)", flexShrink: 0 }}
|
||||
/>
|
||||
<span>
|
||||
{i18n.t(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import BulkSelectionPanel from "@app/components/pageEditor/BulkSelectionPanel";
|
||||
|
||||
/** A document of `n` pages in the shape the panel reads. */
|
||||
const doc = (n: number) => ({
|
||||
pages: Array.from({ length: n }, (_, i) => ({
|
||||
id: `page-${i + 1}`,
|
||||
pageNumber: i + 1,
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* Selecting pages by typing a range rather than clicking thumbnails. The CSV
|
||||
* field is the whole point of the panel, so the stories drive it with real
|
||||
* state — typing into a static snapshot would prove nothing.
|
||||
*/
|
||||
const meta: Meta<typeof BulkSelectionPanel> = {
|
||||
title: "PageEditor/BulkSelectionPanel",
|
||||
component: BulkSelectionPanel,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof BulkSelectionPanel>;
|
||||
|
||||
function Demo({
|
||||
initialCsv = "",
|
||||
selected = [],
|
||||
pages = 24,
|
||||
}: {
|
||||
initialCsv?: string;
|
||||
selected?: string[];
|
||||
pages?: number;
|
||||
}) {
|
||||
const [csvInput, setCsvInput] = useState(initialCsv);
|
||||
return (
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={selected}
|
||||
displayDocument={doc(pages)}
|
||||
onUpdatePagesFromCSV={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nothing selected yet. */
|
||||
export const Empty: Story = { render: () => <Demo /> };
|
||||
|
||||
/** A typed range, with the matching pages selected. */
|
||||
export const WithRange: Story = {
|
||||
render: () => (
|
||||
<Demo
|
||||
initialCsv="1-5"
|
||||
selected={["page-1", "page-2", "page-3", "page-4", "page-5"]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** A mixed expression — individual pages and ranges together. */
|
||||
export const MixedExpression: Story = {
|
||||
render: () => (
|
||||
<Demo initialCsv="1,4-6,12" selected={["page-1", "page-4", "page-12"]} />
|
||||
),
|
||||
};
|
||||
|
||||
/** Every page selected. */
|
||||
export const AllSelected: Story = {
|
||||
render: () => (
|
||||
<Demo
|
||||
pages={8}
|
||||
initialCsv="1-8"
|
||||
selected={Array.from({ length: 8 }, (_, i) => `page-${i + 1}`)}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** A single-page document, where ranges have little to do. */
|
||||
export const SinglePage: Story = {
|
||||
render: () => <Demo pages={1} />,
|
||||
};
|
||||
|
||||
/** A long document, to check the summary stays readable as counts grow. */
|
||||
export const LongDocument: Story = {
|
||||
render: () => (
|
||||
<Demo
|
||||
pages={480}
|
||||
initialCsv="1-200"
|
||||
selected={Array.from({ length: 200 }, (_, i) => `page-${i + 1}`)}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton";
|
||||
|
||||
const doc = (n: number) => ({
|
||||
pages: Array.from({ length: n }, (_, i) => ({
|
||||
id: `page-${i + 1}`,
|
||||
pageNumber: i + 1,
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* The toolbar affordance that opens bulk page selection. It disables itself
|
||||
* when there are no pages to select, so an empty document offers no dead
|
||||
* control.
|
||||
*/
|
||||
const meta: Meta<typeof PageSelectByNumberButton> = {
|
||||
title: "PageEditor/PageSelectByNumberButton",
|
||||
component: PageSelectByNumberButton,
|
||||
parameters: { layout: "centered" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof PageSelectByNumberButton>;
|
||||
|
||||
function Demo({
|
||||
totalPages = 24,
|
||||
disabled = false,
|
||||
initialCsv = "",
|
||||
selected = [],
|
||||
}: {
|
||||
totalPages?: number;
|
||||
disabled?: boolean;
|
||||
initialCsv?: string;
|
||||
selected?: string[];
|
||||
}) {
|
||||
const [csvInput, setCsvInput] = useState(initialCsv);
|
||||
return (
|
||||
<PageSelectByNumberButton
|
||||
disabled={disabled}
|
||||
totalPages={totalPages}
|
||||
label="Select pages by number"
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={selected}
|
||||
displayDocument={doc(totalPages)}
|
||||
updatePagesFromCSV={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Available — click to open the selection popover. */
|
||||
export const Default: Story = { render: () => <Demo /> };
|
||||
|
||||
/** A selection already in place. */
|
||||
export const WithSelection: Story = {
|
||||
render: () => (
|
||||
<Demo initialCsv="2,5-9" selected={["page-2", "page-5", "page-9"]} />
|
||||
),
|
||||
};
|
||||
|
||||
/** Explicitly disabled, e.g. while the document is still loading. */
|
||||
export const Disabled: Story = { render: () => <Demo disabled /> };
|
||||
|
||||
/** No pages: the control disables itself regardless of the `disabled` prop. */
|
||||
export const NoPages: Story = { render: () => <Demo totalPages={0} /> };
|
||||
|
||||
/** A single page — selectable, but ranges have little to do. */
|
||||
export const SinglePage: Story = { render: () => <Demo totalPages={1} /> };
|
||||
@@ -35,16 +35,17 @@ export default function PageSelectByNumberButton({
|
||||
>
|
||||
<div>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
{/* The button is the target, not a wrapper: Popover.Target puts
|
||||
aria-haspopup and aria-expanded on whatever it wraps, and
|
||||
aria-expanded is not a permitted attribute on a plain div. */}
|
||||
<Popover.Target>
|
||||
<div style={{ display: "inline-flex" }}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
disabled={disabled || totalPages === 0}
|
||||
aria-label={label}
|
||||
>
|
||||
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
disabled={disabled || totalPages === 0}
|
||||
aria-label={label}
|
||||
>
|
||||
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: "24rem", maxWidth: "32rem" }}>
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The page-selection expression field at the top of the bulk selection panel —
|
||||
* a title with a syntax-guide tooltip, an optional "Advanced" switch, and the
|
||||
* comma/range input itself.
|
||||
*
|
||||
* Two things decide what renders. The clear button in the input's right section
|
||||
* appears only while the expression is non-empty, and the Advanced switch is
|
||||
* present only when the caller passes an `advancedOpened` boolean at all —
|
||||
* panels that have no advanced mode omit the prop and get no switch.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import PageSelectionInput from "@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput";
|
||||
|
||||
const meta = {
|
||||
title: "PageEditor/BulkSelectionPanel/PageSelectionInput",
|
||||
component: PageSelectionInput,
|
||||
args: {
|
||||
csvInput: "",
|
||||
setCsvInput: () => {},
|
||||
onUpdatePagesFromCSV: () => {},
|
||||
onClear: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof PageSelectionInput>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Empty: placeholder syntax only, and no clear affordance to offer yet. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A non-empty expression reveals the clear button beside the input. */
|
||||
export const WithExpression: Story = {
|
||||
args: { csvInput: "1,3,5-10" },
|
||||
};
|
||||
|
||||
/** Passing `advancedOpened` adds the Advanced switch to the header row. */
|
||||
export const WithAdvancedToggle: Story = {
|
||||
args: { advancedOpened: false, onToggleAdvanced: () => {} },
|
||||
};
|
||||
|
||||
/** The switch on, as it sits while the advanced panel below is expanded. */
|
||||
export const AdvancedOpened: Story = {
|
||||
args: {
|
||||
csvInput: "odd & 1-50",
|
||||
advancedOpened: true,
|
||||
onToggleAdvanced: () => {},
|
||||
},
|
||||
};
|
||||
+1
-1
@@ -60,7 +60,7 @@ const PageSelectionInput = ({
|
||||
size="sm"
|
||||
checked={!!advancedOpened}
|
||||
onChange={(e) => onToggleAdvanced?.(e.currentTarget.checked)}
|
||||
title={t("bulkSelection.advanced.title", "Advanced")}
|
||||
aria-label={t("bulkSelection.advanced.title", "Advanced")}
|
||||
className={classes.advancedSwitch}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Lazy wrapper around the settings modal: it renders nothing until opened, so
|
||||
* the heavy config bundle is only fetched when someone asks for settings.
|
||||
*
|
||||
* Sections come from the build's registry, and hosts can add their own or hide
|
||||
* ones they cannot run.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AppConfigModalLazy from "@app/components/shared/AppConfigModalLazy";
|
||||
|
||||
const meta: Meta<typeof AppConfigModalLazy> = {
|
||||
title: "Shared/AppConfigModalLazy",
|
||||
component: AppConfigModalLazy,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { opened: false, onClose: () => {}, urlSync: false },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AppConfigModalLazy>;
|
||||
|
||||
/** Closed, which is the state it spends nearly all its life in. */
|
||||
export const Closed: Story = {};
|
||||
|
||||
export const Opened: Story = { args: { opened: true } };
|
||||
|
||||
/** A host that cannot run a registry section drops it by key. */
|
||||
export const WithHiddenSection: Story = {
|
||||
args: { opened: true, hiddenSectionKeys: ["about"] as never },
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
|
||||
|
||||
/**
|
||||
* The sidebar brand header. Core has no admin portal to switch to, so this is
|
||||
* just the logo — builds that bundle the portal shadow this file with a version
|
||||
* whose logo doubles as the editor⇄processor switcher. Both states matter here
|
||||
* because the rail collapses.
|
||||
*/
|
||||
const meta: Meta<typeof AppSwitcher> = {
|
||||
title: "Shared/AppSwitcher",
|
||||
component: AppSwitcher,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AppSwitcher>;
|
||||
|
||||
/** Expanded rail — mark and wordmark. */
|
||||
export const Expanded: Story = {};
|
||||
|
||||
/** Collapsed rail — icon only. */
|
||||
export const Collapsed: Story = { args: { collapsed: true } };
|
||||
|
||||
/** Both, to compare the mark's optical size between the two rail widths. */
|
||||
export const BothStates: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "3rem", alignItems: "center" }}>
|
||||
<AppSwitcher />
|
||||
<AppSwitcher collapsed />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import Badge from "@app/components/shared/Badge";
|
||||
|
||||
/** Small inline label. `colored` takes an explicit palette so callers can tint
|
||||
* a badge to whatever the surrounding feature already uses. */
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: "Shared/Badge",
|
||||
component: Badge,
|
||||
parameters: { layout: "centered" },
|
||||
args: { children: "Beta" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Badge>;
|
||||
|
||||
/** Default tone. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The three sizes together, so their baselines can be compared. */
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
|
||||
<Badge size="sm">Small</Badge>
|
||||
<Badge size="md">Medium</Badge>
|
||||
<Badge size="lg">Large</Badge>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Explicitly tinted. The pairing is the caller's to get right — these use the
|
||||
* semantic tokens rather than raw hues so they hold up in both themes. */
|
||||
export const Colored: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
|
||||
<Badge
|
||||
variant="colored"
|
||||
backgroundColor="var(--c-success-solid)"
|
||||
textColor="var(--c-text-on-primary)"
|
||||
>
|
||||
Active
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="colored"
|
||||
backgroundColor="var(--c-warning-solid)"
|
||||
textColor="var(--c-text-on-primary)"
|
||||
>
|
||||
Pending
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="colored"
|
||||
backgroundColor="var(--c-danger-solid)"
|
||||
textColor="var(--c-text-on-primary)"
|
||||
>
|
||||
Failed
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** A long label, to check it stays on one line rather than breaking the row. */
|
||||
export const LongLabel: Story = {
|
||||
args: { children: "Requires the AI engine" },
|
||||
};
|
||||
|
||||
/** A numeral, the other common use. */
|
||||
export const Count: Story = { args: { children: "12", size: "sm" } };
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* The brand marks and the chrome controls that sit beside them. Grouped in one
|
||||
* file because each is a handful of props and they are always seen together in
|
||||
* the app's top-left corner.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button, Dropdown } from "@app/ui";
|
||||
import { AppSwitchMenuItems } from "@app/components/shared/AppSwitch";
|
||||
import { LogoIcon } from "@app/components/shared/LogoIcon";
|
||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Shared/Brand marks",
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
const Row = ({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1.25rem",
|
||||
padding: "0.75rem 0",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 190,
|
||||
fontSize: "0.78rem",
|
||||
color: "var(--c-text-muted)",
|
||||
fontFamily: "var(--font-mono, monospace)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** The wordmark, at rest and muted. Both swap asset with the theme, so switch
|
||||
* the toolbar theme to check the dark pairing. */
|
||||
export const WordmarkVariants: StoryObj = {
|
||||
render: () => (
|
||||
<div>
|
||||
<Row label="default">
|
||||
<Wordmark alt="Stirling PDF" style={{ height: 28 }} />
|
||||
</Row>
|
||||
<Row label="muted">
|
||||
<Wordmark muted alt="Stirling PDF" style={{ height: 28 }} />
|
||||
</Row>
|
||||
<Row label="small">
|
||||
<Wordmark alt="Stirling PDF" style={{ height: 18 }} />
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** The icon-only mark, at the sizes the chrome uses it. */
|
||||
export const LogoIconSizes: StoryObj = {
|
||||
render: () => (
|
||||
<div>
|
||||
<Row label="16">
|
||||
<LogoIcon alt="Stirling" style={{ height: 16 }} />
|
||||
</Row>
|
||||
<Row label="24">
|
||||
<LogoIcon alt="Stirling" style={{ height: 24 }} />
|
||||
</Row>
|
||||
<Row label="40">
|
||||
<LogoIcon alt="Stirling" style={{ height: 40 }} />
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** The sidebar toggle. `mirrored` points it at the opposite edge, so the same
|
||||
* glyph serves a left and a right rail. */
|
||||
export const SidebarToggle: StoryObj = {
|
||||
render: () => (
|
||||
<div>
|
||||
<Row label="default">
|
||||
<SidebarToggleIcon />
|
||||
</Row>
|
||||
<Row label="mirrored">
|
||||
<SidebarToggleIcon mirrored />
|
||||
</Row>
|
||||
<Row label="size 28">
|
||||
<SidebarToggleIcon size={28} />
|
||||
</Row>
|
||||
<Row label="mirrored, size 28">
|
||||
<SidebarToggleIcon mirrored size={28} />
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Switching between the editor and the processor. `AppSwitchMenuItems` is a
|
||||
* fragment of dropdown items rather than a standalone menu, so it is shown in
|
||||
* the Dropdown its callers mount it in. The app you are already in shows as
|
||||
* current and is not offered as a destination. */
|
||||
function AppSwitchDemo({ current }: { current: "editor" | "processor" }) {
|
||||
return (
|
||||
<Dropdown.Root defaultOpen>
|
||||
<Dropdown.Trigger>
|
||||
<Button variant="tertiary">Switch app</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu>
|
||||
<AppSwitchMenuItems current={current} onSwitch={() => {}} />
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export const AppSwitchFromEditor: StoryObj = {
|
||||
render: () => <AppSwitchDemo current="editor" />,
|
||||
};
|
||||
|
||||
/** The same menu seen from the processor. */
|
||||
export const AppSwitchFromProcessor: StoryObj = {
|
||||
render: () => <AppSwitchDemo current="processor" />,
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import CardSelector from "@app/components/shared/CardSelector";
|
||||
import {
|
||||
METHOD_OPTIONS,
|
||||
type MethodOption,
|
||||
type SplitMethod,
|
||||
} from "@app/constants/splitConstants";
|
||||
|
||||
/**
|
||||
* A stack of choice cards, each labelled from an i18n prefix + name pair.
|
||||
* Driven here by the Split tool's real method options rather than invented
|
||||
* keys, so the labels are the ones users actually see and the story does not
|
||||
* introduce translation keys that have to be maintained.
|
||||
*/
|
||||
const meta: Meta<typeof CardSelector<SplitMethod, MethodOption>> = {
|
||||
title: "Shared/CardSelector",
|
||||
component: CardSelector,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onSelect: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof CardSelector<SplitMethod, MethodOption>>;
|
||||
|
||||
/** Every split method. */
|
||||
export const Default: Story = { args: { options: METHOD_OPTIONS } };
|
||||
|
||||
/** A short list — two choices. */
|
||||
export const FewOptions: Story = {
|
||||
args: { options: METHOD_OPTIONS.slice(0, 2) },
|
||||
};
|
||||
|
||||
/** A single choice, where the selector is really just a confirmation. */
|
||||
export const SingleOption: Story = {
|
||||
args: { options: METHOD_OPTIONS.slice(0, 1) },
|
||||
};
|
||||
|
||||
/** Inert while the tool is busy or its endpoint is still resolving. */
|
||||
export const Disabled: Story = {
|
||||
args: { options: METHOD_OPTIONS, disabled: true },
|
||||
};
|
||||
|
||||
/** Nothing available — e.g. every method needs an endpoint that is switched
|
||||
* off. */
|
||||
export const Empty: Story = { args: { options: [] } };
|
||||
@@ -57,6 +57,20 @@ const CardSelector = <T, K extends CardOption<T>>({
|
||||
radius="md"
|
||||
w="100%"
|
||||
h={"2.8rem"}
|
||||
// A choice card is a control: without these it is a div with an
|
||||
// onClick, so the option cannot be reached or chosen by keyboard.
|
||||
// aria-disabled rather than removal keeps the option visible and
|
||||
// explains why it is inert.
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-disabled={disabled || undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleOptionClick(option.value);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
backgroundColor: "var(--mantine-color-gray-2)",
|
||||
|
||||
@@ -56,7 +56,7 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
items,
|
||||
placeholder,
|
||||
placeholder = "Select option",
|
||||
disabled = false,
|
||||
label,
|
||||
header,
|
||||
@@ -73,7 +73,6 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
|
||||
zIndex = Z_INDEX_AUTOMATE_DROPDOWN,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const resolvedPlaceholder = placeholder ?? t("dropdownList.selectOption");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const isMultiValue = Array.isArray(value);
|
||||
@@ -102,7 +101,7 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
|
||||
|
||||
const getDisplayText = () => {
|
||||
if (selectedValues.length === 0) {
|
||||
return resolvedPlaceholder;
|
||||
return placeholder;
|
||||
} else if (selectedValues.length === 1) {
|
||||
const selectedItem = items.find(
|
||||
(item) => item.value === selectedValues[0],
|
||||
@@ -135,7 +134,11 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
|
||||
zIndex={zIndex}
|
||||
>
|
||||
<Popover.Target>
|
||||
{/* A real button: Popover.Target stamps aria-haspopup/aria-expanded on
|
||||
its child, and those are only permitted on an actual control. */}
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
style={{
|
||||
border:
|
||||
"light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))",
|
||||
@@ -143,6 +146,9 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
|
||||
padding: "8px 12px",
|
||||
backgroundColor:
|
||||
"light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))",
|
||||
color: "inherit",
|
||||
textAlign: "left",
|
||||
width: "100%",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
minHeight: "36px",
|
||||
@@ -202,8 +208,8 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
|
||||
<Box style={{ padding: "12px", textAlign: "center" }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{searchable && searchTerm
|
||||
? t("dropdownList.noResults")
|
||||
: t("dropdownList.noItems")}
|
||||
? "No results found"
|
||||
: "No items available"}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useId, useState, useRef, useEffect } from "react";
|
||||
import { PasswordInput, Group, Tooltip, TextInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
@@ -27,12 +27,12 @@ export default function EditableSecretField({
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
placeholder = "Enter value",
|
||||
disabled = false,
|
||||
error,
|
||||
}: EditableSecretFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedPlaceholder = placeholder ?? t("common.enterValue");
|
||||
const fieldId = useId();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [tempValue, setTempValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -67,6 +67,7 @@ export default function EditableSecretField({
|
||||
<div>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 4,
|
||||
@@ -92,7 +93,13 @@ export default function EditableSecretField({
|
||||
{isMasked && !isEditing ? (
|
||||
// Masked value from backend: show display + Edit button
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput value="••••••••" disabled style={{ flex: 1 }} readOnly />
|
||||
<TextInput
|
||||
id={fieldId}
|
||||
value="••••••••"
|
||||
disabled
|
||||
style={{ flex: 1 }}
|
||||
readOnly
|
||||
/>
|
||||
<Tooltip label={t("editSecret")} withArrow>
|
||||
<ActionIcon
|
||||
variant="secondary"
|
||||
@@ -111,10 +118,11 @@ export default function EditableSecretField({
|
||||
) : isEditing ? (
|
||||
// Edit mode: normal password input
|
||||
<PasswordInput
|
||||
id={fieldId}
|
||||
ref={inputRef}
|
||||
value={tempValue}
|
||||
onChange={(e) => setTempValue(e.currentTarget.value)}
|
||||
placeholder={resolvedPlaceholder}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
autoComplete="new-password"
|
||||
@@ -126,9 +134,10 @@ export default function EditableSecretField({
|
||||
) : (
|
||||
// Normal password input: empty or user typing
|
||||
<PasswordInput
|
||||
id={fieldId}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={resolvedPlaceholder}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
autoComplete="new-password"
|
||||
|
||||
@@ -73,7 +73,7 @@ const EncryptedPdfUnlockModal = ({
|
||||
autoFocus
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<Text c="red" size="sm">
|
||||
<Text c="var(--color-red-dark)" size="sm">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -88,7 +88,7 @@ export default class ErrorBoundary extends React.Component<
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<Text size="lg" fw={500} c="red">
|
||||
<Text size="lg" fw={500} c="var(--color-red-dark)">
|
||||
Something went wrong
|
||||
</Text>
|
||||
{process.env.NODE_ENV === "development" && this.state.error && (
|
||||
|
||||
@@ -34,7 +34,12 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
||||
return (
|
||||
<Menu trigger="click" position="bottom" width="30rem">
|
||||
<Menu.Target>
|
||||
{/* Menu.Target stamps aria-haspopup/aria-expanded on its child; those are
|
||||
only permitted once the element declares a control role. It stays a
|
||||
div because it renders inside the workbench SegmentedControl's
|
||||
<label>, which may not contain interactive content. */}
|
||||
<div
|
||||
role="button"
|
||||
style={{ ...viewOptionStyle, cursor: "pointer", maxWidth: "100%" }}
|
||||
>
|
||||
{switchingTo === "viewer" ? (
|
||||
|
||||
@@ -91,6 +91,7 @@ const FileGrid = ({
|
||||
|
||||
{showSort && (
|
||||
<Select
|
||||
aria-label={t("fileManager.sortBy", "Sort files")}
|
||||
data={[
|
||||
{
|
||||
value: "date",
|
||||
|
||||
@@ -189,6 +189,13 @@ const FilePickerModal = ({
|
||||
checked={isSelected}
|
||||
onChange={() => toggleFileSelection(fileId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t(
|
||||
"fileUpload.selectFile",
|
||||
"Select {{name}}",
|
||||
{
|
||||
name: file.name,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Thumbnail */}
|
||||
@@ -239,7 +246,7 @@ const FilePickerModal = ({
|
||||
|
||||
{/* Selection summary */}
|
||||
{selectedFileIds.length > 0 && (
|
||||
<Text size="sm" c="blue" ta="center">
|
||||
<Text size="sm" c="var(--c-accent-text)" ta="center">
|
||||
{selectedFileIds.length}{" "}
|
||||
{t("fileManager.filesSelected", "files selected")}
|
||||
</Text>
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
}
|
||||
|
||||
.slimTabUpload:hover:not(:disabled) {
|
||||
color: var(--mantine-color-blue-filled);
|
||||
color: var(--c-accent-text);
|
||||
background: color-mix(in srgb, var(--mantine-color-blue-1) 35%, transparent);
|
||||
}
|
||||
|
||||
|
||||
@@ -404,6 +404,7 @@ export function FileSelectorPicker({
|
||||
}}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-disabled={disabled || undefined}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
|
||||
@@ -129,14 +129,14 @@
|
||||
}
|
||||
|
||||
.file-sidebar-drop-overlay-icon {
|
||||
color: var(--mantine-color-blue-6, var(--c-primary)) !important;
|
||||
color: var(--c-accent-text) !important;
|
||||
font-size: 28px !important;
|
||||
}
|
||||
|
||||
.file-sidebar-drop-overlay-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--mantine-color-blue-6, var(--c-primary));
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* ---- Search row ---- */
|
||||
@@ -605,7 +605,7 @@
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--mantine-color-blue-6, var(--c-primary));
|
||||
background-color: var(--c-accent-text);
|
||||
color: var(--c-text-on-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.selected .file-sidebar-file-name {
|
||||
color: var(--c-accent-fg);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.file-sidebar-file-meta-row {
|
||||
@@ -208,7 +208,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--c-primary);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* ---- Folder membership tags ---- */
|
||||
@@ -302,7 +302,7 @@
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.viewed .file-sidebar-file-name {
|
||||
color: var(--c-success);
|
||||
color: var(--color-green-dark);
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.viewed .file-sidebar-file-check {
|
||||
@@ -321,7 +321,7 @@
|
||||
/* Always show eye for the currently viewed file */
|
||||
.file-sidebar-file-item.viewed .file-sidebar-eye-btn {
|
||||
opacity: 1;
|
||||
color: var(--c-success);
|
||||
color: var(--color-green-dark);
|
||||
}
|
||||
|
||||
.file-sidebar-eye-btn:hover {
|
||||
|
||||
@@ -74,3 +74,32 @@ export const WithDisabledAction: Story = {
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Seated outside the card's edge rather than inside it. */
|
||||
export const OutsidePosition: Story = {
|
||||
args: { show: true, actions, position: "outside" },
|
||||
};
|
||||
|
||||
/** An action hidden outright — dropped rather than greyed. */
|
||||
export const WithHiddenAction: Story = {
|
||||
args: {
|
||||
show: true,
|
||||
actions: [actions[0], { ...actions[1], hidden: true }, actions[2]],
|
||||
},
|
||||
};
|
||||
|
||||
/** Every action hidden: the menu renders nothing at all, so a card with no
|
||||
* available actions gets no empty affordance. */
|
||||
export const AllHidden: Story = {
|
||||
args: { show: true, actions: actions.map((a) => ({ ...a, hidden: true })) },
|
||||
};
|
||||
|
||||
/** A single action. */
|
||||
export const SingleAction: Story = {
|
||||
args: { show: true, actions: [actions[2]] },
|
||||
};
|
||||
|
||||
/** Revealed by CSS hover rather than React state — hover the card. */
|
||||
export const CssHoverVisibility: Story = {
|
||||
args: { show: false, actions, visibility: "cssHover" },
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ const toneStyles: Record<
|
||||
warning: {
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
border: "var(--mantine-color-orange-3)",
|
||||
text: "var(--mantine-color-orange-9)",
|
||||
text: "var(--color-amber-dark)",
|
||||
icon: "var(--mantine-color-orange-7)",
|
||||
buttonColor: "orange",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The row of upload actions on the landing screen. Which buttons appear is
|
||||
* decided between props, the app config, and the viewport — the mobile-upload
|
||||
* affordance and the browse-files entry are both conditional — so the stories
|
||||
* vary those rather than exposing them as controls.
|
||||
*
|
||||
* The wording and icons come from the file-action hooks, which desktop builds
|
||||
* override; these render the web variants.
|
||||
*/
|
||||
import { useRef } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LandingActions } from "@app/components/shared/LandingActions";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import {
|
||||
FilesModalContext,
|
||||
type FilesModalContextType,
|
||||
} from "@app/contexts/FilesModalContext";
|
||||
|
||||
/** Enough of the config for the actions to decide what to offer. */
|
||||
const CONFIG = { storageEnabled: false, enableMobileUpload: true };
|
||||
|
||||
function Harness(config: Partial<typeof CONFIG>) {
|
||||
return function Wrapped() {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
return (
|
||||
<AppConfigProvider
|
||||
initialConfig={{ ...CONFIG, ...config } as never}
|
||||
bootstrapMode="non-blocking"
|
||||
autoFetch={false}
|
||||
>
|
||||
{/* Only openFilesModal is read. The real provider reaches FileContext
|
||||
and NavigationContext, so a slice is supplied instead. */}
|
||||
<FilesModalContext.Provider
|
||||
value={
|
||||
{ openFilesModal: () => {} } as unknown as FilesModalContextType
|
||||
}
|
||||
>
|
||||
<LandingActions
|
||||
fileInputRef={fileInputRef}
|
||||
onUploadClick={() => {}}
|
||||
onMobileUploadClick={() => {}}
|
||||
onFileSelect={() => {}}
|
||||
/>
|
||||
</FilesModalContext.Provider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const meta: Meta<typeof LandingActions> = {
|
||||
title: "Shared/LandingActions",
|
||||
component: LandingActions,
|
||||
parameters: { layout: "centered" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof LandingActions>;
|
||||
|
||||
export const Default: Story = { render: Harness({}) };
|
||||
|
||||
/** With storage on, the actions also offer the stored-files browser. */
|
||||
export const StorageEnabled: Story = {
|
||||
render: Harness({ storageEnabled: true }),
|
||||
};
|
||||
|
||||
/** Mobile upload off removes the phone affordance entirely. */
|
||||
export const NoMobileUpload: Story = {
|
||||
render: Harness({ enableMobileUpload: false }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrow viewports take the mobile branch, which reflows the row and swaps
|
||||
* some buttons for icon-only controls.
|
||||
*/
|
||||
export const Mobile: Story = {
|
||||
render: Harness({}),
|
||||
globals: { viewport: { value: "mobile1", isRotated: false } },
|
||||
};
|
||||
|
||||
export const MobileWithStorage: Story = {
|
||||
render: Harness({ storageEnabled: true }),
|
||||
globals: { viewport: { value: "mobile1", isRotated: false } },
|
||||
};
|
||||
@@ -397,7 +397,16 @@ export default function MobileUploadModal({
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG value={mobileUrl} size={256} level="H" includeMargin />
|
||||
<QRCodeSVG
|
||||
value={mobileUrl}
|
||||
size={256}
|
||||
level="H"
|
||||
includeMargin
|
||||
title={t(
|
||||
"mobileUpload.qrCodeTitle",
|
||||
"QR code linking to the mobile upload page",
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{filesReceived > 0 && (
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
padding: 16px;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* The workbench control that reports how many files the page editor is showing
|
||||
* and opens a menu to change the set. The menu itself lists every file with a
|
||||
* colour swatch, a selection checkbox and a drag handle for reordering, plus an
|
||||
* entry that opens the files modal.
|
||||
*
|
||||
* The trigger is what the stories can show: it renders the selected/total
|
||||
* counts, and swaps its icon for a spinner while the workbench is switching
|
||||
* into the page editor. The menu opens on click, so it is not a separate story.
|
||||
*/
|
||||
import type { CSSProperties } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PageEditorFileDropdown } from "@app/components/shared/PageEditorFileDropdown";
|
||||
import {
|
||||
FilesModalContext,
|
||||
type FilesModalContextType,
|
||||
} from "@app/contexts/FilesModalContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const FILES = [
|
||||
{
|
||||
fileId: "file-1" as FileId,
|
||||
name: "quarterly-report.pdf",
|
||||
isSelected: true,
|
||||
},
|
||||
{
|
||||
fileId: "file-2" as FileId,
|
||||
name: "invoice-2026-01.pdf",
|
||||
versionNumber: 2,
|
||||
isSelected: true,
|
||||
},
|
||||
{
|
||||
fileId: "file-3" as FileId,
|
||||
name: "scan-of-contract.pdf",
|
||||
isSelected: false,
|
||||
},
|
||||
];
|
||||
|
||||
/** Each file's swatch is looked up by id; missing ids fall back to the first colour. */
|
||||
const FILE_COLOUR_MAP = new Map<string, number>(
|
||||
FILES.map((file, index) => [file.fileId as string, index]),
|
||||
);
|
||||
|
||||
/** Matches the workbench segmented control the trigger renders inside. */
|
||||
const VIEW_OPTION_STYLE: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Shared/PageEditorFileDropdown",
|
||||
component: PageEditorFileDropdown,
|
||||
parameters: { layout: "centered" },
|
||||
args: {
|
||||
files: FILES,
|
||||
onToggleSelection: () => {},
|
||||
onReorder: () => {},
|
||||
viewOptionStyle: VIEW_OPTION_STYLE,
|
||||
fileColorMap: FILE_COLOUR_MAP,
|
||||
selectedCount: 2,
|
||||
totalCount: 3,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
// Only openFilesModal is read, by the menu's "Add File" row. The real
|
||||
// provider reaches FileContext and NavigationContext, so a slice is used.
|
||||
<FilesModalContext.Provider
|
||||
value={{ openFilesModal: () => {} } as unknown as FilesModalContextType}
|
||||
>
|
||||
<Story />
|
||||
</FilesModalContext.Provider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof PageEditorFileDropdown>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Part of the set selected, which is the usual state. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Every file selected. */
|
||||
export const AllSelected: Story = {
|
||||
args: { selectedCount: 3 },
|
||||
};
|
||||
|
||||
/** While the workbench switches into the page editor, a spinner takes the icon's place. */
|
||||
export const Switching: Story = {
|
||||
args: { switchingTo: "pageEditor" },
|
||||
};
|
||||
@@ -175,7 +175,12 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
|
||||
return (
|
||||
<Menu trigger="click" position="bottom" width="40rem">
|
||||
<Menu.Target>
|
||||
{/* role="button" so the aria-haspopup/aria-expanded Menu.Target stamps on
|
||||
this element are permitted. It stays a div because it renders inside
|
||||
the workbench SegmentedControl's <label>, which may not contain
|
||||
interactive content. */}
|
||||
<div
|
||||
role="button"
|
||||
className="ph-no-capture"
|
||||
style={{ ...viewOptionStyle, cursor: "pointer" }}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The shared single-line text field: an optional leading icon, the input, and a
|
||||
* trailing clear button.
|
||||
*
|
||||
* The clear button is the only conditional piece — it appears when the value has
|
||||
* non-whitespace content, the caller has not opted out via `showClearButton`,
|
||||
* and the field is neither disabled nor read-only. Everything else (padding,
|
||||
* icon gutter) follows from those same choices, so the stories vary them
|
||||
* instead of exposing them as controls.
|
||||
*
|
||||
* The field is controlled, so each story wraps it in local state — otherwise
|
||||
* typing would appear to do nothing.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import {
|
||||
TextInput,
|
||||
type TextInputProps,
|
||||
} from "@app/components/shared/TextInput";
|
||||
|
||||
function Controlled({ value, onChange: _onChange, ...props }: TextInputProps) {
|
||||
const [current, setCurrent] = useState(value);
|
||||
return (
|
||||
<div style={{ width: "22rem" }}>
|
||||
<TextInput {...props} value={current} onChange={setCurrent} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Shared/TextInput",
|
||||
component: TextInput,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
id: "story-text-input",
|
||||
name: "story-text-input",
|
||||
value: "",
|
||||
onChange: () => {},
|
||||
placeholder: "Search files",
|
||||
"aria-label": "Search files",
|
||||
},
|
||||
render: (args) => <Controlled {...args} />,
|
||||
} satisfies Meta<typeof TextInput>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Empty and unadorned: placeholder only, and no clear button to show yet. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** With content, the trailing clear button appears and the input reserves room for it. */
|
||||
export const WithValue: Story = {
|
||||
args: { value: "quarterly report" },
|
||||
};
|
||||
|
||||
/** A leading icon indents the text; the clear button still owns the other end. */
|
||||
export const WithIcon: Story = {
|
||||
args: { value: "invoice", icon: <SearchIcon fontSize="small" /> },
|
||||
};
|
||||
|
||||
/** Callers that own their own reset opt out, leaving the value flush to the edge. */
|
||||
export const WithoutClearButton: Story = {
|
||||
args: { value: "locked in", showClearButton: false },
|
||||
};
|
||||
|
||||
/** Disabled suppresses the clear button and greys the field out. */
|
||||
export const Disabled: Story = {
|
||||
args: { value: "cannot edit", disabled: true },
|
||||
};
|
||||
|
||||
/** Read-only keeps the field's normal appearance but drops the clear button. */
|
||||
export const ReadOnly: Story = {
|
||||
args: { value: "reference value", readOnly: true },
|
||||
};
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { useIsomorphicEffect } from "@mantine/hooks";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { mantineTheme } from "@app/theme/mantineTheme";
|
||||
import {
|
||||
mantineTheme,
|
||||
editorCssVariablesResolver,
|
||||
} from "@app/theme/mantineTheme";
|
||||
import { ToastProvider } from "@app/components/toast";
|
||||
import ToastRenderer from "@app/components/toast/ToastRenderer";
|
||||
import { ToastPortalBinder } from "@app/components/toast";
|
||||
@@ -91,6 +94,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
<ThemeContext.Provider value={value}>
|
||||
<MantineProvider
|
||||
theme={mantineTheme}
|
||||
cssVariablesResolver={editorCssVariablesResolver}
|
||||
defaultColorScheme={colorScheme}
|
||||
forceColorScheme={colorScheme}
|
||||
>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
height: 1.75rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
color: var(--c-accent-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -79,15 +79,15 @@
|
||||
var(--mantine-color-blue-filled) 18%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* Dark mode: the subtle gray close button is too dim against the dark rail —
|
||||
brighten it to a clearly-visible light grey (near-white on hover). */
|
||||
[data-mantine-color-scheme="dark"] .sui-panelhdr__close {
|
||||
color: var(--mantine-color-gray-4);
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .sui-panelhdr__close:hover {
|
||||
color: var(--mantine-color-gray-2);
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* The zoom controls that appear in the workbench bar while the viewer is the
|
||||
* active workbench. They render nothing anywhere else, so every story here has
|
||||
* to say the workbench is "viewer".
|
||||
*
|
||||
* Zoom is pushed at the component rather than pulled: it seeds from
|
||||
* getZoomState() and then subscribes for updates, so the fixture below hands
|
||||
* back a fixed level and a subscription that never fires. The Live story wires
|
||||
* the subscription up properly so the slider and buttons actually move.
|
||||
*/
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ViewerInlineControls } from "@app/components/shared/ViewerInlineControls";
|
||||
import {
|
||||
ViewerContext,
|
||||
type ViewerContextType,
|
||||
} from "@app/contexts/ViewerContext";
|
||||
import {
|
||||
NavigationStateContext,
|
||||
type NavigationContextStateValue,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
|
||||
/** Only `workbench` is read; the rest of navigation state is irrelevant here. */
|
||||
function navState(workbench: string) {
|
||||
return { workbench } as unknown as NavigationContextStateValue;
|
||||
}
|
||||
|
||||
/** A viewer that reports one zoom level and never publishes another. */
|
||||
function staticViewer(zoomPercent: number) {
|
||||
return {
|
||||
getZoomState: () => ({ zoomPercent }),
|
||||
registerImmediateZoomUpdate: () => () => {},
|
||||
zoomActions: {
|
||||
zoomIn: () => {},
|
||||
zoomOut: () => {},
|
||||
setZoomLevel: () => {},
|
||||
},
|
||||
} as unknown as ViewerContextType;
|
||||
}
|
||||
|
||||
function Fixture({
|
||||
zoomPercent = 100,
|
||||
workbench = "viewer",
|
||||
}: {
|
||||
zoomPercent?: number;
|
||||
workbench?: string;
|
||||
}) {
|
||||
return (
|
||||
<NavigationStateContext.Provider value={navState(workbench)}>
|
||||
<ViewerContext.Provider value={staticViewer(zoomPercent)}>
|
||||
<ViewerInlineControls />
|
||||
</ViewerContext.Provider>
|
||||
</NavigationStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof ViewerInlineControls> = {
|
||||
title: "Shared/ViewerInlineControls",
|
||||
component: ViewerInlineControls,
|
||||
parameters: { layout: "centered" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ViewerInlineControls>;
|
||||
|
||||
export const Default: Story = { render: () => <Fixture /> };
|
||||
|
||||
/** The slider clamps to 20–500, so these two sit at its ends. */
|
||||
export const MinimumZoom: Story = { render: () => <Fixture zoomPercent={20} /> };
|
||||
|
||||
export const MaximumZoom: Story = {
|
||||
render: () => <Fixture zoomPercent={500} />,
|
||||
};
|
||||
|
||||
/** Levels outside the slider's range still clamp rather than overflow it. */
|
||||
export const BeyondSliderRange: Story = {
|
||||
render: () => <Fixture zoomPercent={900} />,
|
||||
};
|
||||
|
||||
/** Any other workbench renders nothing at all. */
|
||||
export const NotViewerWorkbench: Story = {
|
||||
render: () => <Fixture workbench="fileManager" />,
|
||||
};
|
||||
|
||||
/**
|
||||
* The controls driving real state: the zoom actions publish through the same
|
||||
* subscription the component registers, which is how the app wires them.
|
||||
*/
|
||||
export const Live: Story = {
|
||||
render: function Live() {
|
||||
const [zoom, setZoom] = useState(100);
|
||||
const listener = useRef<((pct: number) => void) | null>(null);
|
||||
|
||||
const publish = useCallback((pct: number) => {
|
||||
const clamped = Math.min(Math.max(pct, 20), 500);
|
||||
setZoom(clamped);
|
||||
listener.current?.(clamped);
|
||||
}, []);
|
||||
|
||||
const viewer = useMemo(
|
||||
() =>
|
||||
({
|
||||
getZoomState: () => ({ zoomPercent: zoom }),
|
||||
registerImmediateZoomUpdate: (cb: (pct: number) => void) => {
|
||||
listener.current = cb;
|
||||
return () => {
|
||||
listener.current = null;
|
||||
};
|
||||
},
|
||||
zoomActions: {
|
||||
zoomIn: () => publish(zoom + 25),
|
||||
zoomOut: () => publish(zoom - 25),
|
||||
setZoomLevel: (level: number) => publish(level * 100),
|
||||
},
|
||||
// Rebuilt per zoom so the seed value stays current.
|
||||
}) as unknown as ViewerContextType,
|
||||
[zoom, publish],
|
||||
);
|
||||
|
||||
return (
|
||||
<NavigationStateContext.Provider value={navState("viewer")}>
|
||||
<ViewerContext.Provider value={viewer}>
|
||||
<ViewerInlineControls />
|
||||
</ViewerContext.Provider>
|
||||
</NavigationStateContext.Provider>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -62,6 +62,9 @@ export function ViewerInlineControls() {
|
||||
track: { height: 3 },
|
||||
}}
|
||||
label={null}
|
||||
// The thumb carries role="slider"; thumbLabel is what names it, and
|
||||
// Slider writes an empty aria-label over anything else.
|
||||
thumbLabel={t("viewer.zoomLevel", "Zoom level")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
.workbench-bar-view-btn.active {
|
||||
background-color: var(--c-primary-subtle);
|
||||
color: var(--c-accent-fg);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.workbench-bar-view-btn.workbench-bar-back-btn {
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
.workbench-bar-view-btn.workbench-bar-back-btn:hover {
|
||||
background-color: color-mix(in srgb, var(--c-primary) 12%, transparent);
|
||||
color: var(--c-accent-fg);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.workbench-bar-view-btn svg {
|
||||
|
||||
@@ -18,7 +18,7 @@ const WARNING_ICON_STYLE: CSSProperties = {
|
||||
fontSize: 36,
|
||||
display: "block",
|
||||
margin: "0 auto 8px",
|
||||
color: "var(--mantine-color-blue-6)",
|
||||
color: "var(--c-accent-text)",
|
||||
};
|
||||
|
||||
const ZipWarningModal = ({
|
||||
|
||||
+25
-11
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useId, useState, useEffect } from "react";
|
||||
import {
|
||||
Paper,
|
||||
Stack,
|
||||
@@ -83,6 +83,15 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
desktopUpdateMode,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Each setting is a row of label text next to a bare control, so the controls
|
||||
// are named by pointing at that text rather than by a <label> association.
|
||||
const labelIds = useId();
|
||||
const updateModeLabelId = `${labelIds}-update-mode`;
|
||||
const viewerZoomLabelId = `${labelIds}-viewer-zoom`;
|
||||
const hideToolsLabelId = `${labelIds}-hide-tools`;
|
||||
const hideConversionsLabelId = `${labelIds}-hide-conversions`;
|
||||
const autoUnzipLabelId = `${labelIds}-auto-unzip`;
|
||||
const autoUnzipLimitLabelId = `${labelIds}-auto-unzip-limit`;
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config } = useAppConfig();
|
||||
const { setTheme, themeMode } = useTheme();
|
||||
@@ -209,7 +218,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
icon="admin-panel-settings-rounded"
|
||||
width="1.2rem"
|
||||
height="1.2rem"
|
||||
style={{ color: "var(--mantine-color-blue-6)" }}
|
||||
style={{ color: "var(--c-accent-text)" }}
|
||||
/>
|
||||
<Text
|
||||
fw={600}
|
||||
@@ -248,7 +257,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
href="https://docs.stirlingpdf.com/Configuration/System%20and%20Security/"
|
||||
target="_blank"
|
||||
size="sm"
|
||||
style={{ color: "var(--mantine-color-blue-6)" }}
|
||||
>
|
||||
{t(
|
||||
"settings.general.enableFeatures.learnMore",
|
||||
@@ -305,7 +313,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Text>
|
||||
</Text>
|
||||
{mismatchVersion && (
|
||||
<Text size="sm" c="red" mt={4}>
|
||||
<Text size="sm" c="var(--color-red-dark)" mt={4}>
|
||||
{t(
|
||||
"settings.general.updates.versionMismatch",
|
||||
"Warning: A mismatch has been detected between the client version and the AppConfig version. Using different versions can lead to compatibility issues, errors, and security risks. Please ensure that server and client are using the same version.",
|
||||
@@ -336,7 +344,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
"Latest Version",
|
||||
)}
|
||||
:{" "}
|
||||
<Text component="span" fw={500} c="blue">
|
||||
<Text component="span" fw={500} c="var(--c-accent-text)">
|
||||
{updateSummary.latest_version}
|
||||
</Text>
|
||||
</Text>
|
||||
@@ -391,7 +399,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
{desktopUpdateMode && (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="sm">
|
||||
<Text id={updateModeLabelId} fw={600} size="sm">
|
||||
{t(
|
||||
"settings.general.updates.updateBehavior",
|
||||
"Update behavior",
|
||||
@@ -422,6 +430,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
)}
|
||||
</Text>
|
||||
<Select
|
||||
aria-labelledby={updateModeLabelId}
|
||||
disabled={desktopUpdateMode.locked}
|
||||
value={desktopUpdateMode.mode}
|
||||
onChange={(value) => {
|
||||
@@ -622,7 +631,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
<Text id={viewerZoomLabelId} fw={500} size="sm">
|
||||
{t("settings.general.defaultViewerZoom", "Default reader zoom")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
@@ -633,6 +642,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Text>
|
||||
</div>
|
||||
<Select
|
||||
aria-labelledby={viewerZoomLabelId}
|
||||
value={preferences.defaultViewerZoom}
|
||||
onChange={(val: string | null) => {
|
||||
if (val)
|
||||
@@ -677,7 +687,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
<Text id={hideToolsLabelId} fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.hideUnavailableTools",
|
||||
"Hide unavailable tools",
|
||||
@@ -691,6 +701,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
aria-labelledby={hideToolsLabelId}
|
||||
checked={preferences.hideUnavailableTools}
|
||||
onChange={(event) =>
|
||||
updatePreference(
|
||||
@@ -708,7 +719,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
<Text id={hideConversionsLabelId} fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.hideUnavailableConversions",
|
||||
"Hide unavailable conversions",
|
||||
@@ -722,6 +733,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
aria-labelledby={hideConversionsLabelId}
|
||||
checked={preferences.hideUnavailableConversions}
|
||||
onChange={(event) =>
|
||||
updatePreference(
|
||||
@@ -749,7 +761,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
<Text id={autoUnzipLabelId} fw={500} size="sm">
|
||||
{t("settings.general.autoUnzip", "Auto-unzip API responses")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
@@ -760,6 +772,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
aria-labelledby={autoUnzipLabelId}
|
||||
checked={preferences.autoUnzip}
|
||||
onChange={(event) =>
|
||||
updatePreference("autoUnzip", event.currentTarget.checked)
|
||||
@@ -786,7 +799,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
<Text id={autoUnzipLimitLabelId} fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.autoUnzipFileLimit",
|
||||
"Auto-unzip file limit",
|
||||
@@ -800,6 +813,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Text>
|
||||
</div>
|
||||
<NumberInput
|
||||
aria-labelledby={autoUnzipLimitLabelId}
|
||||
value={fileLimitInput}
|
||||
onChange={setFileLimitInput}
|
||||
onBlur={() => {
|
||||
|
||||
@@ -13,7 +13,7 @@ const Overview: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Stack gap="xs" mb="md">
|
||||
<Text fw={600} size="md" c="blue">
|
||||
<Text fw={600} size="md" c="var(--c-accent-text)">
|
||||
{title}
|
||||
</Text>
|
||||
<Stack gap="xs" pl="md">
|
||||
|
||||
@@ -270,7 +270,7 @@ export default function ProviderCard({
|
||||
href={provider.documentationUrl}
|
||||
target="_blank"
|
||||
size="xs"
|
||||
c="blue"
|
||||
c="var(--c-accent-text)"
|
||||
>
|
||||
{t(
|
||||
"admin.settings.connections.documentation",
|
||||
|
||||
@@ -33,6 +33,23 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
}) => {
|
||||
if (!file) return null;
|
||||
|
||||
// A thumbnail that takes a click is a control; without these it is a div, so
|
||||
// the file cannot be opened by keyboard. Only applied when a handler was
|
||||
// given — a decorative thumbnail should not take a tab stop.
|
||||
const interactive = onClick
|
||||
? {
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick,
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: "relative",
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
@@ -47,7 +64,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
|
||||
if (thumbnail && !isEncrypted) {
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Box style={containerStyle} {...interactive}>
|
||||
<PrivateContent>
|
||||
<img
|
||||
src={thumbnail}
|
||||
@@ -77,7 +94,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
|
||||
if (isEncrypted) {
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Box style={containerStyle} {...interactive}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -100,7 +117,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--mantine-color-red-6)",
|
||||
color: "var(--color-red-dark)",
|
||||
background: "rgba(220,38,38,0.1)",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "6px",
|
||||
@@ -116,7 +133,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Box style={containerStyle} {...interactive}>
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
@@ -136,7 +153,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
const ext = detectFileExtension(file.name ?? "").toUpperCase();
|
||||
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Box style={containerStyle} {...interactive}>
|
||||
<Center
|
||||
style={{
|
||||
width: "100%",
|
||||
|
||||
@@ -51,7 +51,10 @@ const StepWrapper: React.FC<StepWrapperProps> = ({
|
||||
: isCompleted
|
||||
? "var(--mantine-color-gray-light)"
|
||||
: "transparent",
|
||||
opacity: !isActive && !isCompleted ? 0.6 : 1,
|
||||
// Pending steps recede via a muted text colour rather than opacity,
|
||||
// which would drag their labels below the contrast floor.
|
||||
color:
|
||||
!isActive && !isCompleted ? "var(--c-text-muted)" : "var(--c-text)",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" mb={isActive ? "md" : 0}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import { Slider, Text, Group, NumberInput } from "@mantine/core";
|
||||
|
||||
interface Props {
|
||||
@@ -21,9 +22,10 @@ export default function SliderWithInput({
|
||||
step = 1,
|
||||
suffix = "%",
|
||||
}: Props) {
|
||||
const labelId = useId();
|
||||
return (
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={8}>
|
||||
<Text id={labelId} size="sm" fw={500} mb={8}>
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap="md" align="center">
|
||||
@@ -35,6 +37,9 @@ export default function SliderWithInput({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
// Mantine's slider thumb is a div, not an input, so the heading
|
||||
// above cannot name it through a <label> association.
|
||||
thumbLabel={label}
|
||||
/>
|
||||
</div>
|
||||
<NumberInput
|
||||
@@ -46,6 +51,7 @@ export default function SliderWithInput({
|
||||
disabled={disabled}
|
||||
suffix={suffix}
|
||||
style={{ width: 90 }}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
@@ -129,6 +129,13 @@ export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({
|
||||
onChange={setPenColor}
|
||||
format="hex"
|
||||
size="xs"
|
||||
// The saturation area and hue bar are role="slider" divs; these are
|
||||
// their only accessible names.
|
||||
saturationLabel={t(
|
||||
"colorPicker.saturation",
|
||||
"Saturation and brightness",
|
||||
)}
|
||||
hueLabel={t("colorPicker.hue", "Hue")}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 2 }}>
|
||||
@@ -144,6 +151,12 @@ export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({
|
||||
max={10}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
// The thumb is a div, so the heading above cannot name it.
|
||||
thumbLabel={t(
|
||||
"certSign.collab.signRequest.penSize",
|
||||
"Pen Size: {{size}}px",
|
||||
{ size: penSize },
|
||||
)}
|
||||
marks={[
|
||||
{ value: 1, label: "1" },
|
||||
{ value: 5, label: "5" },
|
||||
|
||||
@@ -118,6 +118,12 @@ export const TypeSignatureText: React.FC<TypeSignatureTextProps> = ({
|
||||
max={80}
|
||||
step={2}
|
||||
disabled={disabled}
|
||||
// The thumb is a div, so the heading above cannot name it.
|
||||
thumbLabel={t(
|
||||
"certSign.collab.signRequest.fontSize",
|
||||
"Font Size: {{size}}px",
|
||||
{ size: fontSize },
|
||||
)}
|
||||
marks={[
|
||||
{ value: 20, label: "20" },
|
||||
{ value: 50, label: "50" },
|
||||
@@ -130,7 +136,18 @@ export const TypeSignatureText: React.FC<TypeSignatureTextProps> = ({
|
||||
<Text size="sm" mb={4}>
|
||||
{t("certSign.collab.signRequest.textColor", "Text Color")}
|
||||
</Text>
|
||||
<ColorPicker value={color} onChange={onColorChange} format="hex" />
|
||||
<ColorPicker
|
||||
value={color}
|
||||
onChange={onColorChange}
|
||||
format="hex"
|
||||
// The saturation area and hue bar are role="slider" divs; these are
|
||||
// their only accessible names.
|
||||
saturationLabel={t(
|
||||
"colorPicker.saturation",
|
||||
"Saturation and brightness",
|
||||
)}
|
||||
hueLabel={t("colorPicker.hue", "Hue")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
|
||||
@@ -124,7 +124,7 @@ export const UploadSignatureImage: React.FC<UploadSignatureImageProps> = ({
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text size="xs" c="red">
|
||||
<Text size="xs" c="var(--color-red-dark)">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* The tool list behind the fullscreen picker. It has two shapes: with no search
|
||||
* term it shows a Quick Access block above the full catalogue; while searching
|
||||
* it shows flat match groups and drops Quick Access entirely.
|
||||
*
|
||||
* Two details decide whether anything renders at all, so the fixture below is
|
||||
* built around them: tools are grouped by `categoryId`, and Quick Access only
|
||||
* accepts entries that carry a component or a link. An entry with neither is
|
||||
* treated as "coming soon" and never reaches the list.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import BuildRoundedIcon from "@mui/icons-material/BuildRounded";
|
||||
import FullscreenToolList from "@app/components/tools/FullscreenToolList";
|
||||
import {
|
||||
ToolCategoryId,
|
||||
SubcategoryId,
|
||||
type ToolRegistryEntry,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import {
|
||||
ToolWorkflowContext,
|
||||
type ToolWorkflowContextValue,
|
||||
} from "@app/contexts/ToolWorkflowContext";
|
||||
import {
|
||||
HotkeyContext,
|
||||
type HotkeyContextValue,
|
||||
} from "@app/contexts/HotkeyContext";
|
||||
|
||||
function tool(
|
||||
name: string,
|
||||
categoryId: ToolCategoryId,
|
||||
subcategoryId: SubcategoryId,
|
||||
): ToolRegistryEntry {
|
||||
return {
|
||||
icon: <BuildRoundedIcon />,
|
||||
name,
|
||||
// Entries with neither a component nor a link are filtered out of Quick
|
||||
// Access and render disabled elsewhere.
|
||||
component: () => null,
|
||||
description: `${name} — what this tool does, in one line.`,
|
||||
categoryId,
|
||||
subcategoryId,
|
||||
automationSettings: null,
|
||||
} as ToolRegistryEntry;
|
||||
}
|
||||
|
||||
const REGISTRY: Record<string, ToolRegistryEntry> = {
|
||||
rotate: tool(
|
||||
"Rotate pages",
|
||||
ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
SubcategoryId.PAGE_FORMATTING,
|
||||
),
|
||||
compress: tool(
|
||||
"Compress",
|
||||
ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
SubcategoryId.GENERAL,
|
||||
),
|
||||
redact: tool(
|
||||
"Redact",
|
||||
ToolCategoryId.STANDARD_TOOLS,
|
||||
SubcategoryId.DOCUMENT_SECURITY,
|
||||
),
|
||||
sign: tool("Sign", ToolCategoryId.STANDARD_TOOLS, SubcategoryId.SIGNING),
|
||||
extract: tool(
|
||||
"Extract pages",
|
||||
ToolCategoryId.STANDARD_TOOLS,
|
||||
SubcategoryId.EXTRACTION,
|
||||
),
|
||||
removeBlanks: tool(
|
||||
"Remove blank pages",
|
||||
ToolCategoryId.ADVANCED_TOOLS,
|
||||
SubcategoryId.REMOVAL,
|
||||
),
|
||||
};
|
||||
|
||||
const ALL_TOOLS = Object.entries(REGISTRY).map(([id, entry]) => ({
|
||||
item: [id as ToolId, entry] as [ToolId, ToolRegistryEntry],
|
||||
}));
|
||||
|
||||
function Harness({
|
||||
filteredTools = ALL_TOOLS,
|
||||
searchQuery = "",
|
||||
showDescriptions = false,
|
||||
selectedToolKey = null,
|
||||
favoriteTools = [] as string[],
|
||||
}: {
|
||||
filteredTools?: typeof ALL_TOOLS;
|
||||
searchQuery?: string;
|
||||
showDescriptions?: boolean;
|
||||
selectedToolKey?: string | null;
|
||||
favoriteTools?: string[];
|
||||
}) {
|
||||
const workflow = {
|
||||
toolRegistry: REGISTRY,
|
||||
favoriteTools,
|
||||
isFavorite: (id: string) => favoriteTools.includes(id),
|
||||
toggleFavorite: () => {},
|
||||
toolAvailability: {},
|
||||
} as unknown as ToolWorkflowContextValue;
|
||||
|
||||
return (
|
||||
<AppConfigProvider
|
||||
initialConfig={{ premiumEnabled: true } as never}
|
||||
bootstrapMode="non-blocking"
|
||||
autoFetch={false}
|
||||
>
|
||||
<HotkeyContext.Provider value={{ hotkeys: {} } as HotkeyContextValue}>
|
||||
<ToolWorkflowContext.Provider value={workflow}>
|
||||
<div style={{ width: 420 }}>
|
||||
<FullscreenToolList
|
||||
filteredTools={filteredTools}
|
||||
searchQuery={searchQuery}
|
||||
showDescriptions={showDescriptions}
|
||||
selectedToolKey={selectedToolKey}
|
||||
matchedTextMap={new Map()}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</ToolWorkflowContext.Provider>
|
||||
</HotkeyContext.Provider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof FullscreenToolList> = {
|
||||
title: "Tools/Fullscreen/FullscreenToolList",
|
||||
component: FullscreenToolList,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FullscreenToolList>;
|
||||
|
||||
/** No search term: Quick Access above the full catalogue. */
|
||||
export const Default: Story = { render: () => <Harness /> };
|
||||
|
||||
/** Descriptions turn each row into the taller detailed form. */
|
||||
export const WithDescriptions: Story = {
|
||||
render: () => <Harness showDescriptions />,
|
||||
};
|
||||
|
||||
/** Favourites join the recommended tools in the Quick Access block. */
|
||||
export const WithFavourites: Story = {
|
||||
render: () => <Harness favoriteTools={["redact", "sign"]} />,
|
||||
};
|
||||
|
||||
export const SelectedTool: Story = {
|
||||
render: () => <Harness selectedToolKey="redact" />,
|
||||
};
|
||||
|
||||
/** Searching drops Quick Access and flattens the results into match groups. */
|
||||
export const Searching: Story = {
|
||||
render: () => (
|
||||
<Harness
|
||||
searchQuery="re"
|
||||
filteredTools={ALL_TOOLS.filter(({ item }) =>
|
||||
item[1].name.toLowerCase().includes("re"),
|
||||
)}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** A search that matches nothing still renders its empty state. */
|
||||
export const NoMatches: Story = {
|
||||
render: () => <Harness searchQuery="zzzz" filteredTools={[]} />,
|
||||
};
|
||||
@@ -255,19 +255,14 @@ const FullscreenToolList = ({
|
||||
>
|
||||
{getSubcategoryIcon(subcategoryId)}
|
||||
</span>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
lts={0.5}
|
||||
style={{
|
||||
color: categoryColor,
|
||||
}}
|
||||
>
|
||||
{/* The icon and the section border already carry the category
|
||||
hue. These are decorative fills — restating one as text
|
||||
colour drops the label under 4.5:1 on the app surface. */}
|
||||
<Text size="sm" fw={600} tt="uppercase" lts={0.5}>
|
||||
{getSubcategoryLabel(t, subcategoryId)}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge size="sm" variant="colored" color={categoryColor}>
|
||||
<Badge size="sm" variant="default">
|
||||
{tools.length}
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The fullscreen tool picker's surface: a search field, a details switch, and
|
||||
* the whole catalogue beneath them.
|
||||
*
|
||||
* The surface is a portal onto the body, absolutely placed from the `geometry`
|
||||
* the sidebar measures for it — with no geometry it renders nothing at all,
|
||||
* which is how it stays out of the way before the panel has been laid out.
|
||||
* Everything else is passed straight through to the list: the search term
|
||||
* decides whether Quick Access survives, and the details switch decides whether
|
||||
* each row carries its description.
|
||||
*
|
||||
* The list rows are ToolButtons, which read the registry, favourites, hotkeys
|
||||
* and app config, so those contexts are stubbed rather than provided in full.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import BuildRoundedIcon from "@mui/icons-material/BuildRounded";
|
||||
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
|
||||
import {
|
||||
ToolCategoryId,
|
||||
SubcategoryId,
|
||||
type ToolRegistryEntry,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import {
|
||||
ToolWorkflowContext,
|
||||
type ToolWorkflowContextValue,
|
||||
} from "@app/contexts/ToolWorkflowContext";
|
||||
import {
|
||||
HotkeyContext,
|
||||
type HotkeyContextValue,
|
||||
} from "@app/contexts/HotkeyContext";
|
||||
|
||||
function tool(
|
||||
name: string,
|
||||
categoryId: ToolCategoryId,
|
||||
subcategoryId: SubcategoryId,
|
||||
): ToolRegistryEntry {
|
||||
return {
|
||||
icon: <BuildRoundedIcon />,
|
||||
name,
|
||||
// Entries with neither a component nor a link count as "coming soon" and
|
||||
// are dropped from Quick Access, which would empty out half the surface.
|
||||
component: () => null,
|
||||
description: `${name} — what this tool does, in one line.`,
|
||||
categoryId,
|
||||
subcategoryId,
|
||||
automationSettings: null,
|
||||
} as ToolRegistryEntry;
|
||||
}
|
||||
|
||||
const REGISTRY: Record<string, ToolRegistryEntry> = {
|
||||
rotate: tool(
|
||||
"Rotate pages",
|
||||
ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
SubcategoryId.PAGE_FORMATTING,
|
||||
),
|
||||
compress: tool(
|
||||
"Compress",
|
||||
ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
SubcategoryId.GENERAL,
|
||||
),
|
||||
redact: tool(
|
||||
"Redact",
|
||||
ToolCategoryId.STANDARD_TOOLS,
|
||||
SubcategoryId.DOCUMENT_SECURITY,
|
||||
),
|
||||
sign: tool("Sign", ToolCategoryId.STANDARD_TOOLS, SubcategoryId.SIGNING),
|
||||
extract: tool(
|
||||
"Extract pages",
|
||||
ToolCategoryId.STANDARD_TOOLS,
|
||||
SubcategoryId.EXTRACTION,
|
||||
),
|
||||
removeBlanks: tool(
|
||||
"Remove blank pages",
|
||||
ToolCategoryId.ADVANCED_TOOLS,
|
||||
SubcategoryId.REMOVAL,
|
||||
),
|
||||
};
|
||||
|
||||
const ALL_TOOLS = Object.entries(REGISTRY).map(([id, entry]) => ({
|
||||
item: [id as ToolId, entry] as [ToolId, ToolRegistryEntry],
|
||||
}));
|
||||
|
||||
/** Roughly the rail the sidebar hands over: inset from the right-hand edge. */
|
||||
const GEOMETRY = { left: 32, top: 32, width: 720, height: 560 };
|
||||
|
||||
const withStubs = (Story: React.ComponentType) => (
|
||||
<AppConfigProvider
|
||||
initialConfig={{ premiumEnabled: true } as never}
|
||||
bootstrapMode="non-blocking"
|
||||
autoFetch={false}
|
||||
>
|
||||
<HotkeyContext.Provider value={{ hotkeys: {} } as HotkeyContextValue}>
|
||||
<ToolWorkflowContext.Provider
|
||||
value={
|
||||
{
|
||||
toolRegistry: REGISTRY,
|
||||
favoriteTools: [],
|
||||
isFavorite: () => false,
|
||||
toggleFavorite: () => {},
|
||||
toolAvailability: {},
|
||||
} as unknown as ToolWorkflowContextValue
|
||||
}
|
||||
>
|
||||
<Story />
|
||||
</ToolWorkflowContext.Provider>
|
||||
</HotkeyContext.Provider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
|
||||
const meta: Meta<typeof FullscreenToolSurface> = {
|
||||
title: "Tools/Fullscreen/FullscreenToolSurface",
|
||||
component: FullscreenToolSurface,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
searchQuery: "",
|
||||
toolRegistry: REGISTRY,
|
||||
filteredTools: ALL_TOOLS,
|
||||
selectedToolKey: null,
|
||||
showDescriptions: false,
|
||||
matchedTextMap: new Map(),
|
||||
geometry: GEOMETRY,
|
||||
onSearchChange: () => {},
|
||||
onSelect: () => {},
|
||||
onToggleDescriptions: () => {},
|
||||
onExitFullscreenMode: () => {},
|
||||
},
|
||||
decorators: [withStubs],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FullscreenToolSurface>;
|
||||
|
||||
/** The full catalogue, Quick Access first. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The details switch on: every row grows to carry its description. */
|
||||
export const WithDescriptions: Story = { args: { showDescriptions: true } };
|
||||
|
||||
/** Searching narrows the surface to matches and drops Quick Access. */
|
||||
export const Searching: Story = {
|
||||
args: {
|
||||
searchQuery: "re",
|
||||
filteredTools: ALL_TOOLS.filter(({ item }) =>
|
||||
item[1].name.toLowerCase().includes("re"),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** A search with no matches still fills the surface with its empty state. */
|
||||
export const NoMatches: Story = {
|
||||
args: { searchQuery: "zzzz", filteredTools: [] },
|
||||
};
|
||||
|
||||
/** The open tool is marked in the list. */
|
||||
export const SelectedTool: Story = { args: { selectedToolKey: "redact" } };
|
||||
|
||||
/** Before the panel has been measured there is nowhere to place the surface. */
|
||||
export const NotYetMeasured: Story = { args: { geometry: null } };
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Search results in the tool picker: matched tools grouped by subcategory,
|
||||
* or an empty state when nothing matches.
|
||||
*
|
||||
* Each row is a ToolButton, which reads the tool registry, favourites, hotkey
|
||||
* bindings and app config. Mounting ToolWorkflowProvider would stand up the
|
||||
* whole registry and its navigation chain, so the fixture supplies the handful
|
||||
* of fields the rows actually read.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import BuildRoundedIcon from "@mui/icons-material/BuildRounded";
|
||||
import SearchResults from "@app/components/tools/SearchResults";
|
||||
import {
|
||||
ToolCategoryId,
|
||||
SubcategoryId,
|
||||
type ToolRegistryEntry,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import {
|
||||
ToolWorkflowDataContext,
|
||||
ToolWorkflowActionsContext,
|
||||
type ToolWorkflowDataValue,
|
||||
type ToolWorkflowActionsValue,
|
||||
} from "@app/contexts/ToolWorkflowContext";
|
||||
import {
|
||||
HotkeyContext,
|
||||
type HotkeyContextValue,
|
||||
} from "@app/contexts/HotkeyContext";
|
||||
|
||||
function tool(name: string, subcategoryId: SubcategoryId): ToolRegistryEntry {
|
||||
return {
|
||||
icon: <BuildRoundedIcon />,
|
||||
name,
|
||||
// An entry with neither a component nor a link is treated as "coming soon"
|
||||
// and renders disabled, which would flatten every story into one look.
|
||||
component: () => null,
|
||||
description: `${name} — what this tool does, in one line.`,
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId,
|
||||
automationSettings: null,
|
||||
} as ToolRegistryEntry;
|
||||
}
|
||||
|
||||
const MATCHES = [
|
||||
{
|
||||
item: ["redact", tool("Redact", SubcategoryId.DOCUMENT_SECURITY)] as [
|
||||
ToolId,
|
||||
ToolRegistryEntry,
|
||||
],
|
||||
},
|
||||
{
|
||||
item: ["sign", tool("Sign", SubcategoryId.SIGNING)] as [
|
||||
ToolId,
|
||||
ToolRegistryEntry,
|
||||
],
|
||||
},
|
||||
{
|
||||
item: ["watermark", tool("Watermark", SubcategoryId.EXTRACTION)] as [
|
||||
ToolId,
|
||||
ToolRegistryEntry,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function Harness({
|
||||
filteredTools = MATCHES,
|
||||
searchQuery = "e",
|
||||
favourites = [] as string[],
|
||||
}: {
|
||||
filteredTools?: typeof MATCHES;
|
||||
searchQuery?: string;
|
||||
favourites?: string[];
|
||||
}) {
|
||||
const data = {
|
||||
isFavorite: (id: string) => favourites.includes(id),
|
||||
toolAvailability: {},
|
||||
toolRegistry: {},
|
||||
favoriteTools: favourites,
|
||||
} as unknown as ToolWorkflowDataValue;
|
||||
|
||||
return (
|
||||
<AppConfigProvider
|
||||
initialConfig={{ premiumEnabled: true } as never}
|
||||
bootstrapMode="non-blocking"
|
||||
autoFetch={false}
|
||||
>
|
||||
<HotkeyContext.Provider value={{ hotkeys: {} } as HotkeyContextValue}>
|
||||
<ToolWorkflowDataContext.Provider value={data}>
|
||||
<ToolWorkflowActionsContext.Provider
|
||||
value={
|
||||
{
|
||||
toggleFavorite: () => {},
|
||||
handleToolSelect: () => {},
|
||||
} as unknown as ToolWorkflowActionsValue
|
||||
}
|
||||
>
|
||||
<div style={{ width: 380 }}>
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
onSelect={() => {}}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
</ToolWorkflowActionsContext.Provider>
|
||||
</ToolWorkflowDataContext.Provider>
|
||||
</HotkeyContext.Provider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof SearchResults> = {
|
||||
title: "Tools/SearchResults",
|
||||
component: SearchResults,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof SearchResults>;
|
||||
|
||||
/** Matches across several subcategories, each with its own heading. */
|
||||
export const Default: Story = { render: () => <Harness /> };
|
||||
|
||||
/** A single match, which is the common case for a specific query. */
|
||||
export const SingleMatch: Story = {
|
||||
render: () => <Harness filteredTools={[MATCHES[0]]} searchQuery="redact" />,
|
||||
};
|
||||
|
||||
/** Nothing matched: the results give way to the empty state. */
|
||||
export const NoMatches: Story = {
|
||||
render: () => <Harness filteredTools={[]} searchQuery="zzzz" />,
|
||||
};
|
||||
|
||||
/** Favourited tools carry a filled star. */
|
||||
export const WithFavourites: Story = {
|
||||
render: () => <Harness favourites={["sign"]} />,
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The body of the right rail: the viewer's mini toolbar, then exactly one of
|
||||
* three things beneath it.
|
||||
*
|
||||
* Which one is a chain of checks on workflow state rather than a prop. A search
|
||||
* term while the all-tools view is open wins outright and shows the matches;
|
||||
* otherwise the panel shows the tool picker, compact until the rail expands into
|
||||
* the full catalogue; otherwise it shows the open tool, or an invitation to pick
|
||||
* one. The fourth branch — a tool actually rendered — needs the real registry
|
||||
* behind ToolRenderer, so it is covered by that component's own stories.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import BuildRoundedIcon from "@mui/icons-material/BuildRounded";
|
||||
import DrawIcon from "@mui/icons-material/Draw";
|
||||
import CommentIcon from "@mui/icons-material/Comment";
|
||||
import ToolPanel from "@app/components/tools/ToolPanel";
|
||||
import { withToolContexts } from "@app/components/tools/storyFixtures";
|
||||
import { WorkbenchBarContext } from "@app/contexts/WorkbenchBarContext";
|
||||
import {
|
||||
ToolCategoryId,
|
||||
SubcategoryId,
|
||||
type ToolRegistryEntry,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
|
||||
function tool(
|
||||
name: string,
|
||||
categoryId: ToolCategoryId,
|
||||
subcategoryId: SubcategoryId,
|
||||
): ToolRegistryEntry {
|
||||
return {
|
||||
icon: <BuildRoundedIcon />,
|
||||
name,
|
||||
// Without a component or a link an entry counts as "coming soon", which
|
||||
// renders every row disabled.
|
||||
component: () => null,
|
||||
description: `${name} — what this tool does, in one line.`,
|
||||
categoryId,
|
||||
subcategoryId,
|
||||
automationSettings: null,
|
||||
} as ToolRegistryEntry;
|
||||
}
|
||||
|
||||
const FILTERED_TOOLS = [
|
||||
[
|
||||
"rotate",
|
||||
tool(
|
||||
"Rotate pages",
|
||||
ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
SubcategoryId.PAGE_FORMATTING,
|
||||
),
|
||||
],
|
||||
[
|
||||
"compress",
|
||||
tool("Compress", ToolCategoryId.RECOMMENDED_TOOLS, SubcategoryId.GENERAL),
|
||||
],
|
||||
[
|
||||
"redact",
|
||||
tool(
|
||||
"Redact",
|
||||
ToolCategoryId.STANDARD_TOOLS,
|
||||
SubcategoryId.DOCUMENT_SECURITY,
|
||||
),
|
||||
],
|
||||
["sign", tool("Sign", ToolCategoryId.STANDARD_TOOLS, SubcategoryId.SIGNING)],
|
||||
[
|
||||
"removeBlanks",
|
||||
tool(
|
||||
"Remove blank pages",
|
||||
ToolCategoryId.ADVANCED_TOOLS,
|
||||
SubcategoryId.REMOVAL,
|
||||
),
|
||||
],
|
||||
].map(([id, entry]) => ({
|
||||
item: [id as ToolId, entry as ToolRegistryEntry] as [
|
||||
ToolId,
|
||||
ToolRegistryEntry,
|
||||
],
|
||||
}));
|
||||
|
||||
/** The viewer bar only shows buttons filed under the tool-panel section. */
|
||||
const VIEWER_BAR_BUTTONS = [
|
||||
{
|
||||
id: "annotate",
|
||||
section: "tool-panel",
|
||||
ariaLabel: "Annotate",
|
||||
icon: <DrawIcon />,
|
||||
},
|
||||
{
|
||||
id: "comment",
|
||||
section: "tool-panel",
|
||||
ariaLabel: "Comment",
|
||||
icon: <CommentIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
const withWorkbenchBar = (Story: () => React.ReactElement) => (
|
||||
<WorkbenchBarContext.Provider
|
||||
value={
|
||||
{
|
||||
buttons: VIEWER_BAR_BUTTONS,
|
||||
actions: {},
|
||||
allButtonsDisabled: false,
|
||||
} as never
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 380,
|
||||
height: "80vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
</WorkbenchBarContext.Provider>
|
||||
);
|
||||
|
||||
const workflow = (overrides: Record<string, unknown>) =>
|
||||
withToolContexts({
|
||||
workbench: "viewer",
|
||||
workflow: {
|
||||
searchQuery: "",
|
||||
filteredTools: FILTERED_TOOLS,
|
||||
selectedToolKey: null,
|
||||
handleToolSelect: () => {},
|
||||
setPreviewFile: () => {},
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
const meta: Meta<typeof ToolPanel> = {
|
||||
title: "Tools/ToolPanel",
|
||||
component: ToolPanel,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { allToolsView: false, onShowAllTools: () => {} },
|
||||
decorators: [workflow({ leftPanelView: "toolPicker" }), withWorkbenchBar],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ToolPanel>;
|
||||
|
||||
/** Resting state: the compact picker of pinned and recommended tools. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Expanded into the full categorised catalogue. */
|
||||
export const AllTools: Story = { args: { allToolsView: true } };
|
||||
|
||||
/** A search term in the all-tools view replaces the picker with its matches. */
|
||||
export const Searching: Story = {
|
||||
args: { allToolsView: true },
|
||||
decorators: [
|
||||
workflow({
|
||||
leftPanelView: "toolPicker",
|
||||
searchQuery: "re",
|
||||
filteredTools: FILTERED_TOOLS.filter(({ item }) =>
|
||||
item[1].name.toLowerCase().includes("re"),
|
||||
),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/** A search that matches nothing still leaves the empty state in place. */
|
||||
export const SearchWithNoMatches: Story = {
|
||||
args: { allToolsView: true },
|
||||
decorators: [
|
||||
workflow({
|
||||
leftPanelView: "toolPicker",
|
||||
searchQuery: "zzzz",
|
||||
filteredTools: [],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
/** Past the picker with no tool open — the panel asks for one. */
|
||||
export const NoToolSelected: Story = {
|
||||
decorators: [workflow({ leftPanelView: "toolContent" })],
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The row of viewer controls that appears inside the tool panel. It renders
|
||||
* only while the viewer is the active workbench, and takes its buttons from the
|
||||
* workbench bar rather than declaring them.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
||||
import PrintIcon from "@mui/icons-material/Print";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
|
||||
import { withToolContexts } from "@app/components/tools/storyFixtures";
|
||||
import { WorkbenchBarContext } from "@app/contexts/WorkbenchBarContext";
|
||||
|
||||
/** The bar renders only the "tool-panel" section, and names each control from
|
||||
* ariaLabel (falling back to a string tooltip) — not from `label`. */
|
||||
const BUTTONS = [
|
||||
{
|
||||
id: "zoom",
|
||||
section: "tool-panel",
|
||||
ariaLabel: "Zoom in",
|
||||
icon: <ZoomInIcon />,
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
id: "print",
|
||||
section: "tool-panel",
|
||||
ariaLabel: "Print",
|
||||
icon: <PrintIcon />,
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
id: "download",
|
||||
section: "tool-panel",
|
||||
ariaLabel: "Download",
|
||||
icon: <DownloadIcon />,
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
|
||||
function bar(buttons: typeof BUTTONS, allButtonsDisabled = false) {
|
||||
return (Story: () => React.ReactElement) => (
|
||||
<WorkbenchBarContext.Provider
|
||||
value={{ buttons, actions: {}, allButtonsDisabled } as never}
|
||||
>
|
||||
<Story />
|
||||
</WorkbenchBarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof ToolPanelViewerBar> = {
|
||||
title: "Tools/ToolPanelViewerBar",
|
||||
component: ToolPanelViewerBar,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ToolPanelViewerBar>;
|
||||
|
||||
export const Default: Story = {
|
||||
decorators: [bar(BUTTONS), withToolContexts({ workbench: "viewer" })],
|
||||
};
|
||||
|
||||
/** Everything disabled, which is how the bar looks while a tool is running. */
|
||||
export const AllDisabled: Story = {
|
||||
decorators: [bar(BUTTONS, true), withToolContexts({ workbench: "viewer" })],
|
||||
};
|
||||
|
||||
/** A single control, the minimum the bar ever shows. */
|
||||
export const OneButton: Story = {
|
||||
decorators: [bar([BUTTONS[0]]), withToolContexts({ workbench: "viewer" })],
|
||||
};
|
||||
|
||||
/** Any other workbench and the bar renders nothing. */
|
||||
export const NotViewerWorkbench: Story = {
|
||||
decorators: [bar(BUTTONS), withToolContexts({ workbench: "fileManager" })],
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { BaseToolProps } from "@app/types/tool";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
@@ -15,7 +14,6 @@ const ToolRenderer = ({
|
||||
onComplete,
|
||||
onError,
|
||||
}: ToolRendererProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Get the tool from context (instead of direct hook call)
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const selectedTool =
|
||||
@@ -29,7 +27,7 @@ const ToolRenderer = ({
|
||||
}
|
||||
|
||||
if (!selectedTool || !selectedTool.component) {
|
||||
return <div>{t("toolRenderer.notFound", { tool: selectedToolKey })}</div>;
|
||||
return <div>Tool not found: {selectedToolKey}</div>;
|
||||
}
|
||||
|
||||
const ToolComponent = selectedTool.component;
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* How the page number looks: margin, size, face, zero padding and the optional
|
||||
* text wrapped around it. This is the other half of the Add Page Numbers
|
||||
* settings — the position panel places the number, this one styles it — and
|
||||
* every parameter here is one that panel does not render.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AddPageNumbersAppearanceSettings from "@app/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings";
|
||||
import {
|
||||
defaultParameters,
|
||||
type AddPageNumbersParameters,
|
||||
} from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters";
|
||||
|
||||
function Demo({
|
||||
overrides,
|
||||
disabled,
|
||||
}: {
|
||||
overrides?: Partial<AddPageNumbersParameters>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [parameters, setParameters] = useState<AddPageNumbersParameters>({
|
||||
...defaultParameters,
|
||||
...overrides,
|
||||
});
|
||||
return (
|
||||
<AddPageNumbersAppearanceSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={(key, value) =>
|
||||
setParameters((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof AddPageNumbersAppearanceSettings> = {
|
||||
title: "Tools/AddPageNumbers/AppearanceSettings",
|
||||
component: AddPageNumbersAppearanceSettings,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AddPageNumbersAppearanceSettings>;
|
||||
|
||||
/** Defaults: medium margin, 12pt Times, no padding, no custom text. */
|
||||
export const Default: Story = { render: () => <Demo /> };
|
||||
|
||||
/** The margin extremes, which decide how far the number sits from the edge. */
|
||||
export const SmallMargin: Story = {
|
||||
render: () => <Demo overrides={{ customMargin: "small" }} />,
|
||||
};
|
||||
|
||||
export const ExtraLargeMargin: Story = {
|
||||
render: () => <Demo overrides={{ customMargin: "x-large" }} />,
|
||||
};
|
||||
|
||||
export const CourierFace: Story = {
|
||||
render: () => <Demo overrides={{ fontType: "Courier" }} />,
|
||||
};
|
||||
|
||||
export const LargeType: Story = {
|
||||
render: () => <Demo overrides={{ fontSize: 24 }} />,
|
||||
};
|
||||
|
||||
/** Padding to a fixed width, for documents whose numbers must sort as text. */
|
||||
export const ZeroPadded: Story = {
|
||||
render: () => <Demo overrides={{ zeroPad: 3 }} />,
|
||||
};
|
||||
|
||||
/** `{n}` is the placeholder the number is substituted into. */
|
||||
export const CustomText: Story = {
|
||||
render: () => <Demo overrides={{ customText: "Page {n}" }} />,
|
||||
};
|
||||
|
||||
/** Everything at once, which is where the fields crowd if they are going to. */
|
||||
export const FullyCustomised: Story = {
|
||||
render: () => (
|
||||
<Demo
|
||||
overrides={{
|
||||
customMargin: "large",
|
||||
fontType: "Helvetica",
|
||||
fontSize: 18,
|
||||
zeroPad: 4,
|
||||
customText: "Section 3 — page {n}",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Disabled while the tool is running. */
|
||||
export const Disabled: Story = { render: () => <Demo disabled /> };
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AddPageNumbersAutomationSettings from "@app/components/tools/addPageNumbers/AddPageNumbersAutomationSettings";
|
||||
import {
|
||||
defaultParameters,
|
||||
type AddPageNumbersParameters,
|
||||
} from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters";
|
||||
|
||||
/** Page-number settings in the form the pipeline builder embeds. */
|
||||
const meta: Meta<typeof AddPageNumbersAutomationSettings> = {
|
||||
title: "Tools/AddPageNumbers/AddPageNumbersAutomationSettings",
|
||||
component: AddPageNumbersAutomationSettings,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AddPageNumbersAutomationSettings>;
|
||||
|
||||
function Demo({
|
||||
overrides,
|
||||
disabled,
|
||||
}: {
|
||||
overrides?: Partial<AddPageNumbersParameters>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [parameters, setParameters] = useState<AddPageNumbersParameters>({
|
||||
...defaultParameters,
|
||||
...overrides,
|
||||
});
|
||||
return (
|
||||
<AddPageNumbersAutomationSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={(key, value) =>
|
||||
setParameters((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Defaults: Times 12pt, starting at 1, no custom text. */
|
||||
export const Default: Story = { render: () => <Demo /> };
|
||||
|
||||
/** A custom template around the number. */
|
||||
export const CustomText: Story = {
|
||||
render: () => <Demo overrides={{ customText: "Page {n} of {total}" }} />,
|
||||
};
|
||||
|
||||
/** Numbering only part of the document, starting mid-way. */
|
||||
export const PageRangeAndOffset: Story = {
|
||||
render: () => (
|
||||
<Demo overrides={{ pagesToNumber: "3-12,15", startingNumber: 7 }} />
|
||||
),
|
||||
};
|
||||
|
||||
/** Zero-padded numbers, for documents filed by page. */
|
||||
export const ZeroPadded: Story = {
|
||||
render: () => <Demo overrides={{ zeroPad: 3 }} />,
|
||||
};
|
||||
|
||||
/** A different face and a larger size. */
|
||||
export const CourierLarge: Story = {
|
||||
render: () => <Demo overrides={{ fontType: "Courier", fontSize: 24 }} />,
|
||||
};
|
||||
|
||||
/** Inert. */
|
||||
export const Disabled: Story = { render: () => <Demo disabled /> };
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Where the page number lands, and what it says. The position is a 1–9 grid
|
||||
* read like a numeric keypad — 1 is bottom-left, 9 is top-right — so the
|
||||
* stories walk the corners rather than exposing position as a control.
|
||||
*
|
||||
* With no file the preview falls back to a blank page outline; passing a real
|
||||
* document is what makes it render a thumbnail, which stories cannot do.
|
||||
*
|
||||
* The preview reads only the position and the page range, so the appearance
|
||||
* parameters (font, margin, custom text, zero padding) are deliberately not
|
||||
* varied here — this panel does not render them, and a story that set them
|
||||
* would be indistinguishable from the default.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AddPageNumbersPositionSettings from "@app/components/tools/addPageNumbers/AddPageNumbersPositionSettings";
|
||||
import {
|
||||
defaultParameters,
|
||||
type AddPageNumbersParameters,
|
||||
} from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters";
|
||||
|
||||
function Demo({
|
||||
overrides,
|
||||
disabled,
|
||||
showQuickGrid,
|
||||
}: {
|
||||
overrides?: Partial<AddPageNumbersParameters>;
|
||||
disabled?: boolean;
|
||||
showQuickGrid?: boolean;
|
||||
}) {
|
||||
const [parameters, setParameters] = useState<AddPageNumbersParameters>({
|
||||
...defaultParameters,
|
||||
...overrides,
|
||||
});
|
||||
return (
|
||||
<AddPageNumbersPositionSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={(key, value) =>
|
||||
setParameters((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
disabled={disabled}
|
||||
showQuickGrid={showQuickGrid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof AddPageNumbersPositionSettings> = {
|
||||
title: "Tools/AddPageNumbers/PositionSettings",
|
||||
component: AddPageNumbersPositionSettings,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AddPageNumbersPositionSettings>;
|
||||
|
||||
/** Bottom centre — where numbering conventionally sits, and the default. */
|
||||
export const Default: Story = { render: () => <Demo /> };
|
||||
|
||||
export const TopLeft: Story = {
|
||||
render: () => <Demo overrides={{ position: 7 }} />,
|
||||
};
|
||||
|
||||
export const TopRight: Story = {
|
||||
render: () => <Demo overrides={{ position: 9 }} />,
|
||||
};
|
||||
|
||||
export const BottomLeft: Story = {
|
||||
render: () => <Demo overrides={{ position: 1 }} />,
|
||||
};
|
||||
|
||||
/** Numbering that starts partway through, for a document split across files. */
|
||||
export const StartingNumber: Story = {
|
||||
render: () => <Demo overrides={{ startingNumber: 42 }} />,
|
||||
};
|
||||
|
||||
/** A range rather than the whole document. */
|
||||
export const PageRange: Story = {
|
||||
render: () => <Demo overrides={{ pagesToNumber: "2-8,10" }} />,
|
||||
};
|
||||
|
||||
/** Without the quick grid the position is set from the preview alone. */
|
||||
export const NoQuickGrid: Story = {
|
||||
render: () => <Demo showQuickGrid={false} />,
|
||||
};
|
||||
|
||||
/** Disabled while the tool is running. */
|
||||
export const Disabled: Story = { render: () => <Demo disabled /> };
|
||||
+1
-1
@@ -126,6 +126,6 @@
|
||||
/* Preview disclaimer */
|
||||
.previewDisclaimer {
|
||||
margin-top: 8px;
|
||||
opacity: 0.7;
|
||||
color: var(--c-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AddStampAutomationSettings from "@app/components/tools/addStamp/AddStampAutomationSettings";
|
||||
import {
|
||||
defaultParameters,
|
||||
type AddStampParameters,
|
||||
} from "@app/components/tools/addStamp/useAddStampParameters";
|
||||
|
||||
/** Stamp settings in the form the pipeline builder embeds. */
|
||||
const meta: Meta<typeof AddStampAutomationSettings> = {
|
||||
title: "Tools/AddStamp/AddStampAutomationSettings",
|
||||
component: AddStampAutomationSettings,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AddStampAutomationSettings>;
|
||||
|
||||
function Demo({
|
||||
overrides,
|
||||
disabled,
|
||||
}: {
|
||||
overrides?: Partial<AddStampParameters>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [parameters, setParameters] = useState<AddStampParameters>({
|
||||
...defaultParameters,
|
||||
...overrides,
|
||||
});
|
||||
return (
|
||||
<AddStampAutomationSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={(key, value) =>
|
||||
setParameters((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Defaults — no stamp text entered yet. */
|
||||
export const Default: Story = { render: () => <Demo /> };
|
||||
|
||||
/** A typical text stamp. */
|
||||
export const TextStamp: Story = {
|
||||
render: () => <Demo overrides={{ stampText: "CONFIDENTIAL" }} />,
|
||||
};
|
||||
|
||||
/** Rotated and part-transparent, the usual watermark-style stamp. */
|
||||
export const RotatedTranslucent: Story = {
|
||||
render: () => (
|
||||
<Demo overrides={{ stampText: "DRAFT", rotation: 45, opacity: 30 }} />
|
||||
),
|
||||
};
|
||||
|
||||
/** A non-Roman alphabet, which selects a different embedded face. */
|
||||
export const JapaneseAlphabet: Story = {
|
||||
render: () => (
|
||||
<Demo overrides={{ stampText: "社外秘", alphabet: "japanese" }} />
|
||||
),
|
||||
};
|
||||
|
||||
/** Applied to a page range rather than the whole document. */
|
||||
export const PageRange: Story = {
|
||||
render: () => (
|
||||
<Demo overrides={{ stampText: "EXHIBIT A", pageNumbers: "1,4-9" }} />
|
||||
),
|
||||
};
|
||||
|
||||
/** Inert. */
|
||||
export const Disabled: Story = { render: () => <Demo disabled /> };
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Placement and formatting for the stamp tool. The three formatting controls —
|
||||
* size, rotation, opacity — share one slider, and `_activePill` decides which
|
||||
* of them is showing, so each pill gets its own story rather than a control.
|
||||
*
|
||||
* Position is the same 1–9 keypad grid the page-number tool uses.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import StampPositionFormattingSettings from "@app/components/tools/addStamp/StampPositionFormattingSettings";
|
||||
import {
|
||||
defaultParameters,
|
||||
type AddStampParameters,
|
||||
} from "@app/components/tools/addStamp/useAddStampParameters";
|
||||
|
||||
function Demo({
|
||||
overrides,
|
||||
disabled,
|
||||
showPositionGrid = true,
|
||||
}: {
|
||||
overrides?: Partial<AddStampParameters>;
|
||||
disabled?: boolean;
|
||||
showPositionGrid?: boolean;
|
||||
}) {
|
||||
const [parameters, setParameters] = useState<AddStampParameters>({
|
||||
...defaultParameters,
|
||||
...overrides,
|
||||
});
|
||||
return (
|
||||
<StampPositionFormattingSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={(key, value) =>
|
||||
setParameters((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
disabled={disabled}
|
||||
showPositionGrid={showPositionGrid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof StampPositionFormattingSettings> = {
|
||||
title: "Tools/AddStamp/PositionFormattingSettings",
|
||||
component: StampPositionFormattingSettings,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof StampPositionFormattingSettings>;
|
||||
|
||||
export const Default: Story = { render: () => <Demo /> };
|
||||
|
||||
/** The formatting pills each swap in a different slider. */
|
||||
export const RotationActive: Story = {
|
||||
render: () => <Demo overrides={{ _activePill: "rotation" }} />,
|
||||
};
|
||||
|
||||
export const OpacityActive: Story = {
|
||||
render: () => <Demo overrides={{ _activePill: "opacity" }} />,
|
||||
};
|
||||
|
||||
/** An image stamp has no font size, so that control becomes a scale instead. */
|
||||
export const ImageStamp: Story = {
|
||||
render: () => <Demo overrides={{ stampType: "image" }} />,
|
||||
};
|
||||
|
||||
export const ImageStampRotated: Story = {
|
||||
render: () => (
|
||||
<Demo overrides={{ stampType: "image", _activePill: "rotation" }} />
|
||||
),
|
||||
};
|
||||
|
||||
/** Corners of the placement grid. */
|
||||
export const TopLeftPosition: Story = {
|
||||
render: () => <Demo overrides={{ position: 7 }} />,
|
||||
};
|
||||
|
||||
export const CentrePosition: Story = {
|
||||
render: () => <Demo overrides={{ position: 5 }} />,
|
||||
};
|
||||
|
||||
/** Values at the ends of their ranges, where the sliders sit hard over. */
|
||||
export const FullyRotated: Story = {
|
||||
render: () => <Demo overrides={{ _activePill: "rotation", rotation: 180 }} />,
|
||||
};
|
||||
|
||||
export const NearlyTransparent: Story = {
|
||||
render: () => <Demo overrides={{ _activePill: "opacity", opacity: 10 }} />,
|
||||
};
|
||||
|
||||
/** Automation drives position numerically, so the grid is hidden there. */
|
||||
export const WithoutPositionGrid: Story = {
|
||||
render: () => <Demo showPositionGrid={false} />,
|
||||
};
|
||||
|
||||
/** Disabled while the tool is running. */
|
||||
export const Disabled: Story = { render: () => <Demo disabled /> };
|
||||
+21
-11
@@ -34,6 +34,16 @@ const StampPositionFormattingSettings = ({
|
||||
}: StampPositionFormattingSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Each formatting pill shows a number field and a slider driving the same
|
||||
// value. The heading above them is plain text, so both controls carry the
|
||||
// label themselves rather than being left unnamed.
|
||||
const sizeLabel =
|
||||
parameters.stampType === "image"
|
||||
? t("AddStampRequest.imageSize", "Image Size")
|
||||
: t("AddStampRequest.fontSize", "Font Size");
|
||||
const rotationLabel = t("AddStampRequest.rotation", "Rotation");
|
||||
const opacityLabel = t("AddStampRequest.opacity", "Opacity");
|
||||
|
||||
return (
|
||||
<Stack gap="md" justify="space-between">
|
||||
{/* Position Grid - shown in automation settings */}
|
||||
@@ -144,11 +154,7 @@ const StampPositionFormattingSettings = ({
|
||||
{/* Single slider bound to selected pill */}
|
||||
{parameters._activePill === "fontSize" && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>
|
||||
{parameters.stampType === "image"
|
||||
? t("AddStampRequest.imageSize", "Image Size")
|
||||
: t("AddStampRequest.fontSize", "Font Size")}
|
||||
</Text>
|
||||
<Text className={styles.labelText}>{sizeLabel}</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={parameters.fontSize}
|
||||
@@ -164,6 +170,7 @@ const StampPositionFormattingSettings = ({
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
disabled={disabled}
|
||||
aria-label={sizeLabel}
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.fontSize}
|
||||
@@ -173,15 +180,14 @@ const StampPositionFormattingSettings = ({
|
||||
step={1}
|
||||
className={styles.slider}
|
||||
disabled={disabled}
|
||||
thumbLabel={sizeLabel}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{parameters._activePill === "rotation" && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>
|
||||
{t("AddStampRequest.rotation", "Rotation")}
|
||||
</Text>
|
||||
<Text className={styles.labelText}>{rotationLabel}</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={parameters.rotation}
|
||||
@@ -195,6 +201,7 @@ const StampPositionFormattingSettings = ({
|
||||
className={styles.numberInput}
|
||||
hideControls
|
||||
disabled={disabled}
|
||||
aria-label={rotationLabel}
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.rotation}
|
||||
@@ -203,15 +210,15 @@ const StampPositionFormattingSettings = ({
|
||||
max={180}
|
||||
step={1}
|
||||
className={styles.sliderWide}
|
||||
disabled={disabled}
|
||||
thumbLabel={rotationLabel}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{parameters._activePill === "opacity" && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>
|
||||
{t("AddStampRequest.opacity", "Opacity")}
|
||||
</Text>
|
||||
<Text className={styles.labelText}>{opacityLabel}</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={parameters.opacity}
|
||||
@@ -224,6 +231,7 @@ const StampPositionFormattingSettings = ({
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
disabled={disabled}
|
||||
aria-label={opacityLabel}
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.opacity}
|
||||
@@ -232,6 +240,8 @@ const StampPositionFormattingSettings = ({
|
||||
max={100}
|
||||
step={1}
|
||||
className={styles.slider}
|
||||
disabled={disabled}
|
||||
thumbLabel={opacityLabel}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
/* Preview disclaimer */
|
||||
.previewDisclaimer {
|
||||
margin-top: 8px;
|
||||
opacity: 0.7;
|
||||
color: var(--c-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -393,6 +393,7 @@ export default function StampPreview({
|
||||
<div
|
||||
className={`${styles.stampItem} ${styles.stampItemGridMode}`}
|
||||
style={style.item as React.CSSProperties}
|
||||
data-user-content-preview=""
|
||||
>
|
||||
{(parameters.stampText || "").split("\n").map((line, idx) => (
|
||||
<span
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import { Stack, Text, NumberInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
|
||||
@@ -17,15 +18,20 @@ const WatermarkStyleSettings = ({
|
||||
disabled = false,
|
||||
}: WatermarkStyleSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const rotationLabelId = useId();
|
||||
const opacityLabelId = useId();
|
||||
const widthLabelId = useId();
|
||||
const heightLabelId = useId();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Appearance Settings */}
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
<Text id={rotationLabelId} size="sm" fw={500}>
|
||||
{t("watermark.settings.rotation", "Rotation (degrees)")}
|
||||
</Text>
|
||||
<NumberInput
|
||||
aria-labelledby={rotationLabelId}
|
||||
value={parameters.rotation}
|
||||
onChange={(value) =>
|
||||
onParameterChange(
|
||||
@@ -40,10 +46,11 @@ const WatermarkStyleSettings = ({
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Text size="sm" fw={500}>
|
||||
<Text id={opacityLabelId} size="sm" fw={500}>
|
||||
{t("watermark.settings.opacity", "Opacity (%)")}
|
||||
</Text>
|
||||
<NumberInput
|
||||
aria-labelledby={opacityLabelId}
|
||||
value={parameters.opacity}
|
||||
onChange={(value) =>
|
||||
onParameterChange(
|
||||
@@ -61,10 +68,11 @@ const WatermarkStyleSettings = ({
|
||||
|
||||
{/* Spacing Settings */}
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
<Text id={widthLabelId} size="sm" fw={500}>
|
||||
{t("watermark.settings.spacing.width", "Width Spacing")}
|
||||
</Text>
|
||||
<NumberInput
|
||||
aria-labelledby={widthLabelId}
|
||||
value={parameters.widthSpacer}
|
||||
onChange={(value) =>
|
||||
onParameterChange(
|
||||
@@ -79,10 +87,11 @@ const WatermarkStyleSettings = ({
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Text size="sm" fw={500}>
|
||||
<Text id={heightLabelId} size="sm" fw={500}>
|
||||
{t("watermark.settings.spacing.height", "Height Spacing")}
|
||||
</Text>
|
||||
<NumberInput
|
||||
aria-labelledby={heightLabelId}
|
||||
value={parameters.heightSpacer}
|
||||
onChange={(value) =>
|
||||
onParameterChange(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import { Stack, Text, Select, ColorInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
|
||||
@@ -19,14 +20,17 @@ const WatermarkTextStyle = ({
|
||||
disabled = false,
|
||||
}: WatermarkTextStyleProps) => {
|
||||
const { t } = useTranslation();
|
||||
const colorLabelId = useId();
|
||||
const alphabetLabelId = useId();
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500}>
|
||||
<Text id={colorLabelId} size="xs" fw={500}>
|
||||
{t("watermark.settings.color", "Colour")}
|
||||
</Text>
|
||||
<ColorInput
|
||||
aria-labelledby={colorLabelId}
|
||||
value={parameters.customColor}
|
||||
onChange={(value) => onParameterChange("customColor", value)}
|
||||
disabled={disabled}
|
||||
@@ -39,10 +43,11 @@ const WatermarkTextStyle = ({
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500}>
|
||||
<Text id={alphabetLabelId} size="xs" fw={500}>
|
||||
{t("watermark.settings.alphabet", "Alphabet")}
|
||||
</Text>
|
||||
<Select
|
||||
aria-labelledby={alphabetLabelId}
|
||||
value={parameters.alphabet}
|
||||
onChange={(value) => value && onParameterChange("alphabet", value)}
|
||||
data={alphabetOptions}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user